🎃

Solidity+Hardhat+React(TypeScript)でFaucetを作ってみる

23 min read

日本にびっくりするほどFaucetの記事がなかったので書いてます

概要

基本的な機能を大体備えた独自トークンFaucetをコントラクト(Solidity+Hardhat)、フロント部分をReact+TypeScript+MaterialUIで作っていきます。

完成イメージ

※ Solidityはもっと細かい機能も実装してます

事前準備

  • Metamaskのインストール
  • Reactの環境構築
  • hardhatの環境構築
  • solidityの環境構築

本文

CRAと必要なパッケージを諸々インストール

$ create-react-app token-faucet --template typescript --use-npm
$ npm install ethers hardhat @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers @openzeppelin/contracts @mui/material @emotion/react @emotion/styled react-hook-form @hookform/resolvers yup ts-node web3

Hardhat

$ npx hardhat run
❯ Create an advanced sample project that uses TypeScript
Hardhat project root: Enter
Do you want to add a .gitignore? Y

eslint関連のファイルができちゃうが、今回は使わないので削除してOK

hardhat.config.ts
import { HardhatUserConfig, task } from "hardhat/config";
import "@nomiclabs/hardhat-waffle";

// This is a sample Hardhat task. To learn how to create your own go to
// https://hardhat.org/guides/create-task.html
task("accounts", "Prints the list of accounts", async (taskArgs, hre) => {
  const accounts = await hre.ethers.getSigners();

  for (const account of accounts) {
    console.log(account.address);
  }
});

// You need to export an object to set up your config
// Go to https://hardhat.org/config/ to learn more
const config: HardhatUserConfig = {
  paths: {
    artifacts: './src/artifacts',
  },
  networks: {
    hardhat: {
      chainId: 1337
    },
  },
  solidity: "0.8.3"
};

export default config;

deployファイルを書き換える

scripts/deploy.ts
// We require the Hardhat Runtime Environment explicitly here. This is optional
// but useful for running the script in a standalone fashion through `node <script>`.
//
// When running the script with `npx hardhat run <script>` you'll find the Hardhat
// Runtime Environment's members available in the global scope.
import { ethers } from "hardhat";

async function main() {
  const [deployer] = await ethers.getSigners();

  console.log(
    "Deploying contracts with the account:",
    deployer.address
  );

  const FaucetToken = await ethers.getContractFactory("FaucetToken");
  const testToken = await FaucetToken.deploy();

  await testToken.deployed();

  console.log("Token deployed to:", testToken.address);
}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.

main()
  .then(() => process.exit(0))
  .catch(error => {
    console.error(error);
    process.exit(1);
  });

SolidityでFaucetのコントラクトを書く

contractsフォルダの中に、FaucetToken.solERC20.solIERC20.solSafeMath.solを作成し、Greeter.solを削除する

$ cd contracts && touch FaucetToken.sol ERC20.sol IERC20.sol SafeMath.sol && rm Greeter.sol
contracts/FaucetToken.sol
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./ERC20.sol";

contract FaucetToken is ERC20 {
    string public name = "FaucetToken";
    string public symbol = "FAT";
    uint256 public decimals = 18;

    uint256 public faucetMax;
    uint256 public faucetFee;
    address public owner;
    mapping(address => uint256) timeouts;

    constructor() {
        owner = msg.sender;
        faucetFee = 0 ether;
        faucetMax = 1000 * 10**18;
    }

    // Main Faucet Func
    function getMeSome(uint256 _requestValue) public payable {
        require(msg.value == faucetFee, "no_fee");
        require(_requestValue <= faucetMax, "too_much");

        _mint(msg.sender, _requestValue);
    }

    function burnMine(uint256 _burnValue) public {
        _burn(msg.sender, _burnValue);
    }

    function changeMaxAmount(uint256 _newMaxAmount) public onlyOwner {
        faucetMax = _newMaxAmount;
    }

    function changeFee(uint256 _newFee) public onlyOwner {
        faucetFee = _newFee;
    }

    function changeOwner(address payable _newOwner) public onlyOwner {
        owner = _newOwner;
    }

    function withdraw(address payable _account) public onlyOwner {
        _account.transfer(payable(address(this)).balance);
    }

    fallback() external {
        require(msg.data.length == 0, "Invalid detail");
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "You are not owner!!");
        _;
    }

    modifier timeCheck() {
        require(
            timeouts[msg.sender] <= block.timestamp - 30 minutes,
            "You can only withdraw once every 30 minutes. Please check back later."
        );
        _;

        timeouts[msg.sender] = block.timestamp;
    }
}
contracts/ERC20.sol
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./IERC20.sol";
import "./SafeMath.sol";

