😉

SolanaのSOL→USDCスワップを徹底理解する

はじめに:「スワップする」とは、結局何をしているのか?

前回の記事で、Solanaのトークンシステムをアカウント構造から徹底解剖しました。

今回はその知識を活かして、DEX(分散型取引所)のAMM(自動マーケットメイカー)を使った SOL → USDC スワップの内部構造を、コードの1行1行がチェーン上で何をしているのかまで分解して解説します。

「スワップする」と聞くと、何か魔法のような処理をイメージするかもしれません。しかしその正体は、「ユーザが自分のトークンをプールの金庫に送り、プールの金庫から別のトークンを受け取る」というToken Programへの2回のTransfer命令です。この2回のTransferを安全に実行するために、Raydium CLMMプログラムがCPI(Cross-Program Invocation)を使ってToken Programを呼び出します。

この記事では、Solana Mainnet上で実際に動作するTypeScript CLIの実装を通じて、スワップの全プロセスを8ステップに分解し、各ステップで何が起きているのかを具体的なアカウントアドレスとトランザクション署名とともに解説します。

この記事で得られること

  • SOL → USDCスワップの8ステップ・最大4トランザクションの処理フローの理解
  • wSOL(Wrapped SOL)の仕組みと、なぜスワップ前にラップが必要なのか
  • Raydium CLMM Poolのアカウント構造(Vault、Pool State PDA、Tick Array)
  • SDKの「非標準ATA」問題と、ウォレットアプリとの互換性問題への対処法
  • Mainnet実行ログに基づく具体的なトランザクション解析

前提知識

本記事は、以下の概念を前提としています。


1. システム全体像:4つのレイヤー

スワップシステムは、端末 → RPCノード → Solana Networkの3層で構成されています。まず全体像を俯瞰してから、各レイヤーの詳細に入ります。

システムアーキテクチャ
左から、端末(TypeScriptコード群)、RPCノード(Alchemy)、Solana Network上のアカウント・プログラム・トランザクション。各コンポーネントの依存関係と処理の流れを示す。

端末レイヤー(TypeScript)

エントリーポイントのswap.tsがオーケストレーターとして全体の処理順序を制御します。ビジネスロジック自体は持たず、各モジュールを適切な順序で呼び出す役割に徹しています。

swap.ts                  # エントリーポイント(方向選択 → runSolToUsdc / runUsdcToSol)
lib/wallet.ts            # Keypair読み込み・残高確認・確認プロンプト
lib/wsol.ts              # wSOL ATA 作成・クローズ Instructions構築
lib/swap-executor.ts     # Raydium SDK呼び出し・TX送信(HTTPポーリング)
lib/account-logger.ts    # ボックス形式ログ出力ユーティリティ

