Build A Deflationary ERC20 Token From Scratch - No dependencies

By Icon The Great - 2025-08-12 - 16 min read

This post takes a deep dive into MyDeflationaryToken, a Solidity contract that implements an ERC20-like token with a built-in deflationary fee system. The idea is straightforward: every transfer charges a fee, which is split into a burn portion, a treasury portion, and a hodlers reward portion.

The burn portion permanently reduces the supply, creating a deflationary effect over time.

We will go through the contract’s structure, variables, and functions, explaining what each part does and why it is there.

GETTING STARTED

To get started we need to have foundry installed on our computer. To install foundry run:

# Download foundry installer foundryup
curl -L https://foundry.paradigm.xyz | bash
# Install forge, cast, anvil, chisel
foundryup
# Install the latest nightly release
foundryup -i nightly

After getting foundry installed, now lets start building our project, first we will create a new directory in our code editor:

mkdir deflationary-erc20
cd deflationary-erc20

Our deflationary-erc20 will be created, then in our directory, lets run:

forge init

forge init spin up a new foundry project in our directory deflationary-erc20, the next thing will will do is to delete the counter.sol file in src and create a new file named MyDeflationaryToken.sol. You can also go ahead and delete counter.t.sol and counter.s.sol in test and script folders respectively.

All done? Aye, Let’s get building!

LICENSE AND COMPILER VERSION

//SPDX-License-Identifier: MIT

pragma solidity ^0.8.19;

First thing will will do is to indicate the license and solidity version of our contract. The SPDX license identifier declares that the code is under the MIT license, which is permissive and widely used in open source.

The pragma solidity ^0.8.19; statement tells the compiler to use Solidity version 0.8.19 or higher, but not 0.9.0. Solidity 0.8.x includes built-in overflow and underflow checks, which improve safety.

CONTRACT DOCUMENTATION

/**
 * @title MyDeflationaryToken
 * @author ICON
 * @notice This contract implements a basic ERC20 token with a transfer fee mechanism.
 * It allows for minting, transferring, and burning tokens, with fees distributed to a treasury wallet,
 * a hodlers distribution wallet, and a burn mechanism.
 * The transfer fee is defined in basis points (1/100th of a percent) and can be set during contract deployment.
 * The contract also includes custom error messages for better clarity and gas efficiency.
 * This contract is designed to be simple and efficient, focusing on the core functionalities of an ERC20 token.
 */

The docstring at the top of the contract explains its purpose, author, and main features. You can skip this for now as its not necessary but at the same time can be very important - its useful for both developers and auditors to quickly understand the intent.

Make sure you edit the @author to your dev name. Also dont forget to include those NatSpec(/** .... */) and you can edit the comments to better explain your contract if you want

CUSTOM ERRORS

error MyDeflationaryToken__CantExceedMaxTransferFee();
error MyDeflationaryToken__AllFeesMustSumUpToTransferFee();
error MyDeflationaryToken__CantExceedTransferFee();
error MyDeflationaryToken__CantBeZeroAddress();
error MyDeflationaryToken__NotOwner();
error MyDeflationaryToken__LesserBalance();
error MyDeflationaryToken__NotApprovedForThisAmount();
error MyDeflationaryToken__TransferFailed();

Instead of using require with strings, the contract uses custom errors. This reduces gas costs because errors store data more efficiently than string messages. Each error has a descriptive name, making it clear what condition failed. We will be using this errors later in our contract, don’t bother understanding them for now tho i made them more descriptive that you can grab their functions just by reading it.

EVENTS

event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);

These is done to match the ERC20 standard events:

In the ERC20 token standard, there are some certain functions, events, variables that should be used in the contract to be considered an ERC20 token.

STATE VARIABLES

Let’s specify our state variables.

uint256 public transferFee;
uint256 public burnPercent;
uint256 public hodlersPercent;
uint256 public treasuryPercent;
address public immutable treasuryWallet;
address public immutable hodlersDistributionWallet;

These store the tokenomics configuration:

    uint256 private constant MAX_TRANSFER_FEE = 1_000; // 10% in basis points
    uint256 private constant PRECISION = 10_000; // 10000 basis points = 100%

