見出し画像

UniSwapの様なDEXアプリを作成する【その2】

皆さん、こんばんは。
3週間ほど前に、「UniSwapの様なDEXアプリを作成する」という記事を投稿させていただきましたが、その後開発をしてひと段落ついたので第2弾の記事を書いていきたいと思います。

ちなみに前回の記事は、こちらになりますのでご興味のある方はこちらもチェックしていただけると幸いです。

ソースコードですが、こちらも前回に続いて下記のGitHubリポジトリに配置してあります。
このWeb3アプリは、React.jsTruffleのフレームワークを中心に構築してあります!

早速、概要に入っていきたいと思います!

1. 概要

前回までは、UniSwapの公式サイトのチュートリアルに従い、ClientSDKを導入するところまでを実施しました。
やったこととしては、ただ単にコンポーネントを導入しただけだったのでそれほど難しくは無かったです。

そこで今回は、UniSwapの仕組みや他の人の記事などを参考にDEXの中枢機能を実装したコントラクトを自分で実装し、ローカル上にデプロイした独自トークン同士によるSwapを可能するアプリを開発するところまでやってみました!

UniSwapのAMMの様に高機能ではありませんが、DEXの仕組みを理解するために最低限度必要と思われる機能を実装してみましたので最後まで読んで行ってもらえれば幸いです!!

この後、具体的な機能の内容をソースコードの紹介と合わせて書いていきます。

2. 開発したDEXアプリの機能について

開発した機能ですが、DEXコントラクトに次の4つの機能を実装しました。

  1. ネイティブトークンを支払って独自トークンを購入する関数 【buyToken】

  2. 独自トークンを支払ってネイティブトークンを購入する関数 【sellToken】

  3. 独自トークン同士を交換する関数【swapToken】

  4. プールを作成する関数 【createLiquidityPool】

中心となるDEXコントラクトの実際のソースコードは下記の通りです。

インポートしているMyTokenコントラクトの詳細について知りたい方は、下の記事を参考にしてください。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0; 

import "./../ERC20/MyToken.sol";

// DEX コントラクト
contract DEX {

    event buy(address account, address _tokenAddr, uint256 _cost, uint256 _amount);
    event sell(address account, address _tokenAddr, uint256 _cost, uint256 _amount);
    event swap(address _tokenAddrA, address _tokenAddrB, uint256 _cost, uint256 _amount);
    event createPool(address _tokenAddrA, address _tokenAddrB, uint256 _amountA, uint256 _amountB);

    mapping(address => bool) public supportedTokenAddr;

    address public LPTokenAddr;

    /*
    modifier supportsToken(address _tokenAddr) {
        require(supportedTokenAddr[_tokenAddr] == true, "This token is not supported!!");
        _;
    }
    */

    constructor(address[] memory _tokenAddr) {
        // インスタンスを生成
        MyToken LPToken = new MyToken("LPToken", "LPT", 18);
        LPToken.transferOwnership(address(this));
        LPTokenAddr = address(LPToken);

        for(uint i = 0; i < _tokenAddr.length; i++) {
            supportedTokenAddr[_tokenAddr[i]] = true; 
        }
    }

    function buyToken(address _tokenAddr, uint256 _cost, uint _amount) public payable {
        MyToken token = MyToken(_tokenAddr);
        
        require(msg.value >= _cost, "Insufficient fund");
        require(token.balanceOf(address(this)) >= _amount, "Token sold out");
        // call transfer method
        token.transfer(msg.sender, _amount);
        emit buy(msg.sender, _tokenAddr, _cost, _amount);
    }

    function sellToken(address _tokenAddr, uint256 _cost, uint _amount) public {
        MyToken token = MyToken(_tokenAddr);
        
        require(token.balanceOf(msg.sender) >= _cost, "Insufficient token balance");
        require(address(this).balance >= _amount, "DEX does not have enough funds");
        token.transferFrom(msg.sender, address(this), _cost);
        (bool success, ) = payable(msg.sender).call{ value: _amount}("");
        require(success, "ETH transfer failed");
        emit sell(msg.sender, _tokenAddr, _cost, _amount);
    }

    function swapToken(address _tokenAddrA, address _tokenAddrB, uint256 _cost, uint256 _amount) external {
        sellToken(_tokenAddrA, _cost, 0);
        buyToken(_tokenAddrB, 0, _amount);
        emit swap(_tokenAddrA, _tokenAddrB, _cost, _amount);
    }

    function createLiquidityPool(address _tokenAddrA, address _tokenAddrB, uint256 _amountA, uint256 _amountB) external {
        MyToken tokenA = MyToken(_tokenAddrA);
        MyToken tokenB = MyToken(_tokenAddrB);
        MyToken LPToken = MyToken(LPTokenAddr);

        require(tokenA.balanceOf(msg.sender) >= _amountA, "Insufficient tokenA balance");
        require(tokenB.balanceOf(msg.sender) >= _amountB, "Insufficient tokenB balance");
        tokenA.transferFrom(msg.sender, address(this), _amountA);
        tokenB.transferFrom(msg.sender, address(this), _amountB);       
        LPToken.mint(msg.sender, (_amountA + _amountB) / 2);
        emit createPool(_tokenAddrA, _tokenAddrB, _amountA, _amountB);
    }
}

