🦍

【Blockchain/web3】Solanaトランザクションにおける二重トランザクションエラーの解決方法

2025/01/31に公開

はじめに

Solanaを用いたアプリケーション開発において、トランザクションの管理は非常に重要です。特に、ユーザーが意図せず同じトランザクションを複数回送信してしまう「二重トランザクションエラー」は、ユーザー体験を損なう重大な問題となります。今回の開発ではlogとしてそれぞれユニークなhashを出力してましたが、この二重トランザクションエラーが度重なって起こってしまいました。今回んの解決方法としてあまり推奨されませんがskipPreflightをオフにして解決しました。blockchain領域はどうしても海外のDocumentが多く困惑すると思いますので日本のengineerの皆様にとってより良いものになることを願って今回共有します。


二重トランザクションエラーとは

二重トランザクションエラーは、同一のトランザクションが複数回ネットワークに送信されることで発生します。これは以下のような状況で起こり得ます:

  • ユーザーの誤操作: ボタンを連打してしまう。
  • ネットワークの遅延: トランザクション送信後、応答が遅れたために再送信が発生する。
  • 状態管理の不備: トランザクション送信中に再度送信が可能になってしまう。

このエラーが発生すると、同じアクションが複数回実行され、ユーザーに不利益を与える可能性があります。


skipPreflightオプションの概要

SolanaのsendRawTransactionメソッドには、トランザクションを送信する際に様々なオプションを設定できます。その中でもskipPreflightオプションは以下の役割を持ちます:

  • skipPreflight: false(デフォルト): トランザクションをネットワークに送信する前に、ノードがトランザクションをシミュレーション(プレフライトチェック)します。このシミュレーションでは、トランザクションが成功するかどうか、必要なアカウントの存在、十分な残高などが確認されます。

  • skipPreflight: true: プレフライトチェックをスキップし、直接トランザクションをネットワークに送信します。これにより、シミュレーションによる遅延を回避できますが、トランザクションが失敗する可能性が高まります。

プレフライトチェックの利点

プレフライトチェックは、トランザクションが有効かどうかを事前に確認するため、エラーを未然に防ぐ効果があります。例えば、アカウントが存在しない、残高が不足しているなどの問題を事前に検出できます。ですがここでBlockcain側が予期せぬエラーをchatchしてtransactionが行われないということもあります。

プレフライトチェックの欠点

一方で、プレフライトチェックには以下のような欠点も存在します:

  • 遅延の増加: シミュレーションを行うため、トランザクション送信に時間がかかる。
  • リソースの消費: プレフライトチェック自体がノードのリソースを消費する。

skipPreflight: trueを設定した際の影響

skipPreflight: trueを設定すると、以下のような影響があります:

利点

  • トランザクション送信の高速化: シミュレーションをスキップするため、トランザクションの送信が速くなります。
  • 遅延の回避: ネットワークの遅延やノードの負荷による影響を受けにくくなります。

欠点

  • エラーの増加: プレフライトチェックをスキップするため、トランザクションが失敗する可能性が高まります。
  • デバッグの難化: エラーが発生した際に、その原因を特定するのが難しくなります。

BACKGROUND

今回の機能開発ではユーザーがWalletに接続して、そこから指定のaddressのwalletに送金するといった、Deposit, transaction, signatureの機能の開発でした。
そこで対策としてボタンを複数回押せないようにしたり、Blockhashが毎回新規作成されるようにし、それをconsole.logで出力して確認するといった手法を取りましたが同じblockhashを使用していることで誘発されるこのエラーが頻繁に起こってしまいました。

skipPreflightを用いた一時的な解決策

skipPreflight: trueを設定することで、プレフライトチェックをスキップし、二重トランザクションエラーを回避することができました。具体的には、以下のようにオプションを設定しています:

const transactionOptions = IS_PRODUCTION
  ? {
      skipPreflight: false,
      preflightCommitment: 'processed' as const,
    }
  : {
      skipPreflight: true, // プレフライトチェックをスキップ
    };

const signature = await connection.sendRawTransaction(signedTx.serialize(), transactionOptions);

なぜskipPreflight: trueでエラーが解消されたのか?

プレフライトチェックをスキップすることで、同一トランザクションIDが再度送信されても、ノード側で二重送信として認識されずエラーが発生しなくなった可能性があります。ただし、これは根本的な解決ではなく、一時的な回避策に過ぎません。

根本的な解決方法の提案

skipPreflight: trueは一時的な解決策であり、根本的な問題を解決するものではありません。以下に、二重トランザクションエラーを防ぐための根本的な対策を提案します。

1. トランザクション送信中の状態管理

トランザクションを送信中は、再度送信できないようにUIを制御します。具体的には、トランザクション送信中にボタンを無効化するなどの方法があります。

例:

<button
  onClick={handleDeposit}
  disabled={isDepositing || depositInProgressOtherTab}
>
  {isDepositing ? 'Depositing...' : 'Deposit'}
</button>

2. デバウンス(連続クリック防止)

ユーザーがボタンを連続してクリックするのを防ぐために、デバウンスを導入します。これにより、短時間に複数回のクリックが発生しても、一度だけ処理が実行されます。

例:

const handleDeposit = useCallback(
  debounce(async () => {
    if (isDepositing) return;
    // トランザクション送信ロジック
  }, 300),
  [isDepositing, depositAmount, /* 他の依存関係 */]
);

3. トランザクションIDのユニーク化

各トランザクションに対してユニークなIDを生成し、同一IDのトランザクションが再送信されないようにします。これにより、同じトランザクションが複数回送信されることを防ぎます。

4. エラーハンドリングの強化

二重トランザクションエラーが発生した際には、適切に状態をリセットし、ユーザーに通知します。

例:

catch (error: any) {
  if (error.message.includes('already been processed')) {
    setWarningMessage('This transaction has already been processed.');
    setWarningPopupVisible(true);
  } else {
    setWarningMessage(`Deposit failed: ${error.message}`);
    setWarningPopupVisible(true);
  }
} finally {
  setIsDepositing(false);
  localStorage.setItem('SUPERCHARGER_DEPOSIT_IN_PROGRESS', 'false');
}

5. UIの状態リセット

ウォレット接続が成功した際やトランザクションが完了した際に、警告ポップアップの状態をリセットします。これにより、以前のエラーメッセージが残らないようにします。

例:

const handleConnect = () => {
  setWalletConnected(true);
  setWalletAddress(wallet.publicKey?.toString() || null);
  if (wallet.publicKey) {
    fetchWalletInfo(wallet.publicKey);
  }
  // 成功時に警告をリセット
  setWarningPopupVisible(false);
  setWarningMessage('');
};

6. RPCノードの変更

今回は最初にsolanaの公式devnetを使用していましたが安定した挙動に繋がらないため、より高速で安定していると言われているHELIUSのRPCノードを使用しました。
このようなアプローチも一つの方法かもしれません。
https://docs.helius.dev/solana-rpc-nodes/helius-rpcs-overview

結論

skipPreflight: trueを設定することで、プレフライトチェックをスキップし、二重トランザクションエラーを一時的に回避することができました。しかし、この方法は根本的な解決策ではなく、将来的な問題を引き起こす可能性があります。したがって、以下の対策を講じることを強くお勧めします:

  1. 状態管理の徹底: トランザクション送信中は再送信できないように状態を管理する。
  2. UIの制御: ボタンの無効化やデバウンスを導入し、ユーザーが誤って複数回クリックするのを防ぐ。
  3. エラーハンドリングの強化: 二重送信やその他のエラーを適切に処理し、ユーザーにわかりやすく通知する。
  4. プレフライトチェックの利用: skipPreflight: falseを維持し、トランザクションの有効性を事前に確認する。

これらの対策を実施することで、より安全かつユーザーフレンドリーなアプリケーションを構築できるでしょう。二重トランザクションエラーの根本的な解決に向けて、引き続き開発を進めてください。

Discussion