You may be asking why we are using basis points instead of just using 10 to represent 10% and 100 to represent 100%, the issue is solidity doesn’t support float like other programing languages. If we want to for example, use 0.1% as our transferFee, passing 0.1 as a parameter wont work so we have to make use of basis points. 1_000 represents 10%, 500 represents 5%, 10 represents 0.1% and so one, this is widely used in DeFi.

address public immutable owner;
string public constant name = "IconToken";
string public constant symbol = "ICON";
uint8 public constant decimals = 18;
uint256 private _totalSupply;
mapping(address => uint256) public balances;
mapping(address owner => mapping(address spender => uint256 amount)) public approvals;

MODIFIERS

We are going to be using modifier onlyOwner for access control, there are some functions in our contract that we will want only the deployer can call, like the mint() and updateFee().

modifier onlyOwner() {
    if (msg.sender != owner) {
        revert MyDeflationaryToken__NotOwner();
    }
    _;
}

This ensures that certain functions can only be called by the contract owner.

CONSTRUCTOR

And then we have a giant constructor, this is for the contract deployer.

    constructor(
        address _treasuryWallet,
        address _hodlersDistributionWallet,
        uint256 _transferFee,
        uint256 _burnPercent,
        uint256 _treasuryPercent,
        uint256 _hodlersPercent
    ) {
        owner = msg.sender;
        treasuryWallet = _treasuryWallet;
        if (_transferFee > MAX_TRANSFER_FEE) {
            revert MyDeflationaryToken__CantExceedMaxTransferFee();
        }
        transferFee = _transferFee;
        burnPercent = _burnPercent;
        treasuryPercent = _treasuryPercent;
        hodlersPercent = _hodlersPercent;
        uint256 allFees = burnPercent + treasuryPercent + hodlersPercent;
        if (allFees != _transferFee) {
            revert MyDeflationaryToken__AllFeesMustSumUpToTransferFee();
        }
        hodlersDistributionWallet = _hodlersDistributionWallet;
    }

The constructor:

If any of these conditions fail, the constructor reverts using the relevant custom error.

MINT FUNCTION

Next is the mint function, this allows only the owner i.e the deployer of the contract can call.

    function mint(address to, uint256 amount) public onlyOwner {
        if (to == address(0)) {
            revert MyDeflationaryToken__CantBeZeroAddress();
        }
        balances[to] += amount;
        _totalSupply += amount;
        emit Transfer(address(0), to, amount);
    }

TRANSFER FUNCTION

This is the function that allows transfers of certain amount of our token from one address to the other.

function transfer(address receiver, uint256 amount) public returns (bool) {
        if (balances[msg.sender] < amount) {
            revert MyDeflationaryToken__LesserBalance();
        }
        if (receiver == address(0)) {
            revert MyDeflationaryToken__CantBeZeroAddress();
        }
        uint256 fee = (amount * transferFee) / PRECISION;
        uint256 burnShare;
        uint256 treasuryShare;
        uint256 hodlersShare;
        if (fee > 0 && transferFee > 0) {
            burnShare = (fee * burnPercent) / transferFee;
            treasuryShare = (fee * treasuryPercent) / transferFee;
            hodlersShare = fee - burnShare - treasuryShare; // remainder to hodlers
        } else {
            burnShare = 0;
            treasuryShare = 0;
            hodlersShare = 0;
        }

        uint256 netAmount = amount - fee;
        balances[receiver] += netAmount;
        balances[treasuryWallet] += treasuryShare;
        balances[hodlersDistributionWallet] += hodlersShare;
        balances[msg.sender] -= amount;
        _totalSupply -= burnShare; // Reduce total supply by the burned amount
        emit Transfer(msg.sender, receiver, netAmount);
        if (treasuryShare > 0) emit Transfer(msg.sender, treasuryWallet, treasuryShare);
        if (hodlersShare > 0) emit Transfer(msg.sender, hodlersDistributionWallet, hodlersShare);
        if (burnShare > 0) emit Transfer(msg.sender, address(0), burnShare);
        return true;
    }

This transfer function sends tokens from the sender (the person calling the function) to another address, but it also applies a transfer fee that gets split into three parts:

Step-by-Step Explanation:
Function signature

function transfer(address receiver, uint256 amount) public returns (bool)
1. Check the sender’s balance