/**
 * @title Standard ERC20 token
 *
 * @dev Implementation of the basic standard token.
 * https://eips.ethereum.org/EIPS/eip-20
 * Originally based on code by FirstBlood:
 * https://github.com/Firstbloodio/token/blob/master/smart_contract/FirstBloodToken.sol
 *
 * This implementation emits additional Approval events, allowing applications to reconstruct the allowance status for
 * all accounts just by listening to said events. Note that this isn't required by the specification, and other
 * compliant implementations may not do it.
 */
contract ERC20 is IERC20 {
    // Require defined for four arithmetic operations
    using SafeMath for uint256;

    mapping(address => uint256) private _balances;

    mapping(address => mapping(address => uint256)) private _allowed;

    uint256 private _totalSupply;

    /**
     * @dev Total number of tokens in existence.
     */
    function totalSupply() public view override returns (uint256) {
        return _totalSupply;
    }

    /**
     * @dev Gets the balance of the specified address.
     * @param owner The address to query the balance of.
     * @return A uint256 representing the amount owned by the passed address.
     */
    function balanceOf(address owner) public view override returns (uint256) {
        return _balances[owner];
    }

    /**
     * @dev Function to check the amount of tokens that an owner allowed to a spender.
     * @param owner address The address which owns the funds.
     * @param spender address The address which will spend the funds.
     * @return A uint256 specifying the amount of tokens still available for the spender.
     */
    function allowance(address owner, address spender)
        public
        view
        override
        returns (uint256)
    {
        return _allowed[owner][spender];
    }

    /**
     * @dev Transfer token to a specified address.
     * @param to The address to transfer to.
     * @param value The amount to be transferred.
     */
    function transfer(address to, uint256 value)
        public
        override
        returns (bool)
    {
        _transfer(msg.sender, to, value);
        return true;
    }

    /**
     * @dev Approve the passed address to spend the specified amount of tokens on behalf of msg.sender.
     * Beware that changing an allowance with this method brings the risk that someone may use both the old
     * and the new allowance by unfortunate transaction ordering. One possible solution to mitigate this
     * race condition is to first reduce the spender's allowance to 0 and set the desired value afterwards:
     * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
     * @param spender The address which will spend the funds.
     * @param value The amount of tokens to be spent.
     */
    function approve(address spender, uint256 value)
        public
        override
        returns (bool)
    {
        _approve(msg.sender, spender, value);
        return true;
    }

    /**
     * @dev Transfer tokens from one address to another.
     * Note that while this function emits an Approval event, this is not required as per the specification,
     * and other compliant implementations may not emit the event.
     * @param from address The address which you want to send tokens from
     * @param to address The address which you want to transfer to
     * @param value uint256 the amount of tokens to be transferred
     */
    function transferFrom(
        address from,
        address to,
        uint256 value
    ) public override returns (bool) {
        _transfer(from, to, value);
        _approve(from, msg.sender, _allowed[from][msg.sender].sub(value));
        return true;
    }

    /**
     * @dev Increase the amount of tokens that an owner allowed to a spender.
     * approve should be called when _allowed[msg.sender][spender] == 0. To increment
     * allowed value is better to use this function to avoid 2 calls (and wait until
     * the first transaction is mined)
     * From MonolithDAO Token.sol
     * Emits an Approval event.
     * @param spender The address which will spend the funds.
     * @param addedValue The amount of tokens to increase the allowance by.
     */
    function increaseAllowance(address spender, uint256 addedValue)
        public
        returns (bool)
    {
        _approve(
            msg.sender,
            spender,
            _allowed[msg.sender][spender].add(addedValue)
        );
        return true;
    }

    /**
     * @dev Decrease the amount of tokens that an owner allowed to a spender.
     * approve should be called when _allowed[msg.sender][spender] == 0. To decrement
     * allowed value is better to use this function to avoid 2 calls (and wait until
     * the first transaction is mined)
     * From MonolithDAO Token.sol
     * Emits an Approval event.
     * @param spender The address which will spend the funds.
     * @param subtractedValue The amount of tokens to decrease the allowance by.
     */
    function decreaseAllowance(address spender, uint256 subtractedValue)
        public
        returns (bool)
    {
        _approve(
            msg.sender,
            spender,
            _allowed[msg.sender][spender].sub(subtractedValue)
        );
        return true;
    }

    /**
     * @dev Transfer token for a specified addresses.
     * @param from The address to transfer from.
     * @param to The address to transfer to.
     * @param value The amount to be transferred.
     */
    function _transfer(
        address from,
        address to,
        uint256 value
    ) internal {
        require(to != address(0));

        _balances[from] = _balances[from].sub(value);
        _balances[to] = _balances[to].add(value);
        emit Transfer(from, to, value);
    }

    /**
     * @dev Internal function that mints an amount of the token and assigns it to
     * an account. This encapsulates the modification of balances such that the
     * proper events are emitted.
     * @param account The account that will receive the created tokens.
     * @param value The amount that will be created.
     */
    function _mint(address account, uint256 value) internal {
        require(account != address(0));

        _totalSupply = _totalSupply.add(value);
        _balances[account] = _balances[account].add(value);
        emit Transfer(address(0), account, value);
    }

    /**
     * @dev Internal function that burns an amount of the token of a given
     * account.
     * @param account The account whose tokens will be burnt.
     * @param value The amount that will be burnt.
     */
    function _burn(address account, uint256 value) internal {
        require(account != address(0));

        _totalSupply = _totalSupply.sub(value);
        _balances[account] = _balances[account].sub(value);
        emit Transfer(account, address(0), value);
    }

    /**
     * @dev Approve an address to spend another addresses' tokens.
     * @param owner The address that owns the tokens.
     * @param spender The address that will spend the tokens.
     * @param value The number of tokens that can be spent.
     */
    function _approve(
        address owner,
        address spender,
        uint256 value
    ) internal {
        require(spender != address(0));
        require(owner != address(0));

        _allowed[owner][spender] = value;
        emit Approval(owner, spender, value);
    }

    /**
     * @dev Internal function that burns an amount of the token of a given
     * account, deducting from the sender's allowance for said account. Uses the
     * internal burn function.
     * Emits an Approval event (reflecting the reduced allowance).
     * @param account The account whose tokens will be burnt.
     * @param value The amount that will be burnt.
     */
    function _burnFrom(address account, uint256 value) internal {
        _burn(account, value);
        _approve(account, msg.sender, _allowed[account][msg.sender].sub(value));
    }
}
contracts/IERC20.sol
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/**
 * @title ERC20 interface
 * @dev see https://eips.ethereum.org/EIPS/eip-20
 */
