実店舗でのNEM決済を支援するスマホアプリ「Crypto Payment」のプロトタイプを作ってみた


注意

この記事の内容ですが、現時点では、(私自身のweb開発の基礎的なスキルレベルが未熟なため、)CORS対応方法、パスワードの扱い、API情報の保管方法、XSSの防止対策、異常時の処理等の課題が未解決です。あくまでもプロトタイプとしてのご参考として頂ければと思います。
アプリ自体は、Google Play Consoleに登録し、クローズドトラックとしてアルファ版を公開(限定公開)しています。

デモ

百聞は一見に如かず。まずはこちらのデモをご覧ください。

機能概要

以下のような機能が実装してあります。

  • 請求画面

    1. 請求価格を日本円で入力しボタンを押すと
    2. Zaif取引所の現在価格、板情報を取得
    3. 現在価格からXEM換算請求量の計算、即座に日本円に交換可能なレートの計算(板情報から流動性を加味して計算)
    4. (取引所にあらかじめ持っていたXEM残高を利用して日本円に交換)...スキップも可
    5. Zaif取引所の入金アドレス、メッセージ、XEM請求量を含む請求用QRコードを表示
    6. お客様側のウォレットでQRコードを読込んでXEM払いしてもらい
    7. WebSocketで(0confの)着金を補足し、メッセージ表示、効果音を鳴らす
    8. 決済情報を履歴として保存
  • 履歴画面(以下項目リスト表示)

    • 決済の日時
    • 請求額(日本円)
    • レート(円/XEM)
    • 請求量(XEM)
    • 最終的に受取った日本円での金額
    • トランザクションのハッシュ
  • 設定画面

    1. API情報の保存、削除
    2. 入金先アドレスの保存、削除
    3. 決済履歴情報の削除

このようなアプリを作りたいと思った背景

NEM決済の現状

現在、実店舗にて、仮想通貨を支払いの手段として受け入れること自体には、XEMBook(XEMReceiver)Raccoon Wallet等のツールを使わせて頂くことで、ほとんど難しさは無い、素晴らしい時代となったと思います。

NEM決済の課題

しかし、実店舗運営では、受け入れた仮想通貨を日本円に変える必要があると思います。その際には、以下のような手順が必要で、ハードルが高く、面倒で、普及の妨げになっているのでは...と感じています。

  1. 取引所への仮想通貨の送付
  2. 取引所の残高への反映の確認
  3. レートを確認
  4. レートを指定して指値で売却

例えば、毎月1回まとめて上記処理を行う等も考えられるものの、仮想通貨の価格推移の激しさを考えると、決済の都度毎に、こまめに日本円に交換しておかねば、価格変動リスクが許容できないレベルとなってしまうのではないでしょうか。

そこで、これら一連の流れを、自動的に実行してくれるスマホアプリを作りたいと考え、今回紹介させて頂くアプリのプロトタイプを作成しました。

技術について

アプリ開発で活用した技術基盤

HTML5 + Onsen UI + Cordova + Monaca

html + CSS + JavaScriptで、Android・iOS両対応のスマホアプリが作れるというところに魅力を感じ、Cordovaアプリ開発をウェブブラウザ上のIDEで簡単にできるMonacaを用いてアプリ開発を行いました。

UIフレームワークとしてはMonaca上で容易に利用できるOnsen UIも併せて利用しました。

JavaScriptのフレームワークとしては、QRコードの表示で限定的にJQueryを使用しているものの、ほとんど生のJavaScriptで開発しました。
(来年はJavaScriptのフレームワークを上手く活用して、安全性・メンテナンス性の向上、適切なテストの実装、リファクタリングや機能追加ができるよう頑張りたいと思います。)

NEMブロックチェーン

ノード

