LineBotで Ethereum の web3.jsを操作する(その2 : ローカルで署名してから送金)

やりたいこと

前回エントリ LineBotで Ethereum の web3.jsを操作する(その1: バックエンドで残高取得)
ではバックエンドにてLine Messaging APIとweb3.jsを使ってパブリックチェーンのイーサリアム残高を取得しました。しかしながら、送金等その他の処理も全てバックエンドで行うとなると、

  • 秘密鍵を外部に送る機会が生じるため、(暗号化されていようといまいと)危険。
  • WebStorageが使えない。

など色々不便が多いため、フロントで実装したいと思います。

イメージ図

ローカルとは言え、秘密鍵を携帯に保存するのはやはり危険が伴うので、WebStorageに保管するのは、アドレスのみに留めておきます。秘密鍵はペーパーウォレット等のQRコードから読めるようにします。

1 web3.jsの読み込み

web3.jsはフロント用のライブラリが用意されているので、そのままCDNから読み込みます。

<script type="text/javascript" src="https://cdn.jsdelivr.net/gh/ethereum/web3.js/dist/web3.min.js"></script>

2 ethereumjs-txとethereumjs-utilの読み込み

ethereumjs-txは秘密鍵を使ってローカルで署名するための機能を持つライブラリです。

秘密鍵からアドレスを導出する機能についてはver1.0-betaのweb3.jsであればweb3.eth.accounts.privateKeyToAccountというメソッドが用意されていますが、stable版のweb3.jsを使う場合は、別途ethereumjs-utilを使用する必要があります。

browserifyを使ってまとめてフロント用に変換します。

require('ethereumjs-tx');
require('ethereumjs-util');
script.onload(require); 
//メインプロシージャから呼び出す用の関数。uniqueな関数名でrequireが引数であれば何でも。
browserify src.js -o dist.js
<script type="text/javascript" src="dist.js"></script>

メインプロシージャにrequireを渡してライブラリを読み込みます。(もっとスマートな方法があるとは思いますが・・とりあえず動くのでこうしてます。)

var script = {
    onload : function(require){
        const ether = new Ether(require);
        model.init(ether);
        view.init();
        controller.init();
    }
}

3 Etherクラスを定義する

