Ether のエンドユーザー問題と解決方法


はじめに

Ethereum で DApps を作っている時に気になっていた Gas を誰が払うの問題についての考察と対策の実装のメモ

エンドユーザー問題

GMO の Z cloud Blockchain Service Guide に詳しい
考察するのは Ether 保有の問題で、Blockchain Service Guide から引用すると

Ether保有の問題
スマートコントラクトアプリケーション上で何かを書込みたい場合にEtherを保有していないといけない。

現実問題として世界のほとんどの人が仮想通貨を保有していない。

Daaps 開発者としてはこれは大きな問題だと思う。
例えば Token を作って独自経済圏を作るサービスを開発しようと思っても、Token を渡すだけでもユーザーが ether のネットワークに gas を支払わなければいけない。

広くスケールするサービスを開発しようとした場合に、全ユーザーが ether を持っていると仮定するにはかなり無理があるので、以下にユーザーが ether を持っていなくても成立する仕組みを考察してみた

解決方法

1. Web サーバーに秘密鍵を預ける

一番簡単な解決策として思いつくのは、ユーザーは秘密鍵を持たず、ユーザーの代わりに秘密鍵を管理する Web サーバーを作りそこに全てお任せする方法

この方法でのトランザクションの処理は以下のような流れになる

  • ユーザー (browser や app) は http などで web server にリクエストを投げる
  • Web server は http のリクエストに応じて Tx を作成
  • 預かっているユーザーの秘密鍵を使って Tx に署名する
  • Tx の処理に必要な Gas をユーザーのアカウントに移動する
  • Tx を Ethereum のネットワークに投げ込んで処理する

この方法の最大の問題点は、ユーザーはサーバーを完全に信用しないといけないところ
サーバーが Tx の内容を自分の都合の良いように改竄したとしてもユーザーに防ぐ手立ては無い
また、仮にユーザーがサーバーを信用してくれたとしても gas は個々のユーザーのアカウントから支払われるので、Tx の度にユーザーのアカウントに gas 代金をチャージしておかないといけないのもかなり面倒 (取引所がノミ行為をするのはこういった技術的理由もかなり大きいと思う)

2. 秘密鍵はサーバーに預けず、支払いだけを Web サーバーに肩代わりさせる (Meta transaction)

この方法でのトランザクションの処理は以下のような流れになる

  • ユーザーは Tx を作り自分の秘密鍵を使って署名する
  • 署名したデータは ethereum のネットワークには投げ込まず、web server に http などで送信
  • Web server はユーザーから送られた Tx をデータとして持つ Tx を作り自分の秘密鍵で署名する
  • 署名した Tx を ethereum のネットワークに投げ込む
  • Smartcontract が Tx を解析し、元々のユーザーが署名した Tx を実行する

この方式の最大の利点はユーザーは秘密鍵を Web server とシェアする必要が無い事と、gas の支払いは web server 上のアカウントから行われるという 2 点
実際のところはユーザーが署名する Tx の内容に制限がある事や、msg.sender がどのアドレスになるかなど気をつけなければいけない事は多くサービス毎に色々考慮する事は多いと思われるが、少なくとも ethereum の進化などを待たずに現状 (2018年2月) 時点で代払いが可能であるという事実は大きい

因みに上記の仕組みは uPort が Meta transaction という名前で提案している

uPot の github を見ると ユーザーが署名した Tx から署名者を復元する処理の実装などが公開されているので参考になる

bytes data となっている部分がユーザーがサインした元々の Tx
data 以外に SigV SigR sigS を送信して、サインした address を復元出来るようにしている

function relayMetaTx(
    uint8 sigV,
    bytes32 sigR,
    bytes32 sigS,
    address destination,
    bytes data,
    address listOwner
) public {

    // only allow senders from the whitelist specified by the user,
    // 0x0 means no whitelist.
    require(listOwner == 0x0 || whitelist[listOwner][msg.sender]);


    address claimedSender = getAddress(data);

        // 送られてきた署名からサインしたユーザーのアドレスを復元する処理 
    bytes32 h = keccak256(byte(0x19), byte(0), this, listOwner, nonce[claimedSender], destination, data);
    address addressFromSig = ecrecover(h, sigV, sigR, sigS);

        // data に埋め込まれた address と署名した address がマッチしているかをチェック
    require(claimedSender == addressFromSig);

    nonce[claimedSender]++; //if we are going to do tx, update nonce
    require(destination.call(data));
}

// data に埋め込まれた address を取得する部分
// これは application レイヤーのプロトコルの話で、uPort では client は署名するデータに address を埋め込んでいるという前提に立っている
function getAddress(bytes b) public constant returns (address a) {
    if (b.length < 36) return address(0);
    assembly {
        let mask := 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
        a := and(mask, mload(add(b, 36)))
    }
}

コンセプトを試す

https://github.com/tsuzukit/meta-transaction に上記のコンセプトを試した結果をまとめた

実行環境

web3 のバージョン管理などが面倒なので docker で node.js を実行できて必要な library が全て準備された image を作った

  • solidity: 0.4.17
  • compiler: v0.4.19+commit.c4cbbb05
  • web3: 1.0.0-beta.26

