Edited at

Vue CLI 3.0とTypeScriptを使ってNEMウォレットを作ってみた

どうも、shoheiです。

大阪在住のフリーランスエンジニアで業務委託でiOS、Webアプリの開発をしています。また、ブロックチェーンNEMを用いたプロダクト開発も行っております。

これがQiita初投稿となります、よろしくお願い致します。


概要

以前、ブログでVue CLI 2.XX + TypeScriptの環境でNEMウォレットを作りました。

最近Vue CLI 3.0がリリースされまして、TypeScriptやPWA加えてRouterやVuexなどの導入もCLIの対話形式で選択でき、容易に構築できるようになりました。

そこで以前作ったNEMウォレットをVue CLI 3.0で作り直してみました。

完成イメージはこんな感じです。

スクリーンショット 2018-10-03 15.17.42.png

完成コード

GitHub

GitHub Pages


事前準備

Node.js、npm、yarnのインストールをお願いします。

またエディタはVScodeを推奨します。


Node.js、npmの導入

MacにNode.jsとnpmをインストールする方法【初心者向け】

Windows版 Node.js環境構築方法まとめ - Qiita

$ node --version

v9.11.2

$ npm --version
5.6.0


yarnの導入

$ npm install -g yarn

$ yarn --version
1.9.4


開発環境


  • macOS High Sierra 10.13.6

  • Google Chrome

  • Vue CLI 3.01

  • TypeScript

  • PWA

  • yarn


NEMとは

NEMは2015年3月29日に最初のブロックが生成されたブロックチェーンです。NEM上の基軸通貨の単位はXEMであり、取引所等で売買できます。

総発行数は約90億枚、コンセンサスアルゴリズムはPoI(Proof of Importance)であり1万XEM以上を保有し、売買取引が活発な人がブロック作成の権限を得られます。

BTCのマイニング(採掘)と違いNEMはハーベスティング(収集)と呼ばれ、ブロック作成の権限が与えられ人へ取引の手数料が報酬として与えられます。

NEMのトランザクションの発生が活発になればなるほどハーベスティングで得られる量が増えると言われています。

NEMの有名な機能として


  • 独自トークンを簡単に作成できるモザイク

  • 複数アカウントの署名がなければ送金できないように設定できるマルチシグ

  • ブロックチェーン上でファイルを公証できるアポスティーユ

また、それらの機能を誰でも簡単に扱えれるようWeb APIとして提供されており、Web APIをさらに使いやすくしたライブラリが様々なプログラミング言語で作られています。

本記事ではそのライブラリの中の1つであるnem-sdkを使用して、NEMの送受信ができるウォレットを作ります。


ディレクトリ構成

nem-wallet

├── LICENSE
├── README.md
├── babel.config.js
├── package.json
├── postcss.config.js
├── public
│   ├── favicon.ico
│   ├── img
│   │   └── icons
│   │   ├── android-chrome-192x192.png
│   │   ├── android-chrome-512x512.png
│   │   ├── apple-touch-icon-120x120.png
│   │   ├── apple-touch-icon-152x152.png
│   │   ├── apple-touch-icon-180x180.png
│   │   ├── apple-touch-icon-60x60.png
│   │   ├── apple-touch-icon-76x76.png
│   │   ├── apple-touch-icon.png
│   │   ├── favicon-16x16.png
│   │   ├── favicon-32x32.png
│   │   ├── msapplication-icon-144x144.png
│   │   ├── mstile-150x150.png
│   │   └── safari-pinned-tab.svg
│   ├── index.html
│   ├── manifest.json
│   └── robots.txt
├── src
│   ├── App.vue
│   ├── assets
│   │   └── logo.png
│   ├── components
│   │   └── Wallet.vue
│   ├── main.ts
│   ├── model
│   │   └── WalletModel.ts
│   ├── registerServiceWorker.ts
│   ├── router.ts
│   ├── shims-tsx.d.ts
│   ├── shims-vue.d.ts
│   ├── store.ts
│   ├── util
│   │   └── NemUtil.ts
│   ├── views
│   └── wrapper
│   └── NemWrapper.ts
├── tsconfig.json
├── tslint.json
├── vue.config.js
├── yarn.lock
└── .env


環境構築


Vue CLI 3.0をインストール

Vue CLI 3.0をインストールします。インストールにはnpmが必要です。

$ npm install -g @vue/cli

$ vue --version
3.0.1

バージョンが確認できたらひとまず成功です。

次はプロジェクトを作成します。

