Build a DeFi Staking Contract

Build a DeFi staking protocol with ERC-20 tokens and yield mechanics.

Introduction

30 Second Summary

When you deposit money into a savings account, the bank pays you interest for letting them use your funds. In DeFi (decentralized finance), the same concept exists. Users lock cryptocurrency into a smart contract and earn yield over time.

In this project, you will build a mini DeFi staking protocol from scratch using Solidity. You'll create a custom token, build a vault that accepts deposits, and watch rewards accumulate based on how long tokens stay locked.

What You'll Build

A two-contract DeFi staking system consisting of an ERC-20 token called "Stake Token" (STK) and a Staking Vault contract that accepts deposits, calculates time-based rewards, and distributes yield on withdrawal.

By the end of this project, you'll have:

  • A custom ERC-20 token contract built with OpenZeppelin's battle-tested libraries.
  • A staking vault contract with deposit, reward calculation, and withdrawal logic.
  • A deployed and tested DeFi system running in Remix IDE's browser-based blockchain sandbox.
  • Secret Mission: Add an early withdrawal penalty that burns a percentage of rewards if a user unstakes before a minimum lock period.

Are there any prerequisites?

No prior Solidity or blockchain experience is needed. This guide walks you through everything from opening Remix IDE in your browser to deploying your contracts. All you need is a Firefox or Chrome browser.

Set Up Remix IDE

Before you can write any Solidity code, you need a development environment. Most programming requires installing tools on your computer, but smart contract development has a faster option.

Remix IDE is a browser-based development environment purpose-built for writing, compiling, and deploying smart contracts. In this step, you will open Remix, create a fresh workspace, and configure it for your staking project.

In this step, get ready to:
  • Open Remix IDE in your browser.
  • Create a workspace with two Solidity files.
  • Configure the compiler and deployment environment.
Open Remix IDE in your browser

Remix IDE runs entirely in your browser. No installation required.

  • Open Firefox or Chrome on your computer.
  • Navigate to the following URL:
https://remix.ethereum.org
  • Wait for the Remix IDE interface to load. You should see the editor in the centre of the page with a File Explorer panel on the left.

What is Remix IDE?

Remix IDE is a free, browser-based development environment built specifically for Ethereum smart contracts. It includes a code editor, a Solidity compiler, and a built-in blockchain simulator.

Because everything runs in your browser, you do not need to install Node.js, Python, or any other local tooling. Your contracts and workspace data are stored in your browser's local storage.

Create a workspace and Solidity files

Remix organises projects into workspaces. Each workspace is an isolated folder that keeps your files separate from other projects.

  • Click the Workspaces dropdown at the top of the File Explorer panel.
  • Click Create a new workspace.
  • In the template selection page, choose Blank (an empty workspace with no template files).
  • Name your workspace defi-staking and click Create.

Your new workspace appears in the File Explorer. Now create the two contract files.

  • Right-click in the empty area of the File Explorer panel.
  • Select New File from the context menu.
  • Type StakeToken.sol and press Enter.
  • Right-click in the File Explorer again and select New File.
  • Type StakingVault.sol and press Enter.

You should now see both StakeToken.sol and StakingVault.sol listed in the File Explorer under your defi-staking workspace.

Why two files?

Your DeFi staking system consists of two smart contracts that interact with each other. StakeToken.sol will hold the ERC-20 token contract (the asset users stake). StakingVault.sol will hold the vault contract that accepts deposits, tracks time, and calculates rewards.

Don't see your files?

Make sure you are looking inside the correct workspace. Check the Workspaces dropdown at the top of the File Explorer and confirm defi-staking is selected.

help me find my files in the Remix IDE workspace

Configure the compiler and environment

Solidity contracts need to be compiled before they can be deployed. The compiler translates your human-readable Solidity code into bytecode that the Ethereum Virtual Machine can execute.

  • Click the Solidity Compiler icon in the left sidebar (it looks like the Solidity "S" logo).
  • Find the COMPILER dropdown near the top of the panel.
  • Select version 0.8.20 from the dropdown list.

Why version 0.8.20?

OpenZeppelin Contracts v5.x (the library you will import in the next step) requires pragma solidity ^0.8.20. Selecting this compiler version ensures compatibility with those battle-tested contracts.

Now configure the deployment environment so you have a local blockchain ready for testing.

  • Click the Deploy & Run Transactions icon in the left sidebar (it looks like an Ethereum diamond with an arrow).
  • Find the ENVIRONMENT dropdown at the top of the panel.
  • Select Remix VM (Shanghai) from the dropdown.

