Symmio — Staking & Vesting
SYMM token staking and vesting for Symmio, an intent-based meta-derivatives engine; settlement fees flow back to stakers.
Stakers may receive no USDC rewards due to precision loss in reward accrual
Summary
When USDC (a 6-decimal token) is used as a reward token, the reward-per-token calculation — which uses 18-decimal fixed-point arithmetic — loses precision. Small incremental rewards computed over short intervals round down to zero, so stakers receive no USDC rewards even though the distribution is funded.
Root cause
The reward calculation is based on this formula:
rewardState[_rewardsToken].perTokenStored +
(((lastTimeRewardApplicable(_rewardsToken) - rewardState[_rewardsToken].lastUpdated) * rewardState[_rewardsToken].rate * 1e18) /
totalSupply);
Multiplying by 1e18 assumes an 18-decimal token. With USDC (6 decimals) the incremental reward value is tiny — often less than 1 unit at 18-decimal precision — and truncates to zero under integer division. A large totalSupply makes this worse.
Pre-conditions
- ›The reward token is USDC (6 decimals) while the arithmetic runs at 18-decimal precision.
- ›The reward rate, derived from a USDC amount, is scaled by
1e18. - ›The total staked amount is large, shrinking the per-token increment further.
Impact
Stakers can end up with 0 USDC rewards even when a significant USDC reward has been notified, nullifying the reward mechanism and removing the incentive to participate.
Proof of concept
A Foundry test (requires the hardhat-foundry plugin):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import "../contracts/staking/SymmStaking.sol";
import {ERC20} from "@openzeppelin-contracts/token/ERC20/ERC20.sol";
contract SYMMMOCK is ERC20 {
constructor() ERC20("SYMMMOCK", "SYMM") {}
function mint(address to, uint256 amount) external { _mint(to, amount); }
}
contract MockUSDC is ERC20 {
constructor() ERC20("MockUSDC", "mUSDC") {}
function mint(address to, uint256 amount) external { _mint(to, amount); }
function decimals() public view virtual override returns (uint8) { return 6; }
}
contract SymmStakingTest is Test {
SymmStaking staking;
SYMMMOCK stakingToken;
SYMMMOCK rewardToken;
address admin = address(1);
address user1 = address(2);
function setUp() public {
stakingToken = new SYMMMOCK();
rewardToken = new SYMMMOCK();
stakingToken.mint(admin, 1e24);
stakingToken.mint(user1, 1e24);
rewardToken.mint(admin, 1e24);
vm.prank(admin);
staking = new SymmStaking();
staking.initialize(admin, address(stakingToken));
vm.prank(admin);
staking.configureRewardToken(address(rewardToken), true);
vm.prank(user1);
stakingToken.approve(address(staking), 1e24);
vm.prank(admin);
rewardToken.approve(address(staking), 1e24);
}
function test_USDCRewardPrecision() public {
MockUSDC usdcToken = new MockUSDC();
usdcToken.mint(admin, 10_000e6);
vm.prank(admin);
staking.configureRewardToken(address(usdcToken), true);
vm.prank(admin);
usdcToken.approve(address(staking), 1e24);
uint256 stakedAmount = 100_000e18;
vm.prank(user1);
staking.deposit(stakedAmount, user1);
uint256 rewardUSDC = 10_000e6;
address[] memory tokens = new address[](1);
tokens[0] = address(usdcToken);
uint256[] memory amounts = new uint256[](1);
amounts[0] = rewardUSDC;
vm.prank(admin);
staking.notifyRewardAmount(tokens, amounts);
vm.warp(block.timestamp + 2);
uint256 rptAfter = staking.rewardPerToken(address(usdcToken));
console.log("Reward per token for USDC:", rptAfter);
assertEq(rptAfter, 0, "Reward per token for USDC should be 0 due to precision loss");
}
}
After notifying a 10,000 USDC reward, the computed incremental reward per token stays at 0.
Recommendation
Normalise reward amounts to 18 decimals on entry (scale by 10 ** (18 - token.decimals())), or track the reward index per token at that token's own precision.