buyTokenでは、ETHとトークンの残高をチェックした後に、MyTokenコントラクトtransferメソッドが実行される形です。
sellTokenでも、ETHとトークンの残高がチェックされる部分までは同じですが、実行されるメソッドがtransferFromメソッドになっています。後述するのですが、これはapproveの機能を利用するためです。

3のswapTokenメソッドについては、ご覧の通り、buyTokenとsellTokenの組み合わせになります。

「全部swapTokenに統合すればいいじゃん!」 という方もいると思いますが、ネイティブトークンである「ETH」のやり取りにアドレスを使用しないのとフロントエンド側の条件分離が複雑になるのを防ぐためにこのような実装方法としました。(他に良い方法知ってたら教えてください・・・。)

そのため開発したDEXアプリでは次の3つのパターンについてトークンの交換を実施することができます。

① ETH → 独自トークン
② 独自トークン → ETH
③ 独自トークン → 別の独自トークン

文字だけでは伝わりづらいと思いますので、画面キャプチャを交えながら説明したいと思います!

そして最後に作成したメソッドがプールを作成するためのメソッドです。このDEXコントラクト自身は、ブロックチェーン上にデプロイされた時点ではトークンを一つも所持していません。(ETHも持っていません。)

そのため、取引を始めるために財源が必要になるのですが、UniSwapの構造を参考にプールを作る関数を実装してみました。
(本家に比べたら本当にちゃっちい機能ですが笑笑)

プールを作成するために提供してくれるトークン量に応じてLPTokenも発行できたらUniSwapっぽいと思ったので、LPTokenという名前のトークンをmintするような処理も混ぜ込んでみました。この部分はまだ課題がありそうなので今後修正したいきたいと考えています。

これから上記に書いた処理を一通り試していきたいと思いますが、この機能を利用する前提条件として

① MetaMaskを利用できる様にしておくこと。
② Ganacheをインストールして起動できる状態にしておくこと。
③ 独自トークンを2種類以上事前にデプロイしておくこと。
④ Node.jsが使える状態にしておくこと。

が必要となってきます。詳細は以前の記事で投稿させていただいているので今回は割愛させていただきます。
※ そんなに難しくないはずなので大丈夫です!

筆者の環境のバージョン情報は、下記の通りです。
node v16.13.2
npm 8.1.2
ganache v2.5.4
solc: 0.8.0
react: 17.0.2

以下の内容は①〜③が済んでいるものとして進めていきます。

まず、ソースコードの「client」ディレクトリに移動して`npm run start`コマンドを打ち込みます。
しばらくしたらhttp://localhost:3000/ にアクセスできるようになるはずなのでアクセスしましょう!
※ MetaMaskへのログインも求められるので合わせて認証してください。

起動したら、下の様な画面が表示されるはずなので右上のメニューから「Swap」を選択してSwap画面に遷移しましょう!

Home画面

遷移したら下の様な画面が表示されるはずです。MUIコンポーネントを駆使してなんとかUniSwapみたいな見た目にしました・・・。
やっぱり画面デザインはプロに任せないとダメですね笑。

Swap画面

筆者の環境では、事前に「MSH」と「MCH2」という2つの独自トークンを作成済みだったので、ETHを含めた3種類のトークンがやり取りできる様になっています。

