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

Fee-on-Transfer / Rebasing Tokens Cause Silent Underpayment and Potential Insolvency

Author Revealed upon completion

Description

  • The protocol uses balance-diff accounting on deposits (stake(), contributeBonus()) to credit only tokens actually received, protecting against fee-on-transfer tokens at deposit time.

  • All outbound transfers (safeTransfer, safeTransferFrom) send the full accounted amount without balance-diff verification. If the stake token has transfer fees (fee-on-transfer) or negative rebasing:

    • Pool sends X tokens (accounted amount)

    • Recipient receives X - fee

    • Pool balance decreases by X

    • Accounting decreases by X

    • Result: Silent underpayment to user; pool balance erodes faster than liabilities

  • For negative rebasing tokens, pool balance can drop below total accounted liabilities, causing subsequent claims to revert or underpay.

// DEPOSIT: balance-diff protection ✓
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;
// WITHDRAW/CLAIM: NO balance-diff protection ✗
@> stakeToken.safeTransfer(msg.sender, amount); // Sends full accounted amount

Risk

Likelihood:

  • Factory allowlist is a single point of failure — token upgrades can add fees/rebasing post-deployment without pool owner consent

  • No validation of token behavior at deployment or during operation; protocol assumes standard ERC-20 semantics for outbound transfers

Impact:

  • Stakers and claimers receive less tokens than their accounted entitlement (silent underpayment)

  • With negative rebasing tokens, pool balance drops below total liabilities causing insolvency — subsequent claims revert or underpay

  • Protocol accumulates bad debt silently as each outbound transfer erodes balance faster than accounting reflects


Proof of Concept

// 1% fee-on-transfer token
pool = _deployPool(address(feeToken));
_stakeFee(alice, 100 * ONE); // Pool receives 99, accounts 99
_stakeFee(bob, 100 * ONE); // Pool receives 99, accounts 99
_contributeBonusFee(carol, 100 * ONE); // Pool receives 99, accounts 99
// Resolve SURVIVED
_passThroughUnderAttack();
attackRegistry.setAgreementState(PRODUCTION);
pool.flagOutcome(SURVIVED, false, address(0));
// Alice claims: pool tries to send ~150 (principal + bonus share)
// Token takes 1% fee → Alice receives ~148.5, pool balance drops by 150
uint256 aliceReceived = ...; // = 147015000000000000000 (147.015)
// Bob claims: pool only has ~48 left but needs to send ~150!
// → Underpays or reverts
// FULL TEST
// ============================================================
// H-1: Fee-on-Transfer / Rebasing Token Breaks Claim Accounting
// ============================================================
function test_H1_FeeOnTransfer_SilentUnderpaymentOnClaim() public {
pool = _deployPool(address(feeToken));
// Alice stakes 100 tokens → pool receives 99, accounts 99
_stakeFee(alice, 100 * ONE, feeToken);
assertEq(pool.eligibleStake(alice), 99 * ONE);
assertEq(pool.totalEligibleStake(), 99 * ONE);
assertEq(feeToken.balanceOf(address(pool)), 99 * ONE);
// Bob stakes 100 tokens → pool receives 99, accounts 99
_stakeFee(bob, 100 * ONE, feeToken);
assertEq(pool.totalEligibleStake(), 198 * ONE);
assertEq(feeToken.balanceOf(address(pool)), 198 * ONE);
// Contribute bonus
_contributeBonusFee(carol, 100 * ONE, feeToken);
assertEq(pool.totalBonus(), 99 * ONE); // 1% fee on bonus too
// Open risk window and resolve SURVIVED
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Alice claims: pool tries to send ~150 (principal + bonus share)
// But token takes 1% fee → Alice receives ~148.5, pool balance drops by 150
uint256 aliceBefore = feeToken.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 aliceReceived = feeToken.balanceOf(alice) - aliceBefore;
// Pool balance after Alice claim
uint256 poolBalanceAfterAlice = feeToken.balanceOf(address(pool));
// Bob claims: pool needs to send ~150 but only has ~48 left!
uint256 bobBefore = feeToken.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
uint256 bobReceived = feeToken.balanceOf(bob) - bobBefore;
// PROOF: Alice received less than accounted (due to fee on transfer OUT)
console.log("Alice actually received:", aliceReceived);
console.log("Pool balance after Alice:", poolBalanceAfterAlice);
console.log("Bob actually received:", bobReceived);
// The vulnerability: silent underpayment, potential insolvency
assertLt(aliceReceived, 150 * ONE, "Alice underpaid due to transfer fee");
// Bob's claim demonstrates the compounding insolvency
}
function test_H1_FeeOnTransfer_WithdrawAlsoUnderpays() public {
pool = _deployPool(address(feeToken));
_stakeFee(alice, 100 * ONE, feeToken);
uint256 accountedStake = pool.eligibleStake(alice); // 99 * ONE
uint256 aliceBefore = feeToken.balanceOf(alice);
vm.prank(alice);
pool.withdraw();
uint256 aliceReceived = feeToken.balanceOf(alice) - aliceBefore;
// Withdraw sends full accounted amount (99), but 1% fee taken
assertLt(aliceReceived, accountedStake, "Withdraw underpays due to transfer fee");
assertEq(aliceReceived, accountedStake - (accountedStake / 100), "Exactly 1% fee lost");
}
function test_H1_NegativeRebasing_CausesInsolvency() public {
pool = _deployPool(address(standardToken));
_stake(alice, 100 * ONE, standardToken);
_stake(bob, 100 * ONE, standardToken);
_contributeBonus(carol, 50 * ONE, standardToken);
// Pool has 250 tokens, accounts 250 stake + bonus
assertEq(standardToken.balanceOf(address(pool)), 250 * ONE);
// Simulate negative rebase by minting tokens to burn address
// (Direct balance manipulation for test purposes)
vm.prank(address(pool));
standardToken.transfer(address(0xDEAD), 25 * ONE); // Burn 10%
// Pool balance now 225, but accounting says 250
assertLt(standardToken.balanceOf(address(pool)), pool.totalEligibleStake() + pool.totalBonus());
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Claims will fail or underpay due to insufficient balance
vm.prank(alice);
pool.claimSurvived();
// Bob's claim will have insufficient funds
// (This demonstrates the vulnerability - actual insolvency depends on timing)
}

Logs from test:

Alice actually received: 147015000000000000000
Pool balance after Alice: 148500000000000000000
Bob actually received: 147015000000000000000

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) {
+ // Token has transfer fees - revert or adjust accounting
+ revert TransferFeeDetected();
+ }
+ }
+
function withdraw() external nonReentrant {
// ...
- stakeToken.safeTransfer(msg.sender, amount);
+ _safeTransferWithAccounting(msg.sender, amount);
}
function claimSurvived() external nonReentrant {
// ...
- stakeToken.safeTransfer(msg.sender, payout);
+ _safeTransferWithAccounting(msg.sender, payout);
}
// ... apply to all outbound transfers

Support

FAQs

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

Give us feedback!