You should see a list of 10 accounts, each loaded with 100 ETH. These are test accounts for your local blockchain sandbox.

What is Remix VM?

Remix VM is a sandbox blockchain that runs entirely in your browser. Transactions execute instantly with no approval needed. You do not need MetaMask, testnet faucets, or real ETH.

The "Shanghai" label refers to the Ethereum hard fork the VM simulates. This gives you access to the latest EVM opcodes your contracts might use.

Don't see Remix VM (Shanghai)?

Make sure you are in the Deploy & Run Transactions plugin (not the Solidity Compiler). The ENVIRONMENT dropdown only appears in this plugin. If you still do not see the Shanghai option, try refreshing your browser.

help me find the Remix VM Shanghai environment

Your development environment is ready. Next up, you will write the ERC-20 token contract that serves as the stakeable asset in your DeFi protocol.

Create the ERC-20 Stake Token

Your development environment is ready. Now it's time to write your first smart contract.

Every staking system needs a token for users to deposit. You'll create a custom ERC-20 token called Stake Token (STK) using OpenZeppelin's battle-tested contract library.

In this step, get ready to:
  • Write the StakeToken contract inheriting from OpenZeppelin's ERC20 and Ownable contracts.
  • Add a mint function restricted to the contract owner.
  • Compile the contract and verify it compiles without errors.
Write the StakeToken contract

The ERC-20 standard defines how tokens behave on Ethereum. Instead of writing all the token logic yourself, you'll inherit from OpenZeppelin's proven implementations.

  • Open StakeToken.sol in the Remix editor by clicking on it in the File Explorer panel on the left.
  • Delete any placeholder content and paste the following code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

// Import OpenZeppelin's ERC20 token implementation
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
// Import Ownable for access control (restricts who can mint)
import "@openzeppelin/contracts/access/Ownable.sol";

// StakeToken inherits all ERC20 and Ownable functionality
contract StakeToken is ERC20, Ownable {
    // Set token name to "Stake Token", symbol to "STK"
    // Make the deployer the owner
    // Mint 10,000 tokens to the deployer
    constructor() ERC20("Stake Token", "STK") Ownable(msg.sender) {
        _mint(msg.sender, 10000 * 10 ** decimals());
    }

    // Only the owner can mint new tokens (used for staking rewards later)
    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }
}

What does this code do?

The pragma solidity ^0.8.20 line tells the compiler which Solidity version to use. The two import lines pull in OpenZeppelin's pre-built contracts. Remix resolves these imports automatically from npm.

The constructor runs once when the contract is deployed. It names the token "Stake Token" with the symbol "STK", sets the deployer as the owner, and mints 10,000 tokens to the deployer's address. The 10 ** decimals() part accounts for the 18 decimal places that ERC-20 tokens use.

The mint function lets the owner create new tokens on demand. The onlyOwner modifier restricts access so only the contract owner can call it. Later, you'll transfer ownership to the staking vault so it can mint reward tokens automatically.

Compile the contract

Compiling checks your Solidity code for syntax errors and converts it into bytecode that can be deployed.

  • Make sure StakeToken.sol is the active file in the editor (its tab should be selected).
  • Click the Solidity Compiler icon in the left icon panel.
  • Confirm the compiler version in the COMPILER dropdown is set to 0.8.20 or higher.
  • Click the blue Compile StakeToken.sol button.

Keyboard shortcut for compiling

Press Cmd+S (macOS) or Ctrl+S (Windows) to compile the active file without clicking through the panel.

You should see a green checkmark appear on the Solidity Compiler icon in the left panel. This confirms your contract compiled without errors.

Seeing a red error instead?

Check that your compiler version is set to 0.8.20 or higher in the COMPILER dropdown. OpenZeppelin's contracts require pragma solidity ^0.8.20.

Make sure the mint function is inside the contract's curly braces (after the constructor but before the final }). A misplaced brace is the most common cause of compilation failure.

Why won't my StakeToken contract compile in Remix?

✔️ Awesome, I've got everything!

Great. Double check you've saved the file and see the green checkmark on the compiler icon.

ⓧ I'd like to double check the full code

Here's the complete StakeToken.sol file at this point:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract StakeToken is ERC20, Ownable {
    constructor() ERC20("Stake Token", "STK") Ownable(msg.sender) {
        _mint(msg.sender, 10000 * 10 ** decimals());
    }

    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }
}