interface IERC20 {
    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);

    function totalSupply() external view returns (uint256);

    function balanceOf(address who) external view returns (uint256);

    function allowance(address owner, address spender)
        external
        view
        returns (uint256);

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

    event Approval(
        address indexed owner,
        address indexed spender,
        uint256 value
    );
}
contracts/SafeMath.sol
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/**
 * @title SafeMath
 * @dev Unsigned math operations with safety checks that revert on error.
 */
library SafeMath {
    /**
     * @dev Multiplies two unsigned integers, reverts on overflow.
     */
    function mul(uint256 a, uint256 b) internal pure returns (uint256) {
        // Gas optimization: this is cheaper than requiring 'a' not being zero, but the
        // benefit is lost if 'b' is also tested.
        // See: https://github.com/OpenZeppelin/openzeppelin-solidity/pull/522
        if (a == 0) {
            return 0;
        }

        uint256 c = a * b;
        require(c / a == b);

        return c;
    }

    /**
     * @dev Integer division of two unsigned integers truncating the quotient, reverts on division by zero.
     */
    function div(uint256 a, uint256 b) internal pure returns (uint256) {
        // Solidity only automatically asserts when dividing by 0
        require(b > 0);
        uint256 c = a / b;
        // assert(a == b * c + a % b); // There is no case in which this doesn't hold

        return c;
    }

    /**
     * @dev Subtracts two unsigned integers, reverts on overflow (i.e. if subtrahend is greater than minuend).
     */
    function sub(uint256 a, uint256 b) internal pure returns (uint256) {
        require(b <= a);
        uint256 c = a - b;

        return c;
    }

    /**
     * @dev Adds two unsigned integers, reverts on overflow.
     */
    function add(uint256 a, uint256 b) internal pure returns (uint256) {
        uint256 c = a + b;
        require(c >= a);

        return c;
    }

    /**
     * @dev Divides two unsigned integers and returns the remainder (unsigned integer modulo),
     * reverts when dividing by zero.
     */
    function mod(uint256 a, uint256 b) internal pure returns (uint256) {
        require(b != 0);
        return a % b;
    }
}