今回のアプリでは、トランザクションを発生させる必要はありません。NEMのメインネットのNIS1ノードにアクセスし、WebSocketで着金を補足する機能を利用しました。
当初はxembookさん提供の優良ノード一覧からランダムにノードを選んで使用する予定だったのですが、ランダムに選んだノードでWebSocketを上手く動作させられない場合が多かったため、現時点ではノードは http://alice7.nem.ninja 決め打ちで動作させています。

nem-sdkを使用

NEMのメインネットを使います。ライブラリはnem-sdkを使用しました。基本はNode.jsベースで使用する前提のライブラリだと思いますが、ブラウザで使用する場合は、以下の例のように、ライブラリのdistディレクトリ内のnem-sdk.jsファイルをhtmlファイル内でscriptタグで読み込んで、メインプログラムでrequireしてやればOKです。

index.html
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Example of usage of nem-sdk in browser</title>

  <!-- nem-sdk.jsの読込:パスはnem-sdk.jsの配置場所にあわせて適宜調整 -->
  <script src="nem-sdk.js"></script>
  <!-- main program:メインプログラムで以下のようにrequireすれば利用可能 -->
  <script>
    const nem = require('nem-sdk').default;
    console.log(nem);
  </script>
</head>

<body>

</body>

</html>

WebSocketを利用した着金補足は以下のような形で実装しました。(実際の実装を簡略化した関数を示しておきます。)

webSocketExample.js
//nem-sdkの読込
const nem = require("nem-sdk").default;

//WebSocketで着金を補足する関数の一例
const receive = async (address, amount, message) => {
  const intAmount = Math.round(amount * 1000000);
  const wsNodes = [
    "http://alice7.nem.ninja"
  ];
  const endpointUrl = wsNodes[Math.floor(Math.random() * wsNodes.length)];
  const endpoint = nem.model.objects.create("endpoint")(endpointUrl, nem.model.nodes.websocketPort);
  const connector = nem.com.websockets.connector.create(endpoint, address);
  setTimeout(()=>{
    connector.close();
  }, 120000);//TimeOutの設定:2分後に切断
  connector.connect().then(() => {
    console.log("Connected");
    nem.com.websockets.subscribe.errors(
      connector,
      (error) => {
        console.error("errors", error);
      }
    );
    nem.com.websockets.subscribe.account.transactions.unconfirmed(
      connector,
      (res) => {
        console.log("unconfirmed", res);
        //対象のアドレス宛のトランザクションに対してのみ処理を行う
        if (res.transaction.recipient === address) {
          //対象のメッセージを含むトランザクションに対してのみ処理を行う
          if (nem.utils.format.hexToUtf8(res.transaction.message.payload) === message) {
            //対象の請求数量と等しいトランザクションに対してのみ処理を行う
            if (res.transaction.amount === intAmount) {
              //ここに着金確認後の処理を書く
              connector.close();//WebSocketの接続を切断する
            }
          }
        }
      }
    );
  },
  (error) => {
    console.error("errors", error);
  });
}

QRコード

請求情報のQRコードは以下のように扱っています。QRコードの表示を生のJavaScriptで行う方法としては、簡単な方法を見つけられず、JQueryを使って以下の記事を参考に実装しました。
【jQuery】自動でQRコードを生成する[jquery.qrcode.js]が便利だった!
実際に使ったコードを簡略化したものを以下に置いておきますので、参考にしてみてください。

nem-qr.js
//請求用QRコードの文字列データを作成する関数
getInvoiceData = (recipientAddress, amount, message) => {
  const json = {
    "data": {
      "addr": "",
      "amount": 0,
      "msg": "",
      "name": "nem-sdk-helper"
    },
    "type": 2,
    "v": 2
  };
  //json.data.nameは任意の文字列でOKですが、
  //空白だとエラーになるウォレットがあったような気がします。
  //後で何のサービスか分かるよう、サービス名等を入れておくくらいのイメージで良いでしょうか?
  json.data.addr = recipientAddress;
  json.data.amount = Math.round(amount * 1000000); //マイクロXEM単位の整数で指定する必要があります
  json.data.msg = message;
  const stringData = JSON.stringify(json);
  return stringData;
}