Your token contract is ready. Next up, you'll build the Staking Vault that accepts these tokens as deposits and calculates rewards over time.

Build the Staking Vault

Your Stake Token contract is compiled and ready to go. Now it's time to build the contract that actually puts those tokens to work.

The Staking Vault is where the DeFi magic happens. It accepts token deposits from users, tracks when they staked, and calculates rewards based on how long their tokens have been locked.

In this step, get ready to:
  • Write the vault contract structure with an interface and state variables.
  • Implement the stake function to accept token deposits.
  • Implement the reward calculation logic.
Write the interface and contract structure

The vault needs a way to talk to your Stake Token contract. In Solidity, you define an interface that lists the functions your vault will call on the token.

  • Open StakingVault.sol in the Remix editor.
  • Add the license, pragma, import, and interface definition by pasting the following code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

// Define the functions our vault needs to call on the token
interface IStakeToken {
    function mint(address to, uint256 amount) external;
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

What does this code do?

The IStakeToken interface declares the functions the vault will call on your Stake Token contract. It includes mint (for creating reward tokens), transfer (for sending tokens back), transferFrom (for pulling tokens in), and balanceOf (for checking balances).

The IERC20 import from OpenZeppelin ensures the compiler knows the standard ERC-20 interface. Your custom interface extends beyond the standard by including the mint function that your vault needs for reward distribution.

  • You won't see a change yet. The next chunk builds the contract body that uses this interface.

Now you need the contract itself. This defines how the vault tracks each user's deposit.

  • Below the interface closing brace, add the contract declaration with state variables and constructor:
contract StakingVault {
    // Reference to the token contract
    IStakeToken public stakeToken;
    uint256 public rewardRatePerSecond = 1e15; // 0.001 tokens per second per token staked

    // Track each user's stake amount and start time
    struct StakeInfo {
        uint256 amount;
        uint256 stakedAt;
    }

    mapping(address => StakeInfo) public stakes;

    constructor(address _stakeToken) {
        stakeToken = IStakeToken(_stakeToken);
    }
}

What does this code do?

The stakeToken variable stores a reference to your deployed Stake Token contract. The rewardRatePerSecond sets how fast rewards accumulate. At 1e15, users earn 0.001 tokens per second for every token they stake.

The StakeInfo struct bundles each user's deposit amount with the timestamp of when they staked. The mapping connects each wallet address to its StakeInfo, so the contract knows exactly how much each user deposited and when.

The constructor accepts the token's deployed address and wraps it in the IStakeToken interface so the vault can call token functions.

  • Check that the Remix editor does not show any red squiggly lines in your code. Red lines indicate a syntax error.

Seeing red squiggly lines?

Make sure the closing brace } of the IStakeToken interface is on its own line before the contract StakingVault declaration.

Check that your pragma line reads pragma solidity ^0.8.20; exactly. A mismatched version will cause import errors.

Help me fix red squiggly lines in my StakingVault.sol file

Implement the stake function

When a user wants to stake tokens, the vault needs to pull those tokens from the user's wallet into itself. This uses the transferFrom pattern that all ERC-20 tokens support.

  • Inside the StakingVault contract, just before the final closing brace }, add the stake function:
    function stake(uint256 _amount) external {
        require(_amount > 0, "Cannot stake zero");
        require(stakes[msg.sender].amount == 0, "Already staking");

        // Pull tokens from the user into this contract
        stakeToken.transferFrom(msg.sender, address(this), _amount);

        // Record the user's stake details
        stakes[msg.sender] = StakeInfo({
            amount: _amount,
            stakedAt: block.timestamp
        });
    }

What does this code do?

The two require statements act as guards. The first prevents staking zero tokens. The second prevents a user from staking twice (which would overwrite their existing stake and lose their reward progress).

The transferFrom call moves tokens from the user's wallet into the vault contract. For this to work, the user must first approve the vault to spend their tokens. You will handle this approval during testing in Step 5.

Finally, block.timestamp records exactly when the user staked. The reward calculation uses this timestamp to determine how long the tokens have been locked.

  • Check that the Remix editor shows no red squiggly lines on the new function.

Red line on the stake function?

Make sure the function is inside the contract's braces. It should sit after the constructor and before the contract's final }.

Check that StakeInfo uses curly braces for named arguments: StakeInfo({ amount: _amount, stakedAt: block.timestamp }).

