Immunefi Audit Competitions
Folks Finance: Staking Contracts

69245 sc insight no view function to compute current claimable amounts

Submitted on Mar 13th 2026 at 19:17:53 UTC by @ZenHunter for Audit Comp | Folks Finance: Staking Contracts

Description

Brief/Intro

Staking exposes getUserStake to read raw UserStake storage fields, but provides no external view function that computes how much a user can currently withdraw. The internal accrual logic lives entirely in _getAccrued, an internal pure function that is inaccessible to any external caller. Every off-chain consumer — frontend, monitoring bot, aggregator, or composing smart contract — must independently replicate the accrual formula to compute claimable amounts.

Vulnerability Details

The accrual formula is implemented once, in _getAccrued:

// src/Staking.sol#L332-L334
function _getAccrued(uint256 amount, uint256 duration, uint256 elapsed) internal pure returns (uint256) {
    return Math.mulDiv(amount, Math.min(elapsed, duration), duration);
}

It is called exclusively inside _withdraw, which also applies the pre-conditions that gate a valid withdrawal:

// src/Staking.sol#L304-L330
function _withdraw(uint8 stakeIndex) internal {
    if (stakeIndex >= userStakes[msg.sender].length) revert StakeNotFound();

    UserStake storage userStake = userStakes[msg.sender][stakeIndex];
    if (block.timestamp <= userStake.unlockTime) {
        revert RewardsNotAvailableYet(uint64(block.timestamp), userStake.unlockTime);
    }
    if (userStake.claimedAmount + userStake.claimedReward >= userStake.amount + userStake.reward) {
        revert AlreadyWithdrawn(stakeIndex);
    }

    uint256 accruedAmount =
        _getAccrued(userStake.amount, userStake.unlockDuration, block.timestamp - userStake.unlockTime);
    uint256 accruedReward =
        _getAccrued(userStake.reward, userStake.unlockDuration, block.timestamp - userStake.unlockTime);

    uint256 amountToClaim = accruedAmount - userStake.claimedAmount;
    uint256 rewardToClaim = accruedReward - userStake.claimedReward;
    ...
}

IStakingV1 exposes only raw storage reads:

// src/interfaces/IStakingV1.sol#L111-L114
function getUserStakes(address user) external view returns (UserStake[] memory);
function getUserStake(address user, uint8 stakeIndex) external view returns (UserStake memory);

To derive the currently claimable amounts from these, an external caller must independently reconstruct the full computation — including the pre-conditions and the Math.mulDiv + Math.min formula:

UserStake memory s = IStakingV1(staking).getUserStake(user, stakeIndex);

// Caller must replicate every step:
if (block.timestamp <= s.unlockTime) { /* not yet claimable */ }
if (s.claimedAmount + s.claimedReward >= s.amount + s.reward) { /* already fully withdrawn */ }

uint256 elapsed      = block.timestamp - s.unlockTime;
uint256 clampedElapsed = elapsed < s.unlockDuration ? elapsed : s.unlockDuration;
uint256 accruedAmount  = (s.amount * clampedElapsed) / s.unlockDuration; // simplified; real uses mulDiv
uint256 accruedReward  = (s.reward * clampedElapsed) / s.unlockDuration;
uint256 claimableAmount = accruedAmount - s.claimedAmount;
uint256 claimableReward = accruedReward - s.claimedReward;

This is non-trivial to replicate correctly — Math.mulDiv uses full 512-bit intermediate multiplication to avoid overflow — and there is no interface-level contract that these externally-replicated formulas will remain valid across contract upgrades.

Impact Details

Impact category: Architectural Decentralization and Composability

References

  • Accrual logic: src/Staking.sol#L332–L334
  • Only call site: src/Staking.sol#L315–L321
  • Interface view functions: src/interfaces/IStakingV1.sol#L111–L114

Recommendation

Add getClaimable and getClaimableAll to IStakingV1 and implement them in Staking. Storage fields are cached into locals to avoid repeated warm SLOADs. getClaimableAll delegates to getClaimable to avoid duplicating the per-stake logic:

// IStakingV1.sol
function getClaimable(address user, uint8 stakeIndex)
    external view returns (uint256 claimableAmount, uint256 claimableReward);

function getClaimableAll(address user)
    external view returns (uint256 totalClaimableAmount, uint256 totalClaimableReward);
// Staking.sol
function getClaimable(address user, uint8 stakeIndex)
    external
    view
    returns (uint256 claimableAmount, uint256 claimableReward)
{
    if (stakeIndex >= userStakes[user].length) revert StakeNotFound();
    UserStake storage userStake = userStakes[user][stakeIndex];

    // Cache storage fields into locals — avoids repeated warm SLOADs.
    uint256 amount         = userStake.amount;
    uint256 reward         = userStake.reward;
    uint256 claimedAmount  = userStake.claimedAmount;
    uint256 claimedReward  = userStake.claimedReward;
    uint64  unlockTime     = userStake.unlockTime;
    uint64  unlockDuration = userStake.unlockDuration;

    if (block.timestamp <= unlockTime) return (0, 0);
    if (claimedAmount + claimedReward >= amount + reward) return (0, 0);

    uint256 elapsed       = block.timestamp - unlockTime;
    uint256 accruedAmount = _getAccrued(amount, unlockDuration, elapsed);
    uint256 accruedReward = _getAccrued(reward, unlockDuration, elapsed);

    claimableAmount = accruedAmount - claimedAmount;
    claimableReward = accruedReward - claimedReward;
}

function getClaimableAll(address user)
    external
    view
    returns (uint256 totalClaimableAmount, uint256 totalClaimableReward)
{
    uint256 count = userStakes[user].length;
    for (uint8 i = 0; i < count; ++i) {
        (uint256 ca, uint256 cr) = this.getClaimable(user, i);
        totalClaimableAmount += ca;
        totalClaimableReward += cr;
    }
}

This surfaces the authoritative computation through the interface with no additional logic risk, and ensures any future formula change is automatically reflected to all callers.

Proof of Concept

File: test/NoClaimableView.t.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import {Test, console} from "forge-std/Test.sol";
import {ERC20Permit, ERC20} from "openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Permit.sol";
import {Staking} from "../src/Staking.sol";
import {IStakingV1} from "../src/interfaces/IStakingV1.sol";

contract Token is ERC20Permit {
    constructor() ERC20Permit("T") ERC20("T", "T") {}
    function mint(address to, uint256 amount) external { _mint(to, amount); }
}

contract StakingWithClaimable is Staking {
    constructor(address _admin, address _manager, address _pauser, address _token)
        Staking(_admin, _manager, _pauser, _token) {}

    function getClaimable(address user, uint8 stakeIndex)
        external view returns (uint256 claimableAmount, uint256 claimableReward)
    {
        if (stakeIndex >= userStakes[user].length) revert StakeNotFound();
        UserStake storage userStake = userStakes[user][stakeIndex];

        uint256 amount         = userStake.amount;
        uint256 reward         = userStake.reward;
        uint256 claimedAmount  = userStake.claimedAmount;
        uint256 claimedReward  = userStake.claimedReward;
        uint64  unlockTime     = userStake.unlockTime;
        uint64  unlockDuration = userStake.unlockDuration;

        if (block.timestamp <= unlockTime) return (0, 0);
        if (claimedAmount + claimedReward >= amount + reward) return (0, 0);

        uint256 elapsed       = block.timestamp - unlockTime;
        uint256 accruedAmount = _getAccrued(amount, unlockDuration, elapsed);
        uint256 accruedReward = _getAccrued(reward, unlockDuration, elapsed);

        claimableAmount = accruedAmount - claimedAmount;
        claimableReward = accruedReward - claimedReward;
    }

    function getClaimableAll(address user)
        external view returns (uint256 totalClaimableAmount, uint256 totalClaimableReward)
    {
        uint256 count = userStakes[user].length;
        for (uint8 i = 0; i < count; ++i) {
            (uint256 ca, uint256 cr) = this.getClaimable(user, i);
            totalClaimableAmount += ca;
            totalClaimableReward += cr;
        }
    }
}