$ vue create nem-wallet

色々と聞いてくるので以下のように選択します。

TypeScript以外はお好みで選んでいただいて大丈夫です。

スクリーンショット 2018-10-03 11.01.54.png

作成されると一度serveしてlocalhostで立ち上がるか確認してください。

なお本記事ではyarnを使ってビルドしますのでインストールしておいてください。

$ yarn serve

ちなみにtslint.jsonの設定はこんな感じにしています。ここもお好みでどうぞ。

{

"defaultSeverity": "warning",
"extends": [
"tslint:recommended"
],
"linterOptions": {
"exclude": [
"node_modules/**"
]
},
"rules": {
"quotemark": [true, "single"],
"semicolon": [
false,
"always"
],
"no-console": [
false
],
"indent": [true, "spaces", 2],
"max-line-length": [false, 120],
"trailing-comma": [true, {"multiline": "always", "singleline": "never"}],
"interface-name": false,
"ordered-imports": false,
"object-literal-sort-keys": false,
"no-consecutive-blank-lines": false
}
}


ライブラリのインストールと設定

ウォレットに必要なライブラリをインストールします。

$ yarn add nem-sdk dotenv-webpack encoding-japanese localforage vue-qriously vue2-toast vuetify

ライブラリ
概要
用途

nem-sdk
NEM APIのライブラリ
アカウント作成、残高取得、送金など

dotenv-webpack
.envのwebpack版
NEMのネットワークIDとノードの設定定義

encoding-japanese
日本語のエンコーディング
日本語が含まれたQRコードのJSON生成

localforage
ブラウザのローカルストレージ操作
アカウントの保存

vue-qriously
QRコード表示
ウォレット情報のQRコード表示

vue2-toast
AndroidのToast
メッセージ表示

vuetify
マテリアルデザイン
フォーム作成

問題なくインストールできたらプロジェクト内でライブラリを扱えるよう設定していきます。


/src/main.ts

import Vue from 'vue'

import App from './App.vue'
import router from './router'
import store from './store'
import './registerServiceWorker'
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'

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.config.productionTip = false;

new Vue({
router,
store,
render: (h) => h(App),
}).$mount('#app')


/src/shims-vue.d.ts

declare module '*.vue' {

import Vue from 'vue';
export default Vue;
}

declare module 'nem-sdk'
declare module 'encoding-japanese'
declare module 'vuetify/es5/util/colors'
declare module 'vue2-toast'
declare module 'vue-qriously'


/public/index.html

<!DOCTYPE html>

<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!-- 追加 -->
<link href='https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons' rel="stylesheet" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/vuetify@1.2.6/dist/vuetify.min.css" rel="stylesheet">
<!-- ここまで -->
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>nem-wallet</title>
</head>
<body>
<noscript>
<strong>We're sorry but nem-wallet doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

プロジェクトのルート上に.envとvue.config.jsを作成します。


/.env

# mainnet:104, testnet:-104, mijin:96

NEM_NET = 104

# Node URL
NEM_NODE_HOST = 'https://aqualife2.supernode.me'
NEM_NODE_PORT = '7891'

補足になりますが、NEMには3つのネットワークがあります。

パブリックチェーンで取引所で扱われてるネットワークをmainnet、パブリックチェーンですがテスト用として扱われているネットワークをtestnet、テックビューロ株式会社 (現・テックビューロホールディングス株式会社)様が開発したプライベートチェーンをmijinと呼んでいます。

これらのネットワークを使用するためにはノード(NIS)へアクセスする必要があります。クライアントからノードへリスクエスト/レスポンスができ、NEMの機能を利用できます(NISとはNEM Infrastructure Serverの略称です)。

今回はmainnetを利用して取引所で扱われているNEMを送受信できるウォレットを作成します。

vue.config.jsは以下のように設定します。.envの設定とGitHub Pagesで使えるようにするための設定をします


/vue.config.js

const Dotenv = require('dotenv-webpack')

module.exports = {
baseUrl: process.env.NODE_ENV === 'production'
? '/nem-wallet/'
: '/',
outputDir: 'docs',
configureWebpack: {
plugins: [new Dotenv()]
}
}

こちらはGitHub Pagesを使用できるよう "start_url" を "./index.html" に変更しています。


/public/manifest.json

