Liquidity Mining Contract Development
Liquidity mining is a mechanism where users provide liquidity and receive rewards in protocol tokens. SushiSwap in 2020 executed a "vampire attack" on Uniswap through exactly this: deployed an identical AMM with aggressive SUSHI rewards and within 24 hours attracted $1B in TVL. The mechanics work.
But behind the apparent simplicity of "stake LP token → get reward" lies non-trivial mathematics and several classes of vulnerabilities that regularly appear in production.
Reward Distribution Mathematics
Masterchef Algorithm (Synthetix StakingRewards)
The basic algorithm used by most protocols is based on accumulated reward value per stake unit (rewardPerTokenStored).
// Accumulated reward per token
uint256 public rewardPerTokenStored;
function rewardPerToken() public view returns (uint256) {
if (totalSupply == 0) return rewardPerTokenStored;
return rewardPerTokenStored + (
(block.timestamp - lastUpdateTime)
* rewardRate
* 1e18
/ totalSupply
);
}
// Reward for specific user
function earned(address account) public view returns (uint256) {
return (balanceOf[account] *
(rewardPerToken() - userRewardPerTokenPaid[account])
/ 1e18)
+ rewards[account];
}
Key property: updating rewardPerTokenStored happens on every stake/withdraw/getReward, not on every block. This is an O(1) operation independent of the number of participants.
On each user interaction we store userRewardPerTokenPaid[user] = rewardPerToken(). The difference between the current rewardPerToken() and the stored value represents accumulated rewards since the last interaction.
This mathematics is correct under continuous time. In practice, discrete blocks create rounding errors that must be accounted for.
Boosted Rewards (ve-tokens)
Convex/Curve model: rewards depend not only on stake amount but also on locked governance tokens (veToken). Formula:
effective_balance = min(
0.4 * balance + 0.6 * (totalSupply * veBal / veTotalSupply),
balance
)
This creates an incentive to hold governance tokens, reduces sell pressure on reward tokens. Mathematically more complex, requires careful testing of edge cases with zero veBalance.
Multi-reward Distribution
When distributing multiple tokens simultaneously (e.g., protocol token + USDC fee sharing), architecture becomes more complex. Each reward token requires its own rewardPerTokenStored. OpenZeppelin doesn't provide a ready multi-reward implementation; we use Synthetix StakingMultiRewards as reference.
Vulnerabilities We've Seen in Audits
Inflation Attack on First Deposit
With empty staking contract (totalSupply = 0), the first user can manipulate rewardPerToken(). Attack: deploy contract, attacker makes micro-deposit (1 wei), contract accumulates all rewards for 1 wei, attacker gets disproportionately large share.
Solution: virtual initial balance (VIRTUAL_TOTAL_SUPPLY = 1e18) that is never withdrawn, or minimum deposit check.
Flash Loan Attack on Rewards
Attacker borrows flash loan on LP tokens, stakes huge amount, gets reward for one block, withdraws. With high rewardRate this can be profitable.
Protection: minimum staking period (lockup period, 1-7 days). Alternative: vesting rewards — don't release immediately, distribute linearly over N days. Even 24-hour vesting makes flash loan attacks unprofitable.
Griefing via updateReward
Functions stake, withdraw, getReward should call _updateReward(msg.sender). If this is a modifier — every call updates state for msg.sender. But if _updateReward is expensive (e.g., iterates over array), external user can call functions in loop and make contract inaccessible via gas griefing.
Solution: _updateReward must be O(1). Don't iterate over participant arrays.
Precision Loss with Small Amounts
When dividing rewardRate * dt / totalSupply, if totalSupply is very large and dt small, result can be 0 due to integer division. Rewards "evaporate".
Solution: scaling via 1e18 (or 1e27 for Aave-like precision), accumulating remainders.
Economics and Parameters
rewardRate — number of reward tokens per second. Calculated as rewardAmount / duration. On notifyRewardAmount() recalculated accounting for remaining period balance.
function notifyRewardAmount(uint256 reward) external onlyOwner {
if (block.timestamp >= periodFinish) {
rewardRate = reward / rewardsDuration;
} else {
uint256 remaining = periodFinish - block.timestamp;
uint256 leftover = remaining * rewardRate;
rewardRate = (reward + leftover) / rewardsDuration;
}
// ...
}
Emergency withdrawal — user should be able to withdraw stake even if contract is paused. Rewards don't need to be issued, but principal stake is mandatory. This is required not only by common sense but also by some jurisdictions.
Process
Design (0.5-1 day). Determine: one reward token or multiple, need boosting, minimum lockup, reward replenishment mechanism (manual vs automated).
Development (2-3 days). Main contract, events for indexing (The Graph subgraph built on events), tests with fuzz testing of arithmetic.
Testing (1-2 days). Cover: normal staking/withdrawal, flash loan scenario with minimum lockup, precision tests with boundary values, multi-user scenarios with different ratios.
Deployment. Separate deployer script with initial rewardRate configuration, transferOwnership to multisig.
For most projects, full cycle is 3-5 working days. Complex schemes with veToken boosting and multiple reward tokens — 8-12 days.