if (balances[msg.sender] < amount) {
    revert MyDeflationaryToken__LesserBalance();
}

If the sender don’t have enough tokens, the transaction fails with a custom error MyDeflationaryToken__LesserBalance.

2. Prevent sending to the zero address

if (receiver == address(0)) {
    revert MyDeflationaryToken__CantBeZeroAddress();
}

The zero address (0x000...000) is like a black hole for tokens. This check prevents accidental loss.

3. Calculate the fee

uint256 fee = (amount * transferFee) / PRECISION;
transferFee is a percentage (like 200 for 2% if PRECISION is 10,000).

This line calculates the fee to deduct from the transfer.

4. Split the fee into parts

if (fee > 0 && transferFee > 0) {
    burnShare = (fee * burnPercent) / transferFee;
    treasuryShare = (fee * treasuryPercent) / transferFee;
    hodlersShare = fee - burnShare - treasuryShare;
} else {
    burnShare = 0;
    treasuryShare = 0;
    hodlersShare = 0;
}

If there’s no fee, all shares are set to 0.

5. Calculate the net amount to send
uint256 netAmount = amount - fee;

This is the actual amount the receiver will get after subtracting the fee.

6. Update balances

balances[receiver] += netAmount;
balances[treasuryWallet] += treasuryShare;
balances[hodlersDistributionWallet] += hodlersShare;
balances[msg.sender] -= amount;
_totalSupply -= burnShare;

Reduce _totalSupply by the burn amount (permanently removing tokens).

7. Emit Transfer events

emit Transfer(msg.sender, receiver, netAmount);
if (treasuryShare > 0) emit Transfer(msg.sender, treasuryWallet, treasuryShare);
if (hodlersShare > 0) emit Transfer(msg.sender, hodlersDistributionWallet, hodlersShare);
if (burnShare > 0) emit Transfer(msg.sender, address(0), burnShare);

Transfer events let blockchain explorers (like Etherscan) and frontends track token movements. Even burning is logged as a transfer to the zero address.

8. Return success

return true;
The function ends successfully and returns true.
Example:

If Alice sends 100 tokens to Bob with:

The rest goes to hodlers.

Then:

Bob gets 95 tokens.

Supply decreases by 2 tokens.

TRANSFER FROM FUNCTION

This performs almost the same function as the transfer() but here someone or another contract can transfer a user tokens on their behalf.

function transferFrom(address sender, address receiver, uint256 amount) public returns (bool) {
        if (approvals[sender][msg.sender] < amount) {
            revert MyDeflationaryToken__NotApprovedForThisAmount();
        }
        if (balances[sender] < amount) {
            revert MyDeflationaryToken__LesserBalance();
        }
        if (sender == address(0) || receiver == address(0)) {
            revert MyDeflationaryToken__CantBeZeroAddress();
        }
        uint256 fee = (amount * transferFee) / PRECISION;
        uint256 burnShare;
        uint256 treasuryShare;
        uint256 hodlersShare;

        if (fee > 0 && transferFee > 0) {
            burnShare = (fee * burnPercent) / transferFee;
            treasuryShare = (fee * treasuryPercent) / transferFee;
            hodlersShare = fee - burnShare - treasuryShare; // remainder to hodlers
        } else {
            burnShare = 0;
            treasuryShare = 0;
            hodlersShare = 0;
        }

        uint256 netAmount = amount - fee;
        balances[receiver] += netAmount;
        balances[treasuryWallet] += treasuryShare;

        balances[hodlersDistributionWallet] += hodlersShare;

        balances[sender] -= amount;
        approvals[sender][msg.sender] -= amount; // Decrease the allowance
        _totalSupply -= burnShare; // Reduce total supply by the burned amount
        emit Transfer(sender, receiver, netAmount);
        if (treasuryShare > 0) emit Transfer(sender, treasuryWallet, treasuryShare);
        if (hodlersShare > 0) emit Transfer(sender, hodlersDistributionWallet, hodlersShare);
        if (burnShare > 0) emit Transfer(sender, address(0), burnShare);

        return true;
    }

Steps:

APPROVE FUNCTION

