ローコード開発ツールRetoolで爆速Symbolブロックチェーンウォレット開発（後編）

今回はローコードアプリ開発プラットフォーム、Retoolを用いてさらに複雑な機能を爆速で開発してみましょう。

前回の記事を読んでいない方は必ずこちらを先にお読みください。

https://qiita.com/nem_takanobu/items/571f7dc2e05b672b525d


今回作るのはこんなウォレットです。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29187/1c28371d-5418-b94d-4a55-0b9e2bb8a623.png)

業務システム向けウォレットって感じがしますね。
コピペだけならRetoolの扱いに慣れれば5分程度で作れます。
結構複雑になってしまったので写経には向きませんが、部分的に参考になるロジックをちりばめていますので、必要に応じて流用してください。

##### 何ができるの？

パスフレーズで秘密鍵をロック可能なアカウント作成し、アグリゲートトランザクションを使ってNFTを持っているユーザとXYMで交換きます。


# 基本機能

### アカウント管理

- パスフレーズで暗号化したアカウント生成機能
- アカウント情報をlocalstrageに保存
- 暗号化データからアカウント情報復元
- バックアップ用QRコード出力

### NFT交換機能

- 交換先アカウント検索
- 所有NFT一覧表示
- XYM数量、メッセージ登録フォーム
- ボンデッド/コンプリートトランザクション作成
- ボンデッドトランザクション発行
- ボンデッドトランザクション署名要求検知
- コンプリートトランザクション出力
- コンプリートトランザクション発行





# アカウント管理

Header領域に以下のようなアカウント管理機能を実装していきます。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29187/0edb4f6b-6eaa-6501-e898-650fdc7179ba.png)



### アカウント生成
[パスフレーズ] Password:password1
[アカウント生成] Button:button1<-query1.trigger()
[バックアップ用QR] Image:image1
[暗号化データ] Text Area:textArea1
[秘密鍵] Password:password2
[アドレス] text Input:textInput1


```js:query1
//ランダムに秘密鍵を生成
const asset = sym.Account.generateNewAccount(window.networkType);
//パスフレーズでロックされたアカウント生成
const signerQR = window.qr.QRCodeGenerator.createExportAccount(
  asset.privateKey, window.networkType, window.generationHash, password1.value
)
//アドレス表示
textInput1.setValue(asset.address.plain());
//秘密鍵を伏字で表示
password2.setValue(asset.privateKey);
//QRコード表示
signerQR.toBase64().subscribe(x =>{
	image1.setImageUrl(x)
});

//アカウントを暗号化したJSONデータとして表示
textArea1.setValue(signerQR.toJSON());
//ローカルストレージへの保存
localStorage.setValue("signer_account_json",signerQR.toJSON());
//リスナー設定（後述）
query10.trigger();
```

### ストレージからのアカウント復元

パスフレーズとローカルストレージに保存された情報からアカウントを復元します。
[ストレージから復元] Button:button2<-query2.trigger()

```js:query2

//ローカルストレージに保存した暗号化データからアカウント生成
const signerQR = window.qr.AccountQR.fromJSON(localStorage.values.signer_account_json,password1.value);
const assetAccount = sym.Account.createFromPrivateKey(signerQR.accountPrivateKey,window.networkType);

//アドレス、秘密鍵、QRコード表示
textInput1.setValue(assetAccount.address.plain());
password2.setValue(assetAccount.privateKey);
signerQR.toBase64().subscribe(x =>{
	image1.setImageUrl(x)
});

//アカウントを暗号化したJSONデータとして表示
textArea1.setValue(signerQR.toJSON());
//リスナー設定（後述）
query10.trigger();
```


### 暗号化データから復元

パスフレーズと暗号化データからアカウントを復元します。
[暗号化データからアカウント復元] Button:button3<-query3.trigger()

```js:query3
//暗号化データからアカウント生成
const signerQR = qr.AccountQR.fromJSON(textArea1.value,password1.value);
const assetAccount = sym.Account.createFromPrivateKey(signerQR.accountPrivateKey,window.networkType);

//アドレス、秘密鍵、QRコード表示
textInput1.setValue(assetAccount.address.plain());
password2.setValue(assetAccount.privateKey);
signerQR.toBase64().subscribe(x =>{
	image1.setImageUrl(x)
});
//ローカルストレージへの保存
localStorage.setValue("signer_account_json",signerQR.toJSON());
//リスナー設定（後述）
query10.trigger();
```

QRコードを出力していますが、Retoolのスキャン機能に若干の不具合があるような気がするため（使用後にWebカメラのリソースが解放されない）、今回はQRコードのスキャン機能の実装を見合わせています。

