JKになりたい

何か書きたいことを書きます。主にWeb方面の技術系記事が多いかも。

ERC20に準拠したトークンの実装とテスト、あと脆弱性の話

この記事の内容

・ERC20に準拠したトークンを実装して、テストを書いた
・どうやらERC20には脆弱性があるらしいのでそれについても調べた
・最後にRopstenテストネットワークにトークンをデプロイした

概要

OpenZeppelinを使えば簡単にERC20に準拠したトークンを作ることが可能ですが、今回は全てのメソッドを自分で実装していきたいと思います。
(OpenZeppelinではERC20以外にERC721/ERC827に準拠したトークンの実装のヘルパーも存在します)
GitHub - OpenZeppelin/openzeppelin-solidity: OpenZeppelin, a framework to build secure smart contracts on Ethereum

ERC20で実装すべきメソッドやフィールドはEIPs/eip-20.md at master · ethereum/EIPs · GitHubで定義されています。

準拠しなければいけないメソッド・イベント

メソッド

name : (Optional)トークンの名前

function name() view returns (string name)

symbol: (Optional)トークンのシンボル(HIXやETHのような)

function symbol() view returns (string symbol)

decimals: (Optional)トークンが使用する小数点以下の桁数

function decimals() view returns (uint8 decimals)

この3つはfunctionでなくconst variableとして宣言している実装を多く見かけます

totalSupply:トークンの総発行量

function totalSupply() view returns (uint256 totalSupply)

blanceOf:指定されたアドレスが所持しているトークン量を返す

function balanceOf(address _owner) view returns (uint256 balance)

transfer:トークンをtoへ送金する。その際にTransferイベントを発火する(送金額が0でも)。所持トークンが足りなく送金できない場合はthrowを投げる。

function transfer(address _to, uint256 _value) returns (bool success)

transferFrom:トークンをfromからtoへ送金する。その際にTransferイベントを発火する(送金額が0でも)。これは第三者トークンを送金するのに用いられます。もし、第三者が送金することを承認していなければthrowを投げます。承認には後述するapproveが用いられます。

function transferFrom(address _from, address _to, uint256 _value) returns (bool success)

approve:spenderがvalue分までのトークンを口座から引き出すことを許可します。この関数が呼び出されると、以前の呼び出しから上書きされます。
このAPIには脆弱性があることに注意してください(詳細は以下の記事より。次セクションで解説します)
ERC20 API: An Attack Vector on Approve/TransferFrom Methods - Google ドキュメント

function approve(address _spender, uint256 _value) returns (bool success)

allowance: spenderがownerからまだ引き出すことができる量を返します

function allowance(address _owner, address _spender) view returns (uint256 remaining)

イベント

Transfer: トークンが送金された時に発火

event Transfer(address indexed _from, address indexed _to, uint256 _value)

Approval:approveが成功した時に発火

event Approval(address indexed _owner, address indexed _spender, uint256 _value)

ERC20 API: An Attack Vector on Approve/TransferFrom Methods

ちょっと記事の本流からは逸れますが、Approve/TransferFromメソッドの脆弱性とそれを回避する方法について紹介したいと思います。

元記事はERC20 API: An Attack Vector on Approve/TransferFrom Methods - Google ドキュメント
どういうことが書かれているのか、簡単に記していきます。

攻撃のシナリオ

1)アリスはボブがNトークンをアリスのウォレットから送金することを許可します。
アリスが、ボブのアドレスと送金許可額Nを引数としてapproveメソッドをcallします。

2)その後、アリスはボブの送金許可額をNからMに変更します。
アリスはもう一度、ボブのアドレスと送金許可額Mを引数としてapproveメソッドをcallします。

3)ボブは、マイニングされる前にアリスが2番目のトランザクションを発行したことを知り、transferFromメソッドを含むトランザクションをすぐに送信して、アリスに送金が許可されているNトークンをどこかに送ります。

4)ボブのトランザクションがアリスのトランザクションの前に実行される場合、ボブはNトークンを正常に送金し、更にMトークン送金する権利を得ます。

5)アリスがおかしい事に気付く前に、ボブはtransferFromメソッドをもう一度呼び、Mトークンをアリスのアドレスから転送します

つまり、送金許可額をNからMに変更する瞬間に、N+Mトークン送金されてしまうリスクを負う、ということです。

これを防ぐためには?

変更するときに、一度送金許可額を0に上書きしてから行うことにすると安全であることが知られています。

先の脆弱性は、N+Mトークン送金されてしまうというものでしたので、N+0となるように、一度value:0を引数にしてapproveをcallします。
そして、このトランザクションが取り込まれた後に、自分の今のトーク保有量に問題がない事(トークンが引き出されていないこと)を確認した後にvalue:Mでapproveをcallし値を上書きします。
こうすることで、上記の脆弱性は回避できます。

