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

Post-allowlist token behavior change causes claimants to be marked fully paid while receiving less than the accounted payout

Author Revealed upon completion

Description

ConfidencePoolFactory checks whether a stake token is allowlisted only when a pool is created. After the pool is created, ConfidencePool assumes the token will continue behaving as a standard ERC20 for the lifetime of the pool.

The deposit paths defend against non-standard token behavior by using balance-difference accounting. However, the payout paths transfer the computed payout directly and do not verify that the claimant actually received the same amount.

If an allowlisted token behaves normally during allowlisting, staking, and bonus contribution, but later changes behavior through a proxy upgrade or token-admin configuration change to become fee-on-transfer, a claimant can be marked as fully paid while receiving less than the pool-accounted payout.

Affected payout path:

function claimSurvived() external nonReentrant whenNotPaused {
if (resolvedOutcome != Outcome.SURVIVED) revert InvalidOutcome();
if (hasClaimed[msg.sender]) revert AlreadyClaimed();
uint256 userEligible = eligibleBalance[msg.sender];
if (userEligible == 0) revert NothingToClaim();
claimsStarted = true;
hasClaimed[msg.sender] = true;
totalPrincipalClaimed += userEligible;
uint256 bonusShare = _bonusShare(msg.sender, userEligible);
uint256 payout = userEligible + bonusShare;
eligibleBalance[msg.sender] = 0;
stakeToken.safeTransfer(msg.sender, payout);
emit ClaimSurvived(msg.sender, userEligible, bonusShare);
}

The issue is that payout is treated as fully delivered once safeTransfer() succeeds, even if the token takes an outbound transfer fee and the recipient receives less.

Risk

This causes claimant underpayment and accounting finality loss.

In the PoC:

Alice stake: 100
Bonus contribution: 50
Pool-accounted payout: 150
Token fee after resolve: 10%
Alice actually receives: 135
Lost/burned amount: 15
Pool remaining balance: 0
Alice marked claimed: true

The pool accounts Alice as having received the full 150, clears her accounting, and marks her as claimed. However, the token burns 15 on the outgoing transfer, so Alice receives only 135.

Alice cannot recover the missing 15 because:

hasClaimed[msg.sender] = true;
eligibleBalance[msg.sender] = 0;

This is not an arbitrary drain against immutable standard ERC20 tokens. The required condition is that an allowlisted stake token can change transfer semantics after pool creation, such as through an upgradeable/proxy token or token-admin configuration change.

The protocol already recognizes this class of risk on inbound deposits. For example, deposit paths use balance-difference accounting as a defense against fee-on-transfer or rebasing behavior. The same exact-transfer invariant is not enforced on outbound claim transfers.

Proof of Concept

The PoC uses the real ConfidencePoolFactory allowlist path:

1. Deploy a configurable fee-on-transfer ERC20 with feeBps = 0.
2. Factory owner allowlists the token.
3. Agreement owner creates a pool through ConfidencePoolFactory.
4. Alice stakes 100.
5. Carol contributes 50 bonus.
6. Registry resolves as SURVIVED.
7. Token fee is changed to 10%.
8. Alice calls claimSurvived().
9. Pool accounts payout as 150.
10. Alice receives only 135.
11. Pool balance becomes 0.
12. Alice cannot claim the missing 15 again.

Relevant PoC assertions:

assertEq(aliceReceived, 135 * ONE, "claimant receives less than the pool-accounted payout");
assertEq(token.balanceOf(address(pool)), 0, "pool balance is drained despite underpaying claimant");

The local trace shows that claimSurvived() attempts to transfer 150e18, but the token burns 15e18 and transfers only 135e18 to Alice.

Recommended Mitigation

Add exact-transfer validation to outbound payout paths.

Example:

error NonStandardTokenTransfer();
function _safeTransferExact(address to, uint256 amount) internal {
uint256 beforeBalance = stakeToken.balanceOf(to);
stakeToken.safeTransfer(to, amount);
uint256 afterBalance = stakeToken.balanceOf(to);
if (afterBalance < beforeBalance || afterBalance - beforeBalance != amount) {
revert NonStandardTokenTransfer();
}
}

Then use _safeTransferExact() instead of stakeToken.safeTransfer() in payout paths, including:

claimSurvived()
claimExpired()
claimCorrupted()
claimAttackerBounty()
withdraw()

Alternatively, strictly disallow upgradeable or configurable stake tokens at allowlisting time. This is weaker unless it is enforceable on-chain, because the current allowlist check is only performed when the pool is created.

Severity

Suggested severity: Medium.

Reasoning:

  • The issue can cause real claimant underpayment.

  • It affects core payout accounting.

  • The claimant is marked fully paid even though they receive less than the pool-accounted payout.

  • It requires the allowed stake token to change behavior after allowlisting.

  • It does not allow arbitrary theft from pools using immutable standard ERC20 tokens.

Support

FAQs

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

Give us feedback!