# 交換対象アカウント情報表示

次にNFT交換対象となるアカウント情報を表示させてみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29187/de20c107-8596-c36c-a5c3-e7c994f0103d.png)

### アカウント検索、所有NFT一覧

[対象アドレス] text Input:textInput2
[公開鍵] text Input:textInput3
[検索] Button:button4<-query4.trigger()
[NFT一覧] Table:table1

```js:query4

//アドレスの生成とアカウント情報の表示
const address = sym.Address.createFromRawAddress(textInput2.value);
const accountRepo = repo.createAccountRepository();
const accountInfo = await accountRepo.getAccountInfo(address).toPromise();
textInput3.setValue(accountInfo.publicKey);

//数量1で所有しているモザイク一覧の表示
const nftList = [];
accountInfo.mosaics.forEach(mosaic => {
	if(mosaic.amount.compact() === 1){
		nftList.push({id:mosaic.id.toHex(),amount:mosaic.amount.compact()})
  }
});
table1.setData(nftList);
```

今回は横着して数量1で所有しているモザイクとNFTとみなして一覧表示させています。
本当はモザイク定義の供給量を参照しなければいけません。

# トランザクション発行

トランザクションを生成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29187/1232bc42-6462-ec69-8bc1-9e3088f8e7eb.png)

### トランザクション生成

[交換希望数量] Number Input:numberInput1
[交換メッセージ] Text Area:textArea2
[ボンデッド（ハッシュロック）送信　/　コンプリート（ペイロード）作成] Switch:switch1
[実行] Button:button5<-query5.trigger()



```js:query5 実行ボタン押下時

//署名者情報生成
const signerQR = window.qr.AccountQR.fromJSON(localStorage.values.signer_account_json,password1.value);
const signerAccount  = sym.Account.createFromPrivateKey(signerQR.accountPrivateKey,window.networkType);
const assetAccount = sym.Account.createFromPrivateKey(signerQR.accountPrivateKey,window.networkType);
const transactionService = new sym.TransactionService(txRepo, receiptRepo);

//XYM支払いトランザクション
const tx1 = sym.TransferTransaction.create(
    sym.Deadline.create(window.epochAdjustment),
    sym.Address.createFromRawAddress(textInput2.value), 
    [window.networkCurrency.createRelative(numberInput1.value)],
    sym.PlainMessage.create(textArea3.value),
    window.networkType
);

//NFT購入トランザクション
const tx2 = sym.TransferTransaction.create(
    sym.Deadline.create(window.epochAdjustment),
    signerAccount.address, 
    [new sym.Mosaic(new sym.MosaicId(table1.selectedRow.data.id), sym.UInt64.fromUint(1))],
    sym.EmptyMessage,
    window.networkType
);

//アグリゲート・コンプリートトランザクションを生成する場合
if(switch1.value == true){

  const completeTx = sym.AggregateTransaction.createComplete(
    sym.Deadline.create(window.epochAdjustment),
    [
      tx1.toAggregate(assetAccount.publicAccount),
      tx2.toAggregate(
        sym.PublicAccount.createFromPublicKey(textInput3.value,window.networkType)
      )
    ],
    window.networkType,[],
	).setMaxFeeForAggregate(100, 1);

  //署名
  const signedTx =  signerAccount.sign(completeTx,window.generationHash);
  //署名済みトランザクションのペイロードを表示
  textArea3.setValue(signedTx.payload);

//アグリゲート・ボンデッドトランザクションを生成する場合
}else{
  const bondedTx = sym.AggregateTransaction.createBonded(
      sym.Deadline.create(window.epochAdjustment),
      [
        tx1.toAggregate(assetAccount.publicAccount),
        tx2.toAggregate(
          sym.PublicAccount.createFromPublicKey(textInput3.value,window.networkType)
        )
      ],
      window.networkType,[],
  ).setMaxFeeForAggregate(100, 1);
  //署名
  const signedTx =  signerAccount.sign(bondedTx,window.generationHash);
  //ハッシュロックトランザクションの生成
  const hashLockTx = sym.HashLockTransaction.create(
    sym.Deadline.create(window.epochAdjustment),
    window.networkCurrency.createRelative(10),
    sym.UInt64.fromUint(480),
    signedTx,
    window.networkType
  ).setMaxFee(100);
  //ハッシュロックトランザクションの署名
  const signedLockTx = signerAccount.sign(hashLockTx, window.generationHash);
  textArea4.setValue(signedTx.hash);
  //トランザクション発行
  transactionService.announceHashLockAggregateBonded(
    signedLockTx,signedTx,listener
  ).subscribe(aggTx => {
    //メッセージ表示
    utils.showNotification({ 
      title: "成功", 
      description: "交換申請中です", 
      notificationType: "success",
    });
  },
  err=>{
    utils.showNotification({ 
      title: "失敗", 
      description: err, 
      notificationType: "error",
    });    
  });
}
```