//文字列データ(invoiceData)が埋め込まれたQRコードをhtml中の要素(elementId)に表示する関数
//事前にhtml上で、JQuery、jquery.qrcode.min.jsの読込が必要
setInvoiceQrCode = (elementId, invoiceData) => {
  $(() => {
    $(`#${elementId}`).empty();
    $(`#${elementId}`).qrcode({
      width: 256,
      height: 256,
      text: invoiceData
    });
  });
}

取引所

Zaif + ccxt

取引所はZaifを使います。今回のアプリでは、実際に売り注文を出す必要があるので、認証系等も含め、多数の取引所に対応したccxtというライブラリを利用しました。
使用した機能を簡易的にhtmlファイルに盛り込んだものを以下に置いておくので、もしよければ、ご参照ください。

useZaifApiWithCcxtSample.html
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Use Zaif API with ccxt</title>
  <style type="text/css">
    #asksBook {float: left;}
    #bidsBook {float: left;}
    #fetchXemOrderBookButton {
      clear: both;
      display: block;
    }
  </style>
  <!-- ブラウザで使うためccxtをCDNから読込 -->
  <script type="text/javascript" src="https://unpkg.com/[email protected]/dist/ccxt.browser.js"></script>
  <!-- メインプログラム -->
  <script>
    'use strict'
    //CORS対応
    const proxy = "https://cors-anywhere.herokuapp.com/";
    //ccxtを用いてZaif取引所APIを活用するための設定(パブリックなAPIをたたく場合、apiKey, secretは何でもOK、CORS回避のためプロキシを設定)
    const zaif = new ccxt.zaif({
      "apiKey": "dummy",
      "secret": "dummy",
      "proxy": proxy
    });
    //価格情報取得
    const fetchXemInfo = async () => {
      const xemInfo = await zaif.fetchTicker("XEM/JPY");
      console.log(xemInfo);
      document.getElementById("last").textContent = xemInfo.last;
      document.getElementById("ask").textContent = xemInfo.ask;
      document.getElementById("bid").textContent = xemInfo.bid;
      return xemInfo;
    }
    //板情報取得
    const fetchXemOrderBook = async () => {
      const xemOrderBook = await zaif.fetchOrderBook("XEM/JPY")
      console.log(xemOrderBook);
      xemOrderBook.asks.forEach((element) => {
        const tr = document.createElement("tr");
        const tdAsksRate = document.createElement("td");
        const tdAsksAmount = document.createElement("td");
        tdAsksRate.textContent = element[0];
        tdAsksAmount.textContent = element[1];
        tr.appendChild(tdAsksAmount);
        tr.appendChild(tdAsksRate);
        document.getElementById("asksBook").appendChild(tr);
      });
      xemOrderBook.bids.forEach((element) => {
        const tr = document.createElement("tr");
        const tdBidsRate = document.createElement("td");
        const tdBidsAmount = document.createElement("td");
        tdBidsRate.textContent = element[0];
        tdBidsAmount.textContent = element[1];
        tr.appendChild(tdBidsRate);
        tr.appendChild(tdBidsAmount);
        document.getElementById("bidsBook").appendChild(tr);
      });
      return xemOrderBook;
    }
    //指値で売却
    const sellXEM = async () => {
      const apiKey = document.getElementById("apiKey").value;
      const secret = document.getElementById("apiSecret").value;
      const amount = document.getElementById("amount").value;
      const rate = document.getElementById("rate").value;
      zaif.apiKey = apiKey; //認証が必要なAPIを利用する際は、自ら発行した、適切な権限を持つapiKey, secretの設定が必要
      zaif.secret = secret; //認証が必要なAPIを利用する際は、自ら発行した、適切な権限を持つapiKey, secretの設定が必要
      const sellResult = await zaif.createLimitSellOrder("XEM/JPY", amount, rate);
      zaif.apiKey = "dummy";
      zaif.secret = "dummy";
      document.getElementById("apiKey").value = "";
      document.getElementById("apiSecret").value = "";
      document.getElementById("amount").value = "";
      document.getElementById("rate").value = "";
      console.log(sellResult);
      return sellResult;
    }
  </script>
