この記事は Blockchain Advent Calendar 2019 の 12 日目の記事です。
Ethereum のコントラクトウォレットのひとつである Dapper の仕組みを解説します。
はじめに
Ethereum のウォレットには UI/UX の課題が多いです。
「セットアップ手順が多い」「秘密鍵をなくすと資産をすべて失う」といった点は、一般ユーザーにウォレットが普及するには致命的です。コントラクトウォレットは、これらの課題を解決できるアイデアです。
コントラクトウォレットについて文章で説明している記事はみつかりますが、コードの解説はあまりなかったので、ここで紹介したいと思います。
基礎知識
Ethereum のアドレスは 2 種類あり、通常は EOA がウォレットとして扱われます。一番大きな違いは、秘密鍵があるかどうか(秘密鍵を紛失するリスクがあるかどうか)です。
- EOA (Externally Owned Account)
- 秘密鍵で署名して Tx を送れる
- コントラクトアカウント (Contract Account)
- 秘密鍵がなく署名できない
- EOA 起点でインターナル Tx を送れる
コントラクトウォレットとは
コントラクトアカウントをユーザーのウォレットとして使っているものを「コントラクトウォレット」と呼びます。
代表的なプロダクトとして、Dapper と Argent があります。
Dapper https://www.meetdapper.com
Argent https://www.argent.xyz/
Dapper の概要
- CryptoKitties 開発チームが開発している(まだβ版)
- MetaMask ライクなブラウザ拡張
- Gas 代を Dapper チーム側で持つことができる
- リカバリー機能がある
- オープンソース https://github.com/dapperlabs/dapper-contracts
Dapper の要点
2 of 2 のマルチシグウォレットである
コントラクトウォレットとは、つまるところ、複数 EOA のマルチシグウォレットです。Dapper の場合、 Device Key
/ Cosigning Key
と呼ばれる 2 種類の秘密鍵による署名が揃うとコントラクトを実行できます。Device Key
はユーザーのデバイスに自動的に保管され、 Cosigning Key
は Dapper チームが保管しています。
リカバリー用の Key がある
コントラクトウォレットの強みとして、独自のリカバリー機能を実装できる点が挙げられます。Dapper は、 Recovery Key
と呼ばれる秘密鍵の署名があれば、既存の Device Key
/ Cosigning Key
をすべて無効化して、新しい Key を登録し直せる仕組みを提供しています。 Recovery Key
は Dapper チーム側が(おそらく)コールドウォレットに保管しています。また、このリカバリー操作を行ったとき、事前にユーザーが作成しておいた Backup Key
と呼ばれる秘密鍵が、唯一の有効な Device Key
として登録されます。
構図
ユーザーがウォレットに対して ETH 送金などを行うとき、ユーザーは tx に署名をして、Dapper 側がその tx を送信します(逆パターンもあります)。
リカバリーを行うときは、下図のようになります。Dapper では、この操作を行うときは、ユーザーがあらかじめ発行・保管しておいた Backup Key
、メール、パスワードによる認証を必要としています。このあたりはオフチェーンで担保されています。
コード解説
ユーザーが Dapper を利用するとき、次のフローでコントラクトが呼び出されます。
1. コントラクト生成
2. 初期化
3. 利用
4. リカバリー
順番に説明します。
1. コントラクト生成
コントラクトウォレットでは、ユーザーごとに1つのウォレット用コントラクトが生成されます。Dapper ではこのコントラクト生成処理もコントラクト内部で行われます。
コントラクト生成には 2 種類の方法が用意されています。ひとつはコントラクトのフルコードをデプロイするやり方で、もうひとつは EIP-1167: Minimal Proxy Contract を使って、デプロイ済みのコントラクトにデリゲートコールするプロキシコントラクトをデプロイするやり方です。通常、後者のプロキシコントラクトのデプロイが使われています。こちらはデプロイするコードがたった 45 バイトと小さいため、100 Kwei 以下の非常に安い Gas 代でコントラクトを生成できます。
実際のコードは下記です(Github)
function createClone2(address target, bytes32 salt) internal returns (address payable result) {
bytes20 targetBytes = bytes20(target);
assembly {
let clone := mload(0x40)
mstore(clone, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000)
mstore(add(clone, 0x14), targetBytes)
mstore(add(clone, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000)
result := create2(0, clone, 0x37, salt)
}
}
引数の target
にはデプロイ済みコントラクトのアドレスが、 salt
には Device Key
/ Cosigning Key
/ Recovery Key
(およびランダムな数値)から生成した値が指定されます。デプロイには CREATE2
の OP_CODE を使っており、これによりコントラクトの初期化がよりセキュアになります。
このコードで生成されるコントラクトのバイトコードは下記となります。非常にサイズが小さいです。 bebebebebebebebebebebebebebebebebebebebe
の部分が、デプロイ済みのコントラクトアドレスです。
363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3
2. 初期化
生成したコントラクトに、 Device Key
/ Cosigning Key
/ Recovery Key
を設定します。コードは下記です(Github)
(コードでは、 Device Key
のアドレスを authorizedAddress
と呼んでいます。)
function init(address _authorizedAddress, uint256 _cosigner, address _recoveryAddress) public onlyOnce {
require(_authorizedAddress != _recoveryAddress, "Do not use the recovery address as an authorized address.");
require(address(_cosigner) != _recoveryAddress, "Do not use the recovery address as a cosigner.");
require(_authorizedAddress != address(0), "Authorized addresses must not be zero.");
require(address(_cosigner) != address(0), "Initial cosigner must not be zero.");
recoveryAddress = _recoveryAddress;
// set initial authorization value
authVersion = AUTH_VERSION_INCREMENTOR;
// add initial authorized address
authorizations[authVersion + uint256(_authorizedAddress)] = _cosigner;
emit Authorized(_authorizedAddress, _cosigner);
}
onlyOnce
修飾子がついている点が重要です。この処理は、コントラクトデプロイ後に一度だけ実行できることになります。
bool public initialized;
modifier onlyOnce() {
require(!initialized, "must not already be initialized");
initialized = true;
_;
}
3. 利用
ユーザーがウォレットを利用するときは、ユーザーが行いたい ETH 送金や ERC20 トークン送信などのトランザクションの内容を、メタトランザクションとしてラップして、ユーザーまたは Dapper チーム側が下記いずれかの関数を呼び出します。
-
invoke0()
:Device Key
=Cosigning Key
である場合、そのアカウントによる Tx 送信 -
invoke1SignerSend()
:Cosigning Key
による署名 +Device Key
による Tx 送信 -
invoke1CosignerSend()
:Device Key
による署名 +Cosigning Key
による Tx 送信 -
invoke2()
:Device Key
による署名 +Cosigning Key
による署名 + 任意のアカウントによる Tx 送信
通常は invoke0()
以外が使われます。Dapper チーム側が Gas 代を支払う場合、 invoke1CosignerSend()
が使われます。
これらの関数は、まず 2 of 2 の署名の検証を行います。署名が正しい場合、Tx のデータに含まれる操作内容に従って、コントラクト内部から ETH を送金したり、指定されたコントラクトを呼び出します。
ここでは、 invoke1CosignerSend()
のコードを紹介します(Github)。なお、コードでは、 Device Key
のアドレスを authorizedAddress
または signer
、 Cosigning Key
のアドレスを cosigner
と呼んでいます。
function invoke1CosignerSends(uint8 v, bytes32 r, bytes32 s, uint256 nonce, address authorizedAddress, bytes calldata data) external {
// 署名のバージョンをチェック
require(v == 27 || v == 28, "Invalid signature version.");
// 署名の検証に必要なハッシュを計算
bytes32 operationHash = keccak256(
abi.encodePacked(
EIP191_PREFIX,
EIP191_VERSION_DATA,
this,
nonce,
authorizedAddress,
data));
// 署名からアドレスを復元
address signer = ecrecover(operationHash, v, r, s);
// 復元したアドレスがゼロアドレスでないことをチェック
require(signer != address(0), "Invalid signature.");
// ナンス(ここでは Tx 送信の連番)が正しいことをチェック
require(nonce == nonces[signer], "must use correct nonce");
// 復元したアドレスが、Device Key のアドレスであることをチェック
require(signer == authorizedAddress, "authorized addresses must be equal");
// Cosigning Key のアドレスを取得
address requiredCosigner = address(authorizations[authVersion + uint256(signer)]);
// Tx 送信者が Cosigning Key のアドレスであることをチェック (または、Cosigning Key = Device Key の場合でも OK)
require(requiredCosigner == signer || requiredCosigner == msg.sender, "Invalid authorization.");
// リプレイ攻撃を防ぐためにナンスをインクリメント
nonces[signer] = nonce + 1;
// internal function を呼び出す
internalInvoke(operationHash, data);
}
要するにこれは、Ethereum におけるマルチシグの実装です。このコードは監査されているので、実装例として有益です。
実際の処理は、最後に呼び出している internalInvoke()
の中で行われます。この関数は internal
であるため、外部から直接実行できません。コードが少し長いので掲載は割愛しますが(興味がある方はここをご確認ください)、やっていることは、Tx データから送信先アドレスと送金額を取り出して、call を実行することです。また、Tx のデータに InnerData がある場合は、そこに書かれているコントラクトの関数を実行します。
例として、ETH 送金の Tx 送信は、下記の図のように処理が行われます。
もうひとつの例として、ERC721 トークンを送信する場合は、下記の図のように処理が行われます。
ERC721 トークンの送信など、コントラクトを呼び出す場合は、Tx のデータに Inner Data を記載します。
Tx のデータの構造は、この図に書かれています。
https://github.com/dapperlabs/dapper-contracts/blob/2ccb/out/transaction/Dapper%20Wallet%20-%20Multi-Sig%20Transaction%20Structure.svg
Device Key の追加/削除
コントラクトウォレットで実現できる素晴らしい機能のひとつは、マルチシグのアドレスを追加/削除できることです。
昨今、ユーザーは複数のデバイスを利用します。EOA ウォレットの場合、ユーザーはひとつの秘密鍵を、複数のデバイスにコピーする必要があります。これは面倒な作業であり、セキュリティ的にも好ましくありません。
Dapper の場合、デバイスごとに新しい秘密鍵を生成して、その秘密鍵はデバイス内にのみ保管します。新しいデバイスを登録するときは、登録済みのデバイスで承認を行います。
コードは下記です(Github)
function setAuthorized(address _authorizedAddress, uint256 _cosigner) external onlyInvoked {
require(_authorizedAddress != address(0), "Authorized addresses must not be zero.");
require(_authorizedAddress != recoveryAddress, "Do not use the recovery address as an authorized address.");
require(address(_cosigner) == address(0) || address(_cosigner) != recoveryAddress, "Do not use the recovery address as a cosigner.");
authorizations[authVersion + uint256(_authorizedAddress)] = _cosigner;
emit Authorized(_authorizedAddress, _cosigner);
}
Device Key
/ Cosigning Key
のペアは mapping で保持しており、ここに新しくアドレスを追加します(authVersion
については後述)。
最近、Dapper は Android 版のアプリ(β版)をリリースしました。なかなか軽快に動作します。
デバイス追加の承認がモバイルでできることは重要ですね。
4. リカバリー
既存の Device Key
/ Cosigning Key
を無効化して、 Backup Key
を唯一の Device Key
として登録する処理を、 Recovery Key
による Tx 送信で実行できます。
実際のコードは下記です(Github)
function emergencyRecovery(address _authorizedAddress, uint256 _cosigner) external onlyRecoveryAddress {
require(_authorizedAddress != address(0), "Authorized addresses must not be zero.");
require(_authorizedAddress != recoveryAddress, "Do not use the recovery address as an authorized address.");
require(address(_cosigner) != address(0), "The cosigner must not be zero.");
// Incrementing the authVersion number effectively erases the authorizations mapping. See the comments
// on the authorizations variable (above) for more information.
authVersion += AUTH_VERSION_INCREMENTOR;
// Store the new signer/cosigner pair as the only remaining authorized address
authorizations[authVersion + uint256(_authorizedAddress)] = _cosigner;
emit EmergencyRecovery(_authorizedAddress, _cosigner);
}
Device Key
/ Cosigning Key
のペアは mapping で保持していますが、ここに一工夫あります。
mapping のキーに、バージョン番号(authVersion
)と Device Key
のアドレスを足した値を使用しています。これにより、バージョン番号を変えるだけで、既存のペアを無効化できます。
ちなみに、コントラクトの関数としては、任意の 新しい Device Key
/ Cosigning Key
を引数に指定可能です。「 Backup Key
を唯一の Device Key
として登録する」という処理は、オフチェーンで実現されています。
その他
Dapper コントラクトは、delegate call の仕組みを使って、あとからコントラクトに関数を追加できる作りになっています。
新しいトークン規格などが出てきた際にも、同じコントラクトを使い続けることが可能です。
Dapperコントラクトのここがすごい
技術面
- コントラクト生成が効率的
- マルチシグ/メタトランザクションの実践的な実装
ユーザービリティ面
- サービス提供者が Gas 代を肩代わりできる
- 盗難リスクを抑えたリカバリー機能の提供
- ウォレットにあとから機能を追加できる
以上、簡単ではありますが、Dapper コントラクトの紹介でした。
Dapper Labs には今後も要注目です。
参考
https://github.com/dapperlabs/dapper-contracts
https://etherscan.io/address/0x37932f3eca864632156ccba7e2814b51a374caec#code
https://medium.com/dapperlabs/why-dapper-is-a-smart-contract-wallet-ef44cc51cfa5
https://medium.com/dapperlabs/introducing-dappers-multi-device-support-db6b4f53fb
https://blog.sigmaprime.io/dapper-wallet-review.html