複数のSymbolノードに同じトランザクションを送信して不達回避する

複数のSymbolノードに同じトランザクションを送信して不達回避する

タグ
Symbol
公開日
May 9, 2023

挨拶

こんにちは。OpeningLineの坂本です。

今回は、複数のノードに同一のトランザクションを同時に送信しても問題ないんじゃないか、というテーマでやってみようと思います。

背景

Symbolを使ったアプリを作る場合、トランザクションを送信する際は、単一のノードを指定していると思います。

しかし、この方法ではそのノードに障害が発生した場合にはトランザクションを送信できなくなってしまいます。

この問題を解決するためには、ひとつ送信を試してダメなら次のノードに送信するなどの方法がありますが、実装が複雑になるという懸念があります。

そこで、複数のノードに同じトランザクションを送信するのはどうでしょうか。同じトランザクションというのは、トランザクションハッシュが同じ、ということです。

トランザクションハッシュが同じである2つのトランザクションが承認されるといったことは、これまで聞いたことがありません。

しかし、もしかしたら、送ったトランザクションそれぞれが別々に処理される可能性が考えられます。例えば、同じ送金が複数回行われるなどです。

というわけで、これを実際にやってみて、確認しましょう。

コード

symbol-sdkは2系を使います。

まずは何の変哲もない普通の転送トランザクションを作ります。

const transferTransaction = TransferTransaction.create(
    Deadline.create(epochAdjustment),
    recipientAddress,
    [currency.createRelative(100)],
    PlainMessage.create(''),
    networkType,
    UInt64.fromUint(17600),
  );

  const account = Account.createFromPrivateKey(PRIVATE_KEY, networkType);
  const signedTransaction = account.sign(
    transferTransaction,
    networkGenerationHash,
  );

  console.log('Transaction Hash:', signedTransaction.hash);

次に、このトランザクションを送信する処理を関数にして、

async function send(nodeUrl: string, signedTransaction: SignedTransaction) {
  try {
    const repositoryFactory = new RepositoryFactoryHttp(nodeUrl);

    const transactionRepository = repositoryFactory.createTransactionRepository();
    const response = await firstValueFrom(transactionRepository.announce(signedTransaction));
    console.log(response);
  } catch (e: any) {
    console.log(e);
  }
}

複数のノードに連続して送信します。

const NODE_URL_LIST = [
	"NODE_1",
	"NODE_2",
	...
];

await Promise.all(NODE_URL_LIST.map((nodeUrl) => send(nodeUrl, signedTransaction)));

あとは残高をチェックする処理を作ったり。

async function checkBalance() {
  const repositoryFactory = new RepositoryFactoryHttp(NODE_URL);
  const accountHttp = repositoryFactory.createAccountRepository();
  const networkType = await firstValueFrom(repositoryFactory.getNetworkType());
  const address = Account.createFromPrivateKey(PRIVATE_KEY, networkType).address;
  const accountInfo = await firstValueFrom(accountHttp.getAccountInfo(address));
  console.log('Balance:', accountInfo.mosaics[0].amount.compact());
}
全体のコードです。
import {
  Account,
  Address,
  Deadline,
  PlainMessage,
  RepositoryFactoryHttp,
  SignedTransaction,
  TransferTransaction,
  UInt64,
} from 'symbol-sdk';
import {
  firstValueFrom
} from "rxjs";
import {
  NODE_URL,
  NODE_URL_LIST,
  ADDRESS_RECEIVER_1,
  PRIVATE_KEY,
} from './env.js';

async function checkBalance() {
  const repositoryFactory = new RepositoryFactoryHttp(NODE_URL);
  const accountHttp = repositoryFactory.createAccountRepository();
  const networkType = await firstValueFrom(repositoryFactory.getNetworkType());
  const address = Account.createFromPrivateKey(PRIVATE_KEY, networkType).address;
  const accountInfo = await firstValueFrom(accountHttp.getAccountInfo(address));
  console.log('Balance:', accountInfo.mosaics[0].amount.compact());
}

