Symbolブロックチェーンは、PoSでの確率的ファイナリティに基づく新規ブロック生成と、定期的な投票ベースの確定的ファイナリティに基づくファイナライズの二段階のコンセンサスで、ネットワーク全体のデータの同一性を実現する仕組みです。
(あまり多くはないと思いますが、)トランザクションを送信後、確定的ファイナリティが確保されるまでの、最大6時間程度の期間は、一度ブロックに組み込まれたトランザクションが、ブロックの巻き戻り等によって、無かったことになってしまう可能性もあります。要件としてそのような状況を許容できない場合、送信済トランザクションのハッシュ及びペイロードを残しておくことで、ブロックの巻き戻り等によってトランザクションが無かったことになってしまった場合でも、(トランザクションの期限以内、残高不足等の非整合が無い場合に限られますが、)まったく同じトランザクションを(ユーザーの署名を再度求めることなく)再送することができます。
この記事では、Version2系SDKを使用する場合、REST APIを直接呼び出す場合のそれぞれのケースで、具体的にどのような実装で(ユーザーの署名を再度求めることなく)トランザクションの再送が実現できるかを説明します。
シンプルなトランザクション送信例
まず、以下のようなシンプルなトランザクション送信例を考えてみます。
import {
Account,
defaultChronoUnit,
Deadline,
NetworkType,
TransferTransaction,
Address,
MosaicId,
Mosaic,
UInt64,
PlainMessage,
RepositoryFactoryHttp,
SignedTransaction,
} from "symbol-sdk";
import { firstValueFrom } from "rxjs";
const blockExplorerUrl = "https://testnet.symbol.fyi"; // テストネットのブロックエクスプローラーのURLを指定(デバッグしやすいようブロックエクスプローラーのトランザクションのリンクをconsole.logする等)
const networkType = NetworkType.TEST_NET; // テストネットを指定
(async () => {
const activeNodeUrl = "https://sym-test-03.opening-line.jp:3001";
const repositoryFactoryHttp = new RepositoryFactoryHttp(activeNodeUrl); // ノードのURLを指定してREST APIを呼び出すためのインスタンスを作成
console.log({ activeNodeUrl });
// REST APIを呼び出してネットワーク関連の情報を取得
const networkRepository = repositoryFactoryHttp.createNetworkRepository();
const networkProperties = await firstValueFrom(
networkRepository.getNetworkProperties()
);
const epochAdjustment = networkProperties.network.epochAdjustment;
const generationHash = networkProperties.network.generationHashSeed;
const networkCurrencyMosaicId = networkProperties.chain.currencyMosaicId
?.replace(/0x/g, "")
.replace(/'/g, "")
.toUpperCase();
// ネットワーク関連の情報が取得できているかチェック
if (!epochAdjustment) {
throw Error("epochAdjustment is invalid");
}
if (!generationHash) {
throw Error("generationHash is invalid");
}
if (!networkCurrencyMosaicId) {
throw Error("networkCurrencyMosaicId is invalid");
}
// REST APIを呼び出して基軸通貨(XYM)のトークンの情報を取得
const mosaicRepository = repositoryFactoryHttp.createMosaicRepository();
const mosaicId = new MosaicId(networkCurrencyMosaicId);
const mosaicInfo = await firstValueFrom(mosaicRepository.getMosaic(mosaicId));
const divisibility = mosaicInfo.divisibility; // トークン数量として小数点以下扱える桁数, XYMは6桁
// トークンとメッセージの転送トランザクションに必要な情報を作成
// トランザクションの有効期限6時間
const deadline = Deadline.create(
parseInt(epochAdjustment),
6,
defaultChronoUnit
);
// 送信先アドレス(=受信者アドレス)...仮にFaucetのアドレスを指定
const recipientAddress = Address.createFromRawAddress(
"TARDV42KTAIZEF64EQT4NXT7K55DHWBEFIXVJQY"
);
// 送信するトークンの種類とトークンの数量(例. 10 XYMの場合)
const relativeAmount = 10;
const absoluteAmount = Math.round(
relativeAmount * Math.pow(10, divisibility)
); // システム的にはdivisibility乗した整数で扱われることに注意
const uint64AbsoluteAmount = UInt64.fromUint(absoluteAmount); // 旧SDKではSDKのUInt64を使う必要があることに注意
const mosaic = new Mosaic(mosaicId, uint64AbsoluteAmount);
const mosaics = [mosaic];
// 送信するメッセージ(平文メッセージの場合)
const rawMessage = "Symbolブロックチェーンの世界へようこそ!";
const message = PlainMessage.create(rawMessage);
// 手数料率 ... デフォルトのノードだと100。手数料の把握できているノードを指定してこの数字をそのノードの値に変更することで手数料を節約できる
const feeMultiplier = 100;
// トランザクションデータ生成
const transferTransaction = TransferTransaction.create(
deadline,
recipientAddress,
mosaics,
message,
networkType
).setMaxFee(feeMultiplier);
// トランザクション送信者(&今回の場合は同時に署名者)のアカウント情報を秘密鍵から復元
const privateKey = process.env.QIITA_ADVENT_CALENDAR_PRIVATE_KEY!; // ここは各自秘密鍵を適切に指定してください
const account = Account.createFromPrivateKey(privateKey, networkType);
// トランザクションに署名
const signedTransferTransaction = account.sign(
transferTransaction,
generationHash
);
// トランザクションのハッシュとペイロードを表示(もし再アナウンスが必要な場合に使う)
const transactionHash = signedTransferTransaction.hash;
const transactionPayload = signedTransferTransaction.payload;
const transactionType = signedTransferTransaction.type;
console.log({ transactionHash });
console.log({ transactionPayload });
console.log({ transactionType });
// WebSocketでのトランザクション送信結果の監視設定
const listener = repositoryFactoryHttp.createListener();
await listener.open().then(() => {
console.log("listener opened");
// エラー監視 ... 手数料が足りていない場合、ここのエラーにも情報が流れてこないことに注意が必要!
listener.status(account.address).subscribe((error) => {
console.error(error);
});
// 未承認(=0conf)トランザクション監視
listener
.unconfirmedAdded(account.address)
.subscribe((unconfirmedTransaction) => {
if (unconfirmedTransaction.transactionInfo?.hash === transactionHash) {
console.log({ unconfirmedTransaction });
}
});
// 承認済み(=1conf)トランザクション監視し、確認でき次第監視を終了
listener.confirmed(account.address).subscribe((confirmedTransaction) => {
if (confirmedTransaction.transactionInfo?.hash === transactionHash) {
console.log({ confirmedTransaction });
console.log("transaction has been confirmed");
listener.close();
console.log("listener closed");
}
});
// 新規ブロック監視 ... これを入れないとWebSocketの応答がない時間が長くなるとタイムアウトで接続が切れてしまうので注意
listener.newBlock().subscribe((newBlock) => {
console.log({ newBlock });
});
});
// REST APIを呼び出してトランザクションを送信するための事前準備
const transactionRepository =
repositoryFactoryHttp.createTransactionRepository();
// REST APIを呼び出してトランザクションを送信する
console.log("sending transaction...");
const sendingTransactionResponse = await firstValueFrom(
transactionRepository.announce(signedTransferTransaction)
);
console.log("sendingTransactionResponse: ", sendingTransactionResponse);
console.log(
"transaction block explorer link: ",
`${blockExplorerUrl}/transactions/${transactionHash}`
);
})();
上記コードでトランザクション送信後、一度はブロックにトランザクションが組み込まれたものの、ファイナライズ前にブロックの巻き戻り等によって、送信済トランザクションが無効になってしまった場合、以下の箇所でトランザクションのハッシュ、ペイロード、トランザクションタイプ、トランザクションへの署名を行ったアカウントの公開鍵、ネットワークの種類(今回の場合Symbolテストネット)を保存していたと仮定して、どうやってトランザクションを再送するかを説明します。
// トランザクションのハッシュとペイロードとトランザクションタイプを表示(もし再アナウンスが必要な場合に使う)
const transactionHash = signedTransferTransaction.hash;
const transactionPayload = signedTransferTransaction.payload;
const transactionType = signedTransferTransaction.type;
console.log({ transactionHash });
console.log({ transactionPayload });
console.log({ transactionType });
// トランザクション送信者(&今回の場合は同時に署名者)のアカウント情報を秘密鍵から復元
const privateKey = process.env.QIITA_ADVENT_CALENDAR_PRIVATE_KEY!; // 秘密鍵を適切に指定
const account = Account.createFromPrivateKey(privateKey, networkType);
const networkType = NetworkType.TEST_NET; // テストネットを指定
Version2系のSDKを使用してトランザクションを再送する方法
署名済トランザクションのSignedTransaction
を再構成する方法がシンプルでわかりやすいでしょうか。再構成は以下のような実装で可能なようです。
// トランザクションのpayload, hash, type, 署名者の公開鍵, ネットワークタイプからSDKを用いて署名済みトランザクションを復元
const restoredSignedTransaction = new SignedTransaction(
transactionPayload,
transactionHash,
account.publicKey,
transactionType,
networkType
);
署名済トランザクションが再構成できればトランザクションのネットワークへのアナウンスはVersion2系のSDKを使用して行う方法と同じです。
// REST APIを呼び出して復元したトランザクションをSDKを用いて再送
console.log("resending restored transaction...");
const resendingRestoredTransactionResponse = await firstValueFrom(
transactionRepository.announce(restoredSignedTransaction)
);
console.log(
"resendingRestoredTransactionResponse: ",
resendingRestoredTransactionResponse
);
console.log(
"transaction block explorer link: ",
`${blockExplorerUrl}/transactions/${transactionHash}`
);
全コード一式は以下をご参照ください。
import {
Account,
defaultChronoUnit,
Deadline,
NetworkType,
TransferTransaction,
Address,
MosaicId,
Mosaic,
UInt64,
PlainMessage,
RepositoryFactoryHttp,
SignedTransaction,
} from "symbol-sdk";
import { firstValueFrom } from "rxjs";
import axios from "axios";
const blockExplorerUrl = "https://testnet.symbol.fyi"; // テストネットのブロックエクスプローラーのURLを指定(デバッグしやすいようブロックエクスプローラーのトランザクションのリンクをconsole.logする等)
const networkType = NetworkType.TEST_NET; // テストネットを指定
(async () => {
const activeNodeUrl = "https://sym-test-03.opening-line.jp:3001";
const repositoryFactoryHttp = new RepositoryFactoryHttp(activeNodeUrl); // ノードのURLを指定してREST APIを呼び出すためのインスタンスを作成
console.log({ activeNodeUrl });
// REST APIを呼び出してネットワーク関連の情報を取得
const networkRepository = repositoryFactoryHttp.createNetworkRepository();
const networkProperties = await firstValueFrom(
networkRepository.getNetworkProperties()
);
const epochAdjustment = networkProperties.network.epochAdjustment;
const generationHash = networkProperties.network.generationHashSeed;
const networkCurrencyMosaicId = networkProperties.chain.currencyMosaicId
?.replace(/0x/g, "")
.replace(/'/g, "")
.toUpperCase();
// ネットワーク関連の情報が取得できているかチェック
if (!epochAdjustment) {
throw Error("epochAdjustment is invalid");
}
if (!generationHash) {
throw Error("generationHash is invalid");
}
if (!networkCurrencyMosaicId) {
throw Error("networkCurrencyMosaicId is invalid");
}
// REST APIを呼び出して基軸通貨(XYM)のトークンの情報を取得
const mosaicRepository = repositoryFactoryHttp.createMosaicRepository();
const mosaicId = new MosaicId(networkCurrencyMosaicId);
const mosaicInfo = await firstValueFrom(mosaicRepository.getMosaic(mosaicId));
const divisibility = mosaicInfo.divisibility; // トークン数量として小数点以下扱える桁数, XYMは6桁
// トークンとメッセージの転送トランザクションに必要な情報を作成
// トランザクションの有効期限6時間
const deadline = Deadline.create(
parseInt(epochAdjustment),
6,
defaultChronoUnit
);
// 送信先アドレス(=受信者アドレス)...仮にFaucetのアドレスを指定
const recipientAddress = Address.createFromRawAddress(
"TARDV42KTAIZEF64EQT4NXT7K55DHWBEFIXVJQY"
);
// 送信するトークンの種類とトークンの数量(例. 10 XYMの場合)
const relativeAmount = 10;
const absoluteAmount = Math.round(
relativeAmount * Math.pow(10, divisibility)
); // システム的にはdivisibility乗した整数で扱われることに注意
const uint64AbsoluteAmount = UInt64.fromUint(absoluteAmount); // 旧SDKではSDKのUInt64を使う必要があることに注意
const mosaic = new Mosaic(mosaicId, uint64AbsoluteAmount);
const mosaics = [mosaic];
// 送信するメッセージ(平文メッセージの場合)
const rawMessage = "Symbolブロックチェーンの世界へようこそ!";
const message = PlainMessage.create(rawMessage);
// 手数料率 ... デフォルトのノードだと100。手数料の把握できているノードを指定してこの数字をそのノードの値に変更することで手数料を節約できる
const feeMultiplier = 100;
// トランザクションデータ生成
const transferTransaction = TransferTransaction.create(
deadline,
recipientAddress,
mosaics,
message,
networkType
).setMaxFee(feeMultiplier);
// トランザクション送信者(&今回の場合は同時に署名者)のアカウント情報を秘密鍵から復元
const privateKey = process.env.QIITA_ADVENT_CALENDAR_PRIVATE_KEY!; // 秘密鍵を適切に指定
const account = Account.createFromPrivateKey(privateKey, networkType);
// トランザクションに署名
const signedTransferTransaction = account.sign(
transferTransaction,
generationHash
);
// トランザクションのハッシュとペイロードを表示(もし再アナウンスが必要な場合に使う)
const transactionHash = signedTransferTransaction.hash;
const transactionPayload = signedTransferTransaction.payload;
const transactionType = signedTransferTransaction.type;
console.log({ transactionHash });
console.log({ transactionPayload });
console.log({ transactionType });
// WebSocketでのトランザクション送信結果の監視設定
const listener = repositoryFactoryHttp.createListener();
await listener.open().then(() => {
console.log("listener opened");
// エラー監視 ... 手数料が足りていない場合、ここのエラーにも情報が流れてこないことに注意が必要!
listener.status(account.address).subscribe((error) => {
console.error(error);
});
// 未承認(=0conf)トランザクション監視
listener
.unconfirmedAdded(account.address)
.subscribe((unconfirmedTransaction) => {
if (unconfirmedTransaction.transactionInfo?.hash === transactionHash) {
console.log({ unconfirmedTransaction });
}
});
// 承認済み(=1conf)トランザクション監視し、確認でき次第監視を終了
listener.confirmed(account.address).subscribe((confirmedTransaction) => {
if (confirmedTransaction.transactionInfo?.hash === transactionHash) {
console.log({ confirmedTransaction });
console.log("transaction has been confirmed");
listener.close();
console.log("listener closed");
}
});
// 新規ブロック監視 ... これを入れないとWebSocketの応答がない時間が長くなるとタイムアウトで接続が切れてしまうので注意
listener.newBlock().subscribe((newBlock) => {
console.log({ newBlock });
});
});
// REST APIを呼び出してトランザクションを送信するための事前準備
const transactionRepository =
repositoryFactoryHttp.createTransactionRepository();
// // REST APIを呼び出してトランザクションを送信する ... ロールバック等でここで送信したトランザクションが無効になってしまった場合を想定してコメントアウト
// console.log("sending transaction...");
// const sendingTransactionResponse = await firstValueFrom(
// transactionRepository.announce(signedTransferTransaction)
// );
// console.log("sendingTransactionResponse: ", sendingTransactionResponse);
// console.log(
// "transaction block explorer link: ",
// `${blockExplorerUrl}/transactions/${transactionHash}`
// );
// トランザクションのpayload, hash, type, 署名者の公開鍵, ネットワークタイプからSDKを用いて署名済みトランザクションを復元
const restoredSignedTransaction = new SignedTransaction(
transactionPayload,
transactionHash,
account.publicKey,
transactionType,
networkType
);
// 復元したトランザクションのハッシュとペイロードを表示
const restoredTransactionHash = restoredSignedTransaction.hash;
const restoredTransactionPayload = restoredSignedTransaction.payload;
const restoredTransactionType = restoredSignedTransaction.type;
console.log({ restoredTransactionHash });
console.log({ restoredTransactionPayload });
console.log({ restoredTransactionType });
if (restoredTransactionPayload === transactionPayload) {
console.log("restored transaction payload is same as original one");
}
// REST APIを呼び出して復元したトランザクションをSDKを用いて再送
console.log("resending restored transaction...");
const resendingRestoredTransactionResponse = await firstValueFrom(
transactionRepository.announce(restoredSignedTransaction)
);
console.log(
"resendingRestoredTransactionResponse: ",
resendingRestoredTransactionResponse
);
console.log(
"transaction block explorer link: ",
`${blockExplorerUrl}/transactions/${transactionHash}`
);
})();
REST APIを直接使用してトランザクションを再送する方法
Version2系のSDKを用いて署名済トランザクションを再構成して送信する場合は、上述の通り、ペイロード以外にも必要な情報が複数ありました。しかし、REST APIのトランザクションをネットワークにアナウンスする部分の仕様を https://symbol.github.io/symbol-openapi/v1.0.3/#tag/Transaction-routes/operation/announceTransactionにて確認すると、ペイロードだけでトランザクションを再送できそうです。
その場合はトランザクションをネットワークにアナウンスする箇所を以下のように実装すればよいでしょう。例としてaxiosを使用していますが、REST APIエンドポイントにPUTできさえすればお好みの方法を使って頂いて構いません。
// SDKを経由せずに直接REST APIを叩けばpayloadだけでトランザクションの再送が可能
console.log("resending restored transaction without SDK...");
const axiosResponse = await axios.put(`${activeNodeUrl}/transactions`, {
payload: transactionPayload,
});
const axiosResponseData = axiosResponse.data;
console.log("resendingRestoredTransactionResponse: ", axiosResponseData);
console.log(
"transaction block explorer link: ",
`${blockExplorerUrl}/transactions/${transactionHash}`
);
全コード一式は以下をご参照ください。
import {
Account,
defaultChronoUnit,
Deadline,
NetworkType,
TransferTransaction,
Address,
MosaicId,
Mosaic,
UInt64,
PlainMessage,
RepositoryFactoryHttp,
SignedTransaction,
} from "symbol-sdk";
import { firstValueFrom } from "rxjs";
import axios from "axios";
const blockExplorerUrl = "https://testnet.symbol.fyi"; // テストネットのブロックエクスプローラーのURLを指定(デバッグしやすいようブロックエクスプローラーのトランザクションのリンクをconsole.logする等)
const networkType = NetworkType.TEST_NET; // テストネットを指定
(async () => {
const activeNodeUrl = "https://sym-test-03.opening-line.jp:3001";
const repositoryFactoryHttp = new RepositoryFactoryHttp(activeNodeUrl); // ノードのURLを指定してREST APIを呼び出すためのインスタンスを作成
console.log({ activeNodeUrl });
// REST APIを呼び出してネットワーク関連の情報を取得
const networkRepository = repositoryFactoryHttp.createNetworkRepository();
const networkProperties = await firstValueFrom(
networkRepository.getNetworkProperties()
);
const epochAdjustment = networkProperties.network.epochAdjustment;
const generationHash = networkProperties.network.generationHashSeed;
const networkCurrencyMosaicId = networkProperties.chain.currencyMosaicId
?.replace(/0x/g, "")
.replace(/'/g, "")
.toUpperCase();
// ネットワーク関連の情報が取得できているかチェック
if (!epochAdjustment) {
throw Error("epochAdjustment is invalid");
}
if (!generationHash) {
throw Error("generationHash is invalid");
}
if (!networkCurrencyMosaicId) {
throw Error("networkCurrencyMosaicId is invalid");
}
// REST APIを呼び出して基軸通貨(XYM)のトークンの情報を取得
const mosaicRepository = repositoryFactoryHttp.createMosaicRepository();
const mosaicId = new MosaicId(networkCurrencyMosaicId);
const mosaicInfo = await firstValueFrom(mosaicRepository.getMosaic(mosaicId));
const divisibility = mosaicInfo.divisibility; // トークン数量として小数点以下扱える桁数, XYMは6桁
// トークンとメッセージの転送トランザクションに必要な情報を作成
// トランザクションの有効期限6時間
const deadline = Deadline.create(
parseInt(epochAdjustment),
6,
defaultChronoUnit
);
// 送信先アドレス(=受信者アドレス)...仮にFaucetのアドレスを指定
const recipientAddress = Address.createFromRawAddress(
"TARDV42KTAIZEF64EQT4NXT7K55DHWBEFIXVJQY"
);
// 送信するトークンの種類とトークンの数量(例. 10 XYMの場合)
const relativeAmount = 10;
const absoluteAmount = Math.round(
relativeAmount * Math.pow(10, divisibility)
); // システム的にはdivisibility乗した整数で扱われることに注意
const uint64AbsoluteAmount = UInt64.fromUint(absoluteAmount); // 旧SDKではSDKのUInt64を使う必要があることに注意
const mosaic = new Mosaic(mosaicId, uint64AbsoluteAmount);
const mosaics = [mosaic];
// 送信するメッセージ(平文メッセージの場合)
const rawMessage = "Symbolブロックチェーンの世界へようこそ!";
const message = PlainMessage.create(rawMessage);
// 手数料率 ... デフォルトのノードだと100。手数料の把握できているノードを指定してこの数字をそのノードの値に変更することで手数料を節約できる
const feeMultiplier = 100;
// トランザクションデータ生成
const transferTransaction = TransferTransaction.create(
deadline,
recipientAddress,
mosaics,
message,
networkType
).setMaxFee(feeMultiplier);
// トランザクション送信者(&今回の場合は同時に署名者)のアカウント情報を秘密鍵から復元
const privateKey = process.env.QIITA_ADVENT_CALENDAR_PRIVATE_KEY!; // 秘密鍵を適切に指定
const account = Account.createFromPrivateKey(privateKey, networkType);
// トランザクションに署名
const signedTransferTransaction = account.sign(
transferTransaction,
generationHash
);
// トランザクションのハッシュとペイロードを表示(もし再アナウンスが必要な場合に使う)
const transactionHash = signedTransferTransaction.hash;
const transactionPayload = signedTransferTransaction.payload;
const transactionType = signedTransferTransaction.type;
console.log({ transactionHash });
console.log({ transactionPayload });
console.log({ transactionType });
// WebSocketでのトランザクション送信結果の監視設定
const listener = repositoryFactoryHttp.createListener();
await listener.open().then(() => {
console.log("listener opened");
// エラー監視 ... 手数料が足りていない場合、ここのエラーにも情報が流れてこないことに注意が必要!
listener.status(account.address).subscribe((error) => {
console.error(error);
});
// 未承認(=0conf)トランザクション監視
listener
.unconfirmedAdded(account.address)
.subscribe((unconfirmedTransaction) => {
if (unconfirmedTransaction.transactionInfo?.hash === transactionHash) {
console.log({ unconfirmedTransaction });
}
});
// 承認済み(=1conf)トランザクション監視し、確認でき次第監視を終了
listener.confirmed(account.address).subscribe((confirmedTransaction) => {
if (confirmedTransaction.transactionInfo?.hash === transactionHash) {
console.log({ confirmedTransaction });
console.log("transaction has been confirmed");
listener.close();
console.log("listener closed");
}
});
// 新規ブロック監視 ... これを入れないとWebSocketの応答がない時間が長くなるとタイムアウトで接続が切れてしまうので注意
listener.newBlock().subscribe((newBlock) => {
console.log({ newBlock });
});
});
// // REST APIを呼び出してトランザクションを送信する ... ロールバック等でここで送信したトランザクションが無効になってしまった場合を想定してコメントアウト
// const transactionRepository =
// repositoryFactoryHttp.createTransactionRepository();
// console.log("sending transaction...");
// const sendingTransactionResponse = await firstValueFrom(
// transactionRepository.announce(signedTransferTransaction)
// );
// console.log("sendingTransactionResponse: ", sendingTransactionResponse);
// console.log(
// "transaction block explorer link: ",
// `${blockExplorerUrl}/transactions/${transactionHash}`
// );
// SDKを経由せずに直接REST APIを叩けばpayloadだけでトランザクションの再送が可能
console.log("resending transaction without SDK and with payload...");
const axiosResponse = await axios.put(`${activeNodeUrl}/transactions`, {
payload: transactionPayload,
});
const axiosResponseData = axiosResponse.data;
console.log("resendingRestoredTransactionResponse: ", axiosResponseData);
console.log(
"transaction block explorer link: ",
`${blockExplorerUrl}/transactions/${transactionHash}`
);
})();
ブロックの巻き戻りでUXを悪化させないユーザーフレンドリーなDappsが増えてくれると嬉しい
即時かつ確定的ファイナリティを持っていないブロックチェーンでの構造的な課題として、(頻度は高くないものの、)ブロックの巻き戻りで送信済のトランザクションが無効になっていたといった状況をできるだけ避けられるよう、(実装の手間は増えるものの、)送信済のトランザクションのペイロード、ハッシュを保管しておいて、無効化されていたら再送してあげるようなユーザーフレンドリーなサービスが増えるといいなと個人的に思っていて、今回、アドベントカレンダーの記事執筆をきっかけとして記事にまとめてみました。
中央集権的要素が入ってしまいますが、トランザクションのペイロード、ハッシュを受け取って、エラーの補足、ブロックの巻き戻りの際の再送等まで含めていい感じに面倒見てくれるサービスがあれば、実は意外と需要があったりするかも?みたいなことを思ったりもします。(もちろん、非中央集権的に、ノードに対するREST APIリクエストやWebSocketからの応答で同様のことは実現可能で、そういうライブラリとかが登場したりすると開発が捗りそうだなあと思ったりもします。)
もし需要がありそうならそういう取組にチャレンジしてみたいかもと思っていたりもしますが、既にそういう仕組みをご自身で作って運用していらっしゃる方もいるかもしれません。個人的にはお金を取れるサービスに十分なり得るんじゃないかなあと思っているので、皆さんチャレンジしてみてはどうでしょうか?といったことも思いました。
最後に...
今年は、私自身にとっても、8月からNPO法人NEMTUS https://nemtus.com/ の活動にフルタイムで取り組むことになった等、NEM / Symbol関連のお仕事を色々させて頂くという貴重な経験をさせて頂いた思い出深い一年となりました。その中で、良い結果を出せたものもあれば、あまり良くない結果しか出せなかったものもあったと思います。来年も引き続き、NEM / Symbol関連でより良い成果を出していけるように&コミュニティの皆様にNEM / Symbolを使って色々と楽しんでいただけるようなそんな取組を頑張っていきたいと思いますので今後ともよろしくお願いします。
(余談その1) 後でトランザクションを再送する際にナンスがミスマッチして問題になったりしないんすか?
この記事の説明を、他のブロックチェーンに詳しい方々が聞くと、トランザクション送信毎にインクリメントした値を指定するアカウントシーケンスあるいはナンス(=トランザクション送信毎に1インクリメントした数値をトランザクションのデータに含める必要があるブロックチェーンでその数値をアカウントシーケンスあるいはナンスと呼びます。EVM系ブロックチェーン、Cosmos SDK系ブロックチェーン等が代表的ですがその他にも多くのブロックチェーンで必要なものが多いでしょう。)にミスマッチが生じる可能性があるのでは?という懸念を感じるかもしれません。これはスマートコントラクトでの処理の順序の整合性を確保したり、リプレイアタックを防ぐ効果も付随的に意図されたものでしょう。
例えば以下のようなトランザクションを同一アカウントAから順番に送信するような場合、
- アカウントAによって1番目に署名されたトランザクション1 ... ナンス: 1を含む
- アカウントAによって2番目に署名されたトランザクション2 ... ナンス: 2を含む
- アカウントAによって3番目に署名されたトランザクション3 ... ナンス: 3を含む
トランザクション1 -> トランザクション2 -> トランザクション3の順に送信した場合は問題ないのですが、送信タイミングがずれたり、手数料不足等でトランザクション1が未承認や未送信状態になっていた場合、以降のトランザクション2 -> トランザクション3を送信することができなくなってしまいます。(トランザクション詰まり等と呼ばれることが多いと思います。もちろん、ナンスを利用したトランザクション手数料の更新のような仕組みが逆に便利な場合もあると思います。)
(私自身、大変お恥ずかしいことですが、過去にこの点に対する不正確な理解の元、つたない設計・実装でFaucetを作った結果、アクセスが集中すると、ナンスのミスマッチが大量に発生するという状態を招いてしまったことがあります...)
しかし、Symbolでは(ナンスってなんすか!?=)トランザクションのデータにアカウントシーケンスは含めないという考え方がなされています。そのため、後述する、トランザクションの有効期限内であれば、そのトランザクションのデータが作成され署名がなされた後、先に別のトランザクションが同じアカウントから署名されて送信されていたり、別のトランザクションが未承認で保留状態なっていとしても、それらに関係なく後からそのトランザクションを送信することができます。
これは1アカウントからのトランザクションの大量送信を実現する必要があるようなユースケースにおいて、トランザクション詰まりを回避するためにトランザクションの順序立てされたアナウンスのロジックや仕組みを考えるハードルを大幅に下げてくれるという特徴につながり、開発者に優しい仕様と言えるかもしれません。
(余談その2) ナンスが不要って大丈夫なんすか?
ナンスが不要という仕様に対して、リプレイアタックの点で不安を感じる方もいらっしゃるかもしれません。Symbolブロックチェーンではリプレイアタック防止については、トランザクションの有効期限のタイムスタンプ、ネットワーク毎に異なる値(=Generation Hash)をトランザクションの署名対象データに含めることを必須にしており、それによってリプレイアタックを防いでいると理解しています。トランザクションの有効期限の最長期間はネットワーク毎に定められており、メインネットだと6時間だったと思います。Symbolで特徴的なのはトランザクションの有効期限のタイムスタンプを含めることを必須にしている点で、ナンスを不要にする代わりにトランザクションの有効期限を含めたデータへの署名&有効期限のタイムスタンプよりも現在時刻が前であるという状況を持ってしか、トランザクションを受け入れてもらえないようにすることで、リプレイアタックを防いでいるということだと思います。NEM / Symbolどちらにも共通して、時間というものに対するこだわりはとても大きいと感じており、コア開発者の思想が大きく反映されているポイントだと個人的に感じています。
私自身は、上記理解の元、Symbolブロックチェーンでも、リプレイアタックの防止はきちんと考慮され、有効に機能しているという理解をしていますが、もっとコアプロトコルに近いところも含めて、正確な説明をできる人がいたら、ぜひこのあたりの詳細な話を伺ってみたいなあと思っています。ちょうどAdvent Calendarの季節ですし、誰かそういう記事を書いてくれたら嬉しいな... ぜひよろしくお願いします!