上のフォームに支払うトークン量を設定すると、 上に設定した数字の0.7倍の数字が下のフォームにも表示されます。
これは、為替レートを基にSwapした後に入手できるトークン量を自動で計算してくれています。

本来であれば、ChainLinkなどのオラクル機能を利用して為替レート情報を引っ張ってきてそれを基に算出するのでしょうが、ここでは開発用ということもあり適当にレートを0.7に設定しています。

では、トークンを交換して行く前に、このDEXコントラクト自身はトークンを持っていないのでプールを作成する必要があるので右下の「Let's Create Pool」ボタンを押してプール作成画面に遷移しましょう!遷移したら下の様な画面が表示されるはずです。

見た目はほとんど同じですがここからプールを設定できます。

上と下のフォームにプールに設定するトークン量(十分な量にしてください!)を入力して、「Create Pool」ボタンを押しましょう!
ここで、2つのトークンのapproveメソッドの実行とcreateLiquidityPoolメソッドの実行が必要になるので、合計3回MetaMaskによる確認が求められますが全て許可を選択しましょう!問題なければ成功するはずです!

アクセスの許可が求められます。

approveメソッドとは??approveメソッドとはERC20トークンの規格にも定義されているメソッドの1つで自分以外の第3者へのトークンの転送を許可するためのメソッドのこと。この関数が無いと自分の意図しない転送処理が実行されてしまう恐れがある。

このDEXコントラクトを実装して大きかったことの一つがこのapproveメソッドの理解でした。
最初、ERC20の規格を見た時になんでこんなメソッドが必要なの??と思っていたのですが、このコントラクトを実装したことで理解が深まりました。
確かにapproveの仕組みが無かったら偽サイト等に誘導されて変なリンクをクリックさせられて自分のトークンを根こそぎ盗られる可能性だってありえます。
approveの仕組みはその様な危険からユーザーを守ってくれる仕組みなのです。
ただ、このままだと3回も確認が求められるので少ししつこいですね・・。いい実装方法知っている方がいましたら是非教えてください笑

プールを作成できたら、「Return Swap」ボタンを押してSwap画面に戻りましょう!

これから上記①〜③の処理を順番に実施していきますが、その前にこの画面のフロントエンド側のソースコードを確認して起きたいと思います。

// 必要なモジュールをインポートする。
import '../App.css';
import React, { useState, useEffect } from "react";
import Web3 from 'web3';
import { Link } from "react-router-dom";
import detectEthereumProvider from '@metamask/detect-provider';
import useStyles from '../common/useStyles';
import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import Button from '@mui/material/Button';
import { MenuItem, Select } from "@mui/material";
import SwapVertIcon from '@mui/icons-material/SwapVert';
import { styled } from "@mui/material/styles";
import Paper from "@mui/material/Paper";
import InputBase from '@mui/material/InputBase';
import eth from './../common/assets/image/eth.png';
import mash from './../common/assets/image/mash.png';
import mash2 from './../common/assets/image/mash2.png';
import DEXContract from './../contracts/DEX.json';
import MyTokenContract from './../contracts/MyToken.json';

// トークンのシンボル情報
const ETH = "ETH";
const MSH = "MSH";
const MCH2 = "MCH2";
// トークンのアドレス情報
const MSHAddress = "0x06Dc2032695B30D0166E6f1f21C74Fe804F52553";
const MCH2Address = "0x8dde86fCe1FBE467ec067eF49B2b018AA0D6624d"; 

// トークンのシンボル、アドレス、アイコン画像用の配列の定義
const tokenItems = [
      ETH,
      MSH,
      MCH2
];

const tokenAddrs = [
      "0",
      MSHAddress,
      MCH2Address,
];

const imageItems = [
      eth,
      mash,
      mash2
];

// StyledPaperコンポーネント
const StyledPaper = styled(Paper)(({ theme }) => ({
      padding: theme.spacing(2),
      maxWidth: 600,
      backgroundColor: '#fde9e8'
}));

/**
 * Swapコンポーネント
 */