Reactでフロント部分作成

$ cd src && mkdir components types && cd compoonents && touch Faucet.tsx && cd .. && cd types && touch faucetShema.ts
src/App.tsx
import FaucetToken from './artifacts/contracts/FaucetToken.sol/FaucetToken.json'
import Faucet from './components/Faucet';

function App() {
  return (
    <>
      <Faucet tokenContract={FaucetToken} />
    </>
  );
}

export default App;
src/components/Faucet.tsx
import * as React from 'react';
import { ethers } from 'ethers'
import web3 from 'web3';
import Button from '@mui/material/Button';
import { useForm } from "react-hook-form";
import { yupResolver } from '@hookform/resolvers/yup';
import { faucetSchema } from '../types/faucetSchema';
import { OutlinedInput } from '@mui/material';
import styled from '@emotion/styled';
import { Ethereumish } from '../types/Ethereumish';

const tokenAddress = '0x5fbdb2315678afecb367f032d93f642f64180aa3'

declare global {
  interface Window {
    ethereum: Ethereumish;
  }
}

const FaucetWrapper = styled.div`
  margin-top: 200px;
`;

const FaucetTitle = styled.h2`
  font-size: 35px;
  text-align: center;
  margin-top: 50px;
`;

const FormGroup = styled.form`
  display: flex;
  justify-content: center;
  margin-top: 50px;
  .MuiOutlinedInput-root {
    max-width: 500px;
    @media screen and (min-width: 550px) {
      width: 70%;
    }
  }
`;

const ValidErrorMessage = styled.p`
  color: #fc6767;
  text-align: center;
  margin-top: 10px;
`;

