An example Weakspot report
This is the unedited output of one real audit — the same thing you get when you commission one. It was produced by the live product against a deliberately vulnerable fixture, and nothing below has been rewritten, reordered or removed.
- Input
test-fixtures/solidity/multi-file-staking— 5 files (IERC20.sol, Ownable.sol, PoolMath.sol, RewardToken.sol, StakingPool.sol)- Verify pass
- on
- Findings
- 12 total — 4 critical, 3 high, 3 medium, 1 low, 1 informational
- Run
- 2026-09-06
What was submitted
Every audit takes the sources plus a written description of what the project is supposed to do. The description is not decoration: it is what lets the model call a function "missing access control" rather than "a public withdrawal function". This is the text that went in with the files above.
# StakePool
A staking protocol for `RewardToken`, split across a few contracts:
- `IERC20.sol` — the standard interface `RewardToken` implements.
- `Ownable.sol` — a minimal owner-access-control base contract.
- `RewardToken.sol` — a simple ERC20-style token. Only the owner (the
protocol deployer) can mint new supply.
- `PoolMath.sol` — a small library with the pure reward-share math, kept
separate from `StakingPool` so it can be unit tested and reused.
- `StakingPool.sol` — the main contract. Users `stake()` tokens and later
`unstake()` them. The protocol team periodically transfers extra
`RewardToken` directly into the pool contract as the reward budget;
`claimReward()` lets each staker withdraw their proportional share of
whatever reward balance has accumulated since they staked.
Trust assumptions: the owner is trusted to fund rewards honestly and not
to mint excessively. Any other address, staker or not, is untrusted and
should not be able to affect another staker's balance or claimable reward.The fixture and its answer key are both in the repository, so the report below can be checked against what was actually planted rather than taken on trust.
Summary
The StakingPool protocol contains several critical and high-severity vulnerabilities that enable fund theft and reward manipulation. The most severe issues are: (1) a reentrancy vulnerability in `unstake()` that allows attackers to drain rewards by repeatedly calling back into the pool during token transfer, (2) a critical flaw in `claimReward()` that permits unbounded repeated claims of the same reward balance, (3) a donation/inflation attack that allows manipulation of reward distribution math via direct token transfers, and (4) a division-before-multiplication bug in `PoolMath.shareOf()` that causes reward calculations to truncate to zero for nearly all stakers. These issues combine to create a pool where rewards are either inaccessible or trivially extractable by attackers.
Findings
- 01criticalverified
Critical Reentrancy in unstake() allows draining pool rewards
StakingPool.sol:52-59
The `unstake()` function violates the checks-effects-interactions pattern by calling `token.transfer()` before decrementing `staked[msg.sender]`. If the token is a contract with a receive hook or callback (or replaced with one), an attacker can reenter `claimReward()` while their stake mapping still shows the full unstaked amount, extracting rewards multiple times. Even with the current RewardToken implementation, if the token is ever upgraded or swapped to a contract with hooks, this becomes exploitable. The external call on line 55 happens before the state update on line 57.
Recommendation
Apply the checks-effects-interactions pattern: decrement `staked[msg.sender]` before calling `token.transfer()`. Reorder lines 55 and 57 so that state is updated before any external call.
- 02criticalverified
claimReward() Allows Repeated Reward Extraction from Same Pool Balance
StakingPool.sol:61-70
The claimReward() function calculates each user's reward as shareOf(userStake, totalStaked(), poolBalance) with no tracking of rewards already claimed or snapshots of what portion of the pool balance is 'new' vs. already distributed. An attacker can call claimReward() multiple times in the same transaction (or across transactions if the pool balance hasn't changed) to repeatedly extract 'their share' of the same static balance, draining the pool. For example: Pool has 100 RWD, attacker has 50% stake. First call claims 50 RWD. Pool balance is now 50 RWD. Attacker calls again; now 50% of 50 = 25 RWD claimed again (and again, indefinitely until the pool is drained). The staked[msg.sender] mapping is never modified during reward claiming, so there is no state change to prevent repeated calls on the same balance.
Recommendation
Implement per-user tracking of claimed rewards. Either: (1) maintain a mapping of rewards already claimed per user and subtract that from future calculations, (2) snapshot the pool balance at each distribution event and only reward the difference since the user's last claim, or (3) use a global reward index that increments as rewards are distributed, and track each user's index at their last claim.
- 03criticalverified
Donation/inflation attack enables reward share manipulation
StakingPool.sol:41-43
`totalStaked()` reads directly from `token.balanceOf(address(this))`, which includes both staked principal AND undistributed rewards. Because reward funding arrives as plain token transfers, anyone can send tokens directly to the contract to inflate `totalStaked()`. An attacker can transfer tokens to the pool to artificially increase `totalStaked()`, then call `claimReward()` to extract an outsized share. For example, if the pool has 100 staked tokens and 10 reward tokens, an attacker can transfer 10 tokens to make `totalStaked() = 120`, then claim a share of 20 reward tokens as if they own part of the pool. Combined with the unbounded claim bug, this enables total pool drainage.
Recommendation
Maintain an explicit `totalStaked` state variable (separate from balance) incremented in `stake()` and decremented in `unstake()`. Similarly, maintain a `totalRewards` counter tracking only the reward budget. Alternatively, use a separate reward account or require rewards to be deposited through a dedicated `depositRewards()` function that increments a `rewardPool` variable.
- 04mediumverified
Unguarded pool balance state variable allows donor-funded imbalance
StakingPool.sol:41-43
Because `totalStaked()` relies on the live contract balance rather than an internal counter, any arbitrary token transfer to the contract (not via `stake()`) causes `totalStaked()` to increase. This is a form of the donation attack already mentioned but worth calling out separately: the function itself is not protected, and anyone with the contract address can send tokens, making the pool vulnerable to balance manipulation without any staker involvement.
Recommendation
Maintain an explicit `totalStaked` state variable (counter pattern). Do not derive staked totals from balance checks. This is the same mitigation as for the donation/inflation attack finding.
- 05lowverified
Missing zero-amount validation in claimReward()
StakingPool.sol:61-70
The `claimReward()` function checks `userStake > 0` on line 64 but does not validate that `reward > 0` before calling `transfer()`. Due to the division-before-multiplication bug in PoolMath, `reward` will often be 0. Calling `transfer(msg.sender, 0)` is allowed by the ERC20 spec and will succeed (emitting a Transfer event for 0 tokens), wasting gas. This is low-severity because it does not cause fund loss, but it indicates the function is not defensive.
Recommendation
Add `require(reward > 0, "no reward");` before the transfer on line 68 to prevent spurious zero-value transfers and make intent explicit.
- 06highverified
High: totalStaked() reads live token balance instead of maintaining a counter, enabling inflation attacks
StakingPool.sol:41-43
The totalStaked() function returns token.balanceOf(address(this)), which reflects the current balance of the contract. However, reward funding is implemented as a direct transfer of tokens into the contract address, and anyone can send tokens to the contract to inflate this balance. An attacker can transfer tokens directly to the contract address to increase totalStaked() without actually staking. This inflates the denominator in the reward-share calculation (claimReward uses totalStaked() in PoolMath.shareOf()), reducing the share of every legitimate staker. For example: if there is 100 units staked and 100 units of rewards, and an attacker transfers 900 units directly, legitimate stakers now see totalStaked() = 1000 and their individual share is halved, while the attacker has created 900 units of "phantom stakes." This is a donation/inflation attack.
Recommendation
Maintain a separate state variable, totalStakedAmount (or similar), that is incremented in stake() and decremented in unstake(). Return this counter from totalStaked() instead of reading the token balance. Update stake() to increment totalStakedAmount after the transfer succeeds, and update unstake() to decrement totalStakedAmount before the transfer (following the fixes for the reentrancy issue). Optionally, validate in unstake() that token.balanceOf(address(this)) >= totalStakedAmount + any accrued rewards to catch accidental balance mismatches.
- 07mediumverified
Reentrancy in claimReward() via Transfer Before Event
StakingPool.sol:61-70
The claimReward() function calls token.transfer(msg.sender, reward) on line 68 before emitting the RewardClaimed event on line 69. If the token is a callback-enabled ERC20, the recipient can reenter and call claimReward() again. Although staked[msg.sender] is not modified during claimReward() (so the per-stake check passes), the attacker can still extract multiple copies of rewards from the pool because no per-user reward claim tracking exists (as flagged in the earlier claimReward() finding). Additionally, the event order violation makes off-chain systems incorrect about the pool state.
Recommendation
Emit events before external calls, or use a reentrancy guard (e.g., nonReentrant modifier from OpenZeppelin). However, the deeper fix is to implement reward claim tracking (see the separate claimReward() critical finding). For immediate defense-in-depth, move the emit RewardClaimed() call to before the token.transfer() call.
- 08highverified
Reentrancy in unstake() via Checks-Effects-Interactions Violation
StakingPool.sol:52-59
The unstake() function calls token.transfer(msg.sender, amount) on line 55 before decrementing staked[msg.sender] on line 57. If token is ever a non-standard ERC20 with a callback hook (e.g., ERC777, or a malicious token), the recipient can reenter unstake() during the transfer. The attacker can call unstake() again before staked[msg.sender] is decremented, allowing them to withdraw the same tokens multiple times. For example: staked[attacker] = 100 RWD. Call unstake(100). Token transfer begins. Attacker's callback reenters and calls unstake(100) again. The check on line 53 (staked[msg.sender] >= amount) still passes because staked[attacker] hasn't been decremented yet. Attacker receives another 100 RWD, then both calls return and staked[attacker] -= 100 runs only once, leaving attacker with 200 RWD withdrawn but staked[attacker] = 0. Although the code comment acknowledges this risk, it remains an unmitigated vulnerability.
Recommendation
Follow the checks-effects-interactions pattern: decrement staked[msg.sender] before the external token.transfer() call. Reorder lines 57 before 55, so state is updated first and the pool's accounting cannot be violated during reentrancy.
- 09highverified
Lack of Independent Accounting for Staked vs. Reward Balances
StakingPool.sol:41-43
The contract relies entirely on token.balanceOf(address(this)) for totalStaked(), which conflates staked principal with undistributed rewards and any direct token donations. This breaks the accounting invariant that totalStaked should equal the sum of all staked[user] balances. When rewards are transferred into the pool as plain token transfers, there is no way to distinguish how much is 'reward' vs. 'principal,' making the share calculations in claimReward() fundamentally broken. An audit of the invariant (sum of staked[user] should equal totalStaked for principal tracking) will fail immediately.
Recommendation
Introduce an independent tracking variable (e.g., uint256 totalStakedAmount) that is incremented in stake() and decremented in unstake(). Use this variable instead of token.balanceOf(address(this)) for totalStaked(). The pool balance can then be understood as: pool balance = totalStakedAmount + undistributed rewards, allowing reward calculations to work correctly.
- 10criticalverified
Division-Before-Multiplication in PoolMath.shareOf() Causes Reward Truncation to Zero
PoolMath.sol:12-19
The shareOf() function computes (userStake / totalStaked) * rewardBalance, which performs integer division before multiplication. In Solidity, this truncates to zero whenever userStake < totalStaked, which is the overwhelmingly common case in any pool with multiple stakers. For example, if userStake=1 and totalStaked=1000, then 1/1000 = 0 (truncated), so the result is 0 * rewardBalance = 0, regardless of how large rewardBalance is. This causes nearly all stakers to receive zero rewards, completely breaking the protocol's core functionality.
Recommendation
Reorder the calculation to multiply before dividing: return (userStake * rewardBalance) / totalStaked. This preserves precision for typical staking scenarios and only truncates remainder fractions, which is the correct behavior.
- 11informationalverified
Informational: RewardToken state variables should be declared as constant
RewardToken.sol:12-14
The name, symbol, and decimals variables are assigned static string and uint8 values on lines 12-14 and never modified. They should be declared as constant rather than state variables to reduce gas costs and make intent explicit. Currently, they consume storage slots unnecessarily.
Recommendation
Change lines 12-14 to: string public constant name = "Reward Token"; string public constant symbol = "RWD"; uint8 public constant decimals = 18;
- 12mediumunverified · static analysis
divide-before-multiply
PoolMath.sol:12-19
PoolMath.shareOf(uint256,uint256,uint256) (../tmp/tmpt_w8swu1/PoolMath.sol#12-19) performs a multiplication on the result of a division: - (userStake / totalStaked) * rewardBalance (../tmp/tmpt_w8swu1/PoolMath.sol#18)
Recommendation
Review and address the Slither detector output above.