しかし、これでは利用者が気をつけることでしか解決できません。

元記事にはAPIシグネチャを変更する事によりこの脆弱性に対処するアイデアが書かれています。
非常にシンプルな方法で、approveの引数にcurrentValueを追加する、というものです。
spenderの現在の送金許可額が、currentValeuと等しい時にのみvalueで上書きすることを許可するようにします。
こうすることで、アリスはNトークンをトランザクションが取り込まれる前に引き出されていたとしても、このトランザクションが取り込まれる時点では_currentValueで指定した額と現在の送金許可額が異なるので、更新が失敗します。

また、ERC822ではapproveとtransferFromの機能はトークン自体が持つべきでないとしてこれらのメソッドは削除されています。
これにより独立したコントラクトで独自のシグネチャのapproveを実装することにより、この脆弱性を回避するトークンを実装できそうですね。

実装してみる

実装してみます。
開発にはtruffleを使用しました。

総発行量は1億トークンで、最初は全てコントラクトをデプロイした人が所持することにしています。

※Solidityにおいてuintはuint256の意味になる
※solidity0.4.23からコンストラクタの書き方が変わってるので注意

pragma solidity ^0.4.24;

contract SCoin {
    string public constant name = "SCoin";
    string public constant symbol = "S";
    uint8 public constant decimals = 8;

    mapping (address => uint) accountBalance;
    mapping (address => mapping(address => uint)) accountAllowance;

    event Transfer(address indexed _from, address indexed _to, uint256 _value);
    event Approval(address indexed _owner, address indexed _spender, uint256 _value);

    constructor() public {
        accountBalance[msg.sender] = 100000000;
    }

    function totalSupply() public pure returns(uint) {
        return 100000000;
    }

    function balanceOf(address _owner) public view returns (uint) {
        return accountBalance[_owner];
    }

    function transfer(address _to, uint256 _value) public returns (bool success) {
        require(accountBalance[msg.sender] >= _value);
        accountBalance[msg.sender] -= _value;
        accountBalance[_to] += _value;
        emit Transfer(msg.sender, _to, _value);
        return true;
    }

    function transferFrom(address _from, address _to, uint256 _value) public returns (bool success) {
        require(accountAllowance[msg.sender][_from] >= _value);
        require(accountBalance[_from] >= _value);
        accountAllowance[msg.sender][_from] -= _value;
        accountBalance[_from] -= _value;
        accountBalance[_to] += _value;
        emit Transfer(_from, _to, _value);
        return true;
    }

    function approve(address _spender, uint256 _value) public returns (bool success) {
        accountAllowance[msg.sender][_spender] = _value;
        emit Approval(msg.sender, _spender, _value);
        return true;
    }

    function allowance(address _owner, address _spender) public view returns (uint256 remaining) {
        return accountAllowance[_spender][_owner];
    }
}

特に言及するところはありません。。

テストを書く

テストを書いていく前に、作成したSCoinのデプロイ定義を書いていきます。

var SCoin = artifacts.require("./SCoin.sol");

module.exports = function(deployer) {
    deployer.deploy(SCoin);
}

これで、truffle testによりSCoinのテストが走ります。

今回は以下のようなテストを書きました。

var SCoin = artifacts.require("./SCoin.sol");

contract('SCoin', function(accounts) {

    it('should put 100000000 SCoin in the first account', function(){
        return SCoin.deployed().then(function(instance){
            return instance.balanceOf.call(accounts[0]);
        }).then(function(balance){
            assert.equal(balance, 100000000);
        });
    });

    it('should transfer accounts[0] to [1]', function(){
        var scoin;
        return SCoin.deployed().then(function(instance){
            scoin = instance;
            return scoin.transfer(accounts[1], 1000);
        }).then(function(){
            return scoin.balanceOf.call(accounts[1]);
        }).then(function(balance){
            assert.equal(balance, 1000);
            return scoin.balanceOf.call(accounts[0]);
        }).then(function(balance){
            assert.equal(balance, 100000000 - 1000);
        });
    });

    it('should approve account[0] from account[1]', function(){
        var scoin;
        return SCoin.deployed().then(function(instance){
            scoin = instance;
            return scoin.approve(accounts[0], 1000, {from: accounts[1]});
        }).then(function(){
            return scoin.allowance.call(accounts[0], accounts[1]);
        }).then(function(balance){
            assert.equal(balance, 1000);
        });
    });

    it('should transferFrom account[0] to account[2] from account[1]', function(){
        var scoin;
        return SCoin.deployed().then(function(instance){
            scoin = instance;
            return scoin.approve(accounts[0], 1000, {from: accounts[1]});
        }).then(function(){
            return scoin.transferFrom(accounts[0], accounts[2], 800, {from: accounts[1]});
        }).then(function(){
            return scoin.balanceOf.call(accounts[2]);
        }).then(function(balance){
            assert.equal(balance, 800);
            return scoin.allowance.call(accounts[0], accounts[1]);
        }).then(function(balance){
            assert.equal(balance, 200)
        });
    });
});

