DEV Community

Cover image for ๐Ÿช™ Day 13 of #30DaysOfSolidity โ€” Building a Token Sale (Sell Your ERC-20 for ETH with Foundry)
Saurav Kumar
Saurav Kumar

Posted on

๐Ÿช™ Day 13 of #30DaysOfSolidity โ€” Building a Token Sale (Sell Your ERC-20 for ETH with Foundry)

๐Ÿงฉ Overview

Welcome to Day 13 of my #30DaysOfSolidity journey!

Today, weโ€™ll build something that powers almost every token project โ€” a Token Sale Contract (or Pre-Sale Contract) where users can buy ERC-20 tokens with Ether.

Weโ€™ll use Foundry โ€” a blazing-fast framework for smart contract development.
By the end, youโ€™ll understand how to:

  • Sell your ERC-20 tokens for ETH ๐Ÿ’ฐ
  • Manage pricing, sales, and withdrawals
  • Deploy using Foundry

๐Ÿš€ What Weโ€™re Building

Weโ€™re creating two contracts:

  1. MyToken.sol โ€” ERC-20 token contract
  2. TokenSale.sol โ€” lets users buy tokens with ETH

The owner will:

  • Set a price (tokens per ETH)
  • Fund the sale contract with tokens
  • Withdraw ETH and unsold tokens

๐Ÿงฑ Project Structure

day-13-token-sale/
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ MyToken.sol
โ”‚   โ””โ”€โ”€ TokenSale.sol
โ”œโ”€โ”€ script/
โ”‚   โ””โ”€โ”€ Deploy.s.sol
โ”œโ”€โ”€ test/
โ”‚   โ””โ”€โ”€ TokenSale.t.sol
โ”œโ”€โ”€ foundry.toml
โ””โ”€โ”€ README.md
Enter fullscreen mode Exit fullscreen mode

โš™๏ธ Foundry Setup (Step-by-Step)

If you donโ€™t have Foundry yet, hereโ€™s how to set it up ๐Ÿ‘‡

1๏ธโƒฃ Install Foundry

curl -L https://foundry.paradigm.xyz | bash
foundryup
Enter fullscreen mode Exit fullscreen mode

2๏ธโƒฃ Create a new project

forge init day-13-token-sale
cd day-13-token-sale
Enter fullscreen mode Exit fullscreen mode

3๏ธโƒฃ Install OpenZeppelin (ERC-20 contracts)

forge install OpenZeppelin/openzeppelin-contracts
Enter fullscreen mode Exit fullscreen mode

๐Ÿช™ Step 1 โ€” Create the Token

File: src/MyToken.sol

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

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