var Ether = (function() {
    /**
    * コンストラクタでethereumjs-txとethtereumjs-utilを読み込み
    */
    var Ether = function(require) {
        if(!(this instanceof Ether)) {
            return new Ether(require);
        }
        this.Util = require('ethereumjs-util');
        this.Tx = require('ethereumjs-tx');
    }

    Ether.prototype.setChain = function(chain){
            switch(chain){
            case 'mainnet':
                return {'node':'https://mainnet.infura.io/[infura.ioのAPI KEY]','api':'https://api.etherscan.io/api','id':1};
                break;
            case 'ropsten':              
                return {'node':'https://ropsten.infura.io/[infura.ioのAPI KEY]','api':'https://api-ropsten.etherscan.io/api','id':3};
                break;
            case 'rinkeby':              
                return {'node':'https://rinkeby.infura.io/[infura.ioのAPI KEY]','api':'https://api-rinkeby.etherscan.io/api','id':4};
                break;
            case 'kovan':              
                return {'node':'https://kovan.infura.io/[infura.ioのAPI KEY]','api':'https://api-kovan.etherscan.io/api','id':42};
                break;
            default:
                return {'node':'https://mainnet.infura.io/[infura.ioのAPI KEY]','api':'https://api.etherscan.io/api','id':1};
                break;
        }
    }
    /**
    * @function validateAddress アドレスのvalidation
    */
    Ether.prototype.validateAddress = function(address){
        var valid = true;
        if(String(address) == ''){
            valid = false;
        }
        else if(
                String(address).length != 42
            || !String(address).match(/^[0-9a-zA-Z]/)
            || !(String(address).slice(0,2)=='0x')
        ){
            valid = false;
        }
        return valid;
    }
    /**
    * @function validateSecret 秘密鍵のvalidation
    */
    Ether.prototype.validateSecret = function(secret){
        var valid = true;
        if(String(secret) == ''){
            valid = false;
        }
        else if(
                String(secret).length != 64
            || !String(secret).match(/^[0-9a-z]/)
        ){
            valid = false;
        }
        return valid;
    }
    /**
    * @function getBalance 残高取得するメソッド(web3.jsのみ使用)
    */
    Ether.prototype.getBalance = function(chain,address){
        const web3 = new Web3(new Web3.providers.HttpProvider(this.setChain(chain).node));
        if(this.validateAddress(address)){
              return web3.fromWei(web3.eth.getBalance(address), "ether").toNumber();
        }
        else{
              return false;
        }
    }
    /**
    * @function getAccountFromSecret 秘密鍵からアドレスや残高を取得するメソッド(web3.jsとehtereumjs-util使用)
    */
    Ether.prototype.getAccountFromSecret = function(chain,secret){
        if(this.validateSecret(secret)){
              const web3 = new Web3(new Web3.providers.HttpProvider(this.setChain(chain).node));
              const privkey = this.Util.toBuffer('0x'+ secret);
              const address = '0x' + this.Util.privateToAddress(privkey).toString('hex');
              if(this.validateAddress(address)){
                    return {'address':address,'balance':web3.fromWei(web3.eth.getBalance(address), "ether").toNumber()};
              }
              else{
                    return false;
              }
        }
        else{
              return false;
        }
    }
    /**
    * @function transfer 送金するメソッド(web3.jsとehtereumjs-tx使用)
    */
    Ether.prototype.transfer = function(chain,secret,sendTo,amount,gas){
        const web3 = new Web3(new Web3.providers.HttpProvider(this.setChain(chain).node));

        return new Promise((resolve, reject) => {
            if(!this.validateSecret(secret)){
                    reject('Invalid secret.')
            }
            if(!this.validateAddress(sendTo)){
                    reject('Destination address is invalid.')
            }
            if(typeof(amount)!=='number' || amount<0){ reject('Invalid amount.') } const account = this.getAccountFromSecret(chain,secret); web3.eth.getTransactionCount(account.address, (err,txCount) => {
                const privKey = this.Util.toBuffer('0x'+ secret);
                const rawTx = {
                    nonce: web3.toHex(txCount),
                    gasPrice: web3.toHex(web3.toWei(gas, 'gwei')),
                    gasLimit: web3.toHex(21000),
                    to: sendTo,
                    value: web3.toHex(web3.toWei(amount, 'ether')),  
                    data:null,
                    chainId: this.setChain(chain).id
                }

                const tx = new this.Tx(rawTx);
                tx.sign(privKey);

                const signedTx = tx.serialize();

                web3.eth.sendRawTransaction('0x' + signedTx.toString('hex'),(err, txHash)=>{
                    if(err) {
                        reject(err);
                    } else {
                        resolve(txHash);
                    }
                });
           });
       });
    }
    return Ether;
})();

4 Etherクラスを使用する

メインプロシージャのmodelの中で使います。

var model = {

    ether:{},

    init: function(ether){
        model.ether = ether;
        model.setAddressList();
        model.showTransactionHistory();
    },

    showInfo: function(chain,secret){
        const account  = model.ether.getAccountFromSecret(chain,secret);
        if(account){
            view.showInfo([{'address':account.address,'balance':account.balance}]);
        }
    },

    transfer: function(chain,secret,sendTo,amount,gas){
        model.ether.transfer(chain,secret,sendTo,amount,gas).then(function(txHash){
              alert('Payment completes successfully.');
              view.showList();

        }).catch(function(err){
              alert(err);
        });
    }
};

5 LineBotからアクセスできるようにする

LinebotクラスはLineBotで Ethereum の web3.jsを操作する(その1: バックエンドで残高取得)で定義したものです。ボタンをクリックすると該当URLにジャンプするだけのBOTです。