ハマったのが、callで呼び出されたメソッドはブロックチェーンの状態を変更しないということです。。
なので、送金のテストをする際などはcallで呼ばないようにしましょう。

それにしてもPromiseで書いたテストは美しくないですね。。
Async/Awaitを採用するか、Solidityで書くかした方がいいんでしょうか。

truffle developでテスト

truffle developでローカル開発用のブロックチェーンを生成し、そこに作成したコントラクトをデプロイしてみます。

$ truffle develop
truffle(develop)> test
truffle(develop)> migrate

デプロイされていることを確認

truffle(develop)> scoin = SCoin.at(SCoin.address)
truffle(develop)> scoin.name()
'SCoin'
truffle(develop)> scoin.symbol()
'S'
truffle(develop)> scoin.balanceOf(web3.eth.accounts[0])
BigNumber { s: 1, e: 8, c: [ 100000000 ] }

送金してみます

truffle(develop)> scoin.transfer(web3.eth.accounts[1], 10000)
{ tx:
   '0x3bcee45a9304a60a41aa0797f4538a7ef4ff6a2e171fc498e6d2ed44f997998b',
  receipt:
   { transactionHash:
      '0x3bcee45a9304a60a41aa0797f4538a7ef4ff6a2e171fc498e6d2ed44f997998b',
     transactionIndex: 0,
     blockHash:
      '0x6e8c6b176b24a4c307291c58de84da07a5c8c637a8d9afed0c2c357ec3ace840',
     blockNumber: 216,
     gasUsed: 51194,
     cumulativeGasUsed: 51194,
     contractAddress: null,
     logs: [ [Object] ],
     status: '0x01',
     logsBloom:
      '0x00000000000000000000000000000000000000000000000000000000000000000000000000000020000040000000000000000000000000000000000000000000000000000000000010000008000000000000000000000000000080000000000000000000000000000000000800000000000000000000000000000010000000000000000000010000000000000000000000000000000000000000010000000002000000000000000000000000000000000000000000000000002000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' },
  logs:
   [ { logIndex: 0,
       transactionIndex: 0,
       transactionHash:
        '0x3bcee45a9304a60a41aa0797f4538a7ef4ff6a2e171fc498e6d2ed44f997998b',
       blockHash:
        '0x6e8c6b176b24a4c307291c58de84da07a5c8c637a8d9afed0c2c357ec3ace840',
       blockNumber: 216,
       address: '0x933c6f4851a30e22d0f0f44e198a865e9e9608a7',
       type: 'mined',
       event: 'Transfer',
       args: [Object] } ] }

truffle(develop)> scoin.balanceOf(web3.eth.accounts[1])
BigNumber { s: 1, e: 4, c: [ 10000 ] }

Reciptが流れてきました。
logsに発火したイベント(Transfer)が入っているのが確認できます。

テストネットワークへデプロイ

次は、Ropstenテストネットワークへデプロイしていきたいと思います。
自分でノードを立てるのは面倒なので、infuraを使用します。プロジェクトを作成して、Ropstenネットワークへ接続するENDPOINT、API_KEY,API_SECRETを取得しておきます。

truffle-hdwallet-providerをinstallします。

$ npm install truffle-hdwallet-provider

truffle.jsへRopstenネットワークへの接続設定を加えます

var HDWalletProvider = require("truffle-hdwallet-provider");
var mnemonic = "MetaMaskインストール時に保存したニーモニック";

module.exports = {
    networks: {
      ropsten: {
        provider: function(){
            return new HDWalletProvider(
                mnemonic,
                "INFURAで取得したROPSTENネットワークのENDPOITN"
            );
        },
        network_id: 3,
        gas: 5000000
      }
  }
};

デプロイします。しばらく時間がかかります。

$ truffle migrate --network ropsten
Using network 'ropsten'.

Running migration: 2_deploy_scoin.js
  Deploying SCoin...
  ... 0x0eadb060d48808e0eebdca6c1874849626795b8241e4b6349fac5cf4fcbb70bc
  SCoin: 0xb44ccf69c7c9541dca2ab8eff21af6b80aba25f7
Saving successful migration to network...
  ... 0xa53f8abebf3e72ebafa1914889b676aa880474098c82064ae2db1162669713c6
Saving artifacts...

デプロイされました!凄く簡単に独自のトークンが作れました。
アドレスは0xac8d7d884da28ccef8972d2d15bf3d58cb6a2878になります。
MetaMaskに追加して、送金などして遊ぶことができます。

(いないと思いますが)欲しい方がいたら連絡してください。