LoginSignup
9
5

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-07-14

やりたいこと

前回エントリ 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を使ってまとめてフロント用に変換します。

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

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

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

3 Etherクラスを定義する

ether.js

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の中で使います。

main.js

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です。

server.js
var Main = function(app){

    const bot = new Linebot(app);

    bot.getAction = function(event,message){
      try{
         if(typeof(message['action']) == 'undefined' || message['action']==''){
             throw 'No Action Detected.';
         }
         switch(message['action']){
            case 'accountInfo':
                event.replyFlex(
                    "Show Account Info",
                    null,
                    "Click the button below to show your account information.",
                    "Show",
                    "https://etherbot.glitch.me/redirect/accountinfo"
                );
                break;
            case 'payment':
                event.replyFlex(
                    "Payment",
                    null,
                    "Click the button below to open URL.",
                    "Open URL",
                    "https://etherbot.glitch.me/redirect/transfer"
                );
                break;
            case 'invoice':
                event.replyFlex(
                    "Manage Invoice",
                    null,
                    "Click the button below to open URL.",
                    "Open URL",
                    "https://etherbot.glitch.me/redirect/invoice"
                );
                break;
            default:
                throw 'No Action Detected.';
                break;
            }

          }catch(e){
               event.replyText(e);
          }
    }

    this.onMessageEvent = function(event){
        switch(event.type){
           case 'message':
              var message = queryParse(event.message.text);      
              break;
           case 'postback':
              var message = queryParse(event.postback.data);
              break;
           default:
              event.replyText('The event type is not supported.');
              break;
        }
        bot.getAction(event,message);
    }
    bot.onMessageEvent(this.onMessageEvent);
}
const express = require('express');
new Main(express());

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

redirect.html
<!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

9
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
5