const Swap = () => {
      const [ myTokenContract, setMyTokenContract ] = useState (null);
      const [ dexContract, setDexContract ] = useState (null);
      const [ accounts, setAccounts ] = useState (null);
      const [ tokenA, setTokenA ] = useState(null);
      const [ tokenAAmount, setTokenAAmount ] = useState(0);
      const [ tokenB, setTokenB ] = useState(null);
      const [ tokenBAmount, setTokenBAmount ] = useState(0);
      const [ web3, setWeb3 ] = useState(null);
      const [ dexAddress, setDexAddress ] = useState(null);
      // スタイル用のクラス
      const classes = useStyles();

      /**
       * useEffect関数 
       */ 
      useEffect (() => {
            init();
      }, []);

      // 初期化関数
      const init = async() => {
            try {
                  // Web3が使えるように設定する。
                  const provider = await detectEthereumProvider();
                  const ethWeb3 = new Web3(provider);
                  const web3Accounts = await ethWeb3.eth.getAccounts();
                  const networkId = await ethWeb3.eth.net.getId();
                  const deployedNetwork = DEXContract.networks[networkId];
                  const instance = new ethWeb3.eth.Contract(DEXContract.abi, deployedNetwork && deployedNetwork.address,);
                  setDexContract(instance);
                  setWeb3(ethWeb3);
                  setAccounts(web3Accounts);
                  setDexAddress(deployedNetwork.address);
            } catch (error) {
                  alert(`Failed to load web3, accounts, or contract. Check console for details.`,);
                  console.error(error);
            }
      };

      /**
       * Swapする値を算出するメソッド
       * @param {*} value トークンB情報
       */
      const clacSwapAmount = (value) => {
            // トークンBの情報をステート関数に設定する。
            setTokenB(value);
            // トークンAが同じトークンの場合には、同じ割合でswapする。
            // それ以外であれば、トークンB = トークンA * 0.7で計算する。
            if(tokenA === value) {
                  // トークンBの値を算出する。
                  setTokenBAmount(tokenAAmount);
                  console.log("valueBAmount:", tokenAAmount);
            } else {
                  // トークンBの値を算出する。
                  let valueB = tokenAAmount * 0.7;
                  // トークンBの値を算出する。
                  setTokenBAmount(valueB);
                  console.log("valueBAmount:", valueB);
            }
      };

      /**
       * 「Swap」ボタン実行時の処理
       */
      const swapAction = async () => {
            let tokenAddr;
            // トークンAがETHの場合:トークンを買う
            if(tokenA === "0") {
                  tokenAddr = tokenB;
                  console.log("tokenAddr:", tokenAddr);

                  try {
                        await dexContract.methods.buyToken(tokenAddr, tokenAAmount, tokenBAmount).send({ 
                              from: accounts[0],
                              value: tokenAAmount * 1000000000000000,
                              gas: 6500000
                        });
                        alert("buy token success!");
                  } catch(e) {
                        console.error("buy token err:", e);
                        alert("buy token failed");
                  }
            } else if (tokenA !== "0" && tokenB !== "0") { // 交換するトークンがどちらもネイティブトークンではなかった場合
                  let tokenAddrA = tokenA;
                  let tokenAddrB = tokenB;
                  // approveメソッドとswapTokenメソッドを呼び出す。
                  try {
                        const provider = await detectEthereumProvider();
                        const ethWeb3 = new Web3(provider);
                        const instance = new ethWeb3.eth.Contract(MyTokenContract.abi, tokenAddrA);
                        // まず、approveを実行し、その後sellメソッドを呼び出す。
                        await instance.methods.approve(dexAddress, tokenAAmount).send({
                              from: accounts[0],
                              gas: 6500000
                        });
                        await dexContract.methods.swapToken(tokenAddrA, tokenAddrB, tokenAAmount, tokenBAmount).send({ 
                              from: accounts[0],
                              gas: 6500000
                        });
                        alert("swap token success!");
                  } catch(e) {
                        console.error("swap token err:", e);
                        alert("swap token failed");
                  }
            } else {    // トークンAがETH以外の場合でトークンBがETHの場合:トークンを売る
                  tokenAddr = tokenA;
                  console.log("tokenAddr:", tokenAddr);
                  
                  try {
                        const provider = await detectEthereumProvider();
                        const ethWeb3 = new Web3(provider);
                        const instance = new ethWeb3.eth.Contract(MyTokenContract.abi, tokenAddr);
                        // まず、approveを実行し、その後sellメソッドを呼び出す。
                        await instance.methods.approve(dexAddress, tokenAAmount).send({
                              from: accounts[0],
                              gas: 6500000
                        });
                        await dexContract.methods.sellToken(tokenAddr, tokenAAmount, tokenBAmount).send({ 
                              from: accounts[0],
                              gas: 6500000
                        });
                        alert("sell token success!");
                  } catch(e) {
                        console.error("sell token err:", e);
                        alert("sell token failed");
                  }
            }
      } 

      return (
            <div className={classes.main_container}>
                  <h2>
                        Swap
                  </h2>
                  <Grid
                        container
                        direction="row"
                        justifyContent="center"
                        alignItems="center"
                  >
                        <Box sx={{ flexGrow: 1, overflow: "hidden", px: 3, mt: 10}}>
                              <StyledPaper sx={{my: 1, mx: "auto", p: 0, borderRadius: 4, marginTop: 4}}>
                                    <Grid container justifyContent="center">
                                          <Paper
                                                component="form"
                                                sx={{ 
                                                      p: '2px 4px', 
                                                      display: 'flex', 
                                                      alignItems: 'center', 
                                                      width: 450, 
                                                      marginTop: 4
                                                }}
                                          >  
                                                <InputBase
                                                      sx={{ ml: 2 }}
                                                      placeholder="0.0"
                                                      type='number'
                                                      onChange={(e) => { setTokenAAmount(e.target.value) }}
                                                      inputProps={{ 'aria-label': 'enter amount!' }}
                                                />
                                                
                                                <Select
                                                      labelId="tokenA"
                                                      id="tokenA"
                                                      value={tokenA}
                                                      autoWidth
                                                      sx={{ m: 1, maxWidth: 120 }}
                                                      onChange={(e) => { setTokenA(e.target.value) }}
                                                >
                                                      { tokenItems.map((item, index) => (
                                                            <MenuItem key={index} value={tokenAddrs[index]}>    
                                                                  <Grid 
                                                                        container 
                                                                        sx={{ 
                                                                              display: 'flex', 
                                                                              alignItems: 'center'      
                                                                        }}
                                                                  >
                                                                        {item}
                                                                        <img src={imageItems[index]} height="20px" />
                                                                  </Grid>
                                                            </MenuItem>
                                                      ))}
                                                </Select>
                                          </Paper>     
                                          <Grid container justifyContent="center">
                                                <SwapVertIcon/>
                                          </Grid>
                                          <Paper
                                                component="form"
                                                sx={{ 
                                                      p: '2px 4px', 
                                                      display: 'flex', 
                                                      alignItems: 'center', 
                                                      width: 450, 
                                                      marginBottom: 3
                                                }}
                                          >  
                                                <InputBase
                                                      sx={{ ml: 2 }}
                                                      placeholder="0.0"
                                                      type='number'
                                                      value={tokenBAmount}
                                                      inputProps={{ 'aria-label': 'enter amount!' }}
                                                />
                                                
                                                <Select
                                                      labelId="tokenB"
                                                      id="tokenB"
                                                      value={tokenB}
                                                      autoWidth
                                                      sx={{ m: 1, maxWidth: 120 }}
                                                      onChange={(e) => { clacSwapAmount(e.target.value) }}
                                                >
                                                      { tokenItems.map((item, index) => (
                                                            <MenuItem key={index} value={tokenAddrs[index]}>    
                                                                  <Grid 
                                                                        container 
                                                                        sx={{ 
                                                                              display: 'flex', 
                                                                              alignItems: 'center',       
                                                                        }}
                                                                  >
                                                                        {item}
                                                                        <img src={imageItems[index]} height="20px" />
                                                                  </Grid>
                                                            </MenuItem>
                                                      ))}
                                                </Select>
                                          </Paper>     
                                    </Grid>
                                    <Grid 
                                          container 
                                          justifyContent="center"
                                          sx={{ 
                                                display: 'flex', 
                                                alignItems: 'center', 
                                                m: 1
                                          }}
                                    >
                                          <Grid sx={{marginLeft: 'auto', marginRight: 'auto', marginBottom: 3}}>
                                                <Button variant="outlined" sx={{borderRadius: 4}} onClick={swapAction}>
                                                      Swap
                                                </Button>
                                          </Grid>
                                          <Grid sx={{marginLeft: 'auto', marginRight: 'auto', marginBottom: 3}}>
                                                <Button 
                                                      variant="outlined" 
                                                      color="secondary" 
                                                      sx={{borderRadius: 4}}
                                                      component={Link}
                                                      to="/createPool"
                                                >
                                                      Let's Create Pool
                                                </Button>
                                          </Grid>
                                    </Grid>
                              </StyledPaper>
                        </Box>
                  </Grid>
                <br/>
            </div>
        );
}