contract NoClaimableViewTest is Test {
    uint64  constant STAKING_DURATION = uint64(365 days);
    uint64  constant UNLOCK_DURATION  = uint64(30 days);
    uint32  constant APR_BPS          = 1_000;
    uint256 constant STAKE_AMOUNT     = 1_000e18;
    uint256 constant PREFUND          = 1_000_000e18;

    address admin = address(0x10); address manager = address(0x11);
    address pauser = address(0x12); address alice = address(0x13);

    Token                token;
    StakingWithClaimable stakingFixed;
    IStakingV1.StakeParams stakeParams;

    function setUp() public {
        vm.warp(10 * 365 days);
        token        = new Token();
        stakingFixed = new StakingWithClaimable(admin, manager, pauser, address(token));

        stakeParams = IStakingV1.StakeParams({
            maxStakingDurationSeconds: STAKING_DURATION,
            maxUnlockDurationSeconds:  UNLOCK_DURATION,
            minAprBps: APR_BPS, referrer: address(0)
        });

        vm.prank(manager);
        stakingFixed.addStakingPeriod(10_000_000e18, STAKING_DURATION, UNLOCK_DURATION, APR_BPS, true);

        token.mint(address(stakingFixed), PREFUND);
        token.mint(alice, STAKE_AMOUNT * 2);
        vm.startPrank(alice);
        token.approve(address(stakingFixed), type(uint256).max);
        stakingFixed.stake(0, STAKE_AMOUNT, stakeParams);
        vm.stopPrank();

        // Advance past unlockTime and halfway through the unlock period.
        vm.warp(block.timestamp + STAKING_DURATION + UNLOCK_DURATION / 2);
    }

    // getClaimable() returns values that exactly match what withdraw() transfers.
    function test_GetClaimableMatchesWithdraw() public {
        (uint256 claimableAmount, uint256 claimableReward) = stakingFixed.getClaimable(alice, 0);

        console.log("getClaimable() claimableAmount:", claimableAmount);
        console.log("getClaimable() claimableReward:", claimableReward);

        uint256 balanceBefore = token.balanceOf(alice);
        vm.prank(alice);
        stakingFixed.withdraw(0);
        uint256 received = token.balanceOf(alice) - balanceBefore;

        console.log("withdraw() transferred:", received);
        assertEq(received, claimableAmount + claimableReward);
    }

    // getClaimableAll() equals the sum of per-stake getClaimable() calls.
    function test_GetClaimableAllMatchesSumOfGetClaimable() public {
        token.mint(alice, STAKE_AMOUNT);
        vm.prank(alice);
        stakingFixed.stake(0, STAKE_AMOUNT, stakeParams);

        (uint256 ca0, uint256 cr0) = stakingFixed.getClaimable(alice, 0);
        (uint256 ca1, uint256 cr1) = stakingFixed.getClaimable(alice, 1);
        (uint256 totalAmount, uint256 totalReward) = stakingFixed.getClaimableAll(alice);

        console.log("getClaimable(0) amount:", ca0, "reward:", cr0);
        console.log("getClaimable(1) amount:", ca1, "reward:", cr1);
        console.log("getClaimableAll  amount:", totalAmount, "reward:", totalReward);

        assertEq(totalAmount, ca0 + ca1);
        assertEq(totalReward, cr0 + cr1);
    }
}

Command:

forge test --match-path "test/NoClaimableView.t.sol" -vv

Output:

Ran 2 tests for test/NoClaimableView.t.sol:NoClaimableViewTest
[PASS] test_GetClaimableAllMatchesSumOfGetClaimable() (gas: 231944)
Logs:
  getClaimable(0) amount: 500000000000000000000 reward: 50000000000000000000
  getClaimable(1) amount: 0 reward: 0
  getClaimableAll  amount: 500000000000000000000 reward: 50000000000000000000

[PASS] test_GetClaimableMatchesWithdraw() (gas: 136231)
Logs:
  getClaimable() claimableAmount: 500000000000000000000
  getClaimable() claimableReward: 50000000000000000000
  withdraw() transferred: 550000000000000000000

Suite result: ok. 2 passed; 0 failed; 0 skipped