var model = {

    ether:{},

    init: function(ether){
        model.ether = ether;
        model.setAddressList();
        model.showTransactionHistory();
    },

    showInfo: function(chain,secret){
        const account  = model.ether.getAccountFromSecret(chain,secret);
        if(account){
            view.showInfo([{'address':account.address,'balance':account.balance}]);
        }
    },

    transfer: function(chain,secret,sendTo,amount,gas){
        model.ether.transfer(chain,secret,sendTo,amount,gas).then(function(txHash){
              alert('Payment completes successfully.');
              view.showList();

        }).catch(function(err){
              alert(err);
        });
    }
};

注意点として、WebViewだとQRコードリーダー等のWebRTC系ライブラリが初期状態では使用できないため、WebViewからChrome等のブラウザにRedirectさせるようにします。

<!DOCTYPE html>
<html>
<head>
    <title>Redirect</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, height=device-height">
    <meta name="apple-mobile-web-app-capable" content="yes" />
</head>
<body>
    <script type="text/javascript">
        window.onload = function(){
            if (navigator.userAgent.match(/(Android)/)) {
                location.href= "intent://"+ location.hostname +"/invoice#Intent;scheme=https;package=com.android.chrome;end";
            }
            else if(navigator.userAgent.match(/(iPhone|iPod|iPad|BlackBerry)/)){
                location.href= "googlechromes://"+ location.hostname +"/transfer";
            }
            else{
                location.href= "https://" + location.hostname + '/transfer';
            }
        }
        </script>
</body>
</html>

6 各機能の実装

(1) LINE UI

LINE@マネージャーのリッチコンテンツ作成からメニューを作成します。

メニューからアクションを選択すると、クエリ文字列が送られ、リンクが生成されます。

(2) アカウント情報取得

複数のAddressをWebStorageに保存し、一度に残高等を取得します。

model.showAccountInfo: function(){
   if (typeof localStorage.addressList !== 'undefined') {
          var addressList = JSON.parse(localStorage.getItem("addressList"));
          var data = [];
          var label = [];
          var info = [];

          addressList.forEach((key)=>{
               var balance = model.ether.getBalance(key.chain,key.address);
               data.push(balance);
               label.push(key.chain + ':' + key.name);
               info.push({"name":key.name,"chain":key.chain,"address":key.address,"balance":balance});
          });
          view.drawChart(label,data);
          view.setInfo(info);
    }
    else {
          alert('No showable data exist.')
          return false;
    }
}


(3) 送金

model.transfer: function(chain,secret,sendTo,amount,gas){
    model.ether.transfer(chain,secret,sendTo,amount,gas).then(function(txHash){
          alert('Payment completes successfully.');
     }).catch(function(err){
          alert(err);
     });
}

Senderの秘密鍵、Recipientのアドレスまたは請求書情報はQRコードで読めるようにします。

(4)請求書作成

view.generateQRCode:function(chain,address,price){
      var string = "chain="+chain+"&address="+address+"&price="+price;
      $('#qrcode').empty();
      $('#qrcode').qrcode({width: 250, height: 250, text:string });
}

チェーン(mainnet,ropsten,etc.)、Recipientのアドレス、価格からQRコードを生成します。

まとめ

当初はLineBotだけでEthereum周りのことが色々できたらいいなーとぼんやり思っていましたが、セキュリティや利便性等の事情を考慮して、結局フロントUIを一から作る結果になりました。

それでもやはり、LineBotを使用するメリットとしては、やはり導入の敷居の低さだと思います。
バックエンドの処理が一定以上存在する以上、サーバ負荷を考えた設計にしなければいけないことは勿論ですが、

それを踏まえても、開発側にとっても、使用する側にとっても「とりあえず作ってみる、使ってみる」ができるというメリットは大きいと思います。

今回作成したBotのソースは以下です。

Github

https://github.com/snst-lab/etherbot

Leave a Reply

Your email address will not be published. Required fields are marked *