🧬

Angular,cosmos-clientによるブロックチェーンWebアプリフロントエンド開発2(送信機能追加)

2022/01/17に公開

株式会社 CauchyE の角田です。今回は前回作成した Web アプリケーションに送信機能を追加します。また適切な手数料が設定されていないとトランザクションが通らないようにローカルチェーンのノードの設定を変更します。その他、アプリの見た目を整えるため Angular Material, Tailwind CSS を導入します。

環境構築

実行環境

  • OS
    • Ubuntu 20.04 (on WSL2)
  • Node.js
    • v14.17.2
  • npm
    • 7.19.1
  • Starport
    • v0.18.0

Angular Material

Angular Material は Angular でマテリアルデザインに基づく UI が簡単な設定で作成できるフレームワークです。

詳しくはこちらの情報をご参照ください。
Angular 公式 UI コンポーネントの使い方

後のサンプルコードで使用するので Angular Material をインポートします。

ng add @angular/material

今回は以下 6 つのモジュールを追加します。

app.module.ts
import { MatGridListModule } from '@angular/material/grid-list';//追加
import { MatCardModule } from '@angular/material/card';//追加
import { MatFormFieldModule } from '@angular/material/form-field';//追加
import { MatButtonModule } from '@angular/material/button';//追加
import { MatInputModule } from '@angular/material/input';//追加
import { MatSelectModule } from '@angular/material/select';//追加

  imports: [
    BrowserModule,
    AppRoutingModule,
    FormsModule,
    BrowserAnimationsModule,
    MatGridListModule, //追加
    MatCardModule, //追加
    MatFormFieldModule, //追加
    MatButtonModule, //追加
    MatInputModule, //追加
    MatSelectModule //追加
  ],

TailWind CSS

TailWind CSS は HTML にクラスを設定するだけで CSS に触れることなく画面のデザインができるフレームワークです。

TailWind CSS の有効性は公式ドキュメントの例が分かりやすいです。記述量が減っていることが分かります。

こちらもインポートします。

ng add @ngneat/tailwind

ローカルチェーンのノードの手数料設定

以下のパスにチェーンのノードの設定ファイルがあります。
ファイルが見つからない場合、フォルダ名 .mars の部分を立ち上げたチェーンの名前に適宜変更してください。
サンプルではテキストエディタに Vim を使用しています。適宜使い慣れているものを使用してください。

cd ~/.mars/config
vim app.toml

以下の minimum-gas-prices に gas として使用するトークンとその量を設定します。
ここでは 1gas=0.1stake を手数料の最小単位としています。
この設定値よりも小さな gas-price で手数料が設定されたトランザクションはノードが受け入れを拒否することを覚えておいてください。

app.toml
halt-height = 0
halt-time = 0
index-events = []
inter-block-cache = true
min-retain-blocks = 0
minimum-gas-prices = "0.1stake" // <- 変更
pruning = "default"
pruning-interval = "0"
pruning-keep-every = "0"
pruning-keep-recent = "0"

[api]

トークン送信

前回は Alice と Bob が所持するトークンを表示する機能を実装しました。
今回は Alice と Bob の間でそれぞれが所持するトークンを送信する機能を実装します。

大まかな流れは以下の通りです。

  1. 宛先、送信元、送信するトークン、仮の手数料の値からトランザクションデータを作成
  1. シミュレーション用トランザクションデータに変換し、API で必要な手数料の推定値を取得
  1. 取得した手数料推定値で再度トランザクションデータを作成し、チェーンにアナウンス

基本的なトランザクションの送信機能はcosmos-client-tsExamplesを参照ください。これをベースに機能を追加していきます。

1-1. 送信元アドレスから、ベースアカウントを作ります

const account = await rest.cosmos.auth
  .account(this.sdk, fromAddress)
  .then(
    (res) =>
      res.data.account && cosmosclient.codec.unpackCosmosAny(res.data.account)
  );

1-2. メッセージを作ります

メッセージは送信元アドレス、送信先アドレス、送信内容を含みます。

// build MsgSend
const msgSend = new proto.cosmos.bank.v1beta1.MsgSend({
  from_address: fromAddress.toString(),
  to_address: this.toAddress && this.toAddress.toString(),
  amount: [sendTokens],
});

