Builder 03 of 06
Your First Token
Deploy an ERC-20 and move it. The check calls your contract to confirm it really implements the standard.
13 min read, then one transaction
A token is not a special kind of thing on a blockchain. There is no token opcode, no token registry, no privileged status. A token is a contract that keeps a table of who owns how much, and agrees to answer a fixed set of questions about it.
That is the entire idea. ERC-20 is the list of questions.
The standard is smaller than you think
Six functions and two events. That is all a contract needs to be an ERC-20:
interface IERC20 {
function name() external view returns (string memory);
function symbol() external view returns (string memory);
function decimals() external view returns (uint8);
function totalSupply() external view returns (uint256);
function balanceOf(address owner) external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
function approve(address spender, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}
That is the interface, not the contract you deploy — it declares the questions
without answering any of them. The deployable SimpleToken.sol is printed in
full at the end of the lesson.
Implement those and every wallet, explorer, and exchange on every EVM chain knows how to handle your token, without being told it exists. Nobody registered it anywhere. It works because everybody agreed to ask the same questions.
Your balance is a row in somebody else's table
The most common misconception about tokens is that they live in your wallet. They do not.
mapping(address => uint256) public balanceOf;
That mapping lives in the token's contract, not yours. Your wallet holds no
tokens — it holds a private key that can authorise changes to a row in that
table. When your wallet "shows" a token balance, it has called balanceOf on
the contract and displayed the answer.
Native zkLTC
- Tracked by the chain itself
- Moved by the `value` field of a transaction
- Every node knows it without being told
- Cannot be frozen by a contract
An ERC-20 token
- Tracked by one ordinary contract
- Moved by calling a function on that contract
- Wallets must be told the contract address
- Can do whatever its code says it can
This is why adding a token to a wallet means pasting a contract address, and why a chain's native currency never needs that step.
Decimals are a display convention
The EVM has no fractions. Every balance is an integer. decimals tells
interfaces where to draw the decimal point, and that is the whole of its job.
With decimals = 18, one whole token is stored as
1000000000000000000. The contract never sees "one token" — it only ever moves
integers, and your wallet does the division before showing you a number.
Approve and transferFrom
transfer moves your own tokens. But a DEX cannot call transfer on your
behalf — it is not you, and the contract checks msg.sender.
Hence the two-step dance every DeFi interaction uses:
- 01
You approve
You call approve(dexAddress, amount) on the token contract. This writes a number into the allowance table. Nothing moves yet.
- 02
The DEX pulls
The DEX calls transferFrom(you, itself, amount) on the token. The token checks the allowance you granted, decreases it, and moves the balance.
Two transactions to make one swap, which is why your first DEX trade always asks you to confirm twice.
And this is the single most exploited mechanism in crypto. An approval has no expiry and no amount limit unless you set one. A malicious site asks for an unlimited approval on a token you hold, you confirm because approvals feel harmless, and it drains that token whenever it likes — days later, from a different address, with no further interaction from you.
Deploy one
The contract below the lesson is a complete ERC-20 written out longhand — no
inheritance, so every part of the standard is visible in one file. In real work
you would inherit OpenZeppelin's ERC20 and write none of it. Read it once so
that when you stop writing it, you know what you stopped writing.
- 01
Open it in Remix
Use the button below to open Remix, then create a new file named SimpleToken.sol and paste in the source above.
- 02
Compile with 0.8.20 or newer
The contract relies on Solidity 0.8's checked arithmetic, so an older compiler would change its safety properties.
- 03
Fill the three constructor fields
Remix shows one input per argument under the Deploy button: name_, symbol_ and initialSupply. Type the values from the table below into them, plain text, no quotes.
- 04
Deploy on chain 4441
Confirm your wallet is on LitVM LiteForge before you sign. Copy the deployment transaction hash.
- 05
Copy the contract address
Remix shows it on the deployed contract at the bottom of the panel. Everything below — your wallet, the explorer, anyone you send tokens to — needs that address and nothing else.
The three fields, and what to put in them:
| Field | Type this | What it is |
|---|---|---|
name_ | LitVM Academy Token | Full name a wallet shows. Spaces are fine, and nothing is reserved — use your own. |
symbol_ | LVAT | Ticker, by convention 3-5 uppercase letters. |
initialSupply | 1000000 | Whole tokens minted to you on deploy. Digits only: no commas, no decimal point, no 10 ** 18. |
The contract multiplies initialSupply by 10 ** 18 itself, so 1000000 gives
you a million whole tokens rather than a millionth of one. That is the one field
where a mistake is easy to make and impossible to undo — supply is fixed at
deploy, and this contract has no way to mint more.
Put it in your wallet
Your wallet does not discover tokens on its own. It knows about the ones it was told about, which is why a token you just deployed is invisible until you point your wallet at the contract.
- 01
Copy the contract address
From Remix's deployed contract panel, or from the deployment transaction in the explorer.
- 02
Open Import token
MetaMask: Tokens tab, Import tokens, Custom token. Rabby: Add token, then paste. Make sure the wallet is on LiteForge 4441 first, or the field will refuse the address.
- 03
Let it fill the rest in
The wallet calls symbol() and decimals() on your contract and fills both fields itself. That is the standard doing its job: it read your token without being told anything but where to look.
- 04
Confirm
Your whole supply appears — the constructor gave it to whoever deployed, which was you.
Watching your own balance appear in a real wallet is the moment the abstraction becomes concrete. Nothing was registered anywhere. Your wallet asked a contract a question and drew the answer.
Then send some
You can send from either side, and it is worth doing both once:
- From your wallet. Pick the token, press Send, paste an address, type
5. The wallet multiplies bydecimalsfor you before it builds the transaction. - From Remix. Press
transferwithtoandvalue. There is no wallet in between doing the scaling, sovalueis in base units: five whole tokens is5000000000000000000, five with eighteen zeros after it. Type5here and you have sent five of the smallest possible fractions of a token.
The explorer keeps a page per token at
liteforge.explorer.caldera.xyz/token/<your contract address>, listing every
holder and every transfer. That page is built entirely out of the events your
contract emitted, which is the next thing to look at.
Open your transfer transaction in the explorer and look at the logs.
There is your Transfer event, with from and to in the indexed topics. That
log is the only reason an explorer can show a token transfer at all — it does
not read the contract's storage, it reads the events the contract chose to emit.
Which leads somewhere uncomfortable: a token that moves balances without
emitting Transfer is invisible to every explorer and wallet on the chain, even
though the ledger changed. The standard requires the event for exactly that
reason.
What to remember
- 01A token is an ordinary contract keeping a table of balances, with no special status on the chain.
- 02Your tokens are a row in the token's storage, not something held by your wallet.
- 03decimals is a display convention with no arithmetic meaning, and assuming 18 is a real bug.
- 04approve grants a standing permission with no expiry, which is why unlimited approvals are the most exploited surface in DeFi.
- 05Explorers and wallets read events, not storage — a transfer that emits nothing is invisible.
Primary sources
SimpleToken.sol
107 lines, ready to paste · raw file
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title SimpleToken
/// @notice A complete ERC-20, written out in full rather than inherited, so
/// every part of the standard is visible.
///
/// In production you would inherit OpenZeppelin's ERC20 and write none of this.
/// Read it once, then never write it again.
///
/// Deployed in "Your First Token" at
/// https://litvm-academy.pro/build/your-first-token
contract SimpleToken {
// --- The four functions that make a token readable ---
/// @notice Human-readable name. Not unique, and not an identity: two
/// contracts can both call themselves "Litecoin".
string public name;
/// @notice Short ticker, by convention 3-5 uppercase letters.
string public symbol;
/// @notice Where the decimal point goes when a wallet displays a balance.
/// @dev The chain stores integers only. `decimals` is presentation, not
/// arithmetic: a balance of 1500000 with 6 decimals is shown as 1.5,
/// but the contract only ever sees 1500000.
uint8 public constant decimals = 18;
/// @notice Total number of units in existence.
uint256 public totalSupply;
// --- The two mappings that make it a ledger ---
/// @notice Units held by each address.
mapping(address => uint256) public balanceOf;
/// @notice How much `spender` may move on `owner`'s behalf.
/// @dev This is the approval mechanism every DeFi app depends on, and the
/// one every phishing site abuses. Approving is not spending, it is
/// granting permission to spend later.
mapping(address => mapping(address => uint256)) public allowance;
// --- Events. Wallets and explorers read these, not the mappings ---
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
/// @dev Minting is just a transfer whose sender is the zero address. That
/// convention is what lets an explorer show a token's creation in the
/// same list as every other movement.
constructor(string memory name_, string memory symbol_, uint256 initialSupply) {
name = name_;
symbol = symbol_;
// `initialSupply` is in whole tokens here, scaled up to base units, so
// passing 1000000 gives you a million tokens rather than a millionth.
uint256 supply = initialSupply * (10 ** decimals);
totalSupply = supply;
balanceOf[msg.sender] = supply;
emit Transfer(address(0), msg.sender, supply);
}
/// @notice Move your own tokens.
function transfer(address to, uint256 value) external returns (bool) {
_transfer(msg.sender, to, value);
return true;
}
/// @notice Allow `spender` to move up to `value` of your tokens.
/// @dev Setting a new allowance overwrites the old one, it does not add.
function approve(address spender, uint256 value) external returns (bool) {
allowance[msg.sender][spender] = value;
emit Approval(msg.sender, spender, value);
return true;
}
/// @notice Move somebody else's tokens, within the allowance they granted.
function transferFrom(address from, address to, uint256 value)
external
returns (bool)
{
uint256 allowed = allowance[from][msg.sender];
require(allowed >= value, "allowance too low");
// An unlimited allowance is left untouched, which is the usual
// optimisation: it saves a storage write on every single transfer.
if (allowed != type(uint256).max) {
allowance[from][msg.sender] = allowed - value;
}
_transfer(from, to, value);
return true;
}
function _transfer(address from, address to, uint256 value) private {
require(to != address(0), "transfer to the zero address");
require(balanceOf[from] >= value, "balance too low");
// Since Solidity 0.8 these operations revert on overflow, so no
// SafeMath and no unchecked surprises.
balanceOf[from] -= value;
balanceOf[to] += value;
emit Transfer(from, to, value);
}
}
Your first token
Paste the hash of the transaction that deployed your ERC-20. The check reads the transaction straight from LiteForge, so it has to be one your own wallet sent.