作った contract は ganache-cli のテスト環境で試したが、問題なく rinkeby などのテストネットやメインネットでも動くと思う

準備

まず metamask などでアカウントを 2 つ準備する
1 つはユーザーアカウントでもう一つは代理で gas を支払う代理アカウント

コントラクト

コントラクトも 2 つ作る
1 つめは代理アカウントが Tx 投げ込む relay contract

pragma solidity ^0.4.16;

contract TxRelay {

    // この nonce は署名したアカウントがこの contract にアクセスした回数を記録する
    mapping(address => uint) public nonce;

    /*
     * @dev Relays meta transactions
     * @param sigV, sigR, sigS ECDSA signature on some data to be forwarded
     * @param destination Location the meta-tx should be forwarded to
     * @param data The bytes necessary to call the function in the destination contract.
     * @param sender address of sender who originally signed data
     */
    function relayMetaTx(
        uint8 sigV,
        bytes32 sigR,
        bytes32 sigS,
        address destination,
        bytes data,
        address sender // sender は data に埋め込めれば良かったが decode の方法がわからなかったので直接送ってもらう事にした
    ) public {

        // use EIP 191
        // 0x19 :: version :: relay :: sender :: nonce :: destination :: data
        bytes32 h = keccak256(byte(0x19), byte(0), this, sender, nonce[sender], destination, data);
        address addressFromSig = ecrecover(h, sigV, sigR, sigS);

        // ここで sender と signature から復元した address がマッチしているかをチェック
        require(sender == addressFromSig);

        // nonce を increment する
        nonce[sender]++;

        // 別コントラクトの method を call する
        // callcode では無いので msg.sender にはこのコントラクトの address が入る
        require(destination.call(data));
    }

}

もう一つは TxRelay に呼び出される用のコントラクト
超シンプルな、メッセージをセットしてその際の msg.sender を記録しておくだけのもの

pragma solidity ^0.4.16;

contract MessageBox {

    string public message;
    address public sender;

    function MessageBox(string initialMessage) public {
        message = initialMessage;
    }

    function setMessage(string newMessage) public {
        message = newMessage;
        sender = msg.sender;
    }
}

この 2 つが出来たらまずは compile してイーサのネットワークに deploy 可能な状態にする


// docker を起動
$ sh script/start.sh

// docker の中に入る
$ sh script/enter.sh

// solc でコンパイル
$ node compile.js

コンパイルされた json は build 以下に作られる

クライアント

クライアントでは lib/metaTransactionClient.js を使って web server に送信可能なデータを作る


let newMessage = "更新されたメッセージ";
let nonce = await txRelay.methods.getNonce(config.client_account.address).call();
let messageBoxAbi = JSON.parse(compiledMessageBox.interface);

// 第二引数はターゲットになるコントタクトの関数名、第三引数はその関数の args を渡す
let rawTx = await MetaTransactionClient.createTx(messageBoxAbi, 'setMessage', [newMessage], {
  to: messageBox.options.address,
  value: 0,
  nonce: parseInt(nonce), // nonce must match the one at TxRelay contract
  gas: 2000000,
  gasPrice: 2000000,
  gasLimit: 2000000
});

// txToServer は sig, to, from, data を持つ js の object
// sig はユーザーの秘密鍵で rawTx を署名したもの
txToServer = await MetaTransactionClient.createRawTxToRelay(
  rawTx,
  config.client_account.address,
  config.client_account.privateKey,
  txRelay.options.address
);

サーバー

サーバーはクライアントから送られたデータをさらに署名して TxRelay コントラクトに投げる


let nonce = await web3.eth.getTransactionCount(config.server_account.address);

// sig や data を引数にして TxRelay コントラクトの relayMetaTx method を呼ぶための Tx を作成
// 代理アカウントの秘密鍵で署名する
let signedTxToRelay = await MetaTransactionServer.createRawTxToRelay(
  JSON.parse(compiledTxRelay.interface),
  txToServer.sig,
  txToServer.to,
  txToServer.from,
  txToServer.data,
  {
    "gas": 2000000,
    "gasPrice": 2000000,
    "gasLimit": 2000000,
    "value": 0,
    "to": txRelay.options.address,
    "nonce": parseInt(nonce),
    "from": config.server_account.address
  },
  config.server_account.privateKey
);

// ここで署名した Tx をイーサリアムに投げ込む
const result = await web3.eth.sendSignedTransaction('0x' + signedTxToRelay);

これで、MessageBox コントラクトの message 変数を調べると 更新されたメッセージ に更新されていて、sender は TxRelay アカウントのものになっている

最後に

以上が ethereum のエンドユーザー問題に関する現時点での考察だが、そもそもセキュリティ的に破綻していたり、もっと良い方法がある可能性は否めないので、もしそういった方法をご存知の方は優しく教えて下さい

実はERC208ERC865 も同じような問題に対応する提案で (探せばもっとありそう)、特に ERC865 は transaction を token で払うというアイディアで、実装はメタトランザクションに類似していて面白い (署名済みのデータと署名をコントラクトに送って検証する)