Skip to main content

Command Palette

Search for a command to run...

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

Updated
25 min read
H

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 as approvals[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.timestamp and address(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() or send()? 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 IERC721 interface for transferFrom and ownerOf

  • Listings 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.

7 views