Mpurse
先日公開したMonapartyのChrome拡張であるMpurseについて、コンセプトも含めて解説します。
※一応現時点では限定公開という扱いでChromeウェブストアで検索しても出てきません。その内通常公開に変更します。
作りたかったもの
何がやりたかったかというと、一言で言うならMetaMask。
と言ってもWalletが作りたかったのではなく、どちらかというとweb3
まわりです。
WEBページに対するアドレスの提供や、署名を返す機能など、WEBとの連携のほうが目的で作り始めました。言ってしまえばソーシャルログインとさよならするためのログインボタンが作りたかっただけかも。
設計はMetaMaskの影響を大きく受けており、Identiconのライブラリも同じjazzicon
にしましたし、はじめはしばらくMetaMaskのコードを眺めるところから始め、コードも結構参考にしました。ただ、web3
も含めるとその全貌はなかなか壮大で、途中からは面倒になってしまって割とフリーハンドで書いています。
いつもの事ですが設計には何の確信も持てていないので、こうしたほうが良いよ、バカなの?死ぬの?的なご指摘は常に歓迎です。
使い方
Walletとしては、残高の確認とその送金というシンプルなものです。URL欄の横に表示されるボタンをクリックすると小さなウィンドウが開きます。
パスフレーズは新規で生成しても良いですし、counterwalletのパスフレーズをそのまま持ってくることも可能です。新規で生成しておいて、特定の秘密鍵をインポートすることも可能です。
インストール後有効になっていると、Chromeのすべてのタブにmpurse
のインスタンスをインジェクトするようになっています。
WEBサイト側はこれを拾って各種機能にアクセスするのですが、onload
でwindow.mpurse
にアクセスしてもundefined
になります。
タイマー的なもので拾うと100ミリ秒後ぐらいに拾えます。
version0.1.0からonload
で拾えるようになります。
※記事内にあるコードはAngularで使う想定のもので、RxJSと絶縁している方はGitHubのREADMEのほうでも眺めて下さい。
getMpurse(): Observable<any> { return Observable.create(observer => { const timer: Subscription = interval(100).pipe(take(10)) .subscribe({ next: () => { if (window.mpurse) { timer.unsubscribe(); observer.next(window.mpurse); } }, error: e => observer.error(e), complete: () => observer.error(new Error('Timeout')) }); }).pipe(first()); }
※ちなみにローカルに置いてあるHTMLを開いた際もmpurse
はインジェクトされますが、オリジンチェックまわりで弾くので機能は使えません。エラーも出ないのでテストなどやってくれる場合はハマるかも。
EventEmitterの提供
唯一updateEmitter
というプロパティを公開しています。
これは以下二つのイベントをemit
するEventEmitter
です。
stateChangedイベント
mpurse
の拡張機能がアンロックされているかどうかの状態を受け取ります。
オリジンが未承認でも流れます。
this.getMpurse() .pipe(flatMap(mp => fromEvent(mp.updateEmitter, 'stateChanged'))) .subscribe(isUnlocked => console.log('isUnlocked : ' + isUnlocked));
addressChangedイベント
mpurse
の拡張機能で有効になったアドレスを受け取ります。
承認済のオリジンにのみ流します。一旦mpurse
をロックすると再度承認が必要となり、何も流れてこなくなります。
選択されたアドレスに応じて動的に表示を切り替えるような場合は、このイベントを受け取って処理します。
this.getMpurse() .pipe(flatMap(mp => fromEvent(mp.updateEmitter, 'addressChanged'))) .subscribe(address => console.log('Change Address: ' + address));
Mpurseの基本機能
各メソッドはすべてPromise
を返すので、そのまま処理するなりasync/await
を使うなりObservable
に喰わせるなり、好きな方法で利用することが出来ます。
なお、API系以外は実行するためにサイトのオリジンを承認する必要があります。メソッドを実行すると承認画面が立ち上がり、一度承認された後はmpurse
がロックされるまで有効です。
getAddress
getAddress
を実行すると、拡張側で現在選択されているアドレスが返ってきます。
アドレス詐称などが特に問題無いサイトでは、これをログインボタンに仕込むなどして、アドレスが返って来たらそのアドレスに合わせた表示を行うなどでも良いかと思います。ログイン後は前述のaddressChanged
イベントを監視しておけば、アドレスの変更にも対応出来ます。
this.getMpurse() .pipe(flatMap(mp => mp.getAddress())) .subscribe(address => console.log('Selected Address: ' + address));
sendAsset
sendAsset
を実行すると拡張のSend画面が表示され、手動で送金指示を出すとトランザクションに署名してブロードキャストしてそのハッシュを返します。送金先やアセットなど特定の引数を空にした場合は、Send画面で選択、入力可能になります。キャンセルされた場合はUser Cancelled
のエラーが返ってきます。
this.getMpurse() .pipe(flatMap(mp => mp.sendAsset( 'MLinW5mA2Rnu7EjDQpnsrh6Z8APMBH6rAt', 'XMP', 114.114, 'plain', 'test' ))) .subscribe({ next: txHash => console.log(txHash), error: error => console.log(error) });
signRawTransaction
signRawTransaction
に未署名トランザクション文字列を渡すと、拡張のSign Transaction Request画面が表示され、手動で署名指示を出すと署名済のトランザクション文字列が返ってきます。ネットワークにブロードキャストしないので、必要であれば自分で流す必要があります。
※今のところ拡張側でパースしたりはしていないので、自分が何に署名させられているかはパッと見は分かりません。
this.getMpurse() .pipe(flatMap(mp => mp.signRawTransaction(unsignedTx))) .subscribe({ next: signedTx => console.log(signedTx), error: error => console.log(error) });
sendRawTransaction
sendRawTransaction
はsignRawTransaction
+ブロードキャストです。
this.getMpurse() .pipe(flatMap(mp => mp.sendRawTransaction(unsignedTx))) .subscribe({ next: txHash=> console.log(txHash), error: error => console.log(error) });
signMessage
signMessage
に任意の文字列を渡すと、Signature Request画面が表示され、手動で実行すると署名が返ってきます。
これを利用してユーザがアドレスの所有者であることを検証すれば、ID、PW、ソーシャルログインなどの悪しき習慣に別れを告げることが出来ます。本当はこの機能だけで良かったのですが、とはいえ使ってもらうにはそれなりの周辺機能がいるよねという流れで現在の構成になっています。
this.getMpurse() .pipe(flatMap(mp => mp.signMessage('test message'))) .subscribe({ next: signature => console.log(signature), error: error => console.log(error) });
公開APIの利用
ここからは、既に公開されているAPIにアクセスするだけの機能なので、mpurse
を利用せずに直接アクセスしても結果は同じです。利用にあたってmpurse
へのログインやオリジンの承認も不要です。
すべてをmpurse
経由に出来たほうがシンプルかなぁということで後付けしたもので、今のところAPI自体の使い勝手が改善されるような代物ではありません。
mpchainのAPIを利用する
mpchain
で提供されているAPIへアクセスします。APIの詳細は以下です。
このAPIはRESTっぽいAPIなのですが、他のAPIに合わせてmethod
、params
で呼び出すようにしています。
const mpchainParams = {address: 'MLinW5mA2Rnu7EjDQpnsrh6Z8APMBH6rAt'}; this.getMpurse() .pipe(flatMap(mp => mp.mpchain('balances', mpchainParams))) .subscribe({ next: balances => console.log(balances), error: error => console.log(error) });
counterpartyのAPIを利用する
Counterparty APIへアクセスします。APIの詳細は以下です。
Counterparty API | Counterparty
なお、このドキュメントがよく間違っているので、常に本当の仕様が存在する可能性を意識する必要があります。
実際にはmpchain
で提供されているCounterblock APIのproxy_to_counterpartyd
経由で動いています。
const cpParams = {address: 'MLinW5mA2Rnu7EjDQpnsrh6Z8APMBH6rAt'}; this.getMpurse() .pipe(flatMap(mp => mp.counterParty('get_unspent_txouts', cpParams))) .subscribe({ next: balances => console.log(balances), error: error => console.log(error) });
counterblockのAPIを利用する
Counterblock APIへアクセスします。APIの詳細は以下です。
Counterblock API | Counterparty
const cbParams = {assetsList: ['XMP']}; this.getMpurse() .pipe(flatMap(mp => mp.counterBlock('get_assets_info', cbParams))) .subscribe({ next: balances => console.log(balances), error: error => console.log(error) });
最後に
ここから先の計画は今のところ未定です。
例によって書きなぐった感じなので一旦リファクタリングしてまわるか、DEXやらの機能拡張を進めるか、Chrome以外のブラウザ対応など進めるか、モバイルに手を出すか。まぁオープンソースなので勝手に育っていく可能性もありますが。
または公式APIだけで動くようにしたほうが良いのか?という疑問もあります。mpchain.infoが単一障害点で良いのか、という。
いや、そもそもこのままでは用途がないので、用途を生み出す側に回ってみたほうが良い?
うーん。。
まぁのんびり進めていきます。