Creating a token
Now that we've explored how ERC20 tokens work, you may have noticed some similiarities with the leaderboard in our game. The internal mapping storing the number of points awarded by player address in our game is not too different from the internal mapping storing token balances in an ERC20. Let's lean into this similarity and use a token as our mechanism for awarding points.
Of course, tokens are transferrable, so this is conceptually a little bit different than our existing points scoreboard: less like the high score screen at the end of an arcade game, and more like a skee-ball machine that gives out prize tickets.
To start, we need a token of our own. Let's create a new, empty test file: src/test/Token.t.sol
:
// SPDX-License-Identifier: Apache-2.0
pragma solidity 0.8.10;
import "ds-test/test.sol";
import "forge-std/Vm.sol";
import "../Token.sol";
contract TicTacTokenTest is DSTest {
Vm internal vm = Vm(HEVM_ADDRESS);
Token internal token;
function setUp() public {
token = new Token();
}
}
...and a new, empty contract for our token, src/Token.sol
:
// SPDX-License-Identifier: Apache-2.0
pragma solidity 0.8.10;
contract Token {}
We'll add a few inital tests for the token metadata attributes: name, symbol, and decimals:
function test_token_name() public {
assertEq(token.name(), "Tic Tac Token");
}
function test_token_symbol() public {
assertEq(token.symbol(), "TTT");
}
function test_token_decimals() public {
assertEq(token.decimals(), 18);
}
Over in our token contract, let's import and use OpenZeppelin ERC20:
import "openzeppelin-contracts/contracts/token/ERC20.sol";
contract Token is ERC20 {}
Run the tests, and we'll see a compiler error:
$ forge test
[⠊] Compiling...
[⠒] Compiling 2 files with 0.8.10
[⠢] Solc finished in 12.85ms
Error:
0: Compiler run failed
TypeError: Contract "Token" should be marked as abstract.
--> /Users/ecm/Projects/ttt-book-code/src/Token.sol:6:1:
|
6 | contract Token is ERC20 {}
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
Note: Missing implementation:
--> /Users/ecm/Projects/ttt-book-code/lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol:54:5:
|
54 | constructor(string memory name_, string memory symbol_) {
| ^ (Relevant source part starts here and spans across multiple lines).
This Solidity error is a little cryptic, but it's indicating that our base contract ERC20
is expecting constructor arguments. In this case, we need to pass the token name and symbol at construction time.
Let's add a constructor()
function and pass the name and symbol as arguments. We can use a modifier-like syntax inline next to the constructor function to pass the arguments needed by ERC20
:
import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
contract Token is ERC20 {
constructor() ERC20("Tic Tac Token", "TTT") {}
}
With our base contract set up correctly, our tests should pass:
$ forge test
Running 3 tests for src/test/Token.t.sol:TicTacTokenTest
[PASS] test_token_decimals() (gas: 5481)
[PASS] test_token_name() (gas: 9688)
[PASS] test_token_symbol() (gas: 9687)
Test result: ok. 3 passed; 0 failed; finished in 1.67ms
In order to issue tokens to winners, we'll need to expose the ability to mint new tokens. OpenZeppelin ERC20 provides an internal _mint
function we can use for this purpose.
Let's start with a test. We'll add a public mint
function to our contract that takes an address to credit with the newly created tokens and an amount of tokens to mint. In our test, we'll mint 100 tokens to the test contract, then call balanceOf
on the token contract to check that we've received them:
function test_mint_to_user() public {
token.mint(address(this), 100 ether);
assertEq(token.balanceOf(address(this)), 100 ether);
}
Ether units
The keyword
ether
in the example above may look a little strange.ether
is a globally available unit in Solidity that can apply to any numeric literal.1 ether
is equivalent to1e18
. This is the number of decimal places that denominate ETH, and the number of decimal places that denominate most ERC20 tokens, including ours in the example above. Using the100 ether
in the example above is shorthand for100 * 1e18
, or "100 tokens".The units
gwei
andwei
are also available keywords representing smaller denominations.
Let's add a public function that will call the internal _mint
function from our base ERC20 contract. Just to make sure our tests are working, we'll leave it empty for now:
function mint(address account, uint256 amount) public {
}
Run the tests to verify:
$ forge test
Running 4 tests for src/test/Token.t.sol:TicTacTokenTest
[FAIL] test_mint_to_user() (gas: 19258)
Logs:
Error: a == b not satisfied [uint]
Expected: 100000000000000000000
Actual: 0
[PASS] test_token_decimals() (gas: 5504)
[PASS] test_token_name() (gas: 9644)
[PASS] test_token_symbol() (gas: 9710)
Test result: FAILED. 3 passed; 1 failed; finished in 539.42µs
Failed tests:
[FAIL] test_mint_to_user() (gas: 19258)
Encountered a total of 1 failing tests, 3 tests succeeded
Great! This is the failure we should expect: our account had a balance of zero tokens, but we expected 100. (Note the 18 decimal places in the expected amount!)
Let's update the public mint function to actually call the internal _mint
function and pass through the arguments:
function mint(address account, uint256 amount) public {
_mint(account, amount);
}
When we run the tests again, we'll see that our account has the expected balance:
$ forge test
Running 4 tests for src/test/Token.t.sol:TicTacTokenTest
[PASS] test_mint_to_user() (gas: 52968)
[PASS] test_token_decimals() (gas: 5504)
[PASS] test_token_name() (gas: 9644)
[PASS] test_token_symbol() (gas: 9710)
Test result: ok. 4 passed; 0 failed; finished in 701.88µs
We now have a fully functional ERC20. It's not much use testing the built in behavior of our parent contract, but here's just one more test to prove the point. We'll transfer some of our tokens to another address and check the balances of both:
function test_transfer_tokens() public {
token.mint(address(this), 100 ether);
token.transfer(address(42), 50 ether);
assertEq(token.balanceOf(address(this)), 50 ether);
assertEq(token.balanceOf(address(42)), 50 ether);
}
Run our tests, and this should pass, since we've inherited this behavior from the base ERC20 contract.
$ forge test
Running 5 tests for src/test/Token.t.sol:TicTacTokenTest
[PASS] test_mint_to_user() (gas: 52968)
[PASS] test_token_decimals() (gas: 5504)
[PASS] test_token_name() (gas: 9644)
[PASS] test_token_symbol() (gas: 9710)
[PASS] test_transfer_tokens() (gas: 79779)
Test result: ok. 5 passed; 0 failed; finished in 701.58µs
We're missing one more important thing: permissioning the mint
function so that. Let's use OpenZeppelin Ownable
to limit access to mint
with the onlyOwner
modifier. If we prank
another address and try to mint, the call should revert:
function test_non_owner_cannot_mint() public {
vm.prank(address(42));
vm.expectRevert("Ownable: caller is not the owner");
token.mint(address(this), 100 ether);
}
We can import Ownable
like we did with ERC20
, inherit it and use the onlyOwner
modifier. Note that we don't need to pass any constructor arguments this time:
import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
contract Token is ERC20, Ownable {
constructor() ERC20("Tic Tac Token", "TTT") {}
function mint(address account, uint256 amount) public onlyOwner {
_mint(account, amount);
}
}
With the modifier in place, our test should pass:
$ forge test
Running 6 tests for src/test/Token.t.sol:TicTacTokenTest
[PASS] test_mint_to_user() (gas: 55220)
[PASS] test_non_owner_cannot_mint() (gas: 13342)
[PASS] test_token_decimals() (gas: 5460)
[PASS] test_token_name() (gas: 9665)
[PASS] test_token_symbol() (gas: 9753)
[PASS] test_transfer_tokens() (gas: 81899)
Test result: ok. 6 passed; 0 failed; finished in 828.25µs