Mpurse

先日公開したMonapartyのChrome拡張であるMpurseについて、コンセプトも含めて解説します。

chrome.google.com

※一応現時点では限定公開という扱いでChromeウェブストアで検索しても出てきません。その内通常公開に変更します。

作りたかったもの

何がやりたかったかというと、一言で言うならMetaMask。

と言ってもWalletが作りたかったのではなく、どちらかというとweb3まわりです。 WEBページに対するアドレスの提供や、署名を返す機能など、WEBとの連携のほうが目的で作り始めました。言ってしまえばソーシャルログインとさよならするためのログインボタンが作りたかっただけかも。

設計はMetaMaskの影響を大きく受けており、Identiconのライブラリも同じjazziconにしましたし、はじめはしばらくMetaMaskのコードを眺めるところから始め、コードも結構参考にしました。ただ、web3も含めるとその全貌はなかなか壮大で、途中からは面倒になってしまって割とフリーハンドで書いています。

いつもの事ですが設計には何の確信も持てていないので、こうしたほうが良いよ、バカなの?死ぬの?的なご指摘は常に歓迎です。

使い方

Walletとしては、残高の確認とその送金というシンプルなものです。URL欄の横に表示されるボタンをクリックすると小さなウィンドウが開きます。

パスフレーズは新規で生成しても良いですし、counterwalletのパスフレーズをそのまま持ってくることも可能です。新規で生成しておいて、特定の秘密鍵をインポートすることも可能です。

インストール後有効になっていると、Chromeのすべてのタブにmpurseインスタンスをインジェクトするようになっています。 WEBサイト側はこれを拾って各種機能にアクセスするのですが、onloadwindow.mpurseにアクセスしてもundefinedになります。 タイマー的なもので拾うと100ミリ秒後ぐらいに拾えます。

version0.1.0からonloadで拾えるようになります。

※記事内にあるコードはAngularで使う想定のもので、RxJSと絶縁している方はGitHubのREADMEのほうでも眺めて下さい。

github.com

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.514,
    '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

sendRawTransactionsignRawTransaction+ブロードキャストです。

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の詳細は以下です。

Monaparty Blockchain Explorer

このAPIはRESTっぽいAPIなのですが、他のAPIに合わせてmethodparamsで呼び出すようにしています。

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 APIproxy_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が単一障害点で良いのか、という。

いや、そもそもこのままでは用途がないので、用途を生み出す側に回ってみたほうが良い?

うーん。。

まぁのんびり進めていきます。