My last article 101 Smart Contracts and Decentralized Apps in Ethereum discussed all the introductory concepts for developing Smart Contracts and Decentralized Applications in Ethreum.
This article is focused on implementing a concrete sample that includes Smart Contracts and a Decentralized Application (DApp) written with Next.js.
The smart contracts in this article include a custom ERC-20 token and a Faucet. Don't worry if you don't know what those are. I am planning to give a short overview first.
ERC-20
The acronym ERC stands for Ethereum Request for Comments. Those are Application-Level standards and conventions for Smart Contract development in Ethereum. They are available here.
Any new standard related to Smart Contract development (e.g., new tokens, wallet formats) is proposed through an ERC draft and eventually approved and released on that site.
ERC-20 is the standard for fungible tokens. Fungibility is the ability of an asset to be interchanged for another of the same type. If you have a token of the type "FOO", you can change it for another of the same type without losing any value.
If you look into the specification, it is no other thing than an interface for a smart contract that represents a fungible token.
function name() public view returns (string)
function symbol() public view returns (string)
function decimals() public view returns (uint8)
function totalSupply() public view returns (uint256)
function balanceOf(address _owner) public view returns (uint256 balance)
function transfer(address _to, uint256 _value) public returns (bool success)
function transferFrom(address _from, address _to, uint256 _value) public returns (bool success)
function approve(address _spender, uint256 _value) public returns (bool success)
function allowance(address _owner, address _spender) public view returns (uint256 remaining)
event Transfer(address indexed _from, address indexed _to, uint256 _value)
event Approval(address indexed _owner, address indexed _spender, uint256 _value)
Suppose you take a Smart Contract and implement those methods using Solidity or any other language compatible with the Ethereum VM. In that case, your contract will adhere to the ERC-20 standard and can be considered a token.
Let's discuss those methods more in detail.
function name() public view returns (string)
function symbol() public view returns (string)
name and symbol are the name and symbol associated with the token. For example, a token "My Foo Token" with the symbol "FOO".
function decimals() public view returns (uint8)
Ethereum does not support decimals for amounts. Decimals is a workaround to express how many decimal positions a token supports. For example, 18 would mean a single token uses 18 zeros (1000000000000000000)
function totalSupply() public view returns (uint256)
It returns the total number/supply of available tokens.
function balanceOf(address _owner) public view returns (uint256 balance)
It returns the balance for a given address.
function transfer(address _to, uint256 _value) public returns (bool success)
function transferFrom(address _from, address _to, uint256 _value) public returns (bool success)
These two allow transferring tokens from one address to another.
function approve(address _spender, uint256 _value) public returns (bool success)
function allowance(address _owner, address _spender) public view returns (uint256 remaining)
Approve allows allocating/lending some tokens to a third party (a different address, the spender). The owner of those tokens is taken from the sender address when the smart contract is invoked. This method is what we will use for our Faucet contract. The other method, allowance returns the number of tokens allocated/lent to a given address by the owner.
As you can see, a fungible token never leaves this contract. The available supply and owners are all reflected and stored on it.
Minting vs. Mining
Minting and Mining are two terms often mention when talking about token issuance. ERC-20 are not native tokens in the ETH Blockchain; smart contracts represent them, so you can not obtain those as part of the mining process. You can only mine Ether. On the other hand, minting is about issuing ERC-20 tokens, and it's usually part of the internal implementation of the smart contract. The initial supply can probably be minted in the Smart Contract constructor and updated later by other methods.
A sample token
As part of this article, I decided to implement a fictitious token called "Cibrax". Here is the code.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract CibraxToken is ERC20 {
constructor(uint256 initialSupply) ERC20("Cibrax", "CIBRAX") {
_mint(msg.sender, initialSupply);
}
}
There isn't much to show here. I borrowed the ERC20 contract implementation from OpenZepellin and used that one as the base class for my contract. OpenZepellin distributes the contracts as a library via NPM.
You provide the token name and symbol as part of the constructor and call the base method _mint to specify the initial supply. My implementation takes the initial supply from the constructor when you run the initial deployment. Also, the default value for decimals is 18, but you can optionally override that one too.
Faucet
You might need tokens for paying gas or for any other operation in smart contracts. When you are in a production Blockchain, you can buy those or get them as rewards/fees from mining blocks. However, it does not make much sense to do the same in a testing network. Those networks provide Faucets, which are smart contracts for borrowing tokens. There is no standard for the implementation of a Faucet. Here is a Faucet I implemented for the sample included with this article.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "@openzeppelin/contracts/access/Ownable.sol";
interface ERC20 {
function transfer(address to, uint256 value) external returns (bool);
function name() view external returns (string memory);
event Transfer(address indexed from, address indexed to, uint256 value);
}
contract Faucet is Ownable {
uint256 constant public waitTime = 30 minutes;
ERC20 public tokenInstance;
mapping(address => uint256) lastAccessTime;
constructor(address _tokenInstance) Ownable() {
require(_tokenInstance != address(0));
tokenInstance = ERC20(_tokenInstance);
}
function requestTokens(address _receiver, uint256 tokenAmount) onlyOwner public {
require(allowedToWithdraw(_receiver, tokenAmount));
tokenInstance.transfer(_receiver, tokenAmount);
lastAccessTime[_receiver] = block.timestamp + waitTime;
}
function allowedToWithdraw(address _address, uint256 tokenAmount) public view returns (bool) {
if(tokenAmount > 3) {
return false;
}
if(lastAccessTime[_address] == 0) {
return true;
} else if(block.timestamp >= lastAccessTime[_address]) {
return true;
}
return false;
}
function tokenName() public view returns (string memory) {
return tokenInstance.name();
}
}
This contract works like a private Faucet, as only the owner can transfer tokens to other addresses. I did this on purpose, so we can assign our Dapp as owner of the Faucet and only allow transferring tokens from it. If someone wants to bypass the Dapp and send a transaction to this Smart Contract, it will be rejected.
contract Faucet is Ownable {
The contracts inherit from the Ownable Smart Contract implementation from OpenZeppelin. This contract gives you access to a "onlyOwner" condition for methods, which means only the assigned owner can invoke them. In our scenario, the Dapp will be the assigned owner.
constructor(address _tokenInstance) Ownable() {
require(_tokenInstance != address(0));
tokenInstance = ERC20(_tokenInstance);
}
The constructor ties the Faucet to any Smart Contract implementation that provides an ERC20 interface. In our example, we will assign our Cibrax token. You provide the address of the ERC20 contract, which can not be the empty address (address(0)) for deploying new contracts.
function allowedToWithdraw(address _address, uint256 tokenAmount) public view returns (bool) {
if(tokenAmount > 3) {
return false;
}
if(lastAccessTime[_address] == 0) {
return true;
} else if(block.timestamp >= lastAccessTime[_address]) {
return true;
}
return false;
}
It checks for different conditions before any token is giving away. You can not ask for more than three tokens or do multiple calls within 30 minutes. These are vague conditions, but it shows a purpose. They prevent someone from exhausting all your available tokens.
Testing our Smart Contracts
I decided to use the HardHat tools to compile, test the contracts, and deploy them in an emulator.
Hardhat uses ethers.js as the client library for connecting to a local ETH node, and Waffle as the testing framework.
describe("CibraxToken", function () {
let owner, addr1, addr2, addr3, addr4, addr5;
let CibraxToken, cibraxToken;
let Faucet, faucet;
before(async function() {
[owner, addr1, addr2, addr3, addr4, addr5] = await ethers.getSigners();
CibraxToken = await ethers.getContractFactory("CibraxToken");
cibraxToken = await CibraxToken.deploy(1000);
await cibraxToken.deployed();
Faucet = await ethers.getContractFactory("Faucet");
faucet = await Faucet.deploy(cibraxToken.address);
await faucet.deployed();
await cibraxToken.transfer(faucet.address, 500);
});
it("Deployment should assign a name", async function () {
const name = await cibraxToken.name();
expect(name).to.equal("Cibrax");
});
it("Deployment should assign a symbol", async function () {
const name = await cibraxToken.symbol();
expect(name).to.equal("CIBRAX");
});
it("Owner can transfer tokens", async function () {
const amount = 100;
const tx = await cibraxToken.transfer(addr1.address, amount) ;
const receipt = await tx.wait();
const otherBalance = await cibraxToken.balanceOf(addr1.address);
expect(amount).to.equal(otherBalance);
expect(receipt.events?.filter((x) => {return x.event == "Transfer"})).to.not.be.null;
});
it("Faucet can transfer assigned tokens", async function () {
const amount = 1;
await faucet.requestTokens(addr3.address, amount);
const balance = await cibraxToken.balanceOf(addr3.address);
expect(amount).to.equal(balance);
});
it("Faucet returns token name", async function () {
const name = await faucet.tokenName();
expect(name).to.equal("Cibrax");
});
it("Faucet can not transfer more than assigned tokens", async function () {
await expect(faucet.requestTokens(addr4.address, 5)).to.be.reverted;
});
it("Faucet can not transfer on consecutive calls", async function () {
const amount = 1;
await faucet.requestTokens(addr4.address, amount);
await expect(faucet.requestTokens(addr4.address, amount)).to.be.reverted;
});
it("Faucet can transfer allowed tokens", async function () {
const allowedAmount = 5;
const amount = 1;
await cibraxToken.approve(faucet.address, allowedAmount);
await faucet.requestTokens(addr5.address, amount);
const balance = await cibraxToken.balanceOf(addr5.address);
expect(amount).to.equal(balance);
});
});
The "before" method is used for test initialization. It deploys the ERC-20 and Faucet contracts, and obtains their address. The other methods describe the tests.
it("Faucet can transfer allowed tokens", async function () {
const allowedAmount = 5;
const amount = 1;
await cibraxToken.approve(faucet.address, allowedAmount);
await faucet.requestTokens(addr5.address, amount);
const balance = await cibraxToken.balanceOf(addr5.address);
expect(amount).to.equal(balance);
});
For example, the test above uses the "approve" method in our custom token to pre-assign or authorize some tokens to the Faucet (five tokens in total). To be clear, the tokens are not directly assigned to the Faucet, but it allows the Faucet to use them. The Faucet later transfers one of those tokens to an ETH address (addr5), and the balance on that address is verified.
For compiling the contracts and running the tests, you can run these commands in a console.
npx hardhat compile
npx hardhat test
Dapp
The Decentralized App or DApp, in short, it's the visible layer for our Faucet.
It's implemented in Next.js and leverages Next-Auth.js for authentication with social providers.
The .env file is used to assign the address of the Faucet and private key to our application.
ETH_NODE_URL=http://127.0.0.1:8545/
ETH_FAUCET_ADDRESS=<address>
ETH_PRIVATE_KEY=<private key>
If you want to deploy the contracts in the local Hardhat emulator, you can run the script "cibraxtoken-deploy.js" under contracts/scripts. That script will return the address of the contracts. Also, Hardhat always uses the first address from the available accounts in the emulator for deploying the contracts, so you can take the private key from that account and configure it in the Next.js app.
User authentication
The main page in our application uses the Next-Auth.js react hooks for coordinating the sign-on process.
import Head from 'next/head'
import { TokenForm } from './components/TokenForm';
import { signIn, signOut, useSession } from 'next-auth/client'
export default function Home() {
const [ session ] = useSession()
return (
<div className="flex flex-col items-center justify-center min-h-screen py-2">
<Head>
<title>Faucet with Social Auth</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="flex flex-col items-center justify-center w-full flex-1 px-20 text-center">
<p className="mt-3 text-2xl">
You can use this Faucet to request Cibrax tokens
</p>
{session && <TokenForm session={session}/>}
<p className="mt-3 text-2xl">
{(session) ?
<button onClick={() => signOut()} className="shadow bg-blue-500 hover:bg-blue-400 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded">Logout</button> :
<button onClick={() => signIn()} className="shadow bg-blue-500 hover:bg-blue-400 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded">Login</button>
}
</p>
</main>
</div>
)
}
The useSession hook returns the existing user session if there is one. If no session is available, we can display the Login button and assign the signIn hook to it. This method will do the sign-on handshake with the social providers.
Smart Contract Calls
The integration with the Smart Contract is done server-side through an API.
import type { TransactionResponse } from '../types'
import type { NextApiRequest, NextApiResponse } from 'next'
import { getSession } from "next-auth/client"
import { ethers } from 'ethers';
import artifact from '../../contracts/Faucet.json';
const url = process.env.ETH_NODE_URL;
const privateKey = process.env.ETH_PRIVATE_KEY || "";
const faucetAddress = process.env.ETH_FAUCET_ADDRESS || "";
const provider = new ethers.providers.JsonRpcProvider();
const wallet = new ethers.Wallet(privateKey, provider);
const faucet = new ethers.Contract(faucetAddress, artifact.abi, wallet);
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<TransactionResponse>
) {
const session = await getSession({ req })
if(!session) {
res.status(401);
return;
}
if(!req.body.address) {
res.status(400);
return;
}
await faucet.requestTokens(req.body.address, 1, {
gasPrice: 25e9
});
res.status(200).json({ successful: true })
};
This API does two things, It connects the Faucet contract using ether.js. The settings for connecting to the node are retrieved from the .env file. It checks the user session from Next-Auth.js. If the user is not authenticated, it returns 401. Otherwise, it makes a call to the Faucet to transfer tokens.
Get and Run the code
The complete sample is available to download from this Github repository - social-faucet