// コンポーネントを外部に公開する。
export default Swap;

ソースの頭の部分にこのSwapコンポーネントで使用するトークンのシンボルとアドレス情報を設定しているので、ここに自分のローカル環境にデプロイしたトークンのアドレス情報を設定する必要が出てくる。

このアプリのHome画面には、発行済みのERC20トークンのアドレス情報を確認できる機能があるのでわからなくてもそこでアドレス情報を確認することができる。

それではSwapを初めていきます。

①ETH → 独自トークン

まずは、ETHを支払って独自トークンを入手するパターンから!!
上のフォームでトークンの種類をETHにして適当な数量を入力してください!
そして下のフォームでは、ETH以外のトークンを選択してください。自動的にSwap後に入手できるトークン量が計算されます。

ここで、Swapで本当にトークンが増えているか確認するために今の状態のトークン保有量のキャプチャを貼り付けます。

問題なければ「Swap」ボタンを押しましょう!!
成功すれば上にsuccessのポップアップが表示されるはずなのでMetaMaskで確認しましょう!

700MSH増えています!

↑ ちゃんと増えていますね!

同様に、MCH2トークンもETHとSwapしましょう!

② 独自トークン → ETH

次は独自トークンを支払ってETHを入手するパターンになります。

先ほどの逆で上と下のフォームに必要が情報を埋めていきましょう!