Help me fix an error in my stake function

Implement the reward calculation

The reward formula is the heart of any staking protocol. Your vault calculates rewards based on how long tokens have been locked, multiplied by the reward rate.

  • Below the stake function (still inside the contract), add the calculateRewards function:
    function calculateRewards(address _user) public view returns (uint256) {
        StakeInfo memory userStake = stakes[_user];
        if (userStake.amount == 0) return 0;

        // Calculate how long the user has been staking
        uint256 duration = block.timestamp - userStake.stakedAt;
        // Rewards = staked amount * time * rate, normalized by 1e18
        uint256 rewards = (userStake.amount * duration * rewardRatePerSecond) / 1e18;
        return rewards;
    }

What does this code do?

The function is public view, meaning anyone can call it without spending gas and it does not modify contract state. It is a read-only calculation.

The formula multiplies the staked amount by the duration (in seconds) by the reward rate, then divides by 1e18 to normalize the result. Division by 1e18 is necessary because Solidity does not support decimals. The 1e18 factor keeps precision without floating-point math.

  • Check that the Remix editor shows no red squiggly lines anywhere in StakingVault.sol.

Seeing errors in the editor?

Make sure calculateRewards is inside the contract's braces, after the stake function.

Verify you have memory in StakeInfo memory userStake. Solidity requires you to specify the data location for struct variables inside functions.

Help me fix errors in my calculateRewards function

✔️ Awesome, I've got everything!

Great. Double check you've saved your file. Remix auto-saves every 5 seconds, but you can also press Ctrl+S (Windows) or Cmd+S (macOS) to save immediately.

ⓧ I'd like to double check the full code

Here is the complete StakingVault.sol file at this point:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

interface IStakeToken {
    function mint(address to, uint256 amount) external;
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

contract StakingVault {
    IStakeToken public stakeToken;
    uint256 public rewardRatePerSecond = 1e15; // 0.001 tokens per second per token staked

    struct StakeInfo {
        uint256 amount;
        uint256 stakedAt;
    }

    mapping(address => StakeInfo) public stakes;

    constructor(address _stakeToken) {
        stakeToken = IStakeToken(_stakeToken);
    }

    function stake(uint256 _amount) external {
        require(_amount > 0, "Cannot stake zero");
        require(stakes[msg.sender].amount == 0, "Already staking");

        stakeToken.transferFrom(msg.sender, address(this), _amount);

        stakes[msg.sender] = StakeInfo({
            amount: _amount,
            stakedAt: block.timestamp
        });
    }

    function calculateRewards(address _user) public view returns (uint256) {
        StakeInfo memory userStake = stakes[_user];
        if (userStake.amount == 0) return 0;

        uint256 duration = block.timestamp - userStake.stakedAt;
        uint256 rewards = (userStake.amount * duration * rewardRatePerSecond) / 1e18;
        return rewards;
    }
}

Your vault contract now knows how to accept deposits and calculate rewards over time. Next up, you'll add the withdrawal logic that lets users claim their original stake plus earned rewards.

Add Withdrawal Logic

Your StakingVault can accept deposits and calculate rewards. There's one critical piece missing. Users need a way to actually get their tokens back.

In this step, you'll implement the withdrawal logic that pays out rewards and returns the original stake. You'll also add events so that off-chain applications can track staking activity on your contract.

In this step, get ready to:
  • Add Solidity events for tracking stakes and withdrawals.
  • Implement the withdraw function with reward distribution.
  • Compile the complete StakingVault contract.
Add events for staking and withdrawal tracking

Solidity events are log entries recorded on the blockchain. They let off-chain applications like wallets, dashboards, and block explorers know when something important happened inside your contract.

  • In StakingVault.sol, find this line:
    mapping(address => StakeInfo) public stakes;
  • Add these two event declarations directly below it:
    event Staked(address indexed user, uint256 amount);
    event Withdrawn(address indexed user, uint256 amount, uint256 rewards);

What do these events do?

event Staked fires whenever a user deposits tokens into the vault. The indexed keyword on user makes the event searchable by address in block explorers.

event Withdrawn fires when a user pulls their tokens out, logging both the original amount and the earned rewards.

Now you need to emit the Staked event inside your existing stake function.