const Faucet = ({tokenContract}:any) => {

  const [tx, setTx] = React.useState();
  const [showTx, setShowTx] = React.useState(false);

  const { register, handleSubmit, formState:{ errors } } = useForm({
    resolver: yupResolver(faucetSchema)
  });

  React.useEffect(() => {
    setShowTx(true)
  }, [tx])

  const onClickFaucet = async (inputData: any) => {
    if (typeof window.ethereum !== 'undefined') {
      const provider = new ethers.providers.Web3Provider(window.ethereum);
      const signer = provider.getSigner();
      const contract = new ethers.Contract(tokenAddress, tokenContract.abi, signer);
      const amount = web3.utils.toWei(inputData.amount.toString());
      contract.getMeSome(amount)
        .then((d: any) => setTx(d.hash))
        .catch((err: any) => console.log(err));
    }
  }

  return (
    <FaucetWrapper>
      <FaucetTitle>Test Token Faucet</FaucetTitle>
      <FormGroup onSubmit={handleSubmit(onClickFaucet)}>
        <OutlinedInput
          {...register("amount")}
          placeholder="Amount"
          size="small"
        />

        <Button
          type="submit"
          variant="contained"
        >
          Get Token
        </Button>
      </FormGroup>
      <ValidErrorMessage>{errors.amount?.message}</ValidErrorMessage>
      {showTx && tx ? tx : ""}
    </FaucetWrapper>
  )
}

export default Faucet;
src/types/faucetSchema.ts
import * as yup from "yup";

export const faucetSchema = yup.object({
  amount: yup.number()
    .typeError("Must be a number")
    .positive("Must be a positive number")
    .max(1000, "Too much")
    .required('Invalid Details'),
}).required();
src/types/Ethereumish.d.ts
import { ProviderMessage, ProviderRpcError, ProviderConnectInfo, RequestArguments } from 'hardhat/types';

export interface EthereumEvent {
    connect: ProviderConnectInfo;
    disconnect: ProviderRpcError;
    accountsChanged: Array<string>;
    chainChanged: string;
    message: ProviderMessage
}

type EventKeys = keyof EthereumEvent;
type EventHandler<K extends EventKeys> = (event: EthereumEvent[K]) => void;

export interface Ethereumish {
    autoRefreshOnNetworkChange: boolean;
    chainId: string;
    isMetaMask?: boolean;
    isStatus?: boolean;
    networkVersion: string;
    selectedAddress: any;

    on<K extends EventKeys>(event: K, eventHandler: EventHandler<K>): void;
    enable(): Promise<any>;
    request?: (request: { method: string, params?: Array<any> }) => Promise<any>
    /**
     * @deprecated
     */
    send?: (request: { method: string, params?: Array<any> }, callback: (error: any, response: any) => void) => void
    sendAsync: (request: RequestArguments) => Promise<unknown>
}

コンパイル、ローカル実行

hardhatのサーバーをローカルで立てる

$ npx hardhat node

別ウィンドウを開く...

コンパイルする

$ npx hardhat compile

ローカルホストにデプロイ

$ npx hardhat run scripts/deploy.ts --network localhost
Token deployed to: [この部分がコントラクトアドレス]

Reactをローカル実行

$ npm start

Metamaskの準備

先ほどnpx hardhat nodeで立てたサーバーのコンソールに移動すると、以下のような出力があると思うのでこれのPrivate Keyを一つだけコピーします。

Accounts
========
Account #0: 0x0000000000000 (10000 ETH)
Private Key: 0x00000000000000000
...

そうしたら、ブラウザのMetamaskに移動して、プライベートキーを入力し、アカウントを追加します。

これができたらほぼ終わりで、あとは http://localhost:3000/ に移動し、Metamaskをlocalhost 8545に接続し、Amount部分にFaucetの金額を入力してGET Tokenボタンを押して取引をconfirmすれば受け取れます。

終わりに

日本のWeb3、ブロックチェーン関連の記事はまだまだ圧倒的に少ないのでどんどん追加していければと思います。
何かあればコメントください📝

参考:

https://github.com/apbendi/token_faucet
https://dev.to/richardmelko/ethereum-dapp-crash-course-make-an-erc20-token-faucet-frontend-2m43

Discussion

ログインするとコメントできます