I have started going back through the findings I submitted in past contests and breaking them down one at a time.
Not just what the bug was, but the assumption behind it, the line where it shows up, and what you can look for so the same pattern stands out faster the next time you see it. If you read my piece on the feedback loop, this is me running that loop in public.
This first one is from the Symmio staking and vesting contest on Sherlock, back in March 2025. It was judged a High.
Symmio is an intent-based derivatives engine. The contracts in scope handled staking and vesting for the SYMM token: you stake SYMM, and settlement fees flow back to stakers as rewards.
The rewards side used a pattern you have probably seen many times. The contract keeps a running "reward per token" value. Every time it updates, it adds whatever was earned since the last update, spread across everything that is staked. When you claim, your share is your balance multiplied by how much that value has grown since you last checked.
The update looked like this:
rewardState[_rewardsToken].perTokenStored +
(((lastTimeRewardApplicable(_rewardsToken) - rewardState[_rewardsToken].lastUpdated) * rewardState[_rewardsToken].rate * 1e18) /
totalSupply);
Time elapsed, multiplied by the reward rate, scaled by 1e18, divided by the total staked.
Nothing about it looks wrong. That is the problem.
The
1e18is not just a number. It is an assumption.
Scaling by 1e18 is how you hold on to precision when dividing in Solidity. It works when the reward token itself has 18 decimals, because the units line up and the result stays large enough to survive the division.
But the contract supported more than one reward token, and nothing stopped USDC from being configured as one. USDC has 6 decimals.
That means the reward rate is counted in USDC's smallest unit, which is a far smaller number than the same value in an 18-decimal token. Meanwhile totalSupply is staked SYMM, counted in 18 decimals. So you are dividing a small number by a very large one.
To make it concrete, say 10,000 USDC is distributed over a week. I am using a week here only to put real numbers on it:
- 10,000 USDC is 10,000,000,000 units, spread across 604,800 seconds, so roughly 16,534 units per second
- Two seconds later: 2 × 16,534 × 1e18 ≈ 3.3e22
- 100,000 SYMM staked is 1e23 in raw units
- 3.3e22 divided by 1e23 is 0.33, and integer division makes that 0
The reward per token does not move. The bigger the pool and the shorter the interval between updates, the more of these increments round down to nothing.
The proof of concept showed it directly. Stake 100,000 SYMM, notify a 10,000 USDC reward, move forward two seconds, and check the reward per token:
uint256 rptAfter = staking.rewardPerToken(address(usdcToken));
assertEq(rptAfter, 0, "Reward per token for USDC should be 0 due to precision loss");
It stays at 0.
The distribution is funded. The USDC is sitting in the contract. Stakers receive nothing, and the incentive to stake goes with it.
The full writeup, including the complete Foundry test, is on my audits page.
The fix is to stop assuming. Either normalise every reward amount to 18 decimals when it comes in, scaling by 10 ** (18 - token.decimals()), or track the reward index for each token at that token's own precision. Either way, the math runs in the units the token actually uses.
So what is the pattern worth taking away?
The cue is a hard-coded precision constant sitting next to a token whose decimals are not fixed. When you see 1e18 in reward or accounting math, ask:
- Which token's units is this value actually in?
- Can that token be anything other than 18 decimals?
- How small can the numerator get before the division wipes it out?
- What makes the denominator large? More stakers, bigger deposits, a popular pool?
Then plug in realistic numbers, the uncomfortable kind. Not one token and one staker, but a large pool, a 6-decimal token and a short interval.
Multi-token reward contracts are a good place to look for this, because it is easy to write and test the math against a single 18-decimal token and treat every other token as configuration.
This is the first breakdown. I will keep working through the rest of my contest findings the same way: the assumption, where it hides, and how to catch it next time.
If you are building something with multi-token rewards and want someone to read the math with this kind of question in mind, I am taking on reviews. Get in touch.
