Skip to content
Guide · DeFi

Build a staking contract

Deploy the Synthetix-style staking template, fund reward emissions, and avoid the reward-rate pitfalls that silently underpay stakers.

DeFi
14 min read

What single-asset staking does

Staking locks an ERC20 (the staking token) in a contract and accrues rewards in a separate ERC20 (the rewards token). Holders stake to signal long-term alignment; protocols use it for emissions, loyalty programs, and governance participation gates. Ethereum Toolset's Staking template follows the Synthetix StakingRewards pattern built on OpenZeppelin's ReentrancyGuard and IERC20 safe transfers.

  • stake(amount) — pull staking tokens into the contract
  • withdraw(amount) — return staking tokens; pending rewards are settled first
  • getReward() — claim accrued reward tokens
  • exit() — withdraw entire stake and claim rewards in one tx

The reward-per-token formula

Rewards accrue proportionally to stake share. The contract tracks rewardPerTokenStored — cumulative rewards per staked token — and updates it on every interaction. Each user has userRewardPerTokenPaid and rewards accrued. On stake, withdraw, or getReward, the contract settles pending rewards before changing balances.

  • rewardRate — tokens per second distributed across all stakers
  • totalSupply — total staked amount; if zero, skip accumulation to avoid division by zero
  • periodFinish — when the current emission schedule ends
  • lastUpdateTime — last time rewardPerTokenStored was touched
reward-math.solsolidity
// Conceptual — the template implements this via OpenZeppelin patterns
rewardPerTokenStored += (
    (lastUpdateTime < periodFinish ? lastUpdateTime : periodFinish)
    - lastUpdateTime
) * rewardRate * 1e18 / totalSupply;

rewards[user] += balanceOf(user)
    * (rewardPerTokenStored - userRewardPerTokenPaid[user]) / 1e18;

userRewardPerTokenPaid[user] = rewardPerTokenStored;

Deploy and configure

Open the Staking template and provide the staking token address and rewards token address. Deploy on your target network, verify on Etherscan, then fund the contract with reward tokens before calling notifyRewardAmount or setRewardRate (depending on your template version). The owner sets the emission rate and duration.

  • stakingToken — the ERC20 users lock; must be deployed and verified first
  • rewardsToken — the ERC20 emitted as rewards; can be the same governance token
  • Owner — use a multisig; it controls emission rate and can rescue mistakenly sent tokens
  • Deploy on Sepolia, stake test tokens, and confirm accrual over a few blocks before mainnet

Fund emissions correctly

Transfer reward tokens to the staking contract, then notify the contract of the reward amount and duration. The contract computes rewardRate = amount / duration. Getting decimals wrong is the most common production bug — always fund in the token's smallest unit (wei for 18-decimal tokens).

  • 1,000,000 tokens at 18 decimals = 1_000_000 * 10**18 base units
  • Duration in seconds: 30 days = 30 * 24 * 60 * 60 = 2_592_000
  • rewardRate = rewardAmount / duration — verify on-chain after notify
  • Approve + transferFrom pattern: owner approves, contract pulls on notify

Security patterns in the template

The Staking template applies OpenZeppelin's ReentrancyGuard on stake, withdraw, getReward, and exit. Token transfers use SafeERC20 to handle non-standard ERC20s. These are not optional niceties — staking contracts are high-value targets because they hold two token balances.

StakingRewards.solsolidity
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract StakingRewards is ReentrancyGuard {
    using SafeERC20 for IERC20;

    function stake(uint256 amount) external nonReentrant {
        _updateReward(msg.sender);
        stakingToken.safeTransferFrom(msg.sender, address(this), amount);
        _totalSupply += amount;
        _balances[msg.sender] += amount;
        emit Staked(msg.sender, amount);
    }
}

Edge cases to test

Before opening staking to the public, run through these scenarios on a testnet. Each one has caused mainnet incidents in production staking contracts.

  • Stake → wait → getReward — confirm rewards match hand-calculated expectation
  • Stake → withdraw partial — rewards settle on partial withdrawal
  • Multiple stakers — rewardPerToken splits proportionally
  • Period ends — staking continues but no new rewards accrue until next notify
  • Zero stake at notify — seed treasury stake before opening
  • Emergency — if you add Pausable, test that pause blocks stake but allows withdraw

Operations playbook

Staking is a live system. Plan how you will extend emissions, communicate APY to users, and monitor contract health after launch.

  • Publish the emission schedule: total tokens, duration, and implied APY at current TVL
  • Call notifyRewardAmount before periodFinish to avoid rewardRate reset gaps
  • Monitor Staked and Withdrawn events for TVL dashboards
  • Transfer ownership to a Timelock if emissions should be DAO-controlled
  • If rewards token is also a governance token, link to the Governor DAO guide for voting setup