35
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

WebエンジニアのためのSymbol開発チュートリアル(Symbol Bootcamp for Developers)

Last updated at Posted at 2021-12-06

株式会社CauchyEでエンジニアをしている松岡靖典と申します。NPO法人NEMTUSの設立メンバーでもあります。個人的にもNEM/Symbolを用いた開発に興味を持って色々と活動しています。

この記事では、NEM(NIS1)の次期バージョンとして2021年3月にとうとうローンチされたSymbolを用いたWeb開発を行うにあたり、Symbolブロックチェーンから必要な情報を参照したり、トランザクションを送信したりする方法の概要やコツのようなものをお伝えできればと思います。

本記事の実装について

以下レポジトリにてソースコードを共有しています。必要に応じてご参照ください。記事中のソースコードは一部のみが示されていてimport元の実装が明示されていない部分も多々あるので、必要に応じてこちらのレポジトリの内容を参照しながら内容を確認頂くとよいかと思います。

ノード

Symbolでは全世界に分散化された多数のフルノードが存在し、それらのフルノードではRest APIが有効化されており、一般的なWeb開発の延長線上の方法で、ブロックチェーン上の情報を参照したり、トランザクションを送信したりすることが可能です。

2021/12/06現在で約1400のノードが存在しており、そのうち約500のノードではhttpsから始まるURLのRest APIを利用可能です。

同じデータであることがブロックチェーンの特性によって保証された、全世界の数百台のマシンに分散化された、決して消えることのないと言っても過言ではない分散型データベースに接続できるRest APIのエンドポイントが500以上あって、それらを自由に使えるといっても過言はないでしょう。

実際に開発を行う際には、以下のノードリストが参考になるでしょう。

ただし、実際にWebアプリの中で、多数のノードを活かすには、ノードのリストをもっと動的に取得して、適宜それらを使用していくということが必要になるでしょう。

メインネットでは1000台を超えるノードの死活状態を適宜監視して、有効なノードのみのリストを作るのはそれなりに手間がありますが、有志の方で以下のようなAPIを公開してくださっている方(だいさん: この場を借りてお礼申し上げます。)がいらっしゃるので、それを使わせて頂き、もしそのAPIでノードリストを取得できなかった場合は、Opening Lineさんが10台くらいノードを立ててくださっているので、使わせてもらうとよいでしょう。

テストネットでは(メインネットと異なりノードを立てるインセンティブが無いため)ノードは少ないため、Opening Lineさんのノードを固定で使わせてもらうとよいでしょう。

一旦ここまでの情報をTypeScriptの実装例にまとめてみましたのでよかったら参考にしてみてください。ここに記載していないコードもあるので全コードは本記事冒頭のレポジトリのリンクをご参照ください。

src/symbol/nodes/nodes.ts
import axios from "axios";
import url from "url";
import * as SymbolSdk from "symbol-sdk";
import { convertNetworkToNetworkType, Network } from "../utils";

export type Nodes = Node[];

export type Node = {
  host: string;
  friendlyName: string;
  https: boolean;
  roles: string;
  updated_at: string;
  websocket: boolean;
};