Now, Let’s make sure people can approve some particular contract to use transferFrom() on thier tokens.

    function approve(address spender, uint256 amount) public returns (bool) {
        approvals[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }
Function Overview
function approve(address spender, uint256 amount) public returns (bool)

This is part of the ERC-20 token standard and is used before someone calls transferFrom().

Step-by-Step:
1. Set the allowance
approvals[msg.sender][spender] = amount;

This line sets the allowed amount to amount.

Example:

If Alice calls:

approve(Bob, 50);
That means:
approvals[Alice][Bob] = 50;

So Bob can now move up to 50 tokens from Alice’s balance using transferFrom().

2. Emit an Approval event
emit Approval(msg.sender, spender, amount);

This logs the approval on the blockchain. Wallets and dApps (like Uniswap) watch for this event so they know when they have permission.

3. Return success
return true;

Returns true to confirm the approval worked.

Remember this function doesn’t transfer tokens — it only sets permission. Once approved, the spender can call transferFrom() until they use up the allowance, or the owner changes it with another approve() call.

If you approve again, it overwrites the previous allowance.

Example in Action:
approve(Bob, 40);

→ Now Bob is allowed to take up to 40 tokens from Alice.

Bob can now call:
transferFrom(Alice, Charlie, 25);

→ Charlie gets 25 tokens, Bob’s remaining allowance = 15.

UPDATE FEES function

The purpose of the updateFee() is to let the contract owner change the transfer fee and how that fee is split between burn, treasury, and hodlers.

    function updateFees(
        uint256 _newTransferFee,
        uint256 _newBurnPercent,
        uint256 _newTreasuryPercent,
        uint256 _newHodlersPercent
    ) public onlyOwner {
        if (_newTransferFee > MAX_TRANSFER_FEE) {
            revert MyDeflationaryToken__CantExceedMaxTransferFee();
        }
        transferFee = _newTransferFee;
        burnPercent = _newBurnPercent;
        treasuryPercent = _newTreasuryPercent;
        hodlersPercent = _newHodlersPercent;
        uint256 allFees = burnPercent + treasuryPercent + hodlersPercent;
        if (allFees != _newTransferFee) {
            revert MyDeflationaryToken__AllFeesMustSumUpToTransferFee();
        }
    }
Step-by-step

Uses onlyOwner modifier → only deployer/owner can call.

If _newTransferFee > MAX_TRANSFER_FEE (10%), it reverts.

Sets new transferFee, burnPercent, treasuryPercent, hodlersPercent.

INCREASE AND DECREASE ALLOWANCE FUNCTION

    function increaseAllowance(address spender, uint256 addedValue) public onlyOwner returns (bool) {
        approvals[msg.sender][spender] += addedValue;
        emit Approval(msg.sender, spender, approvals[msg.sender][spender]);
        return true;
    }

    function decreaseAllowance(address spender, uint256 subtractedValue) public onlyOwner returns (bool) {
        if (approvals[msg.sender][spender] < subtractedValue) {
            revert MyDeflationaryToken__NotApprovedForThisAmount();
        }
        approvals[msg.sender][spender] -= subtractedValue;
        emit Approval(msg.sender, spender, approvals[msg.sender][spender]);
        return true;
    }

These two function does so simple and almost similar thing, increaseAllowance() to obviously increase the spender allowance. addedValue is added to existing approvals[msg.sender][spender], emits Approval with the new total allowance and return true for success.

The decreaseAllowance() on the other hand to decrease the spender allwonces. It gets the current allowance, If trying to subtract more than allowed, revert. If not, subtract subtractedValue from allowance. Emit Approval with the new allowance.

Return true.

VIEW FUNCTION

And Voila! We’ve just built a deflationary ERC-20 token with a transfer fee mechanism that burns tokens, funds the treasury, and rewards holders.

This mechanism can help create scarcity while funding development and incentivizing long-term holding. It’s a great fit for projects that want sustainable tokenomics.

You can try deploying this contract on a testnet, tweak the fee percentages, or extend it with staking features. If you build something with it, share your results. I’d love to see them!

Also remember we use no battles tested dependencies like Openzeppelin here. I’m not perfect, if you spot any bug in this contract, feel free to PR on my Github (Link below)

THANKS FOR READING!!

Check the full code here on Github

And don’t forget to follow me on my socials to keep up on what im building next Twitter

ciao ciao!!