</head>

<body>
  <h2>Zaif取引所でAPIで現在価格を取得</h2>
  <table>
    <tr>
      <td>last</td>
      <td id="last"></td>
      <td>円/XEM</td>
    </tr>
    <tr>
      <td>ask</td>
      <td id="ask"></td>
      <td>円/XEM</td>
    </tr>
    <tr>
      <td>bid</td>
      <td id="bid"></td>
      <td>円/XEM</td>
    </tr>
  </table>
  <button onclick="fetchXemInfo()">価格取得</button>

  <h2>Zaif取引所でAPIで板情報を取得</h2>
  <p>
    <table id="asksBook">
      <tr>
        <th>ask</th>
        <th></th>
      </tr>
      <tr>
        <th>数量</th>
        <th>レート</th>
      </tr>
      <tr>
        <th>XEM</th>
        <th>円/XEM</th>
      </tr>
    </table>
    <table id="bidsBook">
      <tr>
        <th>bid</th>
        <th></th>
      </tr>
      <tr>
        <th>レート</th>
        <th>数量</th>
      </tr>
      <tr>
        <th>円/XEM</th>
        <th>XEM</th>
      </tr>
    </table>
  </p>
  <button onclick="fetchXemOrderBook()" id="fetchXemOrderBookButton">板情報取得</button>

  <h2>Zaif取引所でAPIで指値で売却</h2>
  <table>
    <tr>
      <td>APIキー</td>
      <td><input type="text" id="apiKey"></td>
    </tr>
    <tr>
      <td>APIシークレット</td>
      <td><input type="password" id="apiSecret"></td>
    </tr>
    <tr>
      <td>売却量</td>
      <td><input type="number" id="amount"></td>
      <td>XEM</td>
    </tr>
    <tr>
      <td>レート</td>
      <td><input type="number" id="rate"></td>
      <td>円/XEM</td>
    </tr>
  </table>
  <button onclick="sellXEM()">売却実行</button>
</body>

</html>

ソースコード

アプリのソースコードはこちらをご参照ください。もし、コメント、プルリク等頂けると非常にうれしいです。「これダメ!」みたいな指摘も大歓迎です。
https://github.com/YasunoriMATSUOKA/crypto-payment-cordova

はまったところ

Monacaの開発ツールでは動くがビルドしてみると動かないパターン

このパターンが時々ありました。基本的には、Monaca(≒Cordova)での開発は、webエンジニアにとって楽な部分がとても多く、私自身もすごく恩恵を受けました。しかし、最後の詰めの箇所では、独特のつらさも感じました。ハイブリッドアプリ開発あるあるなのかな?という感じです。(何事にも銀の弾丸はなかなか無いですよね...)

デフォルトの設定ではhttp通信ができない

これは、Cordova 9から(≒Android 9以降から)デフォルトの設定ではアプリによるhttp通信が禁止されたことに原因がありました。以下記事のようにhttp通信を明示的に許可する設定とすることで、解決しました。
monacaのカスタムビルド版デバッガーでAjax通信ができません
しかし、時代の流れは全https化の方向だと思うので、私自身もアプリ自体をhttps通信へ対応できるようきちんと対応しなければ...という状況です。
Monacaの開発ツールではなんで動いたんだろ?と疑問を感じたのですが、Monacaの開発者ツールはhttp通信を明示的に許可する設定で作られているようで、確かにドキュメントにもそう書かれていました。(ちょっと罠っぽいような...)

開発で感じたNEMの強み(≒仮想通貨・ブロックチェーンの強み)

