Practice Writing Smart Contracts: Five Progressive Exercises From Beginner to Advanced
Build Five Complete Contracts From Scratch — Each One Combines Concepts From Every Previous Article, Escalating in Complexity Until You're Thinking Like a Blockchain Architect
In my free time iwrite and raise my voice in toast.
Exercise 1: Voting Contract — State Machines, Access Control, and Mappings
1. The Brief: What You're Building
Build a contract where an admin creates proposals with a time window, registered voters cast one vote per proposal, and anyone can view results after voting ends.
This exercise practices: enums (state machines), mappings (voters, proposals), modifiers (access control), block.timestamp (time-based logic), and events.
2. Requirements Specification
FR-1: Admin can create proposals with a description and voting duration
FR-2: Admin can register voters (whitelist)
FR-3: Registered voters can vote FOR or AGAINST on any active proposal
FR-4: Each voter can only vote once per proposal
FR-5: Voting is only valid during the proposal's time window
FR-6: Anyone can read proposal results after voting ends
FR-7: No one can vote after the deadline
3. Design Hints: Think Before You Code
Try building this yourself before looking at the solution. Consider:
What struct do you need for a Proposal? (description, vote counts, deadline, exists)
How do you track "has this voter voted on this proposal"? (nested mapping:
mapping(uint256 => mapping(address => bool))— same pattern asapprovals[txId][owner]in Multi-Sig)How do you enforce the time window? (
require(block.timestamp <= proposal.deadline))What events should you emit? (ProposalCreated, Voted, VoterRegistered)
4. The Complete Solution
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Voting {
address public admin;
struct Proposal {
string description;
uint256 votesFor;
uint256 votesAgainst;
uint256 deadline;
bool exists;
}
mapping(uint256 => Proposal) public proposals;
uint256 public proposalCount;
mapping(address => bool) public isRegisteredVoter;
mapping(uint256 => mapping(address => bool)) public hasVoted;
event ProposalCreated(uint256 indexed proposalId, string description, uint256 deadline);
event Voted(uint256 indexed proposalId, address indexed voter, bool inFavor);
event VoterRegistered(address indexed voter);
modifier onlyAdmin() {
require(msg.sender == admin, "Voting: not admin");
_;
}
modifier onlyRegistered() {
require(isRegisteredVoter[msg.sender], "Voting: not registered");
_;
}
constructor() {
admin = msg.sender;
}
function registerVoter(address _voter) external onlyAdmin {
require(_voter != address(0), "Voting: zero address");
require(!isRegisteredVoter[_voter], "Voting: already registered");
isRegisteredVoter[_voter] = true;
emit VoterRegistered(_voter);
}
function createProposal(string calldata _description, uint256 _durationSeconds)
external onlyAdmin returns (uint256)
{
require(_durationSeconds > 0, "Voting: zero duration");
uint256 proposalId = proposalCount;
proposals[proposalId] = Proposal({
description: _description,
votesFor: 0,
votesAgainst: 0,
deadline: block.timestamp + _durationSeconds,
exists: true
});
proposalCount++;
emit ProposalCreated(proposalId, _description, block.timestamp + _durationSeconds);
return proposalId;
}
function vote(uint256 _proposalId, bool _inFavor) external onlyRegistered {
Proposal storage proposal = proposals[_proposalId];
require(proposal.exists, "Voting: proposal not found");
require(block.timestamp <= proposal.deadline, "Voting: voting ended");
require(!hasVoted[_proposalId][msg.sender], "Voting: already voted");
hasVoted[_proposalId][msg.sender] = true;
if (_inFavor) {
proposal.votesFor++;
} else {
proposal.votesAgainst++;
}
emit Voted(_proposalId, msg.sender, _inFavor);
}
function getProposalResult(uint256 _proposalId)
external view returns (string memory description, uint256 votesFor, uint256 votesAgainst, bool ended)
{
Proposal storage proposal = proposals[_proposalId];
require(proposal.exists, "Voting: proposal not found");
return (proposal.description, proposal.votesFor, proposal.votesAgainst, block.timestamp > proposal.deadline);
}
}
5. Deep Analysis: Every Design Decision Explained
mapping(uint256 => Proposal) proposals + uint256 proposalCount: The enumerable mapping pattern — proposalCount is the auto-incrementing ID, the mapping gives O(1) lookup. Same pattern as transactions[] in the Multi-Sig but with a mapping instead of an array (either works; here we use a mapping to practice the ProofOfExistence pattern).
mapping(uint256 => mapping(address => bool)) hasVoted: Nested mapping — identical structure to approvals[txId][owner] in Multi-Sig and allowances[owner][spender] in ERC20. Three contracts, same pattern, three different use cases.
bool _inFavor: A boolean parameter for the vote direction. Simple and gas-efficient — one bit of information, one control flow branch. No enum overhead needed for a binary choice.
block.timestamp <= proposal.deadline: Time-based access control. Testing this requires vm.warp() in Foundry — you set the timestamp to before and after the deadline to verify both paths.
Storage reference Proposal storage proposal: Reads and writes directly to the struct's storage slots. When proposal.votesFor++ executes, it increments the value at the struct's storage slot — no memory copy, no re-assignment to the mapping.
6. Foundry Tests for the Voting Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {Voting} from "../src/Voting.sol";
contract VotingTest is Test {
Voting public voting;
address public admin;
address public alice;
address public bob;
function setUp() public {
admin = makeAddr("admin");
alice = makeAddr("alice");
bob = makeAddr("bob");
vm.prank(admin);
voting = new Voting();
// Register voters
vm.startPrank(admin);
voting.registerVoter(alice);
voting.registerVoter(bob);
vm.stopPrank();
}
function test_CreateProposal() public {
vm.prank(admin);
uint256 id = voting.createProposal("Increase budget", 1 days);
assertEq(id, 0);
assertEq(voting.proposalCount(), 1);
}
function test_VoteFor() public {
vm.prank(admin);
voting.createProposal("Test proposal", 1 days);
vm.prank(alice);
voting.vote(0, true);
(, uint256 votesFor, , ) = voting.getProposalResult(0);
assertEq(votesFor, 1);
}
function test_VoteAgainst() public {
vm.prank(admin);
voting.createProposal("Test proposal", 1 days);
vm.prank(alice);
voting.vote(0, false);
(, , uint256 votesAgainst, ) = voting.getProposalResult(0);
assertEq(votesAgainst, 1);
}
function test_RevertWhen_VoteAfterDeadline() public {
vm.prank(admin);
voting.createProposal("Test", 1 hours);
// Travel past the deadline
vm.warp(block.timestamp + 2 hours);
vm.prank(alice);
vm.expectRevert("Voting: voting ended");
voting.vote(0, true);
}
function test_RevertWhen_DoubleVote() public {
vm.prank(admin);
voting.createProposal("Test", 1 days);
vm.prank(alice);
voting.vote(0, true);
vm.prank(alice);
vm.expectRevert("Voting: already voted");
voting.vote(0, true);
}
function test_RevertWhen_UnregisteredVoterVotes() public {
vm.prank(admin);
voting.createProposal("Test", 1 days);
address stranger = makeAddr("stranger");
vm.prank(stranger);
vm.expectRevert("Voting: not registered");
voting.vote(0, true);
}
function test_RevertWhen_NonAdminCreatesProposal() public {
vm.prank(alice);
vm.expectRevert("Voting: not admin");
voting.createProposal("Unauthorized", 1 days);
}
function test_MultipleVotersMultipleProposals() public {
vm.startPrank(admin);
voting.createProposal("Proposal A", 1 days);
voting.createProposal("Proposal B", 1 days);
vm.stopPrank();
vm.prank(alice);
voting.vote(0, true); // Alice votes FOR proposal 0
vm.prank(alice);
voting.vote(1, false); // Alice votes AGAINST proposal 1
vm.prank(bob);
voting.vote(0, false); // Bob votes AGAINST proposal 0
vm.prank(bob);
voting.vote(1, true); // Bob votes FOR proposal 1
(, uint256 forA, uint256 againstA, ) = voting.getProposalResult(0);
assertEq(forA, 1);
assertEq(againstA, 1);
(, uint256 forB, uint256 againstB, ) = voting.getProposalResult(1);
assertEq(forB, 1);
assertEq(againstB, 1);
}
}
Exercise 2: Crowdfunding Contract — Deadlines, Refunds, and the Pull Pattern
7. The Brief: What You're Building
Build a crowdfunding contract where a creator sets a funding goal and deadline. Contributors send ETH. If the goal is met by the deadline, the creator can withdraw. If not, contributors can claim refunds.
This exercise practices: ETH handling (msg.value, .call{value:}), the pull pattern (refunds), time logic (block.timestamp), state transitions, and the CEI pattern.
8. Requirements Specification
FR-1: Creator deploys the contract with a goal (in ETH) and a deadline
FR-2: Anyone can contribute ETH before the deadline
FR-3: Contributions are tracked per address (can contribute multiple times)
FR-4: If goal is met by deadline, creator can withdraw all funds
FR-5: If goal is NOT met by deadline, each contributor can claim a refund
FR-6: No one can contribute after the deadline
FR-7: Creator cannot withdraw before deadline (prevents rug pulls)
FR-8: Refunds are only available if the campaign FAILED
9. Design Hints: Think Before You Code
State machine: The campaign has three states: Active, Successful, Failed. But you don't need an enum — you can derive the state from
block.timestampandaddress(this).balance >= goal.Pull pattern: Contributors withdraw their OWN refund. The contract doesn't push refunds to everyone (that would be O(n) and a DoS vector).
Why not
transfer()orsend()? Use.call{value:}("")— modern best practice for ETH transfers.
10. The Complete Solution
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Crowdfunding {
address public creator;
uint256 public goal;
uint256 public deadline;
uint256 public totalRaised;
bool public creatorWithdrawn;
mapping(address => uint256) public contributions;
event Contributed(address indexed contributor, uint256 amount, uint256 totalRaised);
event CreatorWithdrew(uint256 amount);
event RefundClaimed(address indexed contributor, uint256 amount);
constructor(uint256 _goalInWei, uint256 _durationSeconds) {
require(_goalInWei > 0, "Crowdfunding: zero goal");
require(_durationSeconds > 0, "Crowdfunding: zero duration");
creator = msg.sender;
goal = _goalInWei;
deadline = block.timestamp + _durationSeconds;
}
function contribute() external payable {
require(block.timestamp <= deadline, "Crowdfunding: campaign ended");
require(msg.value > 0, "Crowdfunding: zero contribution");
contributions[msg.sender] += msg.value;
totalRaised += msg.value;
emit Contributed(msg.sender, msg.value, totalRaised);
}
function creatorWithdraw() external {
require(msg.sender == creator, "Crowdfunding: not creator");
require(block.timestamp > deadline, "Crowdfunding: campaign still active");
require(totalRaised >= goal, "Crowdfunding: goal not met");
require(!creatorWithdrawn, "Crowdfunding: already withdrawn");
creatorWithdrawn = true;
(bool success, ) = payable(creator).call{value: address(this).balance}("");
require(success, "Crowdfunding: transfer failed");
emit CreatorWithdrew(address(this).balance);
}
function claimRefund() external {
require(block.timestamp > deadline, "Crowdfunding: campaign still active");
require(totalRaised < goal, "Crowdfunding: goal was met, no refunds");
uint256 amount = contributions[msg.sender];
require(amount > 0, "Crowdfunding: nothing to refund");
contributions[msg.sender] = 0;
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "Crowdfunding: refund failed");
emit RefundClaimed(msg.sender, amount);
}
function isActive() external view returns (bool) {
return block.timestamp <= deadline;
}
function isSuccessful() external view returns (bool) {
return block.timestamp > deadline && totalRaised >= goal;
}
function isFailed() external view returns (bool) {
return block.timestamp > deadline && totalRaised < goal;
}
}
11. Deep Analysis: Every Design Decision Explained
totalRaised as an accumulator: Instead of computing address(this).balance (which can be manipulated via selfdestruct), we track contributions explicitly. This is the accumulator pattern from the Time Complexity article — O(1) to read instead of O(n) to sum.
contributions[msg.sender] = 0 before the ETH transfer: CEI pattern. The balance is zeroed BEFORE the external call. If the recipient is a malicious contract that re-enters claimRefund, it finds contributions[msg.sender] == 0 and the require(amount > 0) blocks it.
bool creatorWithdrawn: Prevents the creator from withdrawing twice. Same finality flag as executed in the Multi-Sig.
Derived state instead of enum: isActive(), isSuccessful(), isFailed() are pure functions of block.timestamp and totalRaised. No mutable state machine variable that could get out of sync.
12. Foundry Tests for the Crowdfunding Contract
contract CrowdfundingTest is Test {
Crowdfunding public campaign;
address public creator;
address public alice;
address public bob;
uint256 constant GOAL = 10 ether;
uint256 constant DURATION = 7 days;
function setUp() public {
creator = makeAddr("creator");
alice = makeAddr("alice");
bob = makeAddr("bob");
vm.deal(alice, 100 ether);
vm.deal(bob, 100 ether);
vm.prank(creator);
campaign = new Crowdfunding(GOAL, DURATION);
}
function test_ContributeIncrementsTotal() public {
vm.prank(alice);
campaign.contribute{value: 3 ether}();
assertEq(campaign.totalRaised(), 3 ether);
assertEq(campaign.contributions(alice), 3 ether);
}
function test_SuccessfulCampaign_CreatorWithdraws() public {
vm.prank(alice);
campaign.contribute{value: 10 ether}();
vm.warp(block.timestamp + DURATION + 1); // Past deadline
uint256 balanceBefore = creator.balance;
vm.prank(creator);
campaign.creatorWithdraw();
assertEq(creator.balance, balanceBefore + 10 ether);
}
function test_FailedCampaign_ContributorGetsRefund() public {
vm.prank(alice);
campaign.contribute{value: 5 ether}(); // Below goal
vm.warp(block.timestamp + DURATION + 1); // Past deadline
uint256 balanceBefore = alice.balance;
vm.prank(alice);
campaign.claimRefund();
assertEq(alice.balance, balanceBefore + 5 ether);
assertEq(campaign.contributions(alice), 0);
}
function test_RevertWhen_ContributeAfterDeadline() public {
vm.warp(block.timestamp + DURATION + 1);
vm.prank(alice);
vm.expectRevert("Crowdfunding: campaign ended");
campaign.contribute{value: 1 ether}();
}
function test_RevertWhen_CreatorWithdrawsBeforeDeadline() public {
vm.prank(alice);
campaign.contribute{value: 10 ether}();
vm.prank(creator);
vm.expectRevert("Crowdfunding: campaign still active");
campaign.creatorWithdraw();
}
function test_RevertWhen_RefundOnSuccessfulCampaign() public {
vm.prank(alice);
campaign.contribute{value: 10 ether}();
vm.warp(block.timestamp + DURATION + 1);
vm.prank(alice);
vm.expectRevert("Crowdfunding: goal was met, no refunds");
campaign.claimRefund();
}
function test_Fuzz_ContributionTracking(uint256 amount1, uint256 amount2) public {
amount1 = bound(amount1, 0.01 ether, 50 ether);
amount2 = bound(amount2, 0.01 ether, 50 ether);
vm.prank(alice);
campaign.contribute{value: amount1}();
vm.prank(bob);
campaign.contribute{value: amount2}();
assertEq(campaign.totalRaised(), amount1 + amount2);
assertEq(campaign.contributions(alice), amount1);
assertEq(campaign.contributions(bob), amount2);
}
}
Exercise 3: Token Vesting Contract — Time-Based Releases and Cliff Periods
13. The Brief: What You're Building
Build a contract that holds ERC20 tokens and releases them gradually to a beneficiary over time, with an initial cliff period where no tokens are released.
This exercise practices: ERC20 interaction (the IERC20 interface, transferFrom pull pattern), time-based math, linear interpolation, and the distinction between storage and computation.
14. Requirements Specification
FR-1: An admin creates a vesting schedule with: beneficiary, total amount, start time, cliff duration, vesting duration
FR-2: During the cliff period, zero tokens are releasable
FR-3: After the cliff, tokens vest linearly over the remaining duration
FR-4: The beneficiary can call release() at any time to claim vested tokens
FR-5: Already-released tokens are tracked to prevent double-claiming
FR-6: After the full vesting duration, all tokens are releasable
15. Design Hints: Think Before You Code
The vesting calculation is:
If block.timestamp < start + cliff: vested = 0
If block.timestamp >= start + duration: vested = totalAmount
Otherwise: vested = totalAmount × (elapsed / duration)
releasable = vested - alreadyReleased
16. The Complete Solution
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "./IERC20.sol";
contract TokenVesting {
struct VestingSchedule {
address beneficiary;
address token;
uint256 totalAmount;
uint256 startTime;
uint256 cliffDuration;
uint256 vestingDuration;
uint256 released;
bool revoked;
}
mapping(uint256 => VestingSchedule) public schedules;
uint256 public scheduleCount;
address public admin;
event ScheduleCreated(uint256 indexed scheduleId, address indexed beneficiary, uint256 totalAmount);
event TokensReleased(uint256 indexed scheduleId, address indexed beneficiary, uint256 amount);
event ScheduleRevoked(uint256 indexed scheduleId, uint256 unvestedReturned);
constructor() { admin = msg.sender; }
modifier onlyAdmin() { require(msg.sender == admin, "Vesting: not admin"); _; }
function createSchedule(
address _beneficiary,
address _token,
uint256 _totalAmount,
uint256 _startTime,
uint256 _cliffDuration,
uint256 _vestingDuration
) external onlyAdmin returns (uint256) {
require(_beneficiary != address(0), "Vesting: zero address");
require(_totalAmount > 0, "Vesting: zero amount");
require(_vestingDuration > 0, "Vesting: zero duration");
require(_cliffDuration <= _vestingDuration, "Vesting: cliff > duration");
// Pull tokens from admin to this contract
require(
IERC20(_token).transferFrom(msg.sender, address(this), _totalAmount),
"Vesting: transfer failed"
);
uint256 id = scheduleCount;
schedules[id] = VestingSchedule({
beneficiary: _beneficiary,
token: _token,
totalAmount: _totalAmount,
startTime: _startTime,
cliffDuration: _cliffDuration,
vestingDuration: _vestingDuration,
released: 0,
revoked: false
});
scheduleCount++;
emit ScheduleCreated(id, _beneficiary, _totalAmount);
return id;
}
function vestedAmount(uint256 _scheduleId) public view returns (uint256) {
VestingSchedule storage schedule = schedules[_scheduleId];
if (block.timestamp < schedule.startTime + schedule.cliffDuration) {
return 0; // Still in cliff period
}
if (block.timestamp >= schedule.startTime + schedule.vestingDuration) {
return schedule.totalAmount; // Fully vested
}
// Linear vesting
uint256 elapsed = block.timestamp - schedule.startTime;
return (schedule.totalAmount * elapsed) / schedule.vestingDuration;
}
function releasable(uint256 _scheduleId) public view returns (uint256) {
return vestedAmount(_scheduleId) - schedules[_scheduleId].released;
}
function release(uint256 _scheduleId) external {
VestingSchedule storage schedule = schedules[_scheduleId];
require(msg.sender == schedule.beneficiary, "Vesting: not beneficiary");
require(!schedule.revoked, "Vesting: schedule revoked");
uint256 amount = releasable(_scheduleId);
require(amount > 0, "Vesting: nothing to release");
schedule.released += amount;
require(
IERC20(schedule.token).transfer(schedule.beneficiary, amount),
"Vesting: transfer failed"
);
emit TokensReleased(_scheduleId, schedule.beneficiary, amount);
}
function revoke(uint256 _scheduleId) external onlyAdmin {
VestingSchedule storage schedule = schedules[_scheduleId];
require(!schedule.revoked, "Vesting: already revoked");
uint256 vested = vestedAmount(_scheduleId);
uint256 unvested = schedule.totalAmount - vested;
schedule.revoked = true;
schedule.totalAmount = vested; // Cap at what's already vested
if (unvested > 0) {
require(IERC20(schedule.token).transfer(admin, unvested), "Vesting: return failed");
}
emit ScheduleRevoked(_scheduleId, unvested);
}
}
17. Deep Analysis: Every Design Decision Explained
vestedAmount() is a pure calculation: No storage writes — it reads the schedule and the timestamp, computes the vested amount, and returns it. This can be called as a view function for free. The vesting curve is computed, not stored.
released tracks what's already been claimed: releasable = vested - released. If 50% has vested and 30% has been released, 20% is claimable. This is the same "balance tracking" concept as erc20Savingsbalance in SaveAsset.
Linear interpolation: totalAmount × elapsed / duration. This is integer division, so the result is floored. A tiny rounding dust (up to vestingDuration - 1 wei) may remain unclaimed. This is acceptable — it's less than a billionth of a cent.
transferFrom in createSchedule: The admin approves this contract, then calls createSchedule, which pulls tokens via transferFrom. Same pattern as SaveAsset's depositERC20 — the approve → transferFrom flow from the ERC20 article.
18. Foundry Tests for the Vesting Contract
contract VestingTest is Test {
TokenVesting public vesting;
ERC20 public token;
address public admin;
address public beneficiary;
uint256 constant TOTAL = 10_000e18;
uint256 constant CLIFF = 30 days;
uint256 constant DURATION = 365 days;
function setUp() public {
admin = makeAddr("admin");
beneficiary = makeAddr("beneficiary");
vm.startPrank(admin);
token = new ERC20();
token.mint(admin, TOTAL);
vesting = new TokenVesting();
token.approve(address(vesting), TOTAL);
vesting.createSchedule(beneficiary, address(token), TOTAL, block.timestamp, CLIFF, DURATION);
vm.stopPrank();
}
function test_ZeroDuringCliff() public {
vm.warp(block.timestamp + 15 days); // Halfway through cliff
assertEq(vesting.releasable(0), 0);
}
function test_LinearVestingAfterCliff() public {
vm.warp(block.timestamp + 182.5 days); // ~50% through duration
uint256 expected = TOTAL / 2;
assertApproxEqAbs(vesting.vestedAmount(0), expected, 1e18);
}
function test_FullyVestedAfterDuration() public {
vm.warp(block.timestamp + DURATION);
assertEq(vesting.vestedAmount(0), TOTAL);
}
function test_ReleaseTokens() public {
vm.warp(block.timestamp + DURATION);
vm.prank(beneficiary);
vesting.release(0);
assertEq(token.balanceOf(beneficiary), TOTAL);
}
function test_PartialRelease() public {
vm.warp(block.timestamp + DURATION / 2);
vm.prank(beneficiary);
vesting.release(0);
uint256 firstRelease = token.balanceOf(beneficiary);
assertTrue(firstRelease > 0);
// Move to end
vm.warp(block.timestamp + DURATION);
vm.prank(beneficiary);
vesting.release(0);
assertApproxEqAbs(token.balanceOf(beneficiary), TOTAL, 1e18);
}
}
Exercise 4: Staking Rewards Contract — The Synthetix Pattern
19. The Brief: What You're Building
Build a contract where users stake an ERC20 token and earn rewards over time, proportional to their share of the total staked amount. This uses the Synthetix reward-per-token accumulator — the most important DeFi pattern for O(1) reward distribution.
20. Requirements Specification
FR-1: Users can stake an ERC20 token
FR-2: Users can withdraw their staked tokens
FR-3: The admin sets a reward rate (tokens per second) and funds the reward pool
FR-4: Rewards accumulate proportionally to each user's share of total staked
FR-5: Users can claim earned rewards at any time
FR-6: All operations are O(1) — no loops, regardless of staker count
21. Design Hints: Think Before You Code
The naive approach — loop through all stakers to distribute rewards — is O(n) and would fail with thousands of stakers. The Synthetix pattern uses a global accumulator (rewardPerTokenStored) that each user "catches up to" when they interact. This was covered in the Time Complexity article under "Amortized Write Pattern."
22. The Complete Solution
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "./IERC20.sol";
contract StakingRewards {
IERC20 public immutable stakingToken;
IERC20 public immutable rewardToken;
address public admin;
uint256 public rewardRate; // Reward tokens per second
uint256 public lastUpdateTime; // Last time rewards were calculated
uint256 public rewardPerTokenStored; // Accumulated reward per staked token
uint256 public totalStaked;
mapping(address => uint256) public stakedBalance;
mapping(address => uint256) public userRewardPerTokenPaid;
mapping(address => uint256) public rewards;
event Staked(address indexed user, uint256 amount);
event Withdrawn(address indexed user, uint256 amount);
event RewardClaimed(address indexed user, uint256 reward);
event RewardRateSet(uint256 newRate);
constructor(address _stakingToken, address _rewardToken) {
stakingToken = IERC20(_stakingToken);
rewardToken = IERC20(_rewardToken);
admin = msg.sender;
}
modifier updateReward(address _account) {
rewardPerTokenStored = rewardPerToken();
lastUpdateTime = block.timestamp;
if (_account != address(0)) {
rewards[_account] = earned(_account);
userRewardPerTokenPaid[_account] = rewardPerTokenStored;
}
_;
}
function rewardPerToken() public view returns (uint256) {
if (totalStaked == 0) return rewardPerTokenStored;
return rewardPerTokenStored + (
(block.timestamp - lastUpdateTime) * rewardRate * 1e18 / totalStaked
);
}
function earned(address _account) public view returns (uint256) {
return (
stakedBalance[_account] * (rewardPerToken() - userRewardPerTokenPaid[_account]) / 1e18
) + rewards[_account];
}
function stake(uint256 _amount) external updateReward(msg.sender) {
require(_amount > 0, "Staking: zero amount");
stakedBalance[msg.sender] += _amount;
totalStaked += _amount;
require(stakingToken.transferFrom(msg.sender, address(this), _amount), "Staking: transfer failed");
emit Staked(msg.sender, _amount);
}
function withdraw(uint256 _amount) external updateReward(msg.sender) {
require(_amount > 0, "Staking: zero amount");
require(stakedBalance[msg.sender] >= _amount, "Staking: insufficient balance");
stakedBalance[msg.sender] -= _amount;
totalStaked -= _amount;
require(stakingToken.transfer(msg.sender, _amount), "Staking: transfer failed");
emit Withdrawn(msg.sender, _amount);
}
function claimReward() external updateReward(msg.sender) {
uint256 reward = rewards[msg.sender];
require(reward > 0, "Staking: no rewards");
rewards[msg.sender] = 0;
require(rewardToken.transfer(msg.sender, reward), "Staking: reward transfer failed");
emit RewardClaimed(msg.sender, reward);
}
function setRewardRate(uint256 _rate) external updateReward(address(0)) {
require(msg.sender == admin, "Staking: not admin");
rewardRate = _rate;
emit RewardRateSet(_rate);
}
}
23. Deep Analysis: The Reward-Per-Token Accumulator
This is the most important pattern in this entire article. It transforms O(n) reward distribution into O(1) per user.
WITHOUT accumulator (O(n) — breaks at scale):
function distributeRewards() {
for each staker: ← Loops through ALL stakers
reward = totalReward × staker.balance / totalStaked;
staker.pending += reward;
}
WITH accumulator (O(1) — scales infinitely):
Global: rewardPerTokenStored += timePassed × rewardRate / totalStaked
Per user: earned = balance × (global - userSnapshot) + previousRewards
How it works step by step:
Time 0: Alice stakes 100 tokens
rewardPerTokenStored = 0
userRewardPerTokenPaid[Alice] = 0
Time 100: Bob stakes 200 tokens. rewardRate = 1 token/sec
rewardPerTokenStored = 0 + (100 sec × 1 × 1e18 / 100) = 1e18
Alice earned = 100 × (1e18 - 0) / 1e18 = 100 reward tokens ← correct!
userRewardPerTokenPaid[Alice] = 1e18
userRewardPerTokenPaid[Bob] = 1e18
Time 200: Alice claims
rewardPerTokenStored = 1e18 + (100 sec × 1 × 1e18 / 300) = 1e18 + 0.333e18 = 1.333e18
Alice earned = 100 × (1.333e18 - 1e18) / 1e18 + 100 = 33.33 + 100 = 133.33
Bob earned = 200 × (1.333e18 - 1e18) / 1e18 = 66.66
Total distributed: 133.33 + 66.66 = 200 (100 sec × 1 + 100 sec × 1 = 200) ✓
No loops. No matter if there are 10 or 10 million stakers, every operation is O(1). This is the same pattern as total_supply tracking in your ERC20 — an accumulator that avoids looping.
24. Foundry Tests for the Staking Contract
contract StakingTest is Test {
StakingRewards public staking;
ERC20 public stakingToken;
ERC20 public rewardToken;
address public admin;
address public alice;
address public bob;
function setUp() public {
admin = makeAddr("admin");
alice = makeAddr("alice");
bob = makeAddr("bob");
vm.startPrank(admin);
stakingToken = new ERC20();
rewardToken = new ERC20();
staking = new StakingRewards(address(stakingToken), address(rewardToken));
stakingToken.mint(alice, 1000e18);
stakingToken.mint(bob, 1000e18);
rewardToken.mint(address(staking), 1_000_000e18);
staking.setRewardRate(1e18); // 1 reward token per second
vm.stopPrank();
}
function test_StakeAndEarnRewards() public {
vm.startPrank(alice);
stakingToken.approve(address(staking), 100e18);
staking.stake(100e18);
vm.stopPrank();
vm.warp(block.timestamp + 100); // 100 seconds pass
uint256 earned = staking.earned(alice);
assertApproxEqAbs(earned, 100e18, 1e15); // ~100 reward tokens
}
function test_TwoStakersProportionalRewards() public {
// Alice stakes 100, Bob stakes 200
vm.prank(alice);
stakingToken.approve(address(staking), 100e18);
vm.prank(alice);
staking.stake(100e18);
vm.prank(bob);
stakingToken.approve(address(staking), 200e18);
vm.prank(bob);
staking.stake(200e18);
vm.warp(block.timestamp + 300); // 300 seconds = 300 reward tokens
uint256 aliceEarned = staking.earned(alice);
uint256 bobEarned = staking.earned(bob);
// Alice: 100/300 share = 1/3 of 300 = 100
// Bob: 200/300 share = 2/3 of 300 = 200
assertApproxEqAbs(aliceEarned, 100e18, 1e15);
assertApproxEqAbs(bobEarned, 200e18, 1e15);
}
}
Exercise 5: NFT Marketplace — Listings, Offers, and Multi-Contract Interaction
25. The Brief: What You're Building
Build a marketplace where NFT owners can list their tokens for sale, buyers can purchase at the listed price, and sellers receive payment. This is the most complex exercise — it combines ERC721 interaction, ETH handling, listings with state management, and multi-contract orchestration.
26. Requirements Specification
FR-1: Sellers can list an NFT at a price (the NFT is transferred to the marketplace)
FR-2: Buyers can purchase a listed NFT by sending the exact price in ETH
FR-3: Upon purchase, ETH goes to seller, NFT goes to buyer
FR-4: Sellers can cancel their listing and reclaim the NFT
FR-5: A marketplace fee (e.g., 2.5%) is deducted from sales
FR-6: The marketplace owner can withdraw collected fees
27. Design Hints: Think Before You Code
You'll need an
IERC721interface fortransferFromandownerOfListings stored in a struct + mapping (same pattern as ProofOfExistence, Multi-Sig)
The marketplace holds the NFT during listing (escrow pattern)
CEI pattern for the purchase (update state before ETH transfers)
28. The Complete Solution
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC721 {
function transferFrom(address from, address to, uint256 tokenId) external;
function ownerOf(uint256 tokenId) external view returns (address);
}
contract NFTMarketplace {
struct Listing {
address seller;
address nftContract;
uint256 tokenId;
uint256 price;
bool active;
}
mapping(uint256 => Listing) public listings;
uint256 public listingCount;
address public owner;
uint256 public feePercent; // Basis points (250 = 2.5%)
uint256 public collectedFees;
event Listed(uint256 indexed listingId, address indexed seller, address nftContract, uint256 tokenId, uint256 price);
event Purchased(uint256 indexed listingId, address indexed buyer, uint256 price);
event ListingCancelled(uint256 indexed listingId);
event FeesWithdrawn(uint256 amount);
constructor(uint256 _feePercent) {
require(_feePercent <= 1000, "Marketplace: fee too high"); // Max 10%
owner = msg.sender;
feePercent = _feePercent;
}
function listNFT(address _nftContract, uint256 _tokenId, uint256 _price) external returns (uint256) {
require(_price > 0, "Marketplace: zero price");
require(
IERC721(_nftContract).ownerOf(_tokenId) == msg.sender,
"Marketplace: not NFT owner"
);
// Transfer NFT from seller to marketplace (escrow)
IERC721(_nftContract).transferFrom(msg.sender, address(this), _tokenId);
uint256 listingId = listingCount;
listings[listingId] = Listing({
seller: msg.sender,
nftContract: _nftContract,
tokenId: _tokenId,
price: _price,
active: true
});
listingCount++;
emit Listed(listingId, msg.sender, _nftContract, _tokenId, _price);
return listingId;
}
function purchase(uint256 _listingId) external payable {
Listing storage listing = listings[_listingId];
require(listing.active, "Marketplace: not active");
require(msg.value == listing.price, "Marketplace: wrong price");
listing.active = false;
uint256 fee = (listing.price * feePercent) / 10000;
uint256 sellerProceeds = listing.price - fee;
collectedFees += fee;
// Transfer NFT to buyer
IERC721(listing.nftContract).transferFrom(address(this), msg.sender, listing.tokenId);
// Transfer ETH to seller
(bool success, ) = payable(listing.seller).call{value: sellerProceeds}("");
require(success, "Marketplace: payment failed");
emit Purchased(_listingId, msg.sender, listing.price);
}
function cancelListing(uint256 _listingId) external {
Listing storage listing = listings[_listingId];
require(listing.active, "Marketplace: not active");
require(listing.seller == msg.sender, "Marketplace: not seller");
listing.active = false;
IERC721(listing.nftContract).transferFrom(address(this), msg.sender, listing.tokenId);
emit ListingCancelled(_listingId);
}
function withdrawFees() external {
require(msg.sender == owner, "Marketplace: not owner");
uint256 amount = collectedFees;
require(amount > 0, "Marketplace: no fees");
collectedFees = 0;
(bool success, ) = payable(owner).call{value: amount}("");
require(success, "Marketplace: withdrawal failed");
emit FeesWithdrawn(amount);
}
}
29. Deep Analysis: Every Design Decision Explained
Escrow pattern: The NFT is transferred TO the marketplace during listing. This ensures the seller can't sell it elsewhere while it's listed. The marketplace holds it as a trusted intermediary — same concept as SaveAsset holding ERC20 tokens.
Fee calculation in basis points: feePercent = 250 means 2.5%. (price × 250) / 10000 = 2.5%. Basis points give more precision than percentages with integer math.
listing.active = false before external calls: CEI pattern. The listing is deactivated before transferring the NFT and ETH. If the seller's address is a malicious contract that re-enters purchase, the listing is already inactive.
IERC721.transferFrom: The same transferFrom pattern as ERC20 — but for NFTs. The seller must approve the marketplace before listing: nft.approve(marketplace, tokenId) or nft.setApprovalForAll(marketplace, true).
30. Foundry Tests for the Marketplace
contract MarketplaceTest is Test {
NFTMarketplace public market;
MockNFT public nft;
address public admin;
address public seller;
address public buyer;
function setUp() public {
admin = makeAddr("admin");
seller = makeAddr("seller");
buyer = makeAddr("buyer");
vm.deal(buyer, 100 ether);
vm.prank(admin);
market = new NFTMarketplace(250); // 2.5% fee
nft = new MockNFT();
nft.mint(seller, 1); // Mint token #1 to seller
}
function test_ListAndPurchase() public {
// Seller lists NFT
vm.startPrank(seller);
nft.approve(address(market), 1);
uint256 listingId = market.listNFT(address(nft), 1, 1 ether);
vm.stopPrank();
// Buyer purchases
vm.prank(buyer);
market.purchase{value: 1 ether}(listingId);
// NFT is now owned by buyer
assertEq(nft.ownerOf(1), buyer);
// Seller received 97.5% (1 ETH - 2.5% fee = 0.975 ETH)
assertEq(seller.balance, 0.975 ether);
// Marketplace collected 2.5% fee
assertEq(market.collectedFees(), 0.025 ether);
}
function test_CancelListing() public {
vm.startPrank(seller);
nft.approve(address(market), 1);
uint256 listingId = market.listNFT(address(nft), 1, 1 ether);
market.cancelListing(listingId);
vm.stopPrank();
assertEq(nft.ownerOf(1), seller); // NFT returned to seller
}
function test_RevertWhen_BuyerSendsWrongPrice() public {
vm.startPrank(seller);
nft.approve(address(market), 1);
market.listNFT(address(nft), 1, 1 ether);
vm.stopPrank();
vm.prank(buyer);
vm.expectRevert("Marketplace: wrong price");
market.purchase{value: 0.5 ether}(0);
}
}
// Minimal NFT for testing
contract MockNFT {
mapping(uint256 => address) public ownerOf;
mapping(uint256 => address) public getApproved;
function mint(address to, uint256 tokenId) external { ownerOf[tokenId] = to; }
function approve(address to, uint256 tokenId) external {
require(ownerOf[tokenId] == msg.sender);
getApproved[tokenId] = to;
}
function transferFrom(address from, address to, uint256 tokenId) external {
require(ownerOf[tokenId] == from);
require(msg.sender == from || msg.sender == getApproved[tokenId]);
ownerOf[tokenId] = to;
delete getApproved[tokenId];
}
}
Wrap-Up
31. The Progression Map: What Each Exercise Teaches
Exercise 1: Voting
├─ Enums / state derivation
├─ Nested mappings (hasVoted)
├─ Time-based logic (block.timestamp)
├─ Modifiers (onlyAdmin, onlyRegistered)
└─ vm.warp() in Foundry tests
Exercise 2: Crowdfunding
├─ ETH handling (msg.value, .call)
├─ Pull pattern (refunds)
├─ CEI pattern (refund safety)
├─ Derived state (no explicit enum)
└─ Accumulators (totalRaised)
Exercise 3: Token Vesting
├─ ERC20 interaction (IERC20, transferFrom)
├─ Time-based math (linear interpolation)
├─ Computation vs storage (vestedAmount is calculated, not stored)
├─ Admin functions + revocation
└─ vm.warp() for time progression tests
Exercise 4: Staking Rewards
├─ The Synthetix accumulator pattern
├─ O(1) reward distribution at scale
├─ Modifier as state updater (updateReward)
├─ Two-token system (staking token + reward token)
└─ Proportional math with 1e18 precision
Exercise 5: NFT Marketplace
├─ ERC721 interaction (transferFrom, ownerOf, approve)
├─ Escrow pattern (marketplace holds NFT)
├─ Fee calculation (basis points)
├─ Multi-asset handling (NFT + ETH in one transaction)
└─ Complex multi-contract call chains
32. Self-Assessment: How to Know You've Mastered Each Concept
For each exercise, ask yourself:
□ Can I write the contract from scratch without looking at the solution?
□ Can I draw the storage layout on paper?
□ Can I trace the msg.sender chain through every cross-contract call?
□ Can I identify every potential security issue?
□ Can I write the complete Foundry test suite from memory?
□ Can I explain why each design decision was made?
□ Can I estimate the gas cost of each function?
□ Can I modify the contract to add a new feature without breaking existing functionality?
If you can answer yes to all eight questions for all five exercises, you're not just writing smart contracts — you're thinking like a blockchain architect. Every concept from every previous article (ERC20 mechanics, EVM storage, mappings, inter-contract execution, inheritance, system design, security, Foundry testing, Uniswap integration) has been exercised and reinforced through these hands-on challenges.