export const MAINNET_STATIC_NODE_LIST: Nodes = [
  {
    host: "sym-main-01.opening-line.jp",
    friendlyName: "sym-main-01.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-main-02.opening-line.jp",
    friendlyName: "sym-main-02.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-main-03.opening-line.jp",
    friendlyName: "sym-main-03.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-main-04.opening-line.jp",
    friendlyName: "sym-main-04.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-main-05.opening-line.jp",
    friendlyName: "sym-main-05.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-main-06.opening-line.jp",
    friendlyName: "sym-main-06.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-main-07.opening-line.jp",
    friendlyName: "sym-main-07.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-main-08.opening-line.jp",
    friendlyName: "sym-main-08.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-main-09.opening-line.jp",
    friendlyName: "sym-main-09.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-main-10.opening-line.jp",
    friendlyName: "sym-main-10.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-main-11.opening-line.jp",
    friendlyName: "sym-main-11.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-main.opening-line.jp",
    friendlyName: "sym-main.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "symbol-sakura-16.next-web-technology.com",
    friendlyName: "next-web-technology",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
];

export const TESTNET_STATIC_NODE_LIST: Nodes = [
  {
    host: "sym-test-01.opening-line.jp",
    friendlyName: "sym-test-01.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-test-02.opening-line.jp",
    friendlyName: "sym-test-02.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-test-03.opening-line.jp",
    friendlyName: "sym-test-03.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-test-04.opening-line.jp",
    friendlyName: "sym-test-04.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-test-05.opening-line.jp",
    friendlyName: "sym-test-05.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-test-06.opening-line.jp",
    friendlyName: "sym-test-06.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-test-07.opening-line.jp",
    friendlyName: "sym-test-07.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-test-08.opening-line.jp",
    friendlyName: "sym-test-08.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-test-09.opening-line.jp",
    friendlyName: "sym-test-09.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-test-10.opening-line.jp",
    friendlyName: "sym-test-10.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "sym-test.opening-line.jp",
    friendlyName: "sym-test.opening-line.jp",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
  {
    host: "symbol-test.next-web-technology.com",
    friendlyName: "next-web-technology",
    https: true,
    roles: "3",
    updated_at: "",
    websocket: true,
  },
];

export const convertNodesToUrls = (nodes: Nodes): string[] => {
  return nodes.map((node) => convertNodeToUrl(node));
};

export const convertNodeToUrl = (node: Node): string => {
  return `${node.https ? "https" : "http"}://${node.host}:${
    node.https ? "3001" : "3000"
  }`;
};

export const selectRandomNodeFromNodes = (nodes: Nodes): Node => {
  const length = nodes.length;
  const random = Math.random();
  const randomIndex = Math.floor(random * (length - 1));
  return nodes[randomIndex];
};

export const selectRandomNodeUrlFromNodeUrls = (nodeUrls: string[]): string => {
  const length = nodeUrls.length;
  const random = Math.random();
  const randomIndex = Math.floor(random * length);
  return nodeUrls[randomIndex];
};

export const fetchNodeUrls = async (
  isHttps: boolean,
  network: Network
): Promise<string[]> => {
  const nodes = await fetchNodes(isHttps, network);
  const nodeUrls = convertNodesToUrls(nodes);
  return nodeUrls;
};

export const fetchNodes = async (
  isHttps: boolean,
  network: Network
): Promise<Nodes> => {
  const networkType = convertNetworkToNetworkType(network);
  if (networkType === SymbolSdk.NetworkType.MAIN_NET) {
    const queryParams = new url.URLSearchParams({
      tk: "xym",
      https: isHttps.toString(),
    });
    try {
      const response = await axios.get<Nodes>(
        "https://rohr.sfn.tools/api/node/lists",
        {
          params: queryParams.toString(),
          timeout: 5000,
        }
      );
      if (response.status === 200) {
        const nodes = response.data;
        const apiNodes = nodes.filter((node) => {
          return (
            node.roles === "2" ||
            node.roles === "3" ||
            node.roles === "6" ||
            node.roles === "7"
          );
        });
        return apiNodes;
      } else {
        console.error(response);
        return MAINNET_STATIC_NODE_LIST;
      }
    } catch (error) {
      console.error(error);
      return MAINNET_STATIC_NODE_LIST;
    }
  } else if (networkType === SymbolSdk.NetworkType.TEST_NET) {
    return TESTNET_STATIC_NODE_LIST;
  } else {
    throw Error("Invalid networkType!");
  }
};

export const fetchRandomNodeUrl = async (network: Network): Promise<string> => {
  const node = await fetchRandomNode(network);
  return convertNodeToUrl(node);
};

export const fetchRandomNode = async (network: Network): Promise<Node> => {
  const networkType = convertNetworkToNetworkType(network);
  if (networkType === SymbolSdk.NetworkType.MAIN_NET) {
    try {
      const response = await axios.get<Node>(
        "https://rohr.sfn.tools/api/node/xym",
        {
          timeout: 5000,
        }
      );
      if (response.status === 200) {
        const node = response.data;
        return node;
      } else {
        console.error(response);
        return selectRandomNodeFromNodes(MAINNET_STATIC_NODE_LIST);
      }
    } catch (error) {
      console.error(error);
      return selectRandomNodeFromNodes(MAINNET_STATIC_NODE_LIST);
    }
  } else if (networkType === SymbolSdk.NetworkType.TEST_NET) {
    return selectRandomNodeFromNodes(TESTNET_STATIC_NODE_LIST);
  } else {
    throw Error("Invalid networkType");
  }
};

ノードから取得できるSymbolブロックチェーンネットワーク固有の情報

ノードリスト等から使用するノードが決めたら、ノードのRest APIを叩いてみるところから始めることになるでしょう。

Rest APIの仕様は以下リンクから参照可能です。

まず最初に叩いてみるAPIとしておすすめなのはそのノードのネットワークに関する情報のAPIです。

{
    "network": {
        "identifier": "mainnet",
        "nemesisSignerPublicKey": "BE0B4CF546B7B4F4BBFCFF9F574FDA527C07A53D3FC76F8BB7DB746F8E8E0A9F",
        "nodeEqualityStrategy": "host",
        "generationHashSeed": "57F7DA205008026C776CB6AED843393F04CD458E0AA2D9F1D5F31A402072B2D6",
        "epochAdjustment": "1615853185s"
    },
    "chain": {
        "enableVerifiableState": true,
        "enableVerifiableReceipts": true,
        "currencyMosaicId": "0x6BED'913F'A202'23F8",
        "harvestingMosaicId": "0x6BED'913F'A202'23F8",
        "blockGenerationTargetTime": "30s",
        "blockTimeSmoothingFactor": "3000",
        "importanceGrouping": "720",
        "importanceActivityPercentage": "5",
        "maxRollbackBlocks": "0",
        "maxDifficultyBlocks": "60",
        "defaultDynamicFeeMultiplier": "100",
        "maxTransactionLifetime": "6h",
        "maxBlockFutureTime": "300ms",
        "initialCurrencyAtomicUnits": "7'842'928'625'000'000",
        "maxMosaicAtomicUnits": "8'999'999'999'000'000",
        "totalChainImportance": "7'842'928'625'000'000",
        "minHarvesterBalance": "10'000'000'000",
        "maxHarvesterBalance": "50'000'000'000'000",
        "minVoterBalance": "3'000'000'000'000",
        "votingSetGrouping": "1440",
        "maxVotingKeysPerAccount": "3",
        "minVotingKeyLifetime": "112",
        "maxVotingKeyLifetime": "360",
        "harvestBeneficiaryPercentage": "25",
        "harvestNetworkPercentage": "5",
        "harvestNetworkFeeSinkAddressV1": "NBUTOBVT5JQDCV6UEPCPFHWWOAOPOCLA5AY5FLI",
        "harvestNetworkFeeSinkAddress": "NAVORTEX3IPBAUWQBBI3I3BDIOS4AVHPZLCFC7Y",
        "maxTransactionsPerBlock": "6'000"
    },
    "plugins": {
        "accountlink": {
            "dummy": "to trigger plugin load"
        },
        "aggregate": {
            "maxTransactionsPerAggregate": "100",
            "maxCosignaturesPerAggregate": "25",
            "enableStrictCosignatureCheck": false,
            "enableBondedAggregateSupport": true,
            "maxBondedTransactionLifetime": "48h"
        },
        "lockhash": {
            "lockedFundsPerAggregate": "10'000'000",
            "maxHashLockDuration": "2d"
        },
        "locksecret": {
            "maxSecretLockDuration": "365d",
            "minProofSize": "0",
            "maxProofSize": "1024"
        },
        "metadata": {
            "maxValueSize": "1024"
        },
        "mosaic": {
            "maxMosaicsPerAccount": "1'000",
            "maxMosaicDuration": "3650d",
            "maxMosaicDivisibility": "6",
            "mosaicRentalFeeSinkAddressV1": "NC733XE7DF46Q7QYLIIZBBSCJN2BEEP5FQ6PAYA",
            "mosaicRentalFeeSinkAddress": "NCVORTEX4XD5IQASZQEHDWUXT33XBOTBMKFDCLI",
            "mosaicRentalFee": "500000"
        },
        "multisig": {
            "maxMultisigDepth": "3",
            "maxCosignatoriesPerAccount": "25",
            "maxCosignedAccountsPerAccount": "25"
        },
        "namespace": {
            "maxNameSize": "64",
            "maxChildNamespaces": "100",
            "maxNamespaceDepth": "3",
            "minNamespaceDuration": "30d",
            "maxNamespaceDuration": "1825d",
            "namespaceGracePeriodDuration": "30d",
            "reservedRootNamespaceNames": "symbol, symbl, xym, xem, nem, user, account, org, com, biz, net, edu, mil, gov, info",
            "namespaceRentalFeeSinkAddressV1": "NBDTBUD6R32ZYJWDEWLJM4YMOX3OOILHGDUMTSA",
            "namespaceRentalFeeSinkAddress": "NCVORTEX4XD5IQASZQEHDWUXT33XBOTBMKFDCLI",
            "rootNamespaceRentalFeePerBlock": "2",
            "childNamespaceRentalFee": "100000"
        },
        "restrictionaccount": {
            "maxAccountRestrictionValues": "100"
        },
        "restrictionmosaic": {
            "maxMosaicRestrictionValues": "20"
        },
        "transfer": {
            "maxMessageSize": "1024"
        }
    }
}

重要なのは以下の情報です。

Generation Hash

network.generationHashSeedはEthereumのChain IDに相当するようなもののイメージで、各ブロックチェーンネットワーク毎に固有の値となります。トランザクションへの署名を行う際等には、この値を含めて署名を行う必要があり、リプレイブロックのために設定されていると思います。

Epoch Adjustment

network.epochAdjustmentという項目があります。Symbolでは、前バージョンのNEM(NIS1)と同じく、時間についての実装には並々ならぬこだわりがあるようで、一般的なUNIX Timeとは別に、ジェネシスブロックの時刻を基準とした独自の時刻をブロックチェーンネットワーク内で整合性のある形で維持できるような工夫がされているようです。そのため、トランザクションへの署名を行う際等には、タイムスタンプとしてジェネシスブロックの時刻とUNIX Timeの時刻の差を調整するため、このEpoch Adjustmentという調整値を使う必要が出ると思います。

Currency MosaicId, Harvesting MosaicId

chain.currencyMosaicIdは、対象ネットワークの基軸トークンのIDです。SymbolではトークンがMosaicと呼ばれています。トークンはそれ自身のIdを持っていますが、それとは別にネームスペースというドメイン名のようなものをトークンに紐づけることができます。Symbolの基軸トークンはsymbol.xymというネームスペースが紐づけられており、ティッカーシンボルとしてはXYMと呼ばれることが多いでしょう。
chain.harvestingMosaicIdは、対象ネットワークのハーベストでの報酬となるトークンのIDです。NEM/Symbolでは、一般的なPoSブロックチェーンでのステーキングに相当するものをハーベストやハーベスティング等と呼びます。パブリックメインネット、パブリックテストネットでは基軸トークンとハーベスティングのトークンは同じですが、ブロックチェーンのコアエンジンとしては技術的にはこれらを別のトークンにすることもできる仕様になっているようです。(例えば、同エンジンを使用した独自ブロックチェーン等では、基軸トークンとハーベスティング報酬のトークンを別のトークンにするといったことも可能であるということです。)

ネットワーク固有の情報の扱い方について

これら、Generation Hash, Epoch Adjustment, Currency MosaicId, Harvesting MosaicId等の値は、ソースコード中にハードコードしている場合、テストネットがリセットされた際等に、適宜変更する必要があります。接続先ノードから適宜取得する形にしておくとテストネットがリセットされた際等に改めて修正する必要が無いため楽だと思いますが、その分余分な通信をすることになるというデメリットもあると思うので、要件と合わせて、ハードコードするか、ノードから適宜取得するか判断するとよいと思います。

SDKについて

SymbolブロックチェーンネットワークのAPIの実例を紹介しましたが、APIの生の値を使ってアプリ開発を進めていくのは結構大変だと思うので、SDKを使うのが現実的でしょう。

ここではJavaScript/TypeScript向けのSDKを紹介します。

レポジトリは以下の通りです。

インストール方法等はレポジトリの記載を参考にしていただくのが良いでしょう。

このSDKの特徴としては、rxjsが全面的に使用されているという点です。rxjsにあまりなじみがない方は、toPromiseを多用してPromiseとして扱うという方法もありだと思います。

具体的な実装例として、先ほど挙げたGeneration Hash, Epoch Adjustment, Currency MosaicId, Harvesting MosaicId等をノードから取得するための実装をSDKの使用方法の一例として以下に示しました。

import * as SymbolSdk from "symbol-sdk";

export const fetchGenerationHash = async (nodeUrl: string): Promise<string> => {
  const repositoryFactoryHttp = new SymbolSdk.RepositoryFactoryHttp(nodeUrl);
  const generationHash = repositoryFactoryHttp.getGenerationHash().toPromise();
  return generationHash;
};

export const fetchEpochAdjustment = async (
  nodeUrl: string
): Promise<number> => {
  const repositoryFactoryHttp = new SymbolSdk.RepositoryFactoryHttp(nodeUrl);
  const generationHash = repositoryFactoryHttp.getEpochAdjustment().toPromise();
  return generationHash;
};

export const fetchNetworkCurrencies = async (
  nodeUrl: string
): Promise<SymbolSdk.NetworkCurrencies> => {
  const repositoryFactoryHttp = new SymbolSdk.RepositoryFactoryHttp(nodeUrl);
  const networkCurrencies = await repositoryFactoryHttp
    .getCurrencies()
    .toPromise();
  return networkCurrencies;
};

export const fetchNetworkType = async (
  nodeUrl: string
): Promise<SymbolSdk.NetworkType> => {
  const repositoryFactoryHttp = new SymbolSdk.RepositoryFactoryHttp(nodeUrl);
  const networkType = await repositoryFactoryHttp.getNetworkType().toPromise();
  return networkType;
};

最初はこのようにSDK自体をSymbolSdk等の名前で丸ごとインポートして書いておくことで、迷ったらSymbolSdk.のようにキータイプすると、エディタで補完候補が出ると思うので、SDKに何があるのか、雰囲気がざっとつかめると思います。慣れてくると、必要なものを個別にインポートしていくほうがコーディングスピード的にも、アプリのパフォーマンス的にもよいと思います。そのあたりはお好みで取り組んでみてください。

アカウントの作成、Faucetからテスト用XYM取得、アカウント情報の取得

SDKのインストールと、Symbolブロックチェーン固有の情報の取得までできたら、アカウントの作成や、FaucetからのXYMの取得や、アカウント情報の取得等にトライしてみましょう。

アカウントの作成では、SymbolSdk.Account, SymbolSdk.PublicAccount, SymbolSdk.Address等を適宜利用していくことになると思います。名前やエディタの補完からある程度推測できるものが多いと思うので、うまく活用しながらコードを書いてみてください。ネットワークの種類に関する部分については、別のファイルに切り分けて書いている部分も併せて参考にしてみてください。

アカウント作成したら、Faucetからテスト用XYMを取得しましょう。
Faucetのアドレスは以下の通りですが、例えばhttps://testnet.symbol.tools/?recipient=TBK7XV2NHC466HZ63XC7RPESLNXFEGCSJ3ZZ2FY&amount=10000のようにクエリパラメーターでアドレスとほしいXYMの量を指定することで、それらが反映された状態でFaucetページのフォームを開けます。必要に応じてご活用ください。

Faucetからテスト用XYMを取得したら、アカウント情報の取得を試してみましょう。大まかな流れは以下の通りです。

  1. ノードのURLをセットしてnew SymbolSdk.RepositoryFactoryHttp(nodeUrl)のようにrepositoryFactoryHttpとしてインスタンス化する
  2. repositoryFactoryHttp配下のcreate***Repository()***Repositoryを作る
  3. ***Repository配下の適切なメソッドでrxjsのObservableな値を取得する
  4. Observableな値をtoPromise()でPromiseに変換する

この流れさえ押さえれば、後は、エディタの補完でさくさく書けると思います。引数に指定すべき型が最初は分からないと思うので、そこは適宜コードジャンプ(VSCodeならCTRLキー押しながら対象をクリック)とかでSDK内部の定義に飛んで型を見ながら書くのが速いでしょう。

実装例は以下の通りです。一部他のファイルにも実装があったりするので、全コードを俯瞰的に把握したい方は、記事冒頭のレポジトリを参考にしてください。

src/symbol/accounts/accounts.ts
import * as SymbolSdk from "symbol-sdk";
import { fetchNetworkType } from "../nodes";
import { convertNetworkToNetworkType, Network } from "../utils";

export function createNewAccount(network: Network) {
  const networkType = convertNetworkToNetworkType(network);
  return SymbolSdk.Account.generateNewAccount(networkType);
}

export function createAccountFromPrivateKey(
  network: Network,
  privateKey: string
) {
  const networkType = convertNetworkToNetworkType(network);
  return SymbolSdk.Account.createFromPrivateKey(privateKey, networkType);
}

export function createPublicAccountFromPublicKey(
  network: Network,
  publicKey: string
) {
  const networkType = convertNetworkToNetworkType(network);
  return SymbolSdk.PublicAccount.createFromPublicKey(publicKey, networkType);
}

export function createAddressFromRawAddress(address: string) {
  return SymbolSdk.Address.createFromRawAddress(address);
}

export function createAddressFromPublicKey(
  network: Network,
  publicKey: string
) {
  const networkType = convertNetworkToNetworkType(network);
  return SymbolSdk.Address.createFromPublicKey(publicKey, networkType);
}

export const fetchAccountInfo = async (
  nodeUrl: string,
  rawAddress: string
): Promise<SymbolSdk.AccountInfo> => {
  if (SymbolSdk.Address.isValidRawAddress(rawAddress) === false) {
    throw Error("Invalid address!");
  }
  const repositoryFactoryHttp = new SymbolSdk.RepositoryFactoryHttp(nodeUrl);
  const accountRepository = repositoryFactoryHttp.createAccountRepository();
  const networkType = await fetchNetworkType(nodeUrl);
  const address = SymbolSdk.Address.createFromRawAddress(rawAddress);
  if (networkType !== address.networkType) {
    throw Error("Different network!");
  }
  const accountInfo = await accountRepository
    .getAccountInfo(address)
    .toPromise();
  return accountInfo;
};

src/symbol/utils/utils.ts
import * as SymbolSdk from "symbol-sdk";

export type Network = "MAIN" | "TEST";

export function convertNetworkToNetworkType(
  network: Network
): SymbolSdk.NetworkType {
  if (network === "MAIN") {
    return SymbolSdk.NetworkType.MAIN_NET;
  } else if (network === "TEST") {
    return SymbolSdk.NetworkType.TEST_NET;
  } else {
    throw Error("Invalid network!");
  }
}

export function convertNetworkTypeToNetwork(
  networkType: SymbolSdk.NetworkType
): Network {
  if (networkType.toString() === SymbolSdk.NetworkType.MAIN_NET.toString()) {
    return "MAIN";
  } else if (
    networkType.toString() === SymbolSdk.NetworkType.TEST_NET.toString()
  ) {
    return "TEST";
  } else {
    throw Error("Invalid networkType!");
  }
}

export const FAUCET_URL = "https://testnet.symbol.tools/";

実際に取得できたアカウント情報は以下のようなデータとなっていると思います。

{
  version: 1,
  recordId: '61AC4777EA0D7471DAACE78D',
  address: Address {
    address: 'TBK7XV2NHC466HZ63XC7RPESLNXFEGCSJ3ZZ2FY',
    networkType: 152
  },
  addressHeight: UInt64 { lower: 5949, higher: 0 },
  publicKey: '653CEB2649A4E86D818E2FACD2F67D76268E2BDBF9DB277627B236BFCE4C2835',
  publicKeyHeight: UInt64 { lower: 6212, higher: 0 },
  accountType: 0,
  supplementalPublicKeys: SupplementalPublicKeys {
    linked: undefined,
    node: undefined,
    vrf: undefined,
    voting: undefined
  },
  activityBucket: [],
  mosaics: [
    Mosaic {
      id: MosaicId { id: Id { lower: 760461000, higher: 981735131 } },
      amount: UInt64 { lower: 739170308, higher: 2 }
    }
  ],
  importance: UInt64 { lower: 0, higher: 0 },
  importanceHeight: UInt64 { lower: 0, higher: 0 }
}

UInt64の扱いについて

特徴的なのはUInt64でしょうか。一般的なnumberでは扱いきれないくらい大きな数字を扱っているところや、IDとしての意味合いの強い数字が扱われているところで頻繁に登場します。

  • 残高やブロック高さ等の明確な数字は、.toString()で文字列化して、BigInt(str)のようにbigintにしておくのがJavaScript/TypeScriptの世界だと自然かなと思います。
  • IDとしての意味合いが強い項目については、.toHex()で 16進数の文字列(Hex String)化して表示したりするのがSymbolの世界では一般的なようです。

また、IDとしてのUInt64を扱うときには、UInt64どうしを直接===等で一致判定等すると、適切な比較ができないので、Hex String化して比較する等、適切な方法で判定するように注意しましょう。

このUInt64は直感的ではないという話はやはり出ていて、今後のSDKの開発では何らかの変更があるかもみたいな話も聞きますが、現状こういう実装になっていることは知っておく必要があると思います。

モザイク(≒トークン)情報の取得について

なお、アカウント情報を取得した結果、保有しているモザイク(≒トークン)の情報は、ネームスペースや可分性(divisibility ... 表示されている整数値に対し10^(-)を掛けた数値が実際の残高等の数値となるの整数値)の情報が含まれていないため、それらも追加で取得したいといったことを思うと思います。

そのような場合、Mosaicについて、Accountの時と同様に以下のような実装例が考えられるでしょう。

src/symbol/mosaics/mosaics.ts
import * as SymbolSdk from "symbol-sdk";

export type MosaicDetail = {
  id: string;
  supply: bigint;
  startHeight: bigint;
  ownerAddress: string;
  flag: {
    supplyMutable: boolean;
    transferable: boolean;
    restrictable: boolean;
    revokable: boolean;
  };
  divisibility: number;
  duration: bigint;
  names?: {
    id: string;
    name: string;
  }[];
};

export const fetchMosaicsDetails = async (
  nodeUrl: string,
  ids: string[]
): Promise<MosaicDetail[]> => {
  const mosaicsNames = await fetchMosaicsNames(nodeUrl, ids);
  const mosaics = await fetchMosaics(nodeUrl, ids);
  const mosaicsDetails = mosaics.map((mosaic, index) => {
    return {
      id: mosaic.id.toHex(),
      supply: BigInt(mosaic.supply.toString()),
      startHeight: BigInt(mosaic.startHeight.toString()),
      ownerAddress: mosaic.ownerAddress.plain(),
      flag: {
        supplyMutable: mosaic.flags.supplyMutable,
        transferable: mosaic.flags.transferable,
        restrictable: mosaic.flags.restrictable,
        revokable: mosaic.flags.revokable,
      },
      divisibility: mosaic.divisibility,
      duration: BigInt(mosaic.duration.toString()),
      names: mosaicsNames[index].names.map((name) => {
        return {
          id: name.namespaceId.toHex(),
          name: name.name,
        };
      }),
    };
  });
  return mosaicsDetails;
};

export const fetchMosaicsNames = async (nodeUrl: string, ids: string[]) => {
  const repositoryFactoryHttp = new SymbolSdk.RepositoryFactoryHttp(nodeUrl);
  const namespaceRepository = repositoryFactoryHttp.createNamespaceRepository();
  const mosaicIds = ids.map((id) => new SymbolSdk.MosaicId(id));
  const mosaicsNames = namespaceRepository
    .getMosaicsNames(mosaicIds)
    .toPromise();
  return mosaicsNames;
};

export const fetchMosaics = async (nodeUrl: string, ids: string[]) => {
  const mosaics = await Promise.all(ids.map((id) => fetchMosaic(nodeUrl, id)));
  return mosaics;
};

export const fetchMosaic = async (nodeUrl: string, id: string) => {
  const repositoryFactoryHttp = new SymbolSdk.RepositoryFactoryHttp(nodeUrl);
  const mosaicRepository = repositoryFactoryHttp.createMosaicRepository();
  const mosaicId = new SymbolSdk.MosaicId(id);
  const mosaic = mosaicRepository.getMosaic(mosaicId).toPromise();
  return mosaic;
};

ブロックチェーンから値を参照するための実装における傾向

***Repository内のメソッドから必要な情報を取得する方法をアカウント情報の取得やモザイク情報の取得を例に説明しましたが、実用的な開発を行いたい場合、APIを複数組み合わせる必要が出る場合がよくあります。特にモザイクやネームスペースが関連する情報の参照では、情報を再帰的に参照する手間が増える気がしています。

そういったところはSDKの中の***Serviceのようなものが便利な機能をサポートしてくれている場合が時折見受けられます。***Repositoryの中だけでよい感じの機能が見つけられなかったときは、***Serviceの中に自分が使いたい機能が無いかコード補完等で探ってみるとよいかもしれません。(***RepositoryはAPI単発の機能がラップされているイメージで、***Serviceは複数のAPIの機能の組み合わせの結果も内容によってはサポートしているイメージです。)

クライアントサイドジョインに疲れたら、色々試してみるとよいかもしれません。便利な機能があったら、ぜひ、教えてください。

トランザクションの送信

ブロックチェーンからの情報の参照はある程度説明できたと思うので、次はトランザクションの送信について説明します。

おそらくどのブロックチェーンも比較的共通だと思うのですが、トランザクションの送信は以下の3プロセスに分割できると思います。

  1. トランザクションデータ生成 ... SymbolSdk.Transaction型のデータを作る。
  2. 署名 ... 1のデータからSymbolSdk.SignedTransaction型のデータを作る。
  3. 署名済トランザクションの送信 ... 2のデータをノードに対して送信する。

1はトランザクションの種類に応じて分ける必要がありますが、2, 3はある程度共通化して使いまわせると思うので、そのように実装しておくと、後々楽だと思います。

トランザクションの種類 ... アグリゲートトランザクションについて

大きく分けて、普通のトランザクションとアグリゲートトランザクション(複数のトランザクションが同時実行されることが保証される形のトランザクション)の二種類がまずあります。そしてアグリゲートトランザクションの中にもトランザクション送信時点で全ての署名がそろっているアグリゲートコンプリートトランザクションと、送信時点では全ての署名はそろっておらず、必要なアカウントによって署名される必要があるアグリゲートボンデッドトランザクションの二種類があります。

  1. 普通のトランザクション 1種類だけのトランザクションを含むシンプルなトランザクション
  2. アグリゲートボンデッドトランザクション ... トランザクション送信後に、他のアカウントによる承認的な意味合いの署名が必要な、複数のトランザクションを内包したトランザクション
  3. アグリゲートコンプリートトランザクション ...トランザクション送信時に必要な全ての署名がそろっている状態の、複数のトランザクションを内包したトランザクション

今回の記事では一旦普通のトランザクションに絞って説明しますが、普通のトランザクションの基本がある程度身についていれば、ほんの少しの応用で、アグリゲートトランザクションを扱うことができると思うのでぜひチャレンジしてみてください。

トランザクションの種類 ... 多彩なトランザクションの種類

Symbolでは多様なトランザクションの種類がデフォルトで用意されており、それらトランザクションを組み合わせて実行することで、ブロックチェーンの強みを生かした開発をお手軽に実現できることが強みです。

トランザクションの種類は以下資料を参考にしてみてください。

公式ドキュメントのトランザクションに関する情報の追い方のすすめ

まず、上記のトランザクションの種類の内、どのトランザクションを実行したいか整理しましょう。どのトランザクションを実行したいか明確になったら、ドキュメントのガイドの中から、一致するトランザクションのページに行きましょう。対象ページに行ったら、ページ内の説明で雰囲気をつかんだ後は、実際にコードを書き始めるときは、GitHubのコードのリンクに飛んで、GitHubの参考コードを一通り見るのがおすすめです。その際、ノードのURLやgenerationHash, epochAdjustment等の情報は、必ずしも最新化されて適切な実装になっていない場合もあるので、本記事前半で説明した内容も参考にしていただき、適宜修正を加える必要があることもあると思います。

単純なトークンの転送トランザクションを例に具体的な実装を説明

  1. まずトランザクション一覧を見ます。その結果TransferTransactionが該当すると判断できます。
  2. 次にガイドを見ます。その結果、転送が該当するように見えたので開き、さらにその先で、2つのアカウント間でモザイクとメッセージを送信が該当するように見えたので、そのページを見ながら雰囲気を把握します。
  3. 「SDKを使用する」の箇所に注目します。一通り雰囲気を把握するためにざっと目を通しましょう。
  4. 雰囲気が把握できたら、「View Code」のリンクをクリックしてGitHub上のサンプルコードに飛びましょう。このサンプルコードをじっくり見ながら、実装していくのが良いでしょう。

トランザクションデータの作成、署名、送信

トランザクションデータの作成では、***Transaction.create***(必要な引数)のような形でデータを作る関数が用意されているので、適切なものを補完等で選んで、適切な引数をセットしてトランザクションデータを作ることになるでしょう。

その際、epochAdjustment, networkTypeをネットワークに合わせて適切にセットしてあげることが重要です。

手数料の設定は、色々な方法があると思いますが、手間がかからず現実的な手数料でトランザクションを通せる方法は、ノードからブロックチェーン上の最近の手数料の情報を取得しておいてそれを使うという方法が考えられます。手数料の設定は以下の5通りが比較的シンプルに使えるようで、個人的にはmedianFeeMultiplierが使いやすいかなと思っていますが、ここは奥が深そうです。

  • medianFeeMultiplier
  • averageFeeMultiplier
  • highestFeeMultiplier
  • lowestFeeMultiplier
  • minFeeMultiplier

署名の際には、署名するアカウントの秘密鍵と、対象ネットワークのgenerationHashが必要であることに注意が必要です。

アナウンスの際には、WebSocketを利用するlistenerを作って、transactionRepositoryだけでなくreceiptRepositoryも作ってそれらを使ってtransactionServiceを作ってtransactionServiceの中のannunce機能を使うと、トランザクションが承認されたらレスポンスが返ってくるような形でトランザクションの送信ができて便利なようです。

アナウンスの際、サーバーサイドのNode.jsで動作させる場合は普通にnodeUrlを指定してrepositoryFactoryHttpをインスタンス化すればよいのですが、ブラウザ上で動作させるクライアントサイドの場合はrepositoryFactoruHttpをインスタンス化する際にブラウザ用のWebSocketを明示的に設定してやる必要があるようです。

実装例は以下をご参照ください。

src/symbol/transactions/transactions.ts
import * as SymbolSdk from "symbol-sdk";
import { createAccountFromPrivateKey } from "../accounts";
import {
  fetchEpochAdjustment,
  fetchGenerationHash,
  fetchNetworkCurrencies,
  fetchNetworkType,
} from "../nodes";
import { convertNetworkTypeToNetwork } from "../utils";

export const fetchTransactionFees = async (
  nodeUrl: string
): Promise<SymbolSdk.TransactionFees> => {
  const repositoryFactoryHttp = new SymbolSdk.RepositoryFactoryHttp(nodeUrl);
  const networkRepository = repositoryFactoryHttp.createNetworkRepository();
  const transactionFees = await networkRepository
    .getTransactionFees()
    .toPromise();
  return transactionFees;
};

export const buildTransferWithPlainMessageTransaction = async (
  nodeUrl: string,
  rawAddress: string,
  relativeAmount: number,
  rawMessage: string | undefined
): Promise<SymbolSdk.Transaction> => {
  const networkType = await fetchNetworkType(nodeUrl);
  const epochAdjustment = await fetchEpochAdjustment(nodeUrl);
  const transactionFees = await fetchTransactionFees(nodeUrl);
  const currency = (await fetchNetworkCurrencies(nodeUrl)).currency;
  if (!SymbolSdk.Address.isValidRawAddress(rawAddress)) {
    throw Error("Invalid address!");
  }
  if (relativeAmount < 0 || relativeAmount >= 8000000000) {
    throw Error("Invalid amount!");
  }
  let message: SymbolSdk.Message;
  if (rawMessage === "undefined") {
    message = SymbolSdk.EmptyMessage;
  } else {    
    message = SymbolSdk.PlainMessage.create(rawMessage);
  }
  const transferTransaction = SymbolSdk.TransferTransaction.create(
    SymbolSdk.Deadline.create(epochAdjustment),
    SymbolSdk.Address.createFromRawAddress(rawAddress),
    [currency.createRelative(relativeAmount)],
    message,
    networkType
  ).setMaxFee(transactionFees.medianFeeMultiplier);
  return transferTransaction;
};

export const signTransactionWithPrivateKey = async (
  nodeUrl: string,
  privateKey: string,
  unsignedTransaction: SymbolSdk.Transaction
): Promise<SymbolSdk.SignedTransaction> => {
  const networkType = await fetchNetworkType(nodeUrl);
  const generationHash = await fetchGenerationHash(nodeUrl);
  const account = createAccountFromPrivateKey(
    convertNetworkTypeToNetwork(networkType),
    privateKey
  );
  const signedTransaction = account.sign(unsignedTransaction, generationHash);
  return signedTransaction;
};

export const announceTransaction = async (
  nodeUrl: string,
  signedTransaction: SymbolSdk.SignedTransaction,
  isClient: boolean
): Promise<SymbolSdk.Transaction> => {
  let repositoryFactoryHttp: SymbolSdk.RepositoryFactoryHttp;
  if (isClient) {
    repositoryFactoryHttp = new SymbolSdk.RepositoryFactoryHttp(nodeUrl, {
      websocketUrl: `${nodeUrl.replace("http", "ws")}/ws`,
      websocketInjected: WebSocket,
    });
  } else {
    repositoryFactoryHttp = new SymbolSdk.RepositoryFactoryHttp(nodeUrl);
  }
  const transactionRepository =
    repositoryFactoryHttp.createTransactionRepository();
  const receiptRepository = repositoryFactoryHttp.createReceiptRepository();
  const transactionService = new SymbolSdk.TransactionService(
    transactionRepository,
    receiptRepository
  );
  const listener = repositoryFactoryHttp.createListener();
  await listener.open();
  try {
    const transaction = await transactionService
      .announce(signedTransaction, listener)
      .toPromise();
    return transaction;
  } catch (error) {
    console.error(error);
    throw error;
  } finally {
    listener.close();
  }
};

export const sendTransferWithPlainMessageTransaction = async (
  nodeUrl: string,
  rawAddress: string,
  relativeAmount: number,
  rawMessage: string | undefined,
  privateKey: string,
  isClient: boolean
): Promise<SymbolSdk.Transaction> => {
  const unsignedTransaction = await buildTransferWithPlainMessageTransaction(
    nodeUrl,
    rawAddress,
    relativeAmount,
    rawMessage
  );
  console.dir(unsignedTransaction, { depth: null });
  console.log("fee: %s", unsignedTransaction.maxFee.toString());
  const signedTransaction = await signTransactionWithPrivateKey(
    nodeUrl,
    privateKey,
    unsignedTransaction
  );
  console.dir(signedTransaction, { depth: null });
  const transaction = await announceTransaction(
    nodeUrl,
    signedTransaction,
    isClient
  );
  console.dir(transaction, { depth: null });
  return transaction;
};

まとめ

JavaScript/TypeScriptの世界で一般的なWeb開発やブロックチェーン関連の開発に関わっているエンジニアの方を意識して、Symbolの特有のはまりやすいところやSDKの使い方やドキュメントとの付き合い方等のコツを意識してまとめてみましたがいかがだったでしょうか?

Symbolブロックチェーンを使うところは比較的ワンパターンでシンプルなので、開発者にとっては、「どのようなサービスで何を実現したいかということを考えるところ」に注力できる良さがあると思います。

とはいえ、初見だとはまりやすいよなあと思うところだったり、SDKを使う上でのコツやドキュメントを読む上でのコツ等も結構あると思うので、そのあたりがこの記事でうまくフォローできていたらうれしいです。

なお、私自身も今、現在進行形で、作っているものもあり、(Advent Calendarには間に合いませんでしたが、)何らかの形で公開したいと思っています。開発者の皆様もぜひ、Symbolでブロックチェーンの良さを生かした素敵なサービスを開発してみてください!

35
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
35
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?