たった1人の開発者が作ってみよう!と決意しただけで、こんなにも簡単にQRコード決済システムを作ってしまえることのすごさ

電子データそれ自体に価格がついており、全世界の誰もがそれを自由に使用できる素晴らしさを改めて感じました。「なんとかPay」みたいなものをこんなに簡単に作ることができる他の技術的な基盤って世の中にないですよね?

開発中にNEMブロックチェーン固有の機能実装にかけた時間は本当に少ない

私自身は、NEMブロックチェーンに魅せられてから初めて本格的なソフトウェア開発を始めました。
開発中に躓いたところは、ほとんどがNEMブロックチェーンに関係のない、一般的なweb開発やソフトウェア開発の範疇の内容でした。(NEMの初期の頃から様々なことを試行錯誤し、アウトプットしてきて頂いた様々な方々のおかげでもあり大変感謝!なのですが、NEMそれ自体にも、技術的に扱いやすいよう配慮された設計思想を随所に感じました。)
今残っている課題も、ほとんどがNEMブロックチェーンに関係のない部分です。
これは、一般的なwebエンジニアさんやソフトウェアエンジニアさんならば容易にNEMブロックチェーンを用いて、ブロックチェーンの技術的な恩恵を受けた開発を進められるということを意味すると思います。やはりこの点はNEMの大きな強みですね。

今後の課題

セキュリティ

パスワード、API情報の保存方法

現時点では、API情報は、初回起動時にユーザー自身でパスワードを設定してもらい、そのパスワードでAES256で暗号化して、localStorageへ保存しています。
正直、パスワードの扱いも雑ですし、いくら暗号化していたとしてもlocalStorageにAPI情報を保管するのは危険ではないかとビビっています。特に以下の記事を読んでかなりビビっています。Cordovaだとどうなのでしょうか?
HTML5のLocal Storageを使ってはいけない

スマホアプリであれば、AndroidのKey StoreやiOSのKey Chain等を利用するのが王道でしょうか。あるいは、shuさんがnemgraphで使っているQR Keyのような形で実装するのも面白いかもしれません。
まずは、Cordovaでの実装を試してみようと思います。Cordovaでの実装が難しそうなら、ネイティブアプリとして開発しなおす方向も検討しようかと思います。

CORS対応

取引所との通信でcors-anywhereを利用しています。参考にしたのは以下の記事の方法です。
CORSを理解する
しかし、この方法は、セキュリティ的にバッドノウハウのような印象も受けます。果たしてこれはやって良い方法なのでしょうか?
AWSのAPI gateway等を使ってみては?というアドバイスを頂いたこともあり、適切な方法を自分なりに色々試しながら模索してみようと思います。

XSS対策

信頼できないソースから取得した文字列のhtmlエスケープやバリデーションやエラー時の処理を漏れなく適切に行うことが重要と認識しています。
しかし、正直、現時点では、とりあえず動かせるところまで来たという状況で、漏れ漏れです。

エラー処理の甘さ

XSS防止策のところと同じで、現時点では甘々です。特にWebSocketの動作を完全に1ノードに依存しているのは、全世界に分散した多数のノードを利用できるブロックチェーンのメリットを完全に殺してしまっている印象です。ここはポーリングで実装しなおすことも視野に入れたほうが良いのではないかと思っています。(NEMの機能に関する課題はこれだけかな...と思っています。そして、これも突き詰めると、web開発のお話ですし...)

httpsノード対応

時代の流れに対応できるよう、きちんと基礎的なweb開発の力をつけて、httpsノードとの通信処理を適切に処理できるようにしていきたいと思います。また、自分でノードを立てる選択肢もとれるよう、フロントエンドだけでなく、サーバーサイドの技術もきちんと習得していきたいと思っています。

持続性のある設計・構造

セキュリティの課題に目途がついたら以下のような内容も進めてみたいと思っています。