  • In the stake function, find the closing of the StakeInfo assignment:
        stakes[msg.sender] = StakeInfo({
            amount: _amount,
            stakedAt: block.timestamp
        });
  • Add this line directly below the assignment, before the function's closing brace:
        emit Staked(msg.sender, _amount);

What does emit do?

emit triggers the event and writes it to the blockchain's event log. Any application watching the contract will see this entry appear when a user stakes tokens.

You won't see a visible change yet. The next substep adds the withdraw function that emits the second event.

Implement the withdraw function

This is the core payout mechanic. The withdraw function calculates how much reward the user earned, mints new tokens as the reward, and sends everything back to the user.

  • In StakingVault.sol, add the following function after the calculateRewards function's closing brace:
    function withdraw() external {
        StakeInfo memory userStake = stakes[msg.sender];
        require(userStake.amount > 0, "No active stake");

        uint256 rewards = calculateRewards(msg.sender);

        delete stakes[msg.sender];

        // Mint rewards to the user
        if (rewards > 0) {
            stakeToken.mint(msg.sender, rewards);
        }

        // Transfer original stake back
        stakeToken.transfer(msg.sender, userStake.amount);

        emit Withdrawn(msg.sender, userStake.amount, rewards);
    }

What does this code do?

  • require(userStake.amount > 0, ...) blocks withdrawals from users who haven't staked anything.
  • calculateRewards(msg.sender) computes how many reward tokens the user earned based on time elapsed since staking.
  • delete stakes[msg.sender] clears the user's stake record BEFORE transferring tokens. This ordering prevents reentrancy attacks.
  • stakeToken.mint(msg.sender, rewards) creates brand new tokens as the reward payout. This is why the vault needs minting permission on the token contract.
  • stakeToken.transfer(msg.sender, userStake.amount) returns the original deposit to the user.

Why delete before transfer?

In DeFi, the order of operations matters for security. If you transferred tokens first and deleted the stake record second, a malicious contract could re-enter withdraw() before the deletion happened and drain the vault. Deleting first closes that attack vector.

Seeing a compilation error?

  • Check that the withdraw function is placed outside the calculateRewards function, not nested inside it. Each function should be at the same indentation level.
  • Make sure you have both event Staked(...) and event Withdrawn(...) declarations from the previous substep. The emit line references them.
  • Verify that the contract's final closing brace } comes after the withdraw function. Missing it causes unexpected end-of-file errors.

help me debug a compilation error in my StakingVault withdraw function

Compile the complete contract
  • In Remix, click the Solidity Compiler plugin in the left icon panel (the Solidity "S" logo).
  • Click the Compile StakingVault.sol button.

You should see a green checkmark next to the file name. This confirms your contract compiled without errors.

Seeing errors instead of a green checkmark?

  • If you see a "DeclarationError" mentioning an undeclared identifier, confirm both event declarations are present between the mapping line and the constructor.
  • If you see "ParserError: Expected '{'", check for missing semicolons at the end of statements or mismatched braces.
  • Make sure the compiler version dropdown is set to 0.8.20 or higher. The OpenZeppelin imports require at least this version.

help me fix compilation errors in my StakingVault.sol

✔️ Awesome, I've got everything!

Great. Double check you've saved your file, then move on.

ⓧ I'd like to double check the full code

Here is the complete StakingVault.sol at this point:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

interface IStakeToken {
    function mint(address to, uint256 amount) external;
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

contract StakingVault {
    IStakeToken public stakeToken;
    uint256 public rewardRatePerSecond = 1e15; // 0.001 tokens per second per token staked

    struct StakeInfo {
        uint256 amount;
        uint256 stakedAt;
    }

    mapping(address => StakeInfo) public stakes;

    event Staked(address indexed user, uint256 amount);
    event Withdrawn(address indexed user, uint256 amount, uint256 rewards);

    constructor(address _stakeToken) {
        stakeToken = IStakeToken(_stakeToken);
    }

    function stake(uint256 _amount) external {
        require(_amount > 0, "Cannot stake zero");
        require(stakes[msg.sender].amount == 0, "Already staking");

        stakeToken.transferFrom(msg.sender, address(this), _amount);

        stakes[msg.sender] = StakeInfo({
            amount: _amount,
            stakedAt: block.timestamp
        });

        emit Staked(msg.sender, _amount);
    }

    function calculateRewards(address _user) public view returns (uint256) {
        StakeInfo memory userStake = stakes[_user];
        if (userStake.amount == 0) return 0;

        uint256 duration = block.timestamp - userStake.stakedAt;
        uint256 rewards = (userStake.amount * duration * rewardRatePerSecond) / 1e18;
        return rewards;
    }

    function withdraw() external {
        StakeInfo memory userStake = stakes[msg.sender];
        require(userStake.amount > 0, "No active stake");

        uint256 rewards = calculateRewards(msg.sender);

        delete stakes[msg.sender];

        // Mint rewards to the user
        if (rewards > 0) {
            stakeToken.mint(msg.sender, rewards);
        }

        // Transfer original stake back
        stakeToken.transfer(msg.sender, userStake.amount);

        emit Withdrawn(msg.sender, userStake.amount, rewards);
    }
}

Your StakingVault contract is now complete with deposit, reward, and withdrawal logic. Next up, you'll deploy both contracts and test the full DeFi lifecycle from minting to staking to earning rewards.

Deploy and Test Your DeFi System

Your contracts are written, compiled, and ready to go live. Now it's time to deploy them to a blockchain and experience the full DeFi lifecycle firsthand.

In this step, you'll deploy both contracts to the Remix VM, connect them with the right permissions, and run through the complete staking flow: approve, stake, wait, and withdraw with rewards.

In this step, get ready to:
  • Deploy both contracts and configure permissions.
  • Stake tokens into the vault.
  • Withdraw and verify that rewards were distributed.
Deploy both contracts to the Remix VM

Deployment order matters here. StakingVault needs the StakeToken address as a constructor argument, so StakeToken must be deployed first.