async function createTransaction(): Promise<SignedTransaction> {
  const repositoryFactory = new RepositoryFactoryHttp(NODE_URL);
  const epochAdjustment = await firstValueFrom(repositoryFactory.getEpochAdjustment());
  const networkType = await firstValueFrom(repositoryFactory.getNetworkType());
  const networkGenerationHash = await firstValueFrom(repositoryFactory.getGenerationHash());
  const { currency } = await firstValueFrom(repositoryFactory.getCurrencies());
  const recipientAddress = Address.createFromRawAddress(ADDRESS_RECEIVER_1);

  const transferTransaction = TransferTransaction.create(
    Deadline.create(epochAdjustment),
    recipientAddress,
    [currency.createRelative(100)],
    PlainMessage.create('plain message'),
    networkType,
    UInt64.fromUint(2000000),
  );

  const account = Account.createFromPrivateKey(PRIVATE_KEY, networkType);
  const signedTransaction = account.sign(
    transferTransaction,
    networkGenerationHash,
  );

  console.log('Transaction Hash:', signedTransaction.hash);

  return signedTransaction;
}

async function send(nodeUrl: string, signedTransaction: SignedTransaction) {
  try {
    const repositoryFactory = new RepositoryFactoryHttp(nodeUrl);

    const transactionRepository = repositoryFactory.createTransactionRepository();
    const response = await firstValueFrom(transactionRepository.announce(signedTransaction));
    console.log(response);
  } catch (e: any) {
    console.log(e);
  }
}


await checkBalance();

const tx = await createTransaction();

await Promise.all(NODE_URL_LIST.map((nodeUrl) => send(nodeUrl, tx)));

await checkBalance();

await new Promise((resolve) => setTimeout(resolve, 30000));

await checkBalance();

実行

コンソールにはこのような表示がなされます。

# 送信前の残高
Balance: 6642440853

Transaction Hash: C35D138777648537FC2C7076A1F2FE243EA3D1168C103297E1942BAE817E1AB0

# 送信が成功したノードからは以下のログが出る
TransactionAnnounceResponse {
  message: 'packet 9 was pushed to the network via /transactions'
}

# 送信が失敗したノードのログ例
FetchError: request to https://<node>:3001/transactions failed, reason: unable to verify the first certificate

# 送信直後の残高
Balance: 6642440853

# トランザクションが承認された後の残高
Balance: 6540440913

いくつかのノードで送信が失敗しましたが、成功したものが複数表示されました。

考察

不達について

今回は、不達になったノードが1つありました。証明書の更新がなされていないようなメッセージでした。もし、単一のノードのみ使っていた場合、このようなエラーが発生するとアプリが停止してしまう恐れがあります。

残高について

実行前の残高は6,642,440,853で、実行後は6,540,440,913となり、100XYMとトランザクション手数料だけ減っているのがわかります。

今回サンプルに使った転送トランザクションは、100XYMを送信していますので、1回分しか減っていないことがわかります。

トランザクションハッシュ

今回複数のノードに送ったトランザクションは、全て同じトランザクションハッシュなのですが、これをAPIに問い合わせてみます。

{
  "meta": {
    "height": "436618",
    "hash": "C35D138777648537FC2C7076A1F2FE243EA3D1168C103297E1942BAE817E1AB0",
    "merkleComponentHash": "C35D138777648537FC2C7076A1F2FE243EA3D1168C103297E1942BAE817E1AB0",
    "index": 0,
    "timestamp": "16016068533",
    "feeMultiplier": 10526
  },
  "transaction": {
    "size": 190,
    "signature": "1096C10F83F4FDB6431FB403706B74F545BCA530F7908340CAF84045B6DBDB659412F8DFEA75DA4891524FB2419EC84A7ECB0E67E903650AD3FF8F43FAE7410F",
    "signerPublicKey": "97F0F997BB59790239CEF1B284A158EFCD87BF7BBA1E0AFD0B54B26C05D15123",
    "version": 1,
    "network": 152,
    "type": 16724,
    "maxFee": "2000000",
    "deadline": "16023266306",
    "recipientAddress": "98223AF34A98119217DC2427C6DE7F577A33D8242A2F54C3",
    "message": "00706C61696E206D657373616765",
    "mosaics": [{ "id": "72C0212E67A08BCE", "amount": "100000000" }]
  },
  "id": "64549BEBDD6A080924107E68"
}

当たり前ですが、1つのトランザクション情報しか返ってきません。

検証方法について

トランザクションが1つしか承認されないことを確認するためには、どんな検証方法があるでしょうか。何かアイディアがありましたらぜひコメントください。

まとめ

Symbolを使ったアプリでトランザクションを送信する際、単一のノードを指定すると障害が発生した場合にトランザクションを送信できなくなる問題がありました。

この問題を解決するために、同じトランザクションを複数のノードに送信することが考えられますが、同じトランザクションが複数回承認されるかもしれませんでした。

これを試してみましたが、今回はそのようなことをは起きず、無事に1回だけ処理されました。