1-3. Transaction に変換

1-2 で作成したメッセージをトランザクション本文として変換します。

// build TxBody
const txBody = new proto.cosmos.tx.v1beta1.TxBody({
  messages: [cosmosclient.codec.packAny(msgSend)],
});

2-1. 署名者の情報の定義

署名者の情報を定義します。この署名者とはトークンが払いだされることを承認する者、つまり送信元アドレスを意味します。

// build authInfo for simulation
const authInfoSim = new proto.cosmos.tx.v1beta1.AuthInfo({
  signer_infos: [
    {
      public_key: cosmosclient.codec.packAny(publicKey),
      mode_info: {
        single: {
          mode: proto.cosmos.tx.signing.v1beta1.SignMode.SIGN_MODE_DIRECT,
        },
      },
      sequence: account.sequence,
    },
  ],
  fee: {
    amount: [{ denom: this.gasDenom, amount: "1" }], // <-- dummy fee
    gas_limit: cosmosclient.Long.fromString("200000"), // <-- dummy gas
  },
});

2-2. 署名データをトランザクションデータに追加

トランザクションのシミュレーションの際にも、適切な署名者が、署名する必要があるため、署名データをトランザクションデータに追加します。

// sign
const txBuilderSim = new cosmosclient.TxBuilder(sdk, txBody, authInfoSim);
const signDocBytesSim = txBuilderSim.signDocBytes(account.account_number);
txBuilderSim.addSignature(privateKey.sign(signDocBytesSim)); // <- 署名

2-3. 必要な gas の推測値の取得

まず、シミュレーションのためのデータ形式にトランザクションデータを変換します。
その際に不要なデータが生成されてしまうため、不要なデータを削除してデータを整えます。
シミュレーションのためのデータが準備できたら、トランザクションのシミュレーションの API を実行し、必要な gas の推測値を取得します。

// restore json from txBuilder
const txForSimulation = JSON.parse(txBuilderSim.cosmosJSONStringify());
delete txForSimulation.auth_info.signer_infos[0].mode_info.multi;

// simulate
const simulatedResult = await rest.tx.simulate(sdk, {
  tx: txForSimulation,
  tx_bytes: txBuilderSim.txBytes(),
});

// estimate gas
const simulatedGasUsed = simulatedResult.data.gas_info?.gas_used; // <- gasの推定必要量

3-1. トランザクション署名の再定義を行います

ここまでの過程で、gas の推定必要量は得られました。
しかし、その値をそのまま使うと、推定誤差による gas 不足でトランザクションが失敗することもあります。
そのため、実際にトランザクションを送信する際には、余裕率を見込んだ数値を設定したほうが良いでしょう。
以降の説明では以下のような設定値を用いてトランザクションに必要な各種データを再生成しています。

  • simulatedGasUsedWithMargin = simulatedGasUsed * 1.1 ... API で確認した gas の推測値に余裕率 1.1 を掛けたもの。小数点以下は切り上げ。
  • simulatedFeeWithMargin = simulatedGasUsedWithMargin * gasPrice ... 上記simulatedGasUsedWithMarginに、さらに設定した 1gas あたりの単価gasPriceを掛けたもの。小数点以下は切り上げ。

gasPriceがローカルチェーンの手数料設定にて設定したminimumGasPrices以上で無い場合、トランザクションをノードは拒否してしまいます。
したがって、ノードのminimumGasPricesの設定値がわかっている場合はgasPriceにその値を使うのが合理的でしょう。

// build authInfo
const authInfo = new proto.cosmos.tx.v1beta1.AuthInfo({
  signer_infos: [
    {
      public_key: cosmosclient.codec.packAny(publicKey),
      mode_info: {
        single: {
          mode: proto.cosmos.tx.signing.v1beta1.SignMode.SIGN_MODE_DIRECT,
        },
      },
      sequence: account.sequence,
    },
  ],
  fee: {
    amount: [{ denom: this.gasDenom, amount: simulatedFeeWithMargin }], // <-- 推測値を反映
    gas_limit: cosmosclient.Long.fromString(
      simulatedGasUsedWithMargin ? simulatedGasUsedWithMargin : "200000" // <-- 推測値を反映
    ),
  },
});

3-2. トランザクションのアナウンス

