Day 5 of 7: Lazy Updates, Time Travel, and Earning Rewards Without Loops
Day 5 connected everything from the previous four days into one working system. The ERC-20 token from Day 4 becomes the asset. The staking contract locks it, tracks time, and pays out rewards — without a single loop.
The Problem With Naive Staking
The obvious approach: store all stakers in an array, iterate through them on a schedule, distribute rewards. In .NET this would be a background job touching a database table.
In Solidity, iterating through all stakers is a Gas Limit DoS attack. The more users stake, the more expensive the distribution call becomes. Eventually it becomes physically impossible to mine. The contract freezes.
The solution: Lazy Updates. Never iterate over users. Calculate each user's rewards on-demand, at the exact moment they interact with the contract.
The Contract: SimpleStaking
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract SimpleStaking {
error ZeroAmount();
error InsufficientStake();
error TransferFailed();
IERC20 public immutable stakingToken;
uint256 public constant REWARD_RATE_PER_SECOND = 1;
struct Staker {
uint256 stakedAmount;
uint256 rewardsAccrued;
uint256 lastUpdateTime;
}
mapping(address => Staker) public stakers;
event Staked(address indexed user, uint256 amount);
event Unstaked(address indexed user, uint256 amount);
event RewardClaimed(address indexed user, uint256 reward);
constructor(address _tokenAddress) {
stakingToken = IERC20(_tokenAddress);
}
modifier updateReward(address _account) {
Staker storage staker = stakers[_account];
if (staker.stakedAmount > 0) {
uint256 timeElapsed = block.timestamp - staker.lastUpdateTime;
uint256 earned = staker.stakedAmount * timeElapsed * REWARD_RATE_PER_SECOND / 100;
staker.rewardsAccrued += earned;
}
staker.lastUpdateTime = block.timestamp;
_;
}
function stake(uint256 _amount) external updateReward(msg.sender) {
if (_amount == 0) revert ZeroAmount();
stakers[msg.sender].stakedAmount += _amount;
emit Staked(msg.sender, _amount);
bool success = stakingToken.transferFrom(msg.sender, address(this), _amount);
if (!success) revert TransferFailed();
}
function unstake(uint256 _amount) external updateReward(msg.sender) {
Staker storage staker = stakers[msg.sender];
if (staker.stakedAmount < _amount) revert InsufficientStake();
staker.stakedAmount -= _amount;
emit Unstaked(msg.sender, _amount);
bool success = stakingToken.transfer(msg.sender, _amount);
if (!success) revert TransferFailed();
}
function claimReward() external updateReward(msg.sender) {
Staker storage staker = stakers[msg.sender];
uint256 reward = staker.rewardsAccrued;
if (reward > 0) {
staker.rewardsAccrued = 0;
emit RewardClaimed(msg.sender, reward);
bool success = stakingToken.transfer(msg.sender, reward);
if (!success) revert TransferFailed();
}
}
}
What Actually Clicked
The updateReward modifier is the entire architecture.
Every function that changes staker state — stake, unstake, claimReward — is decorated with updateReward. Before anything else runs, the modifier calculates how much time has passed since the user's last interaction and accrues the earned rewards to their record. Then _; passes control to the function body.
This is the Lazy Update pattern: no background job, no scheduler, no iteration. The math runs exactly once per user per transaction, in O(1).
The .NET analogy: instead of a scheduled IHostedService that iterates all accounts every minute, you calculate the accrued interest inline when the user touches their account. Same result, zero coordination overhead.
IERC20 vs ERC20 — interface vs implementation.
The staking contract imports IERC20 rather than ERC20. It doesn't need the full implementation — it only needs to call transferFrom and transfer. Using the interface keeps the contract lean and makes it compatible with any ERC-20 token, not just MyToken. The same pattern as coding to IRepository<T> instead of a concrete class in .NET.
The approve dependency.
Before calling stake(), the user must call approve(stakingAddress, amount) on the token contract. The staking contract then calls transferFrom to pull the tokens in. This is the same OAuth2-style delegation from Day 4 — now applied in a real protocol flow.
Struct field access in Viem.
When reading a mapping that returns a struct, Viem returns a tuple. The fields are accessible both by index and by destructuring:
// By index
const profile = await staking.read.stakers([investor.account.address]);
console.log(profile["0"]); // stakedAmount
// By destructuring — cleaner
const [stakedAmount, rewardsAccrued, lastUpdateTime] =
await staking.read.stakers([investor.account.address]);
The destructuring version is more readable and avoids magic index numbers.
Testing: Time Travel in Hardhat
Testing time-dependent reward logic requires advancing the blockchain clock. Hardhat exposes two low-level RPC commands for this:
// Shift the node's clock forward by 100 seconds
await network.provider.send("evm_increaseTime", [100]);
// Mine an empty block to commit the new timestamp
await network.provider.send("evm_mine");
Or via the test helpers in Hardhat 3:
await context.networkHelpers.time.increase(100);
The reward formula: stakedAmount × timeElapsed × REWARD_RATE_PER_SECOND / 100
With 100 MET staked for 100 seconds at rate 1:
100 × 100 × 1 / 100 = 100 MET
The test: investor buys 1000 MET, stakes 100, advances 100 seconds, claims reward. Final balance: 1001 MET (900 unstaked + 100 reward claimed — with one extra block of time from the claimReward call itself adding ~1 MET).
One fix that took time: the original test called buyTokens([], { value: ... }) with an empty args array. Viem requires omitting the args array entirely for functions with no parameters: buyTokens({ value: ... }). Small difference, silent failure.
Console Use Case: Full Staking Cycle
npx hardhat ignition deploy ignition/modules/SimpleStaking.ts --network localhost --reset
npx hardhat console --network localhost
const { viem } = await network.create();
const [owner, investor] = await viem.getWalletClients();
const cViem = require("viem");
const tokenAddress = "0x5FbDB2315678afecb367f032d93F642f64180aa3";
const stakingAddress = "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512";
const token = await viem.getContractAt("MyToken", tokenAddress);
const staking = await viem.getContractAt("SimpleStaking", stakingAddress);
// Seed staking contract with reward reserves
await token.write.transfer([stakingAddress, cViem.parseEther("5000")]);
// Investor buys 1000 MET via ICO
const tokenAsInvestor = await viem.getContractAt("MyToken", tokenAddress, { client: { wallet: investor } });
await tokenAsInvestor.write.buyTokens({ value: cViem.parseEther("1") });
// approve + stake
const stakingAsInvestor = await viem.getContractAt("SimpleStaking", stakingAddress, { client: { wallet: investor } });
await tokenAsInvestor.write.approve([stakingAddress, cViem.parseEther("200")]);
await stakingAsInvestor.write.stake([cViem.parseEther("200")]);
// Verify staked amount
const [stakedAmount] = await staking.read.stakers([investor.account.address]);
console.log("Staked:", cViem.formatEther(stakedAmount)); // 200
// Time travel: advance 100 seconds
await network.provider.send("evm_increaseTime", [100]);
await network.provider.send("evm_mine");
// Claim rewards
await stakingAsInvestor.write.claimReward();
// Check final balance
const finalBalance = await token.read.balanceOf([investor.account.address]);
console.log("Final MET balance:", cViem.formatEther(finalBalance));
// 800 (unstaked) + rewards accrued over 100 seconds
The balance grows. The reward math lands. The Lazy Update pattern works.
What's Next
Day 6: ERC-721 NFT — non-fungible tokens, metadata, and what "ownership" means on-chain.
Repo: github.com/alena-dev-soft
Follow the journey on Telegram: t.me/dotnetToWeb3
Stage: Dinosaur 🦕 — going deeper into the bedrock. Day 5 of 7.
Top comments (0)