SWQoS 向け Durable Nonce ガイド

背景と利用シナリオ

Solana におけるトランザクション送信では、スロットの進行状況、リーダーの配置、ネットワーク経路の混雑状況によって、「どの送信経路が最も早くリーダーに到達するか」は常に変動します。これは特定の RPC 事業者や送信サービスに固有の問題ではなく、Solana の実行モデルに由来する構造的な特性です。
ERPC が提供する SWQoS エンドポイントは、Stake weighted Quality of Service(SWQoS)に基づき、リーダーが優先帯域として割り当てるレーンにトランザクションを投入できる送信経路を提供します。この優先帯域は、非優先帯域と比較して約 5 倍の BandWidth を持ち、さらに Priority fee の評価より前の段階で適用されます。
そのため、SWQoS エンドポイントはトランザクション送信における重要な選択肢となりますが、実運用においては、常に単一のエンドポイントが最速になるとは限りません。同一スロット内でも、瞬間的な経路差や負荷の偏りによって、別の高速エンドポイントが先行する状況は発生します。
このような特性を前提とした場合、単一の送信経路に依存する設計ではなく、複数の高速な送信経路に対して同一のトランザクションを同時に送信し、最初に処理されたものを採用するという運用が有効になります。
一方で、同一のトランザクションを複数のエンドポイントに送信すると、そのままでは「実行されるのは 1 回のみ」という状態を保証できません。この制御を行わずにファンアウト送信を行うと、意図しない二重実行や、再送制御の破綻につながります。
Solana では、この問題に対する仕組みとして Durable Nonce が提供されています。Durable Nonce を利用することで、同一の署名済みトランザクションを複数の送信経路に流しつつ、ネットワーク上で有効になる実行を 1 回に制限することが可能になります。
本ページでは、Durable Nonce を用いたトランザクション送信を前提として、ERPC の SWQoS エンドポイントとその他の高速 RPC エンドポイントを組み合わせたファンアウト運用をどのように実装するかを説明します。

範囲と前提

本ガイドでは、web3.js を用いて Durable Nonce アカウントを作成し、それを利用したトランザクション送信とファンアウト運用を行う手順を説明します。
前提として理解しておくべき事項は次の 3 点です。
  • Durable Nonce を使うトランザクションでは recentBlockhash に nonce 値を使い、先頭命令に nonceAdvance を置く必要がある
  • nonceAdvance が実行されると、後続命令が失敗しても nonce は消費され得る。同じ rawTx をそのまま再送することはできない
  • nonce アカウントの作成は初回セットアップのみで、通常は使い回す

ステップ 1: Nonce Authority と Connection を用意する

Nonce を進める権限(nonce authority)は Keypair で管理します。
typescript
import {
  Connection,
  Keypair,
  SystemProgram,
  NONCE_ACCOUNT_LENGTH,
} from '@solana/web3.js'

const connection = new Connection(
  'https://<primary-rpc-endpoint>',
  'confirmed',
)

const nonceAuthority = Keypair.fromSecretKey(/* secret key */)
  • nonceAuthority は「nonceAdvance を実行できる権限」
  • fee payer と同一でも、分離しても構いません

ステップ 2: Nonce アカウント用 Keypair を生成する

Nonce アカウント自体は System Account です。
typescript
const nonceAccount = Keypair.generate()
この Keypair は以下の用途を持ちます。
  • nonce 値(recentBlockhash の代替)を保持するアカウント
  • 作成時のみ署名に必要
  • 作成後は秘密鍵を保管するだけで、日常送信では使いません

ステップ 3: rent-exempt 最低残高を計算する

Nonce アカウントは rent-exempt が必須です。固定値は使わず、必ず RPC から取得します。
typescript
const lamports =
  await connection.getMinimumBalanceForRentExemption(
    NONCE_ACCOUNT_LENGTH,
  )

ステップ 4: Nonce アカウントを作成・初期化する

Nonce アカウントの作成は createAccount + nonceInitialize を同一トランザクションで行います
typescript
import { Transaction } from '@solana/web3.js'

const tx = new Transaction()

tx.add(
  SystemProgram.createAccount({
    fromPubkey: nonceAuthority.publicKey,
    newAccountPubkey: nonceAccount.publicKey,
    lamports,
    space: NONCE_ACCOUNT_LENGTH,
    programId: SystemProgram.programId,
  }),
  SystemProgram.nonceInitialize({
    noncePubkey: nonceAccount.publicKey,
    authorizedPubkey: nonceAuthority.publicKey,
  }),
)

// fee payer は nonceAuthority
tx.feePayer = nonceAuthority.publicKey

// recentBlockhash は通常の blockhash でよい(初期化用)
const { blockhash, lastValidBlockHeight } =
  await connection.getLatestBlockhash('confirmed')

tx.recentBlockhash = blockhash

// 作成されるアカウントと authority の両方で署名
tx.sign(nonceAccount, nonceAuthority)

const signature = await connection.sendRawTransaction(tx.serialize())
await connection.confirmTransaction(
  { signature, blockhash, lastValidBlockHeight },
  'confirmed',
)
以降、この nonceAccount.publicKey を送信処理で参照します。

ステップ 5: 送信時に Nonce を取得する

トランザクションを送るたびに、現在の nonce 値を取得します。
typescript
import { NonceAccount } from '@solana/web3.js'

const { value, context } =
  await connection.getAccountInfoAndContext(
    nonceAccount.publicKey,
    'confirmed',
  )

if (!value) {
  throw new Error('Nonce account not found')
}

const nonce = NonceAccount.fromAccountData(value.data).nonce
const minContextSlot = context.slot
  • nonce は recentBlockhash として使います
  • context.slot は後段の confirm で使用します

ステップ 6: Durable Nonce トランザクションを組み立てる

Durable Nonce トランザクションは次の条件を満たします。
  • recentBlockhash = nonce
  • 最初の命令が nonceAdvance
typescript
import {
  TransactionMessage,
  VersionedTransaction,
} from '@solana/web3.js'

const instructions = [
  SystemProgram.nonceAdvance({
    noncePubkey: nonceAccount.publicKey,
    authorizedPubkey: nonceAuthority.publicKey,
  }),
  // この後に実際の命令を追加する
]

const message = new TransactionMessage({
  payerKey: nonceAuthority.publicKey,
  recentBlockhash: nonce,
  instructions,
}).compileToV0Message()

const tx = new VersionedTransaction(message)
tx.sign([nonceAuthority /* + other signers */])

const rawTx = tx.serialize()
注意点:
  • ComputeBudget 命令を使う場合も nonceAdvance の後ろ
  • nonceAdvance を先頭に置かないとトランザクションは拒否されます

ステップ 7: 複数 RPC へファンアウト送信する

同一 rawTx を並列で送信します。
typescript
const endpoints = [
  'https://<erpc-swqos-fra>',
  'https://<backup-rpc-1>',
  'https://<backup-rpc-2>',
]

const results = await Promise.allSettled(
  endpoints.map((url) =>
    new Connection(url, 'confirmed').sendRawTransaction(rawTx, {
      skipPreflight: true,
      minContextSlot,
    }),
  ),
)

const success = results.find(
  (r): r is PromiseFulfilledResult<string> =>
    r.status === 'fulfilled',
)

if (!success) {
  throw new Error('All sends failed')
}

const signature = success.value
  • signature は全 RPC で同一です
  • send が通った = 確定ではありません

ステップ 8: Durable Nonce 用に確定確認する

Durable Nonce では、nonce 情報を含めて confirm します。
typescript
await connection.confirmTransaction(
  {
    signature,
    nonceAccountPubkey: nonceAccount.publicKey,
    nonceValue: nonce,
    minContextSlot,
  },
  'confirmed',
)
confirmTransaction が成功した場合は次の状態になります。
  • nonce が進む
  • 他 RPC に送ったコピーは InvalidNonce になる

次の送信について

  • 確定後は nonce を再取得する
  • 同じ rawTx / nonceValue を再利用しない
  • 並列ワークフローを組む場合は nonce アカウントを分ける
次のトランザクション送信では、更新後の nonce を再取得し、同様の手順で組み立ててください。