2-2 と同様に署名したのち、署名されたメッセージをノードに送ります。このトランザクションがノードにとりこまれると、ブロックチェーンに取引の情報が刻まれ、取引(トークンの送信)が成立します。

// broadcast
const res = await rest.cosmos.tx.broadcastTx(this.sdk, {
  tx_bytes: txBuilder.txBytes(),
  mode: rest.cosmos.tx.BroadcastTxMode.Block,
});

実装

環境構築でのモジュールのインポートに加えて前回作成した app.component.ts、app.component.html を以下のコードに変更して動作確認を行ってみましょう。

app.component.ts
app.component.ts
import { Component, OnInit } from '@angular/core';
import { combineLatest, of, Observable, timer } from 'rxjs';
import { mergeMap } from 'rxjs/operators';
import { cosmosclient, proto, rest } from '@cosmos-client/core';
import { InlineResponse20028Balances } from '@cosmos-client/core/cjs/openapi/api';
import { AccAddress } from '@cosmos-client/core/cjs/types/address/acc-address';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent implements OnInit {
  nodeURL = 'http://localhost:1317';
  chainID = 'mars';
  denoms = ['stake', 'token'];
  gasPrice = '0.1';
  gasDenom = this.denoms[0];
  mnemonicA =
    'power cereal remind render enhance muffin kangaroo snow hill nature bleak defense summer crisp scare muscle tiger dress behave verb pond merry voyage already';
  mnemonicB =
    'funny jungle scout crisp tissue dish talk tattoo alone scheme clog kiwi delay property current argue conduct west bounce reason abandon coral lawsuit hunt';
  //pubkey ***サンプルコードのため、ニーモニックをハードコーディングしています。***
  //       ***アカウントのすべてのコントロールを渡すことになるので、決してマネしないよう。***

  balancesAlice$: Observable<InlineResponse20028Balances[] | undefined>;
  accAddressAlice: cosmosclient.AccAddress;

  balancesBob$: Observable<InlineResponse20028Balances[] | undefined>;
  accAddressBob: cosmosclient.AccAddress;

  sdk$: Observable<cosmosclient.CosmosSDK> = of(
    new cosmosclient.CosmosSDK(this.nodeURL, this.chainID)
  );
  timer$: Observable<number> = timer(0, 3 * 1000);

  constructor() {
    //Aliceの所持tokenを取得
    this.accAddressAlice = cosmosclient.AccAddress.fromString(
      'cosmos1lhaml37gselnnthjh9q2av2pkyf9hh67zy9maz'
    );
    this.balancesAlice$ = combineLatest(this.timer$, this.sdk$).pipe(
      mergeMap(([n, sdk]) => {
        return rest.bank
          .allBalances(sdk, this.accAddressAlice)
          .then((res) => res.data.balances);
      })
    );

    //Bobの所持tokenを取得
    this.accAddressBob = cosmosclient.AccAddress.fromString(
      'cosmos1jwk3yttut7645kxwnuehkkzey2ztph9zklsu7u'
    );
    this.balancesBob$ = combineLatest(this.timer$, this.sdk$).pipe(
      mergeMap(([n, sdk]) => {
        return rest.bank
          .allBalances(sdk, this.accAddressBob)
          .then((res) => res.data.balances);
      })
    );
  }

  ngOnInit(): void {}

  //txを送信
  async sendTx(
    mnemonic: string,
    sdk_in: Observable<cosmosclient.CosmosSDK>,
    toAddress: AccAddress,
    denom: string,
    amount: string
  ): Promise<void> {
    //sdk
    const sdk = await sdk_in.toPromise();
    const sendTokens: proto.cosmos.base.v1beta1.ICoin = {
      denom: denom,
      amount: amount,
    };

    //Address
    const privateKey = new proto.cosmos.crypto.secp256k1.PrivKey({
      key: await cosmosclient.generatePrivKeyFromMnemonic(mnemonic),
    });
    const publicKey = privateKey.pubKey();
    const fromAddress: AccAddress =
      cosmosclient.AccAddress.fromPublicKey(publicKey);

    // get account info
    const account = await rest.auth
      .account(sdk, fromAddress)
      .then(
        (res) =>
          res.data.account &&
          cosmosclient.codec.unpackCosmosAny(res.data.account)
      )
      .catch((_) => undefined);
    if (!(account instanceof proto.cosmos.auth.v1beta1.BaseAccount)) {
      throw Error('Address not found');
    }

    // build MsgSend
    const msgSend = new proto.cosmos.bank.v1beta1.MsgSend({
      from_address: fromAddress.toString(),
      to_address: toAddress.toString(),
      amount: [sendTokens],
    });

    // build TxBody
    const txBody = new proto.cosmos.tx.v1beta1.TxBody({
      messages: [cosmosclient.codec.packAny(msgSend)],
    });

    //Check fee -> ////////////////////////////////////////

    // build authInfo for simulation
    const authInfoSim = new proto.cosmos.tx.v1beta1.AuthInfo({
      signer_infos: [
        {
          public_key: cosmosclient.codec.packAny(publicKey),
          mode_info: {
            single: {
              mode: proto.cosmos.tx.signing.v1beta1.SignMode.SIGN_MODE_DIRECT,
            },
          },
          sequence: account.sequence,
        },
      ],
      fee: {
        amount: [{ denom: this.gasDenom, amount: '1' }],
        gas_limit: cosmosclient.Long.fromString('200000'),
      },
    });

    // sign for simulation
    const txBuilderSim = new cosmosclient.TxBuilder(sdk, txBody, authInfoSim);
    const signDocBytesSim = txBuilderSim.signDocBytes(account.account_number);
    txBuilderSim.addSignature(privateKey.sign(signDocBytesSim));

    // restore json from txBuilder
    const txForSimulation = JSON.parse(txBuilderSim.cosmosJSONStringify());

    // fix JSONstringify issue
    delete txForSimulation.auth_info.signer_infos[0].mode_info.multi;

    // simulate
    const simulatedResult = await rest.tx.simulate(sdk, {
      tx: txForSimulation,
      tx_bytes: txBuilderSim.txBytes(),
    });
    console.log('simulatedResult', simulatedResult);

    // estimate fee
    const simulatedGasUsed = simulatedResult.data.gas_info?.gas_used;
    // This margin prevents insufficient fee due to data size difference between simulated tx and actual tx.
    const simulatedGasUsedWithMarginNumber = simulatedGasUsed
      ? parseInt(simulatedGasUsed) * 1.1
      : 200000;
    const simulatedGasUsedWithMargin =
      simulatedGasUsedWithMarginNumber.toFixed(0);

    // minimumGasPrice depends on Node's config(`~/.mars/config/app.toml` minimum-gas-prices).
    const simulatedFeeWithMarginNumber =
      parseInt(simulatedGasUsedWithMargin) * parseFloat(this.gasPrice);
    const simulatedFeeWithMargin = Math.ceil(
      simulatedFeeWithMarginNumber
    ).toFixed(0);
    console.log({
      simulatedGasUsed,
      simulatedGasUsedWithMargin,
      simulatedFeeWithMarginNumber,
      simulatedFeeWithMargin,
    });

    //Check fee <- ////////////////////////////////////////

    // build authInfo
    const authInfo = new proto.cosmos.tx.v1beta1.AuthInfo({
      signer_infos: [
        {
          public_key: cosmosclient.codec.packAny(publicKey),
          mode_info: {
            single: {
              mode: proto.cosmos.tx.signing.v1beta1.SignMode.SIGN_MODE_DIRECT,
            },
          },
          sequence: account.sequence,
        },
      ],
      fee: {
        amount: [{ denom: this.gasDenom, amount: simulatedFeeWithMargin }],
        gas_limit: cosmosclient.Long.fromString(
          simulatedGasUsedWithMargin ? simulatedGasUsedWithMargin : '200000'
        ),
      },
    });

    // sign for transaction
    const txBuilder = new cosmosclient.TxBuilder(sdk, txBody, authInfo);
    const signDocBytes = txBuilder.signDocBytes(account.account_number);
    txBuilder.addSignature(privateKey.sign(signDocBytes));

    // broadcast
    const res = await rest.tx.broadcastTx(sdk, {
      tx_bytes: txBuilder.txBytes(),
      mode: rest.tx.BroadcastTxMode.Block,
    });
    console.log('tx_res', res);
  }
}
app.component.html
app.component.html
<div class="grid-container">
  <h1 class="mat-h1">My-app</h1>
  <mat-grid-list cols="2" rowHeight="450px">
    <!--Bob-->
    <mat-grid-tile [colspan]="1" [rowspan]="1">
      <mat-card class="dashboard-card">
        <mat-card-header>
          <mat-card-title> Bob </mat-card-title>
        </mat-card-header>
        <mat-card-content class="dashboard-card-content">
          <h3>
            <div>address : {{ this.accAddressBob }}</div>
          </h3>
          <!--所持tokenを表示-->
          <ng-container *ngIf="balancesBob$ | async as balancesBob">
            <h3>balances</h3>
            <ng-container *ngFor="let balanceBob of balancesBob">
              <div>
                token: {{ balanceBob.denom }} balance: {{ balanceBob.amount }}
              </div>
            </ng-container>
          </ng-container>
          <!--送信フォーム-->
          <form
            #formB="ngForm"
            class="mt-4"
            (submit)="
              sendTx(
                mnemonicB,
                sdk$,
                accAddressAlice,
                denomBRef.value,
                amountBRef.value
              )
            "
          >
            <mat-form-field appearance="fill">
              <mat-label>amount</mat-label>
              <input
                #amountBRef
                matInput
                required
                id="amountValue"
                name="amountValue"
              />
            </mat-form-field>
            <mat-select
              #denomBRef
              placeholder="Select"
              name="denomOption"
              required
            >
              <mat-option *ngFor="let denom of denoms" [value]="denom">
                {{ denom }}
              </mat-option>
            </mat-select>
            <button mat-flat-button color="primary">Send</button>
          </form>
        </mat-card-content>
      </mat-card>
    </mat-grid-tile>

    <!--Alice-->
    <mat-grid-tile [colspan]="1" [rowspan]="1">
      <mat-card class="dashboard-card">
        <mat-card-header>
          <mat-card-title>Alice</mat-card-title>
        </mat-card-header>
        <mat-card-content class="dashboard-card-content">
          <h3>
            <div>address : {{ this.accAddressAlice }}</div>
          </h3>
          <!--所持tokenを表示-->
          <ng-container *ngIf="balancesAlice$ | async as balancesAlice">
            <h3>balances</h3>
            <ng-container *ngFor="let balanceAlice of balancesAlice">
              <div>
                token: {{ balanceAlice.denom }} balance:
                {{ balanceAlice.amount }}
              </div>
            </ng-container>
          </ng-container>
          <!--送信フォーム-->
          <form
            #formA="ngForm"
            class="mt-4"
            (submit)="
              sendTx(
                mnemonicA,
                sdk$,
                accAddressBob,
                denomARef.value,
                amountARef.value
              )
            "
          >
            <mat-form-field appearance="fill">
              <mat-label>amount</mat-label>
              <input
                #amountARef
                matInput
                required
                id="amountValue"
                name="amountValue"
              />
            </mat-form-field>
            <mat-select
              #denomARef
              placeholder="Select"
              name="denomOption"
              required
            >
              <mat-option *ngFor="let denom of denoms" [value]="denom">
                {{ denom }}
              </mat-option>
            </mat-select>
            <button mat-flat-button color="accent">Send</button>
          </form>
        </mat-card-content>
      </mat-card>
    </mat-grid-tile>
  </mat-grid-list>
</div>

動作確認

Send のボタンを押すと、互いにトークンを送ることができます。
試しに Alice から Bob へ 7token を送信してみましょう。環境構築で minimum-gas-price に stake を設定したので手数料として stake が使用されるはずです。

<送信前>
Alice->Bob 7token send before

<送信後>
Alice->Bob 7token send
Alice から Bob へ 7token が送信され、stake が消費されたことが分かります。

まとめ

ローカルチェーンの手数料の設定と、API を使用した適切な手数料の設定についてサンプルコードの実装と動作確認を行いました。
何気ないトークンの送信でも案外いろいろとやってることが伝われば幸いです。

CauchyE は一緒に働いてくれる人を待ってます!

ブロックチェーンやデータサイエンスに興味のあるエンジニアを積極的に採用中です!
以下のページから応募お待ちしております。
https://cauchye.com/company/recruit

Discussion