{

"name": "nem-wallet",
"short_name": "nem-wallet",
"icons": [
{
"src": "/img/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/img/icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": "./index.html",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#4DBA87"
}

これでライブラリの設定は完了です。


モジュール作成

まずは下回りから作っていきます。


nem-sdkを扱うクラス

nem-sdkと直接やりとりを行うラッパークラスを作成します。nem-sdkの使い方はこちらから確認できます。


/src/wrapper/NemWrapper.ts

import nem from 'nem-sdk'

export default class NemWrapper {
private endpoint: string
private host: string = process.env.NEM_NODE_HOST
private port: string = process.env.NEM_NODE_PORT
private net: number = Number(process.env.NEM_NET)
constructor() {
// Setting network and nis.
// this.net = nem.model.network.data.mainnet.id
console.log(this.host, this.port, this.net)
this.endpoint = nem.model.objects.create('endpoint')(this.host, this.port)
}

// Create account for nem wallet.
public createAccount() {
const walletName = 'self-made-nem-wallet'
const password = 'self-made-nem-wallet'
const wallet = nem.model.wallet.createPRNG(walletName, password, this.net)
const common = nem.model.objects.create('common')(password, '')
const account = wallet.accounts[0]
console.log('createAccount', account)
nem.crypto.helpers.passwordToPrivatekey(common, account, account.algo)
return {
address: account.address,
privateKey: common.privateKey,
}
}

// Get account.
public async getAccount(address: string) {
return await nem.com.requests.account.data(this.endpoint, address)
}

// Transaction for NEM.
public async sendNem(address: string, privateKey: string, amount: number, message: string) {
const common = nem.model.objects.create('common')('', privateKey)
const transferTransaction = nem.model.objects.create('transferTransaction')(address, amount, message)
const transactionEntity = nem.model.transactions.prepare('transferTransaction')(common, transferTransaction, this.net)
return await nem.model.transactions.send(common, transactionEntity, this.endpoint)
}

// Get divisibility for nem.
public getNemDivisibility(): number {
return Math.pow(10, 6)
}
}

アカウント作成、アカウント情報取得、送金だけの必要な関数だけ用意しています。

なおgetNemDivisibilityはNEMの可分性を取得できる関数です。NEMの可分性は6なので、1XEM = 1000000 となります。

また画面上にQRコードを表示するため必要なデータをJSON形式で取得できる関数を用意します。こちらはutilクラスとして作成します。


/src/util/NemUtil.ts

import encoding from 'encoding-japanese'

export default class NemUtil {
// Get JSON for Invoice. v:2, type:1 account, type:2 invoice.
public static getQRcodeJson(v: number, type: number, name: string, addr: string, amount: number, msg: string) {
const params = { type, data: { name, addr, amount: amount * Math.pow(10, 6), msg }, v };
return encoding.codeToString(encoding.convert(this.getStr2Array(JSON.stringify(params)), 'UTF8'))
}

private static getStr2Array(str: string) {
const array = []
for (let i = 0; i < str.length; i++) {
array.push(str.charCodeAt(i))
}
return array
}
}

このjsonはNEMの請求書のjson形式となります。

jsonの仕様は正式に決まっています。

vがjson仕様のバージョンを示し、typeが1の場合はアカウント登録、2の場合は請求書を表しています。

amountには可分性を掛けた量を設定する必要があります。1XEMは1000000です。

{

type: 2
data: {
name: "名前",
addr: "NBHWRG6STRXL2FGLEEB2UOUCBAQ27OSGDTO44UFC",
amount: 1000000,
msg: "メッセージ",
},
v: 2
}


モデルクラス作成

次にアカウント情報の保持とアカウント情報を更新するためのビジネスロジックを持ったモデルクラスを作成します。

作成したアカウントはlocalforageを用いてブラウザ上のストレージへ保存します。大事な秘密鍵をそのまま保存することになるのでカスタマイズする際は秘密鍵を暗号化して保存するか、もしくはストレージに保存せずに紙に写すなどしてください。


/src/model/WalletModel.ts

import localForage from 'localforage'

import NemWrapper from '@/wrapper/NemWrapper'

export default class WalletModel {
public balance: number = 0
public address: string = ''
public publicKey: string = ''
public privateKey: string = ''

private nem = new NemWrapper()
private localStorageKey = 'nem-wallet'

constructor() {
this.load()
.then((result) => {
console.log(result)
if (result === null) {
const wallet = this.nem.createAccount()
this.address = wallet.address
this.privateKey = wallet.privateKey
this.save()
} else {
this.getAccount()
}
}).catch((error) => {
console.error(error)
})
}

// Save to local storage in Browser.
public async save() {
return await localForage.setItem(this.localStorageKey, this.toJSON())
}

// Load from local storage.
public async load() {
const result: any = await localForage.getItem(this.localStorageKey)
if (result !== null) {
this.address = result.address
this.privateKey = result.privateKey
this.publicKey = result.publicKey
}
return result
}

// Delete in local storage.
public async remove() {
return await localForage.removeItem(this.localStorageKey)
}

// Get account.
public async getAccount() {
const 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
}
}

// Send NEM.
public async sendNem(address: string, amount: number, message: string) {
return await this.nem.sendNem(address, this.privateKey, amount, message)
}

public toJSON() {
return {
address: this.address,
privateKey: this.privateKey,
publicKey: this.publicKey,
}
}
}

WalletModelが生成されたタイミングでストレージ上のデータを取得しにいきます。

もしデータがなければアカウントを作成して保存します。データがあれば保存されたアカウント情報をキャッシュ上に設定しています。


画面作成

必要なモジュールは完成したので次は画面を作っていきます。


コンポーネント作成

Vue.jsコンポーネントとしてウォレットフォームを作成します。

フォームにはマテリアルデザインライブラリのVuetifyを使っています。HTMLの構文はVuetifyを確認してください。


/src/components/Wallet.vue

<template>

<div class="wallet">
<v-flex xs12 sm6 offset-sm3>
<v-card>
<v-container fluid>
<v-card flat>
<v-card-actions>
<v-card-title>
<h3>Balance</h3>
</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>
<h3>Address</h3>
</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>
<h3>Send</h3>
</v-card-title>
<v-text-field
label="To address"
v-model="toAddr"
:counter="40"
required
placeholder="ex). NBHWRG6STRXL2FGLEEB2UOUCBAQ27OSGDTO44UFC"
></v-text-field>
<v-text-field
label="NEM"
v-model="toAmount"
type="number"
required
></v-text-field>
<v-text-field
label="Message"
v-model="message"
:counter="1024"
placeholder="ex) Thank you."
></v-text-field>
<v-flex>
<v-btn
color="blue"
class="white--text"
@click="tapSend()"
:loading="isLoading"
:disabled="isLoading">SEND</v-btn>
</v-flex>
<v-flex>
<v-card-title>
<h3>Result</h3>
</v-card-title>
<p v-html="resultMessage"/>
</v-flex>
</v-card>
</v-container>
</v-card>
</v-flex>
</div>
</template>

<script lang="ts">
import { Component, Prop, Watch, Vue } from 'vue-property-decorator'
import WalletModel from '@/model/WalletModel'
import NemUtil from '@/util/NemUtil'

@Component
export default class Wallet extends Vue {
private isLoading: boolean = false
private wallet: WalletModel = new WalletModel()
private qrSize: number = 200
private toAmount: number = 0
private toAddr: string = ''
private message: string = ''
private qrJson: string = ''
private validation: any[] = []
private resultMessage: string = ''
private rules: any = {
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')
private onValueChange(newValue: string, oldValue: string): void {
this.qrJson = NemUtil.getQRcodeJson(2, 2, 'nem-wallet', newValue, 0, '')
}

private mounted() {
Vue.prototype.$toast('Hello self made NEM wallet')
}

private async getAccount() {
this.isLoading = true
await this.wallet.getAccount()
this.isLoading = false
}

private async tapSend() {
this.resultMessage = ''
if (this.isValidation() === true) {
this.isLoading = true
try {
const result = await this.wallet.sendNem(this.toAddr, this.toAmount, this.message)
const message = result.message + ': \n' + result.transactionHash.data
if (result.message === 'SUCCESS') {
this.resultMessage = 'SUCCESS<br><br>TransactionHash<br>' + result.transactionHash.data
this.clear()
} else {
this.resultMessage = result.message
}
Vue.prototype.$toast(message)
} catch (error) {
this.resultMessage = error
Vue.prototype.$toast(error)
}
this.isLoading = false
}
}

private isValidation(): boolean {
this.validation = []
this.validation.push(this.rules.senderAddrLimit(this.toAddr))
this.validation.push(this.rules.senderAddrInput(this.toAddr))
this.validation.push(this.rules.amountLimit(this.toAmount))
this.validation.push(this.rules.amountInput(`${this.toAmount}`))
this.validation.push(this.rules.messageRules(this.message))
const error: any[] = this.validation.filter((obj: any) => obj !== true )
return (error.length === 0) ? true : false
}

private clear() {
this.toAmount = 0
this.toAddr = ''
this.message = ''
}
}
</script>
<style lang="stylus" scoped>
.wallet
word-break break-all

.errorLabel
color red
</style>

Vue.jsやTypeScriptの基本的な書き方については割愛します。

QRコードの表示にはqriouslyを使用し、データはtype:2の請求書のデータとして作成したウォレットの送金先アドレスを読み取れるようにしています。これで公式のNEMウォレットなどのアプリで読み取りができます。

this.qrJson = NemUtil.getQRcodeJson('2', 2, '', newValue, 0, '')

送金時にバリデーションを入れていますが、NIS側でもバリデーションがかかっているので無くても大丈夫です。念のためといったところです。

  private isValidation(): boolean {

this.validation = []
this.validation.push(this.rules.senderAddrLimit(this.toAddr))
this.validation.push(this.rules.senderAddrInput(this.toAddr))
this.validation.push(this.rules.amountLimit(this.toAmount))
this.validation.push(this.rules.amountInput(`${this.toAmount}`))
this.validation.push(this.rules.messageRules(this.message))
const error: any[] = this.validation.filter((obj: any) => obj !== true )
return (error.length === 0) ? true : false
}

送金に成功するとResult欄にトランザクションIDを表示するようにしています。このIDはNEMのブロックチェーンエクスプローラでトランザクションの承認状況を確認できます。

        <v-flex>

<v-card-title>
<h3>Result</h3>
</v-card-title>
<p v-html="resultMessage"/>
</v-flex>

  private async tapSend() {

if (this.isValidation() === true) {
this.resultMessage = ''
this.isLoading = true
try {
const result = await this.wallet.sendNem(this.toAddr, this.toAmount, this.message)
console.log(result)
const message = result.message + ': \n' + result.transactionHash.data
if (result.message === 'SUCCESS') {
this.resultMessage = result.transactionHash.data
} else {
this.resultMessage = result.message
}
Vue.prototype.$toast(message)
} catch (error) {
this.resultMessage = error
Vue.prototype.$toast(error)
}
this.isLoading = false
}
}

なおNEMを送金するとトランザクションが承認されるまで数分かかります。

今回は承認完了通知は実装していませんので、送金してから良い感じになった時にフォーム右上の更新アイコンを押してもらえれば承認後の残高を確認できます(笑)


App.vue変更

VueのルートページにあたるApp.vueに先ほど作ったWallet.vueを反映させます。

CSSの部分はお好みでどうぞ。


/src/App.vue

<template>

<v-app>
<header>
<span>{{ title }} </span>
</header>
<main>
<Wallet/>
</main>
</v-app>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
import Wallet from '@/components/Wallet.vue'; // @ is an alias to /src

@Component({
components: {
Wallet,
},
})
export default class App extends Vue {
private title = 'Self made NEM wallet'
}
</script>
<style lang="stylus">
#app
font-family 'Avenir', Helvetica, Arial, sans-serif
-webkit-font-smoothing antialiased
-moz-osx-font-smoothing grayscale
text-align center
color #2c3e50

body
margin 0

main
text-align center
margin-top 40px
margin-bottom 40px

header
margin 0
height 56px
padding 0 16px 0 24px
background-color #00c4b3
color #ffffff

header span
display block
position relative
font-size 25px
line-height 1
letter-spacing .02em
font-weight 700
box-sizing border-box
padding-top 16px
text-align left
</style>

これで実装は完了です。

最後にyarn serveでlocalhost上で立ち上がるか確認してください。


GitHub Pagesに公開

せっかく作ったのでGitHub Pagesへデプロイして作ったウォレットを公開しましょう。

yarn serveで問題なくlocalhost上で立ち上がれば問題なくビルドできます(そのはず)。

$ yarn build

ビルドが終わるとプロジェクトのルート上にdocsフォルダが作成されます。

この状態でGitHubへデプロイします。

デプロイ後はGitHub Pagesを有効にします。GitHubのSettingから設定できます、詳しくはこちらで。

数分経つと以下のように表示されます。

スクリーンショット 2018-10-03 17.57.14.png

お疲れ様でした。


終わりに

Vue CLI 3.0とTypeScriptを用いてNEMウォレットを作ってみました。今回紹介したのはmainnetでのウォレットです。そのため実際にウォレットを使用する場合は取引所でやりとりされているNEMが必要です。

testnetであればtestnet用のNEMを無料で手に入れることができるので、次回はtestnetでも利用できるようカスタマイズしていきたいと思います。