##### コンプリートトランザクション発行

[コンプリート（ペイロード）] Text Area:textArea3
[ペイロード確認] Button:button6<-query6.trigger()
[署名] text Input:textInput3
[コンプリート発行] Button:button7<-query7.trigger()


起案者は関連するすべての人から署名を受け取って署名済みトランザクションを完成させます。
最後に[コンプリート発行]ボタンをクリックしてネットワークにアナウンスすることで、ブロックチェーンにトランザクションが記録されます。


```js:query6 ペイロード確認ボタン押下時

const tx = sym.TransactionMapping.createFromPayload(textArea3.value);

const innerTxList = [];
tx.innerTransactions.forEach(tx => {
  const dispMosaics= [];
  tx.mosaics.forEach(mosaic =>{
    const dispMosaic = {

      id:mosaic.id.toHex(),
      amount:mosaic.amount.toString()
    }
    dispMosaics.push(dispMosaic);
  });

  innerTxList.push({
    recipientAddress:tx.recipientAddress.plain(),
    signer:tx.signer.address.plain(),
    mosaic:dispMosaics
  })
});

table3.setData(innerTxList);
modal2.open();
```


```js:query7 コンプリート発行ボタン押下時
//署名アカウント生成
const signerQR = window.qr.AccountQR.fromJSON(localStorage.values.signer_account_json,password1.value);
const signerAccount  = sym.Account.createFromPrivateKey(signerQR.accountPrivateKey,window.networkType);
//トランザクション合成
const hash = sym.Transaction.createTransactionHash(textArea3.value,sym.Convert.hexToUint8(window.generationHash));
const cosignSignedTxs = [
    new sym.CosignatureSignedTransaction(
        hash,
        textInput4.value,
        textInput3.value
    )
];
    
const recreatedTx = sym.TransactionMapping.createFromPayload(textArea3.value);
const resignedTx = recreatedTx.signTransactionGivenSignatures(signerAccount, cosignSignedTxs, window.generationHash);

//トランザクションをネットワークにアナウンス
const transactionService = new sym.TransactionService(txRepo, receiptRepo);
transactionService.announce(resignedTx,listener)
.subscribe(aggTx => {
  //メッセージ表示
  utils.showNotification({ 
    title: "成功", 
    description: "NFT交換が完了しました", 
    notificationType: "success",
  });
  console.log(aggTx);
},
err=>{
  utils.showNotification({ 
    title: "失敗", 
    description: err, 
    notificationType: "error",
  });    

});

```


# ポップアップ制御

トランザクションの内容を確認して連署するためにポップアップを表示させます。
ボンデッドトランザクションとコンプリートトランザクションの2種類を作成します。


[ボンデッドトランザクションポップアップ] Modal:modal1<-Hidden:true
[コンプリートトランザクションポップアップ] Modal:modal2<-Hidden:true

上記２つのコンポーネントをMain領域に適当に配置し、Open Modalと表示されたボタンをクリックして表示されるモーダルウィンドウに、以下のコンテンツを配置してください。

### ボンデッドトランザクション

ボンデッドトランザクションを受信したアカウントは
トランザクションの内容を精査し、承諾する場合は署名ボタンをクリックすることでトランザクションが実施されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29187/65833007-ca58-4b82-00db-016c384ee893.png)


[トランザクション一覧] Table:table2
[Hash] Text Area:textArea4
[署名] Button:button8<-query8.trigger()