テストを書く

お恥ずかしながら、私はテストコードをきちんと書いたことがありません。
しかし、現代の開発では、オープンソースなパッケージを大量に利用し、それら多くに日々脆弱性が見つかりアップデートがかかっていて、それを適用して問題無いか判断するだけでも、手動テストではあっというまに死亡してしまう...と感じました。
テストをきちんと書く(=テストをきちんと書けるようなきちんとした設計にリファクタリングする)ことを進めたいと思います。

フレームワークの活用、リファクタリング

全体的な構成として、フレームワークをほとんど使わず、ほとんど全部1ファイルに処理内容を記述しており、記述の無駄が多く、可読性・メンテナンス性が著しく悪いです。
Onsen UIではVueとAngular向けの機能が提供されているようで、どちらかを活用して、Component化された構成に落とし込みたいです。
ぱっと見た印象では、Onsen UIで提供されているAngularのバージョンが古めの印象のため、Vueを使おうと思っています。

ネイティブアプリ、PWAへの移行

素直にネイティブアプリの開発基盤上で開発したほうが良いのでは...という気持ちも出始めています。あるいはwebに完全にふってPWAもありかも...?と思っていますが、まだあまり自信がありません。Cordovaでの開発に限界を感じたら、Kotlin、Swift、PWA等も検討しようと思います。

機能

セキュリティ、構造面の課題に目途がついたら以下のような内容を進めてみたいと思っています。

指紋認証のサポート

やはり毎回パスワードを入力するのは面倒です。セキュリティの向上にもつながると思うので、指紋認証の機能を実装したいです。

設定情報、履歴情報のエクスポート、クラウドへのバックアップ

せっかく履歴情報を保存しているのでcsvファイル等といった形で税金計算等に容易に利用できるようデータをエクスポートしたり、他端末への機種変時に設定をインポートできるような設定ファイルをエクスポートしたりできるような機能を実装していきたいと思っています。
クラウドへのバックアップについても、安全面をきちんと考慮し、いずれは実装していきたいと思っています。

取引所、対応通貨の拡充

通貨としてはETHやXRPへの対応を、取引所としては、それら通貨を取引所形式でccxtでAPI取引可能な日本の取引所(bitbank、Liquid等?)への対応を、まず進めていきたいと思っています。いずれはUTXO型の仮想通貨やそれを扱っている取引所へも対応していきたいと思っています。

0conf以降の確実な確認

履歴ページの表示時に、各トランザクションの承認数をわかりやすく表示しておいたり、0conf = unconfirmedから1conf = confirmedに変化した際にアラート表示や音を鳴らせるようにしたり等と思っています。

Catapult対応

現行バージョンNEMだけでなく、次期バージョンNEM(=NEM2=Catapult)にも対応していきたいと思っています。テストネットが立ち上がって、次期バージョン関連ツールがもう少し安定してきたら、nem2-sdkを使った本格的な開発に着手しようと思います。

所感

「とりあえず動くものを作る」と「きちんと動くものを作って維持する」の間には大きな壁があることを改めて実感

今年1年は「とりあえずこういうものを動かせるようにしたい」にリソースをつぎ込みました。その結果、できることは増え、結果として、この記事で紹介したアプリのプロトタイプ作成までこぎつけることができたのは、達成感もあり、嬉しかったです。
しかし、アプリの品質やセキュリティや継続性を考えると、大きな課題があり、「とりあえず動くものを作る」という意識から「きちんと動くものを作って維持する」という意識に切り替え、必要な技術を習得し、習慣化しなければならないと改めて実感しました。

最後に

アドベントカレンダー参加させて頂きありがとうございました。
きちんと課題を解決して、きちんとリリースできるよう、来年も頑張っていきたいと思います。
来年はNEMの次期バージョンのCatapultもやってくるということで、忙しくも、楽しみな1年になりそうです。来年が皆様にとって良い年になることを祈っています。