どうも、shoheiです。
NEMウォレットアプリの完成を目指してPWAアプリの作り方を連載していきます。
前回はVue.jsにTypeScriptの導入方法を紹介しました。
今回はNEMの残高確認、送金ができる簡単なウォレットの作り方を紹介します。
完成図はこんな感じです。
Webを開くとウォレットアカウントが作成され残高、送金アドレス、QRコード、送金フォームがあり、NEMを送金できるWebアプリです。
開発環境
- macOS High Sierra 10.13.4
- Google Chrome
- vue-pwa-boilerplate 2.1.0
- Vue.js
- TypeScript
ライブラリをインストール
ウォレットで使う外部ライブラリをインストールします。
1 |
$ npm install --save nem-sdk localforage vuetify vue2-toast vue-qriously vue-qrcode-reader encoding-japanese |
各ライブラリの概要は以下の通りです。
ライブラリ名 | 概要 | 用途 |
nem-sdk | NEM APIのライブラリ | アカウント作成、残高取得、送金など |
localforage | Webのローカルストレージ | アカウントの保存時 |
vuetify | マテリアルデザインUI | アプリ全体のUI |
vue2-toast | AndroidのToastのようなUI | メッセージ表示 |
vue-qriously | QRコード表示 | ウォレット情報のQRコード表示 |
vue-qrcode-reader | QRコードリーダー | ウォレット情報の読み取り(※今回は使わない) |
encoding-japanese | 日本語のエンコーディング | QRコード用のJSON生成時 |
ライブラリの設定
インストールしたライブラリを使用できるよう設定していきます。
設定
Vue上でライブラリが使用できるようVue.useで設定します。
src/main.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
import Vue from 'vue' import App from './App.vue' import router from './router' // 追加 import Vuetify from 'vuetify' import colors from 'vuetify/es5/util/colors' import 'vue2-toast/lib/toast.css' import Toast from 'vue2-toast' import VueQriously from 'vue-qriously' import VueQrcodeReader from 'vue-qrcode-reader' Vue.use(Vuetify, { theme: { original: colors.purple.base, theme: '#FFDEEA', background: '#FFF6EA', view: '#ffa07a', select: '#FF7F78', button: '#5FACEF' }, options: { themeVariations: ['original', 'secondary'] } }) Vue.use(Toast, { defaultType: 'bottom', duration: 3000, wordWrap: true, width: '280px' }) Vue.use(VueQriously) Vue.use(VueQrcodeReader) Vue.config.productionTip = false /* eslint-disable no-new */ new Vue({ el: '#app', router, template: '<App/>', components: { App } }) |
index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<!DOCTYPE html> <html lang="en"> <head> 〜〜 省略 〜〜 <link rel="manifest" href="<%= htmlWebpackPlugin.files.publicPath %>static/manifest.json"> <!-- ココ追加 --> <link href='https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons' rel="stylesheet" type="text/css"> <link href="https://unpkg.com/vuetify/dist/vuetify.min.css" rel="stylesheet" type="text/css"> <meta name="theme-color" content="#4DBA87"> <!-- Add to home screen for Safari on iOS --> <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-title" content="easy-wallet"> 〜〜 省略 〜〜 </html> |
モジュール宣言
TypeScriptにした場合、importするライブラリをモジュール宣言しなければ使用できないため以下のコードを追加します。
src/index.d.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 |
declare module "*.vue" { import Vue from 'vue' export default Vue } // 追加 declare module "nem-sdk" declare module "encoding-japanese" declare module "vuetify" declare module "vuetify/es5/util/colors" declare module "vue2-toast" declare module "vue-qriously" declare module "vue-qrcode-reader" |
build設定
このままビルドすると、nem-sdkが使用するfs, net, tlsのライブラリが無いよと怒られるので(実際はES6で使われているはず)、webpackを触ります。
node項目を追加し、それぞれのライブラリに’empty’を設定します。
build/webpack.base.conf
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
'use strict' const path = require('path') const utils = require('./utils') const config = require('../config') const vueLoaderConfig = require('./vue-loader.conf') function resolve (dir) { return path.join(__dirname, '..', dir) } module.exports = { 〜〜 省略 〜〜 resolve: { extensions: ['.js', '.vue', '.json', '.ts'], alias: { 'vue$': 'vue/dist/vue.esm.js', '@': resolve('src') } }, // 追加 node: { fs: 'empty', net: 'empty', tls: 'empty' }, module: { 〜〜 省略 〜〜 |
全て設定ができたら一旦ビルドしてエラーがでないことを確認しましょう。
実装
モジュール・モデル
nem-sdk
nem-sdkを使用するWrapperクラスを作成し、ウォレット機能に必要な処理を実装します。
Wrapper(ラッパー)とは代わりに呼んでくれるクラスのことを示します。
src/ts/nemWrapper.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 |
import nem from 'nem-sdk' import encoding from 'encoding-japanese' export default class nemWrapper { endpoint: string = '' host: string = '' port: string = '' net: string = '' constructor () { // NIS設定. this.host = 'https://aqualife2.supernode.me' this.port = '7891' this.net = nem.model.network.data.mainnet.id this.endpoint = nem.model.objects.create("endpoint")(this.host, this.port) } // NISの状態確認. async isNIS() { let result = await nem.com.requests.endpoint.heartbeat(this.endpoint) if (result.message === 'ok') { return true } else { return false } } // アカウント作成. async createAccount() { let walletName = "wallet" let password = "wallet" let wallet = nem.model.wallet.createPRNG(walletName, password, this.net) let common = nem.model.objects.create("common")(password, "") let account = wallet.accounts[0] nem.crypto.helpers.passwordToPrivatekey(common, account, account.algo) let result = { address: account.address, privateKey: common.privateKey } return result } // アカウント情報取得. async getAccount(address: string) { let result = await nem.com.requests.account.data(this.endpoint, address) return result } // 送金(NEM) async sendNem(address:string, privateKey:string, amount:number, message:string) { let common = nem.model.objects.create('common')('', privateKey) let transferTransaction = nem.model.objects.create('transferTransaction')(address, amount, message) let transactionEntity = nem.model.transactions.prepare('transferTransaction')(common, transferTransaction, this.net) let result = await nem.model.transactions.send(common, transactionEntity, this.endpoint) return result } // 送金(Mosaic) async sendMosaics(address:string, privateKey:string, mosaics:Array<any>, message:string) { let common = nem.model.objects.create('common')('', privateKey) let transferTransaction = nem.model.objects.create('transferTransaction')(address, 1, message) let mosaicDefinitionMetaDataPair:any = await this.getMosaicDefinitionMetaDataPair(this.endpoint, mosaics) mosaics.forEach((mosaic) => { let fullMosaicName = mosaic.namespace + ':' + mosaic.name if ((mosaicDefinitionMetaDataPair[fullMosaicName].mosaicDefinition.id.namespaceId === mosaic.namespace) && (mosaicDefinitionMetaDataPair[fullMosaicName].mosaicDefinition.id.name === mosaic.name)) { let divisibility = 0 mosaicDefinitionMetaDataPair[fullMosaicName].mosaicDefinition.properties.forEach((prop:any) => { if (prop.name === 'divisibility') { divisibility = prop.value } }) let quantity = mosaic.amount * Math.pow(10, divisibility) let mosaicAttachment = nem.model.objects.create('mosaicAttachment')(mosaic.namespace, mosaic.name, quantity) transferTransaction.mosaics.push(mosaicAttachment) } }) let transactionEntity = nem.model.transactions.prepare('mosaicTransferTransaction')(common, transferTransaction, mosaicDefinitionMetaDataPair, this.net) let result = await nem.model.transactions.send(common, transactionEntity, this.endpoint) return result } // モザイク定義取得. async getMosaicDefinitionMetaDataPair(endpoint:string, mosaics:Array<any>) { return new Promise(function(resolve, reject) { let mosaicDefinitionMetaDataPair = nem.model.objects.get('mosaicDefinitionMetaDataPair') let mosaicCount = 0 mosaics.forEach((mosaic) => { let mosaicAttachment = nem.model.objects.create('mosaicAttachment')(mosaic.namespace, mosaic.name, mosaic.amount) let result = nem.com.requests.namespace.mosaicDefinitions(endpoint, mosaicAttachment.mosaicId.namespaceId) .then((result: any) => { mosaicCount = mosaicCount + 1 let neededDefinition = nem.utils.helpers.searchMosaicDefinitionArray(result.data, [mosaic.name]) let fullMosaicName = nem.utils.format.mosaicIdToName(mosaicAttachment.mosaicId) if (undefined === neededDefinition[fullMosaicName]) { console.error('Mosaic not found !') return } mosaicDefinitionMetaDataPair[fullMosaicName] = {} mosaicDefinitionMetaDataPair[fullMosaicName].mosaicDefinition = neededDefinition[fullMosaicName] let supply = 0 result.data.some((obj: any) => { if ((obj.mosaic.id.namespaceId === mosaic.namespace) && (obj.mosaic.id.name === mosaic.name)) { obj.mosaic.properties.some((prop: any) => { if (prop.name === 'initialSupply') { supply = prop.value return true } }) } }) mosaicDefinitionMetaDataPair[fullMosaicName].supply = supply if (mosaicCount >= mosaics.length) { resolve(mosaicDefinitionMetaDataPair) } }).catch((e: any) => { console.error(e) reject(e) }) }) }) } // QRコードjson取得. getQRcodeJson(v:string, type:number, name:string, addr:string, amount:number, msg:string) { // v:2, type:1 アカウント, type:2 請求書 let amountVal = amount * Math.pow(10, 6) let json = { type: type, data: { name: name, addr: addr, amount: amountVal, msg: msg }, v: v } let jsonString = JSON.stringify(json) let result = encoding.codeToString(encoding.convert(this.getStr2Array(jsonString), 'UTF8')) return result } // NEMの可分性取得 getNemDivisibility(): number { return Math.pow(10, 6) } private getStr2Array(str:string) { let array = [] for (let i = 0; i < str.length; i++) { array.push(str.charCodeAt(i)) } return array } } |
※モザイクの実装が入っていますが今回は使いません。
ウォレットアカウントのモデル作成
ウォレットアカウントのモデルを作成します。
必要なデータの保持やそれらのデータに関連するビジネスロジックを持つのがモデルです。
ここでは送金アドレスや秘密鍵などの情報の保持、ローカルストレージへの保存やnemWrapperを用いた残高取得や送金などの処理をここで実装します。
src/ts/walletModel.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
import localForage from 'localforage' import nemWrapper from './nemWrapper' export default class walletModel { balance: number = 0 address: string = '' publicKey: string = '' privateKey: string = '' nem = new nemWrapper() constructor() { // クラス生成時にローカルストレージからアカウント情報を取得 this.load() .then((result) => { console.log(result) // 無ければアカウントを作成します if (result === null) { this.nem.createAccount() .then((wallet) => { this.address = wallet.address this.privateKey = wallet.privateKey this.save() }).catch((error) => { console.error(error) }) } else { // あればNEMの残高を取得します this.getAccount() } }).catch((error) => { console.error(error) }) } // ローカルストレージへ保存. async save() { let key = 'easy-wallet' let result:any = await localForage.setItem(key, this.toJSON()) return result } // ローカルストレージから取得. async load() { let key = 'easy-wallet' let result:any = await localForage.getItem(key) if (result !== null) { this.address = result.address this.privateKey = result.privateKey this.publicKey = result.publicKey } return result } // ローカルストレージから削除. async remove() { let key = 'easy-wallet' let result:any = await localForage.removeItem(key) return result } // アカウント情報を取得. async getAccount() { let result = await this.nem.getAccount(this.address) this.balance = result.account.balance / this.nem.getNemDivisibility() if ( result.account.publicKey !== null ) { this.publicKey = result.account.publicKey } } // 送金(NEM) async sendNem(address:string, amount:number, message:string) { let result = await this.nem.sendNem(address, this.privateKey, amount, message) return result } toJSON() { return { address: this.address, privateKey: this.privateKey, publicKey: this.publicKey } } } |
ウォレットアカウントの保存先
ウォレットアカウントの保存はlocalForageを用いてブラウザのローカルストレージ上に保存しています。
Chrome -> 右クリック -> 検証 -> Application -> Storageより、
Local Storage、Session Storage, IndexDBと3種類のストレージがあり、今回はIndexDBへ保存しています。
ウォレット画面作成
ウォレットの画面を作成します。
ここではUIライブラリのVuetifyを使用して画面を作っていきます。
walletを作成
src/components/wallet.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 |
<template> <div class="wallet"> <v-flex xs12 sm6 offset-sm3> <v-card> <v-container fluid> <v-card flat> <v-card-actions> <v-card-title><b>残高</b></v-card-title> <v-spacer /> <v-btn fab small flat @click="getAccount()" :loading="isLoading"><v-icon>cached</v-icon></v-btn> </v-card-actions> <v-card-text>{{ wallet.balance }} xem</v-card-text> <v-card-title><b>送金先アドレス</b></v-card-title> <v-card-text>{{ wallet.address }}</v-card-text> <v-card flat><qriously v-model="qrJson" :size="qrSize" ></qriously></v-card> </v-card> <v-card flat> <div v-for="(item, index) in validation" :key="index" class="errorLabel"> <div v-if="item!==true">{{ item }}</div> </div> <v-card-title><b>送金</b></v-card-title> <v-text-field label="送金先" v-model="toAddr" :counter="40" required placeholder="例. NBHWRG6STRXL2FGLEEB2UOUCBAQ27OSGDTO44UFC" ></v-text-field> <v-text-field label="NEM" v-model="toAmount" type="number" required ></v-text-field> <v-text-field label="メッセージ" v-model="message" :counter="1024" placeholder="例. ありがとう" ></v-text-field> <v-flex> <v-btn color="blue" class="white--text" @click="tapSend()">送金</v-btn> </v-flex> </v-card> </v-container> </v-card> </v-flex> </div> </template> <script lang="ts"> import Vue from 'vue' import Component from 'vue-class-component' import nemWrapper from '../ts/nemWrapper' import walletModel from '../ts/walletModel' @Component({ name: 'wallet', data: () => ({ nem: new nemWrapper(), qrJson: '', rules: { senderAddrLimit: (value:string) => (value && (value.length === 46 || value.length === 40)) || '送金先アドレス(-除く)は40文字です。', senderAddrInput: (value:string) => { const pattern = /^[a-zA-Z0-9-]+$/ return pattern.test(value) || '送金先の入力が不正です' }, amountLimit: (value:number) => (value >= 0) || '数量を入力してください', amountInput: (value:string) => { // const pattern = /^[0-9.]+$/ ※ブログ上ではちゃんと表示されないため、実装の際はこのコメントアウトを外してください return (pattern.test(value) && !isNaN(Number(value))) || '数量の入力が不正です' }, messageRules: (value:string) => (value.length <= 1024) || 'メッセージの最大文字数が超えています。' } }), watch: { 'wallet.address' (newVal, oldVal) { this.$data.qrJson = this.$data.nem.getQRcodeJson('2', 2, '', newVal, 0, '') } } }) export default class Wallet extends Vue { isLoading:boolean = false wallet:walletModel = new walletModel() qrSize:number = 200 toAmount:number = 0 toAddr:string = '' message:string = '' validation:Array<any> = [] mounted () {} async getAccount () { this.isLoading = true await this.wallet.getAccount() this.isLoading = false } async tapSend() { console.log('tapSend') if (this.isValidation() === true) { let result = await this.wallet.sendNem(this.toAddr, this.toAmount, this.message) console.log('tapSend', result) let message = '送金しました' if (result.message !== 'SUCCESS') { message = "Error " + result.message } Vue.prototype.$toast(message) } } isValidation(): Boolean { this.validation = [] this.validation.push(this.$data.rules.senderAddrLimit(this.toAddr)) this.validation.push(this.$data.rules.senderAddrInput(this.toAddr)) this.validation.push(this.$data.rules.amountLimit(this.toAmount)) this.validation.push(this.$data.rules.amountInput(this.toAmount)) this.validation.push(this.$data.rules.messageRules(this.message)) console.log(this.validation) let isError:Boolean = false this.validation.forEach((obj:any) => { if (obj !== true) { isError = true } }) return !isError } } </script> <style scoped> .wallet { word-break: break-all; } .errorLabel { color: red; } </style> |
wallet生成と同時にwalletModelクラスを生成します。
walletModelクラスにはbalance, address等を保持していますので、表示に必要な情報を {{ }} を用いてHTML上に実装します。
QRコードは qriously のタグを用いて表示し、中身のデータはv-model=”qrJson”で設定しています。
qrJsonの中身は @Component 内で設定します、なお export default class 内では設定できませんでした。。。
@Component のwatchを使ってwallet.addressの値を監視します。wallet.addressの値が変わるとqrJsonの内容を設定するようにしています。
送金時はエラーチェックをし、送金先アドレスと数量とメッセージに誤りが無いか確認します。
誤りがあるとエラー文を画面上に表示するようにしています。
エラーチェックで問題なければ送金します。
送金後、NEMサーバー(NIS)からの返答が返ってくるのでその内容をToastを用いて表示します。送金に問題なければ result.message に’SUCCESS’が書かれています。
App.vue
先ほど作成したwallet.vueをApp.vue上に表示します。
src/App.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
<template> <v-app> <header> <span>{{ title }} </span> </header> <main> <Wallet/> </main> </v-app> </template> <script lang="ts"> import Vue from 'vue' import Component from 'vue-class-component' // Walletをimportする. import Wallet from './components/Wallet.vue' // コンポーネントの設定 @Component({ name: 'app', // Walletをcomponentとして定義する. components: { Wallet } }) // クラス export default class App extends Vue { private title = 'My NEM wallet' mounted () {} } </script> <style> body { margin: 0; } #app { font-family: 'Avenir', Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; color: #2c3e50; } main { text-align: center; margin-top: 40px; } header { margin: 0; height: 56px; padding: 0 16px 0 24px; background-color: #35495E; color: #ffffff; } header span { display: block; position: relative; font-size: 20px; line-height: 1; letter-spacing: .02em; font-weight: 400; box-sizing: border-box; padding-top: 16px; } </style> |
これで完了です。
npm run devでlocalhost上で動作していることを確認してください。
特に問題なければ npm run build でビルドしてGithubにプッシュして完成です!
Github pagesでちゃんと動くかどうか確認してみてください。
終わりに
いかがでしたでしょうか。
今回でNEMウォレットのベースができました。
ここからモザイク送金機能や履歴情報機能を追加などカスタマイズしていけば良いと思います。
要点だけ解説していますので、具体的な内容はコメントアウトやコードを見ていただければと思います。
それでは!
Github
新品価格 |