  • Click the Deploy & Run Transactions plugin icon in the left sidebar (it looks like an Ethereum arrow).
  • Confirm the ENVIRONMENT dropdown is set to Remix VM (Shanghai).
  • In the CONTRACT dropdown, select StakeToken - StakeToken.sol.
  • Click Deploy.

The StakeToken contract appears in the Deployed Contracts section at the bottom of the panel. The transaction executes immediately because you're using the Remix VM.

  • Click the copy icon next to the deployed STAKETOKEN address to copy it to your clipboard.
  • In the CONTRACT dropdown, select StakingVault - StakingVault.sol.
  • Paste the StakeToken address into the input field next to the Deploy button (this is the _stakeToken constructor argument).
  • Click Deploy.

You should now see both STAKETOKEN and STAKINGVAULT in the Deployed Contracts section.

Why deploy StakeToken first?

StakingVault's constructor requires a token address so it knows which ERC-20 token to accept for staking. Without deploying StakeToken first, you wouldn't have an address to pass in.

There's one more configuration step. The vault needs permission to mint reward tokens. Since the mint function on StakeToken is restricted to the contract owner, you need to transfer ownership of StakeToken to the vault.

  • Expand the deployed STAKETOKEN contract by clicking the caret next to it.
  • Copy the deployed STAKINGVAULT address by clicking its copy icon.
  • Find the transferOwnership function in the StakeToken contract.
  • Paste the StakingVault address into the newOwner input field.
  • Click transact.

What does transferring ownership do?

The StakingVault calls stakeToken.mint() to create reward tokens when a user withdraws. The onlyOwner modifier on the mint function means only the owner can call it.

By transferring ownership to the vault, you give it the authority to mint rewards. This is how real DeFi protocols manage permissions between contracts.

Don't see both contracts deployed?

Make sure you selected the correct contract name in the CONTRACT dropdown before each deploy. If you accidentally deployed StakingVault without the address argument, delete it and redeploy with the StakeToken address pasted in.

Help me deploy my contracts in Remix

Approve and stake tokens into the vault

Before the vault can pull tokens from your account, you need to give it explicit permission. This is the ERC-20 approval pattern that all DeFi protocols use.

  • Expand the deployed STAKETOKEN contract.
  • Find the approve function.
  • In the spender field, paste the StakingVault contract address.
  • In the value field, enter 100000000000000000000 (this is 100 tokens with 18 decimals).
  • Click transact.

Why is the number so large?

ERC-20 tokens use 18 decimal places by default. So 100 tokens is represented as 100 followed by 18 zeros. This is the same reason 1 ETH equals 1,000,000,000,000,000,000 wei.

Now stake those approved tokens into the vault.

  • Expand the deployed STAKINGVAULT contract.
  • Find the stake function.
  • In the _amount field, enter 100000000000000000000.
  • Click transact.

Check the Remix terminal at the bottom of the screen. You should see a green checkmark and the transaction details. Expand the transaction log to find the Staked event showing your address and the staked amount.

Transaction reverted?

If you see a revert error, check these common causes:

  • You forgot to call approve before stake. The vault cannot pull tokens without approval.
  • The value in your approve was less than the amount you're trying to stake.
  • You used a different account for approve and stake. Make sure the same account is selected in the ACCOUNT dropdown for both transactions.

Help me fix my staking transaction revert

Withdraw and verify your rewards

Your tokens are now locked in the vault and accumulating rewards every second. The reward rate is 0.001 tokens per second per token staked. With 100 tokens staked, you earn about 0.1 tokens per second.

  • Wait approximately 60 seconds. This gives your stake time to accumulate visible rewards.
  • In the STAKINGVAULT contract, find the calculateRewards function (blue button).
  • Paste your account address into the _user field and click it to preview your rewards.
  • Find the withdraw function and click transact.

Expand the transaction log in the terminal. You should see the Withdrawn event with your original stake amount and the calculated rewards.

  • To confirm your balance, expand the STAKETOKEN contract and call balanceOf with your account address.
  • The returned value should be greater than 10000000000000000000000 (your original 10000 STK). The extra amount is your staking reward.

What just happened?

You just experienced the complete DeFi staking lifecycle. The vault calculated how many seconds your tokens were locked, multiplied by the reward rate, minted new tokens as yield, and returned everything to you in a single transaction.

This is fundamentally how protocols like Aave and Lido work under the hood. They accept deposits, track time, compute rewards, and distribute yield.

Rewards showing as zero?

If calculateRewards returns 0 or the Withdrawn event shows 0 rewards, you likely called withdraw too quickly after staking. The Remix VM uses real wall-clock time for block.timestamp. Wait at least 30 seconds between staking and withdrawing, then try the full flow again (approve, stake, wait, withdraw).

Help me understand why my rewards are zero

Your DeFi staking system is fully operational. You've deployed, configured, and tested a multi-contract protocol that mints, approves, stakes, earns, and withdraws. Next up, a Secret Mission to add penalty mechanics for early withdrawals.

Secret mission

Add an Early Withdrawal Penalty

Real DeFi protocols discourage short-term staking by penalizing early withdrawals. Can you add a minimum lock period to your vault that burns half the rewards if a user unstakes too soon?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely in your browser using Remix IDE. There are no cloud resources, no local files on your computer, and no ongoing costs. Your workspace is stored in your browser's IndexedDB storage.

Resources you used:

  • Remix IDE workspace with StakeToken.sol and StakingVault.sol
  • Contracts deployed on the Remix VM (in-memory, resets on page refresh).

Keep everything running

No action needed. Choose this if you want to continue experimenting with your staking contracts.

  • Your workspace persists in browser storage until you clear it.
  • Deployed contracts on the Remix VM are ephemeral. They disappear when you refresh the page or close the browser tab.
  • To resume testing, you can recompile and redeploy your contracts at any time.

Pause - I'll come back to this later

No action needed. Your Remix workspace stays saved in your browser's storage automatically.

  • Your Solidity files will be there next time you open https://remix.ethereum.org in the same browser.
  • Deployed contracts will be gone (the VM resets), but you can redeploy from your saved source files.

Delete - I don't want to use this again

Remove your Remix workspace entirely. This deletes all files from your browser storage.

  • In Remix IDE, click the workspace dropdown at the top of the File Explorer sidebar.
  • Right-click on your workspace name in the dropdown.
  • Select Delete from the context menu.
  • Confirm the deletion when prompted.

What about browser storage?

Deleting the workspace through Remix is sufficient. If you also want to clear all Remix data from your browser, you can clear site data for remix.ethereum.org in your browser settings.

Nice Work!

Nice Work!

You just built a DeFi staking protocol from scratch using Solidity and Remix IDE. You now understand how protocols like Aave and Lido work under the hood.

Here's what you accomplished:

  • Created a custom ERC-20 token using OpenZeppelin's battle-tested contracts and deployed it in a browser-based blockchain sandbox.
  • Built a staking vault that accepts token deposits, tracks time-based rewards, and distributes yield on withdrawal.
  • Experienced the full DeFi lifecycle: mint tokens, approve spending, stake into a vault, earn rewards over time, and withdraw with yield.
  • Secret Mission: Added an early withdrawal penalty that burns a percentage of rewards if a user unstakes before the minimum lock period.

Ready to quiz yourself?