```js:query8
//ポップアップ閉じる
modal1.close();
//署名アカウントの生成
const signerQR = window.qr.AccountQR.fromJSON(localStorage.values.signer_account_json,password1.value);
const signerAccount  = sym.Account.createFromPrivateKey(signerQR.accountPrivateKey,window.networkType);

//承認監視リスナー
function signedTxConfirmed(address,hash){

	const transactionObservable = listener.confirmed(address);
	const errorObservable = listener.status(address, hash);
	return rxjs.merge(transactionObservable, errorObservable).pipe(
		op.first(),
		op.map((errorOrTransaction) => {
			if (errorOrTransaction.constructor.name === "TransactionStatusError") {
				throw new Error(errorOrTransaction.code);
			} else {
				return errorOrTransaction;
			}
		}),
	);
}

//ボンデッドトランザクションの署名とアナウンス
txRepo.getTransaction(textArea4.value,sym.TransactionGroup.Partial)
.pipe(
  op.map(_ => {
    return 	signerAccount.signCosignatureTransaction(sym.CosignatureTransaction.create(_));
  }),
  op.mergeMap(_ => {
    return rxjs.of({
      ignored:txRepo.announceAggregateBondedCosignature(_),
      hash:_.parentHash
    });
  }),
)
.subscribe(aggTx=> {
  signedTxConfirmed(signerAccount.address,aggTx.hash)
  .subscribe(_=> {
    utils.showNotification({ 
      title: "成功", 
      description: "NFT交換が完了しました", 
      notificationType: "success",
    });
  },err => {})
});

```

### コンプリートトランザクションの署名

コンプリートトランザクションのポップアップは、ペイロードをチェーン外（オフライン）から教えてもらったユーザがコンプリート（ペイロード）テキストエリアにペイロードを入力し、ペイロード確認ボタンをクリックすると表示されます。
コンプリート連署ボタンをクリックすると連署枠に署名が出力されます。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29187/31a7bf9e-4322-d9f2-4c0b-f7a60e15312b.png)

[トランザクション一覧] Table:table3
[コンプリート署名] Button:button9<-query9.trigger()

```js:query9
//署名アカウントの生成
const signerQR = qr.AccountQR.fromJSON(textArea1.value,password1.value);
const assetAccount = sym.Account.createFromPrivateKey(signerQR.accountPrivateKey,window.networkType);
//署名
const signedTx = sym.CosignatureTransaction.signTransactionPayload(assetAccount,textArea3.value,window.generationHash);

//署名結果をMain領域の[署名]に出力させる
textInput4.setValue(signedTx.signature);
modal2.close();
```

署名欄に出力された署名は、コピペしてオフラインで起案者（最初にペイロードを作成した人）に返しましょう。

### コンプリートトランザクション発行

[コンプリート（ペイロード）] Text Area:textArea2
[署名] TextInput:textInput4
[コンプリート発行] Button:button4<-query5.trigger()

起案者は関連するすべての人から署名を受け取って署名済みトランザクションを完成させます。
最後に[コンプリート発行]ボタンをクリックしてネットワークにアナウンスすることで、ブロックチェーンにトランザクションが記録されます。

アグリゲートコンプリートトランザクションは、10XYMのハッシュロックを必要としない分、運用は少し複雑です。UIの意味がよく分からない方はまず簡単なサンプルプログラムからその挙動をご確認ください。

https://qiita.com/nem_takanobu/items/661f49b769193f634e0b




### 共通Query

最後にボンデッドトランザクションを受信した場合にポップアップを開くためのリスナーを共通Queryとして定義しておきます。

```js:query10

//署名者アカウント生成
const signerQR = qr.AccountQR.fromJSON(textArea1.value,password1.value);
const assetAccount = sym.Account.createFromPrivateKey(signerQR.accountPrivateKey,window.networkType);

//リスナー登録
listener.aggregateBondedAdded(assetAccount.address)
.pipe(
    //すでに署名済みでない場合、発起人でない場合のみ通過するフィルタ
	op.filter(_ => !_.signedByAccount(assetAccount.address)),
	op.filter(_ =>  _.signer.address.plain() !== assetAccount.address.plain())
)
.subscribe(tx=>{
    //トランザクション一覧表示
	const innerTxList = [];
	tx.innerTransactions.forEach(tx => {
		const dispMosaics= [];
		tx.mosaics.forEach(mosaic =>{
			const dispMosaic = {
				id:mosaic.id.toHex(),
				amount:mosaic.amount.toString()
			}
			dispMosaics.push(dispMosaic);
		});
		innerTxList.push({
			recipientAddress:tx.recipientAddress.plain(),
			signer:tx.signer.address.plain(),
			mosaic:dispMosaics
		})
	});
	table2.setData(innerTxList);
    //トランザクションハッシュ値表示
	textArea4.setValue(tx.transactionInfo.hash)
	//ポップアップ表示
    modal1.open();
});
```


# さいごに

お疲れ様でした。無事動きましたでしょうか？
システムの開発は非常に多くの作業を伴います。多くの人に触れない部分、単調作業の繰り返し、外部依存度の高い部品開発、そういった部分をいかに素早く開発できるかで本当に開発したい部分に集中できるようになります。

ぜひ、ローコードプラットフォームの活用をご検討ください。