こんにちは
佐々木です。
今回はweb3プロダクトを開発する際に発生するnonceの問題とそれの解決方法を解説します。
nonceとは
ブロックチェーンのコンテキストではトランザクションの重複を防止しネットワークのセキュリティを担保するために使用されています。
トランザクションがユニークであることを保証し、特定のトランザクションがブロックチェーンで一回限りであることを保証します。
またセキュリティ上の利点では以下のようなものがあります。
- 重複トランザクションの防止: トランザクションにNonceを使用することで、同じトランザクションが複数回実行されるのを防ぎます。
- Replay Attackの防止: Replay Attack(リプレイ攻撃)では、有効なデータ伝送が悪意を持って繰り返されます。Nonceは、一度使用されたトランザクションが再度使用されるのを防ぎます。
- ネットワーク整合性の維持: Nonceを使用することで、ブロックチェーンネットワーク上のトランザクションが正しい順序で処理され、整合性が維持されます。
実際にはどのように動いているのか
etherscanを見てみるとこのようにNonceは連番になっています。
Nonceは連番になっていないとTransactionがpendingとなってしまい、トランザクションが取り込まれなくなってしまいます。
よって各ノードは、nocne値に応じて厳密な順序で特定のアカウントからのトランザクションを処理するため、nonce値を正確にインクリメントする必要があります。
nonceを使用する場合のよくあるエラー
Transactionを送信するにあたってnonce値を正確にインクリメントする必要があると前章で記載しました。
インクリメントする際には単一のウォレットからTransactionが実行されている場合には簡単で特にユーザー側で気にせずとも基本的には問題になりません。
しかしEOAが複数のプロセスによって同時に実行されるようなアプリケーションの場合はnonceの重複やnonce値が歯抜けになってしまったりします。
どのような状況なのかを説明します。
例えばNFTマーケットプレイスを運営していてNFTの譲渡権限を受けているEOAアカウントが単一である場合、NFTの売買が行われるたびに管理用EOAのアカウントを使用してsafeTransferFromを動かさないといけなくなります。
NFTの売買は不特定多数のユーザーが同時に行う処理であるため管理用EOA一つでnonceを管理しようとすると上記のような問題が起きることがあります。
上記のような問題が起きるとトランザクションは以下のような処理をします。
- nonce の再利用: 同じアカウントから同じ nonce を持つ 2 つのトランザクションを送信すると、2 つのうち 1 つが拒否されます。
- 2 つの連続するトランザクションに起因する nonce の間にギャップがある場合、このギャップが閉じるまで最後のトランザクションは処理されません。
問題解決方法
なぜnonceが重複したり歯抜けになったりするのか、実装上の問題としてはnode as a serviceを使用する際にサービスが提供してくれる機能を使用するとこのような問題が起きます。
そのためこの問題を解決するためには自前でnonceを設定してTransactionを発行する方法をとるのが良いと思います。
今回はredisを使用して自前でnonce値を管理することで問題を解決する方法を紹介します。
手順は以下の通りです。
node.jsでmintを100回行うスクリプトを作成、実行
チェーンはイーサリアムのテストネット上にスマートコントラクトはデプロイ
node as a serviceはInfuraを使用
redisのinstallは以下を参考
<https://redis.io/docs/install/install-redis/>
index.js
const { Web3 } = require('web3');
const { createClient } = require('redis');
const fs = require('fs');
const { abi } = JSON.parse(fs.readFileSync('Demo.json'));
async function main() {
// Redisクライアントの初期化
const redisClient = createClient();
await redisClient.connect();
const network = process.env.ETHEREUM_NETWORK;
const web3 = new Web3(`https://${network}.infura.io/v3/${process.env.INFURA_API_KEY}`);
const signer = web3.eth.accounts.privateKeyToAccount('0x' + process.env.SIGNER_PRIVATE_KEY);
web3.eth.accounts.wallet.add(signer);
const contract = new web3.eth.Contract(abi, process.env.DEMO_CONTRACT);
const toAddress = '0xE495FA02b77B7feE35A04686048D08476517318E';
const tokenId = process.argv[2];
if (!tokenId) {
console.error('Please provide a tokenId as an argument');
process.exit(1);
}
const method_abi = contract.methods.safeMint(toAddress, tokenId).encodeABI();
// nonceの取得とインクリメント
const nonceKey = `nonce:${signer.address}`;
const currentNonce = Number(await redisClient.get(nonceKey)) || Number(await web3.eth.getTransactionCount(signer.address, 'pending'));
await redisClient.set(nonceKey, currentNonce + 1);
const tx = {
from: signer.address,
to: contract.options.address,
data: method_abi,
gasPrice: '100000000000',
nonce: currentNonce
};
const gas_estimate = await web3.eth.estimateGas(tx);
tx.gas = gas_estimate;
const signedTx = await web3.eth.accounts.signTransaction(tx, signer.privateKey);
console.log('Raw transaction data: ' + signedTx.rawTransaction);
const receipt = await web3.eth.sendSignedTransaction(signedTx.rawTransaction)
.once('transactionHash', (txhash) => {
console.log('Mining transaction ...');
console.log(`https://${network}.etherscan.io/tx/${txhash}`);
});
console.log(`Mined in block ${receipt.blockNumber}`);
await redisClient.quit();
}
require('dotenv').config();
main();
実行スクリプト
const { exec } = require('child_process');
function runScript(tokenId) {
return new Promise((resolve) => {
const startTime = Date.now();
exec(`node index.js ${tokenId}`, (error, stdout, stderr) => {
const endTime = Date.now();
const duration = endTime - startTime;
if (error) {
console.error(`Error for tokenId ${tokenId}: ${error}`);
} else {
console.log(`STDOUT for tokenId ${tokenId}: ${stdout}`);
}
if (stderr) {
console.error(`STDERR for tokenId ${tokenId}: ${stderr}`);
}
console.log(`Script for tokenId ${tokenId} took ${duration}ms`);
resolve();
});
});
}
function main() {
for (let i = 1; i <= 100; i++) {
console.log(`Running script for tokenId: ${i}`);
const tokenId = 1 + i;
runScript(tokenId)
.then(() => console.log(`Script completed for tokenId: ${tokenId}`))
.catch((error) => console.error(`Script failed for tokenId: ${tokenId}, Error: ${error}`));
}
}
main();
上記のソースコードではTransactionを発行する前にredisに現在のnonceを取得する処理とnonceを更新する処理を入れています。
Transactionが失敗した場合のretry処理やredisのデータ保存期間、redisとEOAで管理しているnonceの連番がずれた場合の対処などまだ必要な処理はありますが、根本の原因である「EOAが複数のプロセスによって同時に実行されるようなアプリケーションの場合はnonceの重複」という問題を解決することができます。
終わりに
今回はnonce管理に関する解決策を紹介させいただきましいた。
今後もweb3プロダクト開発における課題や解決策などを紹介していきます。