/// @title MyToken - Simple ERC20 Token
contract MyToken is ERC20, Ownable {
    constructor(string memory name_, string memory symbol_, uint256 initialSupply)
        ERC20(name_, symbol_)
    {
        _mint(msg.sender, initialSupply * 10 ** decimals());
    }

    function mint(address to, uint256 amount) external onlyOwner {
        _mint(to, amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

This is a simple ERC-20 token with an initial supply minted to the deployer.


๐Ÿ’ธ Step 2 โ€” Create the Token Sale Contract

File: src/TokenSale.sol

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

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

/// @title TokenSale - Sell tokens for ETH
contract TokenSale is Ownable {
    IERC20 public token;
    uint256 public tokensPerEth;
    bool public saleActive;

    event TokensPurchased(address indexed buyer, uint256 ethSpent, uint256 tokensBought);
    event PriceUpdated(uint256 oldPrice, uint256 newPrice);
    event SaleToggled(bool active);
    event EtherWithdrawn(address indexed to, uint256 amount);
    event TokensWithdrawn(address indexed to, uint256 amount);

    constructor(address tokenAddress, uint256 _tokensPerEth) {
        require(tokenAddress != address(0), "Invalid token address");
        token = IERC20(tokenAddress);
        tokensPerEth = _tokensPerEth;
        saleActive = true;
    }

    function buyTokens() public payable {
        require(saleActive, "Sale not active");
        require(msg.value > 0, "Send ETH to buy tokens");

        uint256 tokensToBuy = (msg.value * tokensPerEth) / 1 ether;
        require(tokensToBuy > 0, "Not enough ETH for 1 token");

        require(token.balanceOf(address(this)) >= tokensToBuy, "Not enough tokens");

        token.transfer(msg.sender, tokensToBuy);
        emit TokensPurchased(msg.sender, msg.value, tokensToBuy);
    }

    function setPrice(uint256 _tokensPerEth) external onlyOwner {
        require(_tokensPerEth > 0, "Invalid price");
        emit PriceUpdated(tokensPerEth, _tokensPerEth);
        tokensPerEth = _tokensPerEth;
    }

    function toggleSale(bool _active) external onlyOwner {
        saleActive = _active;
        emit SaleToggled(_active);
    }

    function withdrawEther(address payable to) external onlyOwner {
        uint256 amount = address(this).balance;
        require(amount > 0, "No Ether");
        (bool sent, ) = to.call{value: amount}("");
        require(sent, "Transfer failed");
        emit EtherWithdrawn(to, amount);
    }

    function withdrawTokens(address to) external onlyOwner {
        uint256 amount = token.balanceOf(address(this));
        require(amount > 0, "No tokens");
        token.transfer(to, amount);
        emit TokensWithdrawn(to, amount);
    }

    receive() external payable {
        buyTokens();
    }
}
Enter fullscreen mode Exit fullscreen mode

โš™๏ธ Step 3 โ€” Deploy with Foundry

File: script/Deploy.s.sol

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

import "forge-std/Script.sol";
import "../src/MyToken.sol";
import "../src/TokenSale.sol";

contract Deploy is Script {
    function run() external {
        vm.startBroadcast();

        // Deploy ERC20 Token
        MyToken token = new MyToken("MyToken", "MTK", 1_000_000);

        // Deploy Token Sale
        uint256 price = 1000 * 10 ** 18; // 1000 tokens per 1 ETH
        TokenSale sale = new TokenSale(address(token), price);

        // Transfer tokens to sale contract
        token.transfer(address(sale), 100_000 * 10 ** 18);

        vm.stopBroadcast();
    }
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿงช Step 4 โ€” Build and Deploy

Compile

forge build
Enter fullscreen mode Exit fullscreen mode

Run a Local Node

anvil
Enter fullscreen mode Exit fullscreen mode

Deploy the Contracts

forge script script/Deploy.s.sol:Deploy --rpc-url http://127.0.0.1:8545 --private-key <YOUR_PRIVATE_KEY> --broadcast
Enter fullscreen mode Exit fullscreen mode

๐Ÿ’ฐ Step 5 โ€” Interact with Your Contract

  • Buyers can call buyTokens() and send ETH.
  • Tokens are automatically transferred to their wallets.
  • Owner can:

    • Change price (setPrice)
    • Pause sale (toggleSale)
    • Withdraw ETH (withdrawEther)
    • Withdraw unsold tokens (withdrawTokens)

๐Ÿงฎ Example Calculation

If you set tokensPerEth = 1000 * 10^18:

ETH Sent Tokens Received
1 ETH 1000 Tokens
0.5 ETH 500 Tokens
0.1 ETH 100 Tokens

๐Ÿง  What Youโ€™ll Learn

โœ… ERC-20 token creation
โœ… Handling Ether in contracts
โœ… Token pricing & conversion
โœ… Secure withdrawal patterns
โœ… Deployment using Foundry


๐Ÿ” Security Tips

  • Fund the sale contract before making it public.
  • Use onlyOwner modifiers to secure functions.
  • Validate ETH amounts to avoid reentrancy or precision issues.
  • Consider whitelisting buyers for real-world sales.

๐Ÿ“Š Future Improvements

  • Add cap limits per user
  • Integrate vesting & timelocks
  • Add USDT or stablecoin support
  • Build a React frontend for users to interact with your sale

๐Ÿงพ Conclusion

You just created a Token Sale DApp using Foundry and Solidity โ€” the foundation of many Web3 projects like ICOs, presales, and launchpads.

Every token economy begins here: a simple smart contract that turns Ether into tokens.

Keep building! ๐Ÿš€


๐Ÿงก Follow the Journey

Iโ€™m documenting #30DaysOfSolidity โ€” from basics to advanced DeFi & Web3 projects.

๐Ÿ‘‰ Follow me on Dev.to
๐Ÿ‘‰ Connect on LinkedIn
๐Ÿ‘‰ Read all previous days

Top comments (0)