ここで、Swapで本当にトークンが減っているか確認するために今の状態のトークン保有量のキャプチャを貼り付けます。

問題なければ「Swap」ボタンを押しましょう!!
今度は、トークンを第3者であるDEXコントラクトに対して転送する形になるのでapproveの実行が走ります。
なので、MetaMaskによる確認が2回入ります。問題なければ先ほどと逆と処理が行われているはずなのでMetaMaskで確認しましょう!

この後もう一回MetaMaskの確認が入るので確認ボタンを押します。

↑ ちゃんと減っていますね!

③ 独自トークン → 別の独自トークン

最後に独自トークン同士のSwapです。

上にも書いたのですが独自トークン同士を交換する swapTokenメソッドは、 ①と②のメソッドを一気に処理することで実現させているものです。
これをしないとフロントエンド側の条件分岐が複雑になってしまうのでこの様にしています。

これまでと同じ様に上と下のフォームに必要な情報を入力していきます。

今の状態のキャプチャを貼ります。

入力内容に問題がなければ「Swap」ボタンを押しましょう!
今度は、トークンを第3者であるDEXコントラクトに対して転送する処理が走るのでapproveが2回実行される形となります。
なので、MetaMaskによる確認が3回入ります。問題なければSwapされているはずなのでMetaMaskで確認しましょう!

↑ちゃんと交換されていますね!

3. 最後に

ここまで読んでいただきありがとうございました。
その1では主にフロントエンドだけの開発でしたが、今回はバックエンド側であるDEXコントラクトの実装も実施してみました。
本家のソースコードに比べれば規模は小さいですが、根本機能がなんとなく理解できる良いコードになったのでは無いかと考えています。
まだ細かいところで修正ポイントが残っているのでおいおい直していきたいと思います。
あとは、ERC20のapproveの機能の必要性を体系的に理解できたことも大きかったですね・・。

NFTのマーケットプレイスの機能の方も開発しているので次回はそっちの記事を投稿できればと考えております。
また、今後も面白そうな機能があったら実装していきたいと思います!

今回はここまでとなります。

参考文献

このDEXコントラクトを実装する上で参考になったものばかりです。
ありがとうございました。


この記事が気に入ったらサポートをしてみませんか?