呼び出しの流れは .env → swap.ts → lib/*.ts → Raydium SDK → Solana という一方向です。swap.tsがdotenvで環境変数を読み込み、それを各関数の引数として渡すことで、コードを変更せずに接続先やパラメータを切り替えられます。

RPCノード(Alchemy)

端末とSolana Networkの橋渡し役です。接続確認、残高照会、トークンアカウント検索、プール情報取得、Epoch情報取得、Blockhash取得、TX送信、TX確認(ポーリング)、TX詳細取得といったAPIを提供します。

ここで重要なのは、AlchemyなどのRPCプロバイダーはWebSocketのsignatureSubscribeに対応していないことがあるという点です。Raydium SDKのexecute({ sendAndConfirm: true })はWebSocketを前提としているため、そのまま使うとタイムアウトエラーになります。本実装ではbuilder.AllTxData.instructionsからInstruction配列を取り出して手動でTransactionを構築し、getSignatureStatusを3秒間隔でHTTPポーリングする方式を採用しています。

// WebSocketを使わずHTTPポーリングでTX確認を待つ
async function sendAndConfirmWithPolling(
  connection: Connection,
  tx: Transaction,
  signers: Keypair[],
  timeoutMs = 90_000
): Promise<string> {
  tx.sign(...signers);
  const rawTx = tx.serialize();
  const sig = await connection.sendRawTransaction(rawTx, {
    skipPreflight: false,
    preflightCommitment: "confirmed",
  });

  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    await new Promise((r) => setTimeout(r, 3000));
    const status = await connection.getSignatureStatus(sig, {
      searchTransactionHistory: false,
    });
    const confirmation = status?.value?.confirmationStatus;
    if (confirmation === "confirmed" || confirmation === "finalized") {
      if (status!.value!.err) {
        throw new Error(`TX実行失敗: ${JSON.stringify(status!.value!.err)}`);
      }
      return sig;
    }
  }
  throw new Error(`TX確認タイムアウト(${timeoutMs / 1000}秒)`);
}

Solana Networkレイヤー

スワップに関わるオンチェーン上の要素は、大きく アカウントプログラムトランザクション の3つに分類されます。各アカウントの役割と所有関係を次のセクションで詳しく見ていきます。


2. アカウントの種類と所有関係

前回の記事で解説した「アカウントの5つのフィールド」の知識を使って、スワップに登場する全アカウントを整理します。

アカウントの種類
スワップに関わる7種類のアカウント。Owner(dataの管理プログラム)、data内容、executable、Keyの生成方法、そしてスワップでの具体例が一覧化されている。

ウォレット(Dev Wallet)

SOL残高を保持するアカウントです。ownerはSystem Programで、dataは空(0バイト)。SOL残高はlamportsフィールドで管理されます。スワップの起点となるアカウントで、秘密鍵を持つのはこのアカウントだけです。

Mint

トークンの「定義書」です。wSOLとUSDCの2つのMintアカウントが登場します。wSOL MintのアドレスはSo11111111111111111111111111111111111111112で、USDC MintのアドレスはEPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1vです。どちらもownerはToken Programで、dataは82バイトの固定長です。

ATA(Associated Token Account)

特定のウォレットが特定のトークンを保管するための口座です。アドレスはPDA(Program Derived Address)で決定的に導出されます。

ATA = PDA([owner_wallet, token_program_id, mint], AssociatedTokenProgram)

スワップでは3つのATAが登場します。wSOL ATAはスワップのために一時的に作成され、スワップ後にクローズされます。USDC ATA(標準アドレス) はPhantomやSolflareが参照する正規のアドレスです。そしてUSDC ATA(非標準アドレス) はRaydium SDKの副作用で作成される問題のあるアカウントで、この対処が本実装の重要なポイントの1つです。

トークンアカウント(非ATA)

プログラムが独自のseedsで作成したトークン口座です。ATA Programを経由せず、authorityにCPDA(Cross Program Derived Address)が設定されます。Raydium CLMM PoolのVault 1(wSOL)とVault 2(USDC)がこれに該当し、Poolの資金を保管する金庫の役割を果たします。

💡 authorityとは?
authorityは「この口座からトークンを動かす権限を持つ者」です。個人のATAでは自分のウォレットがauthorityですが、VaultではauthorityにPDA(Program Derived Address) という特殊なアドレスが設定されています。
PDAは秘密鍵が存在しないアドレスで、そのPDAを生成したプログラム(ここではRaydium CLMM)だけが署名を発行できます。 つまり、Vault内のトークンを動かせるのはRaydium CLMMプログラムだけであり、外部から直接Vaultの資金を引き出すことは不可能です。これが、DEXに資金を預けても安全である技術的な根拠です。
言い換えると、Vaultは「Raydium CLMMプログラムしか鍵を開けられない金庫」であり、その鍵の仕組みがPDAです。

Pool State PDA / プログラムデータ

Raydium CLMMプログラムが独自に定義したデータ構造を格納するアカウントです。Pool StateにはtickliquiditysqrtPriceX64configなどのプール状態が記録されています。重要なのは、Pool State PDAがVaultのauthority(署名権限)を持っていることです。このPDAの署名を生成できるのはRaydium CLMMプログラムだけなので、不正なTransferは全て失敗します。


3. トークンの流れ:誰から誰へ、何が移動するのか

スワップの本質は「トークンの移動」です。ここでは、SOL → USDCスワップで実際にどのアカウント間でトークンが移動するのかを、具体的なアドレスとともに図解します。

トークンの流れ
Dev Walletから始まり、wSOL ATA → Vault 1(CPI Transfer)、Vault 2 → USDC ATA(CPI Transfer)を経て、最終的にUSDC ATA(標準アドレス)に着地する。非標準ATAの問題と統合処理も示されている。

SOL → wSOLへのラップ

Solana上のDEXプロトコルはSPLトークン同士の交換を前提としています。ネイティブSOLはSPLトークンではないため、wSOL(Wrapped SOL)としてトークンアカウントにラップして使います。

ラップの仕組みは以下の3命令で構成され、各命令の実行後にwSOL ATAの状態が段階的に変化します。

# Instruction 処理内容 実行後のlamports 実行後のamount
1 CreateAssociatedTokenAccount wSOL ATAを新規作成。アカウント維持に必要なrent(預託金)がウォレットから自動的に支払われる 2,039,280(rent) 0
2 SystemProgram.Transfer スワップしたい0.01 SOL(10,000,000 lamports)をrentとは別にwSOL ATAへ送金 12,039,280(rent + 入金) 0(まだ未同期)
3 SyncNative lamportsからrentを差し引いた値をamount(トークン残高)に反映する 12,039,280(変化なし) 10,000,000(= 0.01 WSOL)

💡 ポイント: amountはInstruction #2の時点ではまだ0のままです。lamportsにSOLを入れただけではトークン残高に反映されません。SyncNativeが「lamports − rent = 実際のトークン残高」を計算してamountフィールドに書き込むことで、初めてwSOLとしてSwapに使えるようになります。

なお、STEP 6でwSOL ATAをCloseAccountすると、lamportsの全額(rent + 残りのwSOL)がウォレットに返却されます。つまりrentは一時的な預託金であり、最終的に回収されるため実質的なコストにはなりません

CPI Transfer × 2(スワップの核心)

Raydium CLMM Swap_v2命令の内部では、Token ProgramへのCPI(Cross-Program Invocation) が2回発生します。

💡 CPIとは?
言い換えると、「あるプログラムが別のプログラムを呼び出して処理を代行してもらう仕組み」です。トークンの移動(Transfer)はToken Programだけが実行できるため、Raydium CLMMプログラムは自分で直接トークンを動かすことができません。代わりに「このVaultからユーザのATAにUSDCを送ってください」とToken Programに依頼します。この依頼がCPIです。
日常の例えで言えば、銀行(Token Program)の窓口でしか振込ができないとき、仲介業者(Raydium CLMM)が正しい委任状(PDA署名)を持って銀行に振込を依頼するようなイメージです。

  1. wSOL ATA → Vault 1(WSOL) — ユーザのwSOLをプールの金庫に送る
  2. Vault 2(USDC)→ USDC ATA — プールの金庫からユーザにUSDCを送る

Vault 1とVault 2のauthorityはPool State PDAであり、このPDAの署名はRaydium CLMMプログラムだけがinvoke_signedで生成できます。これがDEXのセキュリティモデルの核心です。

非標準ATAの問題

Raydium SDKはassociatedOnly: falseオプションにより、標準ATA以外のアドレスにUSDCのトークンアカウントを作成することがあります。

💡 なぜassociatedOnly: falseにする必要があるのか?
素朴に考えればassociatedOnly: trueにすれば標準ATAだけを使ってくれそうですが、trueにした場合、SDKは標準ATAしか検索・参照しなくなります。すると、過去のSwapやエアドロップ等で非標準アドレスにトークンが入っている場合、そのトークンをSDKが認識できず、「残高がない」と判断してSwapに失敗します。

つまり、falseは「標準ATA以外のアカウントも見つけて使えるようにする」ための設定ですが、その副作用として出力先のATAも非標準アドレスに作成されることがあるというトレードオフがあります。

なお、associatedOnlyはSDK内部の固定値ではなく、raydium.clmm.swap()の呼び出し時に引数として渡すパラメータです。swap-executor.tsの1行をtrueに変えるだけで切り替えられます。それでもあえてfalseを選択しているのは、上記のトレードオフを検討した上での設計判断です。

本実装ではこのトレードオフを受け入れた上で、STEP 8の統合トランザクションで事後的に標準ATAへ移動するアプローチを採用しています。

標準ATA:    ShnhW9Ky5XApZKjj5gpyWYngQu83PboietR7XRRkTCW  ← ウォレットが参照する
非標準ATA:  3j3nD8VditvXymW8aeNqsMy2UJLMoCeftiebQnbvz7KK  ← SDKが作成、ウォレットから見えない

PhantomやSolflare、Jupiterは標準ATAアドレスを数学的に導出して参照するため、非標準ATAにUSDCが入っても残高0として表示されます。この問題への対処がSTEP 8の統合トランザクションです。


4. 処理ステップ詳解:8ステップ・最大4トランザクション

SOL → USDCスワップの全処理を8ステップに分解し、各ステップで使われるTypeScriptモジュール、関連するトランザクション、ウォレット/ATAの状態変化を整理します。

処理ステップマトリックス
8ステップそれぞれで、どのTypeScriptファイルが関与し、どのトランザクションが送信され、各アカウントの状態がどう変化するかを一覧化したマトリクス。

STEP 1: 初期化(wallet.ts)

dev-wallet.jsonからKeypairを読み込み、RPC接続を確立します。SOL残高が0.01 SOL未満の場合は案内を表示して終了します。

実行ログ:
[STEP 1/7] 初期化
  Network:    Mainnet-Beta
  RPC URL:    https://solana-mainnet.g.alchemy.com/v2/...
  Swap:       0.01 SOL → USDC
  Slippage:   0.5% (50 BPS)

  ウォレット情報:
  ┌────────────────────────────────────────────────────┐
  │ Address:   58K6xM2pkuHeFZEcDAcqqPSmi15kiNwR1zqoGWonQHav │
  │ SOL残高:   0.08004586 SOL (80,045,859 lamports)        │
  └────────────────────────────────────────────────────┘

ウォレット読み込みのコードは以下の通りです。秘密鍵はこのモジュール内でのみ扱い、ログには公開鍵だけを出力する設計としています。

export function loadWallet(): Keypair {
  const walletPath = path.resolve("dev-wallet.json");
  if (!fs.existsSync(walletPath)) {
    console.error("dev-wallet.json が見つかりません。");
    console.error("solana-keygen new --outfile dev-wallet.json で作成してください。");
    process.exit(1);
  }
  const raw = JSON.parse(fs.readFileSync(walletPath, "utf-8"));
  return Keypair.fromSecretKey(new Uint8Array(raw));
}

STEP 2: スワップ前アカウント確認(account-logger.ts + swap-executor.ts)

USDC ATAの存在チェックと、Raydium CLMM Poolの現在状態を取得します。プール情報にはPool Stateアドレス、現在価格、Vault残高が含まれます。

実行ログ:
[STEP 2/7] スワップ前のアカウント状態確認

  USDC 残高:
  ┌────────────────────────────────────────────────────┐
  │ 状態:      存在する                                        │
  │ Address:   3j3nD8VditvXymW8aeNqsMy2UJLMoCeftiebQnbvz7KK │
  │ Amount:    0.000000 USDC (raw: 0)                    │
  └────────────────────────────────────────────────────┘

  Raydium CLMM プール情報:
  ┌────────────────────────────────────────────────────┐
  │ Pool State: 8sLbNZoA1cfnvMJLPfp98ZLAnFSY...    │
  │ 現在価格:   1 SOL ≈ 85.3931      USDC                │
  └────────────────────────────────────────────────────┘

プール情報取得では、Raydium SDKを初期化してRPC経由でオンチェーンのPool Stateを直接読み取ります。

export async function fetchAndLogPoolInfo(
  connection: Connection,
  owner: Keypair
) {
  const raydium = await initRaydium(connection, owner);
  const { poolInfo, poolKeys } = await raydium.clmm.getPoolInfoFromRpc(
    SOL_USDC_POOL_ID.toBase58()
  );
  const price = poolInfo.price?.toFixed(4) ?? "N/A";
  // Pool State, Vault残高, 現在価格をログ出力
}

💡 ポイント: VaultのAuthorityがPool State PDAであることに注目してください。このPDAの署名を生成できるのはCLMMプログラムだけであり、不正なTransferは全て失敗します。これがDEXのセキュリティの根幹です。

STEP 3: wSOL ATA作成 + SOL入金【TX #1】(wsol.ts)

wSOL ATAの作成とSOL入金を1つのトランザクションにまとめて送信します。3つのInstructionで構成されます。

# Instruction 処理内容
1 CreateAssociatedTokenAccount wSOL ATA新規作成
2 SystemProgram.Transfer SOL(0.01) をwSOL ATAへ送金
3 SyncNative lamports(12,039,280) → amount(10,000,000)に同期

wSOL ATAが既存の場合はCreateATAを省略し、TransferとSyncNativeのみの「TopUp」処理になります。

export function buildWsolDepositInstructions(
  owner: PublicKey,
  wsolAta: PublicKey,
  lamports: number
): TransactionInstruction[] {
  return [
    createAssociatedTokenAccountInstruction(owner, wsolAta, owner, WSOL_MINT),
    SystemProgram.transfer({ fromPubkey: owner, toPubkey: wsolAta, lamports }),
    createSyncNativeInstruction(wsolAta),
  ];
}
実行ログ:
[STEP 3/7] TX #1: wSOL ATA作成 + SOL入金
  ✅ トランザクション成功!
  Signature: 3KXjwoSFMNZnjHKSEQNtkpLWnikTaM7S3G4LoTEAw9KDUaV1ESYx563oGde4tHuCQyVW4m7UvZp1uz7YdEadh5co
  Slot: 400,576,249 / Fee: 5,000 lamports

STEP 4: Swap Instruction構築(swap-executor.ts)

Raydium SDKを使ってSwap Instructionを構築します。この段階ではまだトランザクションは送信せず、Instruction配列を生成するだけです。

const computeResult = PoolUtils.computeAmountOut({
  poolInfo: computePoolInfo,
  tickArrayCache: tickData[SOL_USDC_POOL_ID.toBase58()],
  baseMint: WSOL_MINT,
  epochInfo,
  amountIn: amountInBN,         // 10,000,000 lamports (= 0.01 SOL)
  slippage,                     // 0.005 (= 0.5%)
  priceLimit: new Decimal("0"),
  catchLiquidityInsufficient: false,
});

PoolUtils.computeAmountOut()はtickArrayCache(価格帯ごとの流動性情報)を使って期待出力量と最低受取量を計算します。CLMM(Concentrated Liquidity Market Maker)では流動性が特定の価格帯に集中しているため、この計算にはtick配列の走査が必要です。

SDKのraydium.clmm.swap()を呼ぶと、以下の要素が含まれたInstruction配列が生成されます。

  • SetComputeUnitLimit(400,000) — Compute Unitの上限設定
  • SetComputeUnitPrice(Priority Fee) — Priority Feeの設定
  • CreateATA(USDC) — USDC ATAが未存在の場合のみ、SDKが自動追加
  • Raydium CLMM Swap_v2 — 本体のSwap命令

⚠ 重要: SDKのexecute({ sendAndConfirm: true })はWebSocket(signatureSubscribe)必須のため使用できません。代わりにbuilder.AllTxData.instructionsからInstruction配列を取り出し、自前でTransaction構築・HTTPポーリング確認を行います。

STEP 5: Raydium CLMM Swap送信【TX #2】(swap-executor.ts)

STEP 4で構築したInstructionをTransactionにまとめて送信します。

const sdkInstructions = swapData.builder.AllTxData.instructions;

const { blockhash } = await connection.getLatestBlockhash("confirmed");
const swapTx = new Transaction();
swapTx.recentBlockhash = blockhash;
swapTx.feePayer = owner.publicKey;
swapTx.add(...sdkInstructions);

const signature = await sendAndConfirmWithPolling(connection, swapTx, [owner]);

TX #2の内部では、Raydium CLMMプログラムがToken ProgramへのCPI(Cross-Program Invocation)を2回実行します。

  1. Transfer: wSOL ATA → Vault 1(WSOL) — ユーザのwSOLがプールの金庫へ
  2. Transfer: Vault 2(USDC) → USDC ATA — プールの金庫からユーザへUSDC
実行ログ:
[STEP 5/7] TX #2: Raydium CLMM Swap送信
  期待される出力: ≈ 0.853484 USDC
  ✅ トランザクション成功!
  Signature: (Solscanで確認可能)

STEP 6: wSOL ATAクローズ + rent回収【TX #3】(wsol.ts)

スワップ完了後、不要になったwSOL ATAをクローズしてrent(2,039,280 lamports)を回収します。

const closeInstruction = buildWsolCloseInstruction(
  wsolAta,
  wallet.publicKey,  // destination(rent返却先)
  wallet.publicKey   // authority
);

ここで重要なのが、CloseAccountのprogramIdパラメータです。Raydium SDKがwSOLアカウントをTOKEN_2022_PROGRAM_IDで作成した場合、デフォルトのTOKEN_PROGRAM_IDでCloseしようとするとInvalidAccountDataエラーが発生します。そのため、account.owner(プログラムID)を確認してから適切なprogramIdを渡す必要があります。

export function buildWsolCloseInstruction(
  wsolAta: PublicKey,
  destination: PublicKey,
  authority: PublicKey,
  programId: PublicKey = TOKEN_PROGRAM_ID  // account.ownerで判定が必要
): TransactionInstruction {
  return createCloseAccountInstruction(wsolAta, destination, authority, [], programId);
}

STEP 7: スワップ後アカウント確認(account-logger.ts)

スワップ前後のSOL残高差分、USDC受取量、コスト内訳を計算して表示します。

実行ログ:
  コスト内訳:
  ┌────────────────────────────────────────────────────┐
  │ スワップ用 WSOL:          0.01000000 SOL                │
  │ USDC ATA rent:           0.00203928 SOL(初回のみ)      │
  │ wSOL ATA rent:           0.00000000 SOL(回収済み)      │
  │ TX手数料 #1(wSOL設定):  0.00000500 SOL                │
  │ TX手数料 #2(Swap):      0.00000500 SOL                │
  │ TX手数料 #3(Close):     0.00000500 SOL                │
  │─────────────────────────────────────────────────────│
  │ SOL消費合計:              0.01205428 SOL                │
  │ USDC受取:                0.853484 USDC                  │
  │ 実効レート:               1 SOL ≈ 85.39 USDC             │
  └────────────────────────────────────────────────────┘

STEP 8: USDC標準ATA統合【TX #統合】(swap-executor.ts)

これがMainnetテストで発見された、当初の設計にはなかった8番目のステップです。

STEP 5のSwap後、USDCが非標準ATAに入っていると、Phantom/SolflareからはUSDCが見えず、逆方向のUSDC→SOLスワップもできません。この問題を解決するために、非標準ATAから標準ATAへの統合トランザクションを追加しました。

# Instruction 処理内容
1 Transfer 非標準ATA → 標準ATA(全額移動)
2 CloseAccount 非標準ATA → 消滅 + rent回収
export async function executeConsolidateUsdc(
  connection: Connection,
  owner: Keypair,
  priorityFee: number
) {
  // 標準USDC ATAアドレスを計算(TOKEN_PROGRAM_IDベース)
  const standardUsdcAta = await getAssociatedTokenAddress(
    USDC_MINT, owner.publicKey, false,
    TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID
  );

  // 全USDCアカウントを検索
  const allUsdcAccounts = await connection.getParsedTokenAccountsByOwner(
    owner.publicKey, { mint: USDC_MINT }
  );

  // 標準ATA以外のアカウントを検出 → Transfer + Close
  for (const { pubkey, account } of allUsdcAccounts.value) {
    if (!pubkey.equals(standardUsdcAta)) {
      // Transfer: 非標準ATA → 標準ATA
      // CloseAccount: 非標準ATA → rent回収
    }
  }
}

この統合処理を実行することで、Phantom/Solflareで正しくUSDC残高が表示され、USDC→SOLの逆スワップも問題なく実行できるようになります。


5. 関わるプログラムの責任範囲

スワップには4つのプログラムが関与しています。それぞれの責任範囲を整理します。

Program Address 責任
System Program 1111...1111 アカウント作成、SOL Transfer
Token Program Tokenkeg...5DA Transfer、CloseAccount、SyncNative、MintTo
Associated Token Program ATokenGP...8knL PDA導出でATAアドレスを決定的に計算
Raydium CLMM CAMMCzo5...rWqK Swap_v2: 価格計算 → CPIでTransfer × 2

Token Programの責任範囲が広いことがポイントです。wSOL ATA、USDC ATA、Vault 1、Vault 2はすべてowner = Token Programであり、Transfer、CloseAccount、SyncNativeはToken Programだけが実行できます。Raydium CLMMはSwap命令の中でToken ProgramにCPIを行い、Vaultのauthority(Pool State PDA)の署名をinvoke_signedで提供することで、安全なトークン移動を実現しています。


6. 3つの問いフレームワーク

Solanaのトランザクションを理解するために、私は以下の「3つの問い」フレームワークを使っています。実装のロギングもこのフレームワークに沿って設計しており、各ステップで「今何が起きているのか」を追跡できるようにしています。

Q1: このアカウントは誰のもの?(address + owner)

アカウント address owner (Program) 備考
Dev Wallet 58K6xM2p...QHav System Program SOL残高はlamportsフィールド
wSOL ATA PDA導出 Token Program Swap後にClose → 消滅
USDC ATA ShnhW9Ky...kTCW Token Program authority = Dev Wallet
Pool State 8sLbNZoA...Wxj Raydium CLMM Vaultの署名権限を持つPDA
Vault 1 (WSOL) Pool内部 Token Program authority = Pool State PDA
Vault 2 (USDC) Pool内部 Token Program authority = Pool State PDA

Q2: このプログラムは何をする?(Program ID + ロジック)

各プログラムの具体的な処理は前セクションの表の通りです。特にRaydium CLMMのSwap_v2命令が内部で行うCPIの構造を理解することが重要です。

Q3: このトランザクションで何が起きた?(Instructions + CPI結果)

TX 名称 Instructions 結果
TX #1 wSOLセットアップ CreateATA + Transfer + SyncNative wSOL ATAに0.01 WSOLが格納された
TX #2 Raydium CLMM Swap ComputeUnit設定 + CreateATA(USDC) + Swap_v2 0.01 WSOL → ≈ 0.85 USDC
TX #3 wSOL Close CloseAccount 一時アカウント回収完了、rent返却
TX #統合 USDC ATA統合 Transfer + CloseAccount Phantom/SolflareでUSDCが見える状態に

7. TXの分割が必要な理由

「なぜ全部1つのトランザクションにまとめないのか?」という疑問があるかもしれません。

Raydium SDKのraydium.clmm.swap()を呼ぶ時点で、SDKはオンチェーンのwSOL ATAを参照します。wSOL ATAが存在しない場合はuser do not have token accountエラーになります。そのため、wSOL ATAを作成するTX #1を先にconfirmedにしてから、Swap TX #2を送信する必要があるのです。

同様に、TX #3(wSOL Close)はSwap完了後でないとwSOL ATAにまだ残高がある状態でのCloseとなり失敗します。TX #統合もSwap後のUSDC着金を確認してから実行する必要があります。


8. 実装上の注意点まとめ

Mainnetでの実行を通じて発見した重要な実装ポイントを整理します。

Blockhashのリトライ

Blockhashには有効期限(約60-90秒)があり、TX構築から送信まで時間がかかるとBlockhash not foundエラーが発生します。本実装ではリトライロジック(最大3回、2秒間隔)を組み込んでいます。

💡 何をリトライしているのか?
Swap Instructionなどの「TXの中身」はすでに構築済みです。リトライしているのは「最新のBlockhashを取得 → TXにセット → 署名 → 送信」の部分だけです。Blockhashは「このTXは最近作られたものです」というタイムスタンプのような役割を持ち、期限切れのBlockhashを含むTXはネットワークに拒否されます。新しいBlockhashを取り直してTXを再構築・再送信することで、この問題に対処しています。

本実装では4つのTXを順番に送信しますが、各TXの確認待ちに最大90秒かかる可能性があります。そのため、前のTXで取得したBlockhashを使い回すと期限切れのリスクが高くなります。このリトライ処理はTX #1〜TX #統合のそれぞれに個別に組み込まれており、各TXの送信直前に毎回新しいBlockhashを取得し、それでも期限切れになった場合は再取得してリトライする設計です。

let retries = 3;
while (retries > 0) {
  try {
    const { blockhash } = await connection.getLatestBlockhash("confirmed");
    // TX構築・送信
  } catch (err: any) {
    retries--;
    if (err?.message?.includes("Blockhash not found") && retries > 0) {
      await new Promise((r) => setTimeout(r, 2000));
      continue;
    }
    throw err;
  }
}

wSOL Close時のprogramId判定

前述の通り、SDKがTOKEN_2022で作成したアカウントをTOKEN_PROGRAM_IDでCloseしようとすると失敗します。getParsedTokenAccountsByOwnerで取得したaccount.ownerフィールドを使ってprogramIdを判定する必要があります。

大量スワップの安全弁

1 SOL以上のスワップ時には追加の確認プロンプトを表示する安全弁を設けています。Mainnetでの誤操作防止は地味ですが重要な実装ポイントです。


まとめ:「スワップ」の正体

本記事を通じて明らかになったのは、「SOLをUSDCにスワップする」という行為の正体は以下だということです。

  1. SOLをwSOLにラップする(トークンアカウント作成 → SOL送金 → SyncNativeで残高同期)
  2. 自分のwSOLをプールの金庫(Vault)に送る(CPI経由のToken Transfer)
  3. プールの金庫からUSDCを受け取る(CPI経由のToken Transfer)
  4. 一時アカウント(wSOL ATA)をクローズしてrentを回収する
  5. 非標準ATAのUSDCを標準ATAに統合する(ウォレットアプリとの互換性確保)

特別な「スワップ用の送金機能」があるわけではなく、既存のToken ProgramのTransfer命令をRaydium CLMMプログラムがCPIで2回呼び出しているだけです。DEXスワップの核心は「プログラム間の連携(CPI)」と「PDAによるアクセス制御」という、Solanaの基本的な仕組みの組み合わせに過ぎません。

理解のステップ 内容
ステップ1 ネイティブSOLはSPLトークンではないため、wSOLにラップしてからスワップする
ステップ2 スワップの実体はToken Programへの「CPI Transfer × 2」であり、Vault経由でトークンが交換される
ステップ3 Vaultのauthority(PDA)により、Raydium CLMMプログラム以外は金庫のトークンを動かせない
ステップ4 SDKの副作用(非標準ATA)はMainnetテストで初めて発覚し、統合TXの追加で対処した
ステップ5 Blockhashの有効期限、WebSocket非対応、programId判定など、実装上の課題はオンチェーンで動かして初めて見つかる

この構造を理解すれば、USDC → SOLの逆方向スワップへの応用、他のDEXプロトコル(Orca、Jupiter)の内部構造の理解、さらにはCLMMの流動性提供(LP: Liquidity Provider)側の仕組みへと知識を広げることができます。

Accenture Japan (有志)

Discussion