📑

@solana/spl-tokenを使わずに phantom wallet からSPLトークンを送信する

2025/02/28に公開

JavaScriptを使ったSPLトークンの送信プログラムについて解説します。

Phantomを始めとした wallet api に関するドキュメントでは、@solana/web3.jsを使った例はみられますが、そのほとんどはSOLを送信するための実装になっています。

SOLはSolanaチェーン上でのネイティブトークンなので確かに使い勝手はいいです。
しかし同様に、SPLトークン(Solana Program Library)を扱うニーズも高いです。

そこでSPLトークンをweb3.jsのみで扱う例として、本記事を執筆します。

同じようなツールを開発する際の一助となれば幸いです。

前提として、実装以前はWeb3開発についての知識がほぼない人間でした。

しかし、(ありがたいことに)2日程度でこういうニーズを満たすツールを作成しなければいけない機会に遭遇しましたので、インプットしながらの実装になっています。

理解の間違いや実装の不備などございましたらフィードバック大歓迎です。

本記事の対象読者

本記事は下記の読者を想定します。

  • ある程度Web2の開発経験がある人
  • Web3に関する知識が乏しい人
  • SPLトークンの送信方法についての情報が必要な人
  • (Web3開発熟練者。ツッコミお待ちしてます)

都度、Web3開発に関する基本的な用語を押さえながら、実装方法を提供していきます。

本記事で扱わないこと

下記に関しては本記事では扱わないこととします。

  • バックエンド/フロント分離
  • Auth0等の認証認可機能
  • 丁寧なハンドリング
  • 本番環境へのデプロイ手法
  • フロントエンドの技術選定
  • 単一→複数の送金の遷移方法

今回の期間で全てを実現するのが難しい & toC には解放しない社内ツールのため、この辺りは対応しない判断をしました。

この辺りはWeb3特有のものではありません。

一般的なWeb2に関する開発知識があれば実現可能な要件ですので、Web上あるいはAIと協業しながら、必要に応じて追加で実装していただければと思います。

SPLトークンの基礎

私も開発を始めてから知った、「SPLトークン」というものについてちゃんと理解をしておきます。

SPLトークンというのは、「Solana Program Library」の略です。

SolanaのドキュメントGemini を参照するに、

Solana, like Ethereum, supports other tokens as well as native SOL. SPL is the token standard for Solana network tokens, analogous to ERC-20 tokens on the Ethereum network.

「SolanaはEthereumと同様に、ネイティブSOLだけでなく他のトークンもサポートしている。SPLはソラナネットワークトークンのトークン標準であり、イーサリアムネットワークのERC-20トークンに類似している。」

とのこと。

solanaチェーン上にはSOLがネイティブトークンとして存在していますが、その他のカスタムトークンと理解しておけば、実装上は問題ないでしょう。

RPCエンドポイントの設定とデプロイ

今回は API 経由で Solana チェーンとトランザクションのやり取りをする必要があります。

その窓口となるのが「RPCエンドポイント」です。

このエンドポイントを通じて、Solanaチェーン上からデータを取得したり、トランザクションの送信をすることになります。

バリデータとRPCノード


solanaチェーンにはバリデータ(validator)というものが存在しています。

そして validator には leader と その他の役割があります。

leader の validator がブロックチェーンに対して新しいブロックを生成しようとし、その他の validator が承認作業を行います。

この書き込みと承認がそろって、初めて新しいブロックが繋ぎ込まれるという流れです。

これが solana チェーンの仕組みです。

そして、この validator とやりとりするのが 「RPCノード」というものです。

RPCノードとRPCエンドポイント


上記のRPCノードと、私たちが作るアプリの接合点がRPCエンドポイントになります。

つまり私たちはアプリ -> RPCエンドポイント -> RPCノード → solana チェーン

という経路でトランザクションのやり取りをすることになります。

RPCエンドポイントの選定

solanaの公式ドキュメントに記載されているのは下記3つです。

順番に開発用ネットワーク、テスト用ネットワーク、本番用ネットワークです。

今回は実際のSPLトークンを送受信したいので、開発用・テスト用は除外した上で選定を行います。

本番として公開エンドポイントを避けた理由

上記で公開されているのは「公開エンドポイント」です。

つまり、「全世界のシステムがこのエンドポイントに一斉アクセスする」場所です。

必然的に縛りは厳しくなります。

  • rate limit
  • 混雑時のネットワーク不安定さ
  • 責任所在がないこと

しょうがないですが、DeFiやNFTマケプレなどのダウンタイムがそのままビジネス損失につながるようなモデルだと、信頼性に欠けるのが正直なところです。

今回は社内ツールなのでこの考慮は不要ですが、あえてここを選定するのはデメリットがメリットを上回ると判断しました。

サードパーティ中での選定

直接公開エンドポイントにアクセスせず、代理でアクセスをプロキシしてくれるサードパーティのツールがあります。

調査したところ、代表的なのはこのあたりが有名とのこと。

Felo AI による探索結果を下記に添付します。

1. Alchemy
メリット:
無料プラン: 月間330万リクエストまで対応し、小規模から中規模のプロジェクトに適しています。
開発者向けツール: デバッグツールやリアルタイムデータストリーミングなど、豊富な機能を提供しています。
使いやすさ: 詳細なドキュメントと直感的なインターフェースで、迅速な導入が可能です。

デメリット:
有料プランのコスト: 高トラフィックのプロジェクトでは、月額$49から$199のプランが必要となり、コストが増加します。


2. QuickNode
メリット:
高速な応答時間: 高頻度取引やリアルタイムアプリケーションに最適です。
多地域展開: 複数のリージョンでのデプロイにより、低遅延アクセスが可能です。
手頃な価格: 有料プランは月額$10からと、予算に優しい選択肢です。

デメリット:
無料プランの制限: 月間100,000リクエストと制限が厳しく、大規模プロジェクトには不向きです。
追加機能のコスト: JupiterやgRPCプラグインなどの追加機能は別途料金が発生します。


3. Triton
メリット:
Solana特化: Solana向けに最適化され、高パフォーマンスを提供します。
低遅延: リアルタイムアプリケーションや高並列プロジェクトに適しています。

デメリット:
高コスト: 最も安いプランでも月額$500からと、予算に制約のあるプロジェクトには負担が大きいです。
無料プランなし: 試用や小規模プロジェクト向けの無料プランが提供されていません。


4. Helius
メリット:
データインデックス機能: オンチェーンデータへの迅速なアクセスが可能で、データ集約型アプリケーションに最適です。
無料プラン: 月間500,000リクエストまで対応し、小規模から中規模のプロジェクトに適しています。
リアルタイムデータ: WebhookやGeyser APIを通じて、リアルタイムのデータストリーミングが可能です。

デメリット:
応答時間: TritonやQuickNodeと比較して、若干遅れる場合があります。
有料プランのコスト: 高トラフィックのプロジェクトでは、月額$20以上のプランが必要となります。

要件によって選定は変わりますが、私の場合は「日本人による発信者」が目立つことも選定の基準にしました。

自身にWeb3の知識が少ない&アングラな世界だからこそ慎重にいくため、上記基準を加味して「Helius」を選定しました。

結果、社内ツールとしては十二分な

  • 月50,000回無料リクエスト
  • GitHubによる迅速連携

という要件を満たした上で、意思決定〜エンドポイント発行まで5分もかからず実行できました。

以降、プロバイダーはHeliusとして進めていきます。

SPLトークン送金の実装

RPCエンドポイントを払い出せたら、SPLトークンを送金する準備ができました。

Phantom Provider の取得

SPLトークンを送金するにあたって、「どのウォレットから」送金しようとしてるか、可視化できるようにします。

意図しないウォレットから送金しちゃダメですからね。

@solana/web3.jsを任意の package manager で install した後、ブラウザの window object から phantom 拡張機能を通じて、provider を取得する処理を書きます。

const getProvider = () => {
  if ('phantom' in window) {
    const provider = window.phantom?.solana;
    if (provider?.isPhantom) return provider;
  }
  window.open('https://phantom.app/', '_blank');
};

export default getProvider;

この provider と任意のフロントエンドフレームワークで、「送信元」の wallet address を画面に出力するようにします。

こうすることで、意図しない送金を防ぐことができます。

送信元・送信先のATAを算出する

送信元の wallet address が正しいことがわかったら、送信元・送信先のATAを算出します。

ATAとは、Associated Token Account のことです。

と言われてもピンとこないので、例を使ってイメージしてみます。

普通の銀行口座の場合。

「普通預金口座」は円専用の口座ですよね。

「外貨預金口座」はドルやユーロなど、それぞれの通貨専用の口座です。

Solanaでも同じような考え方があります。

基本のウォレットはSOL(Solanaのネイティブ通貨)用です。
それ以外のトークンには、それぞれ専用の「口座」が必要です。

今回でいう、SPLトークンですね。

この専用の「口座」のことを「Associated Token Account (ATA)」と呼びます。

プログラム的には、このATAを算出する必要があります。

具体的には下記の通り。

送信「元」

    const [fromATA] = await PublicKey.findProgramAddress(
      [
        publicKey.toBuffer(),
        TOKEN_PROGRAM_ID.toBuffer(),
        mintAddress.toBuffer()
      ],
      ASSOCIATED_TOKEN_PROGRAM_ID
    );

送信「先」

    const [toATA] = await PublicKey.findProgramAddress(
      [
        new PublicKey(recipientAddress).toBuffer(),
        TOKEN_PROGRAM_ID.toBuffer(),
        mintAddress.toBuffer()
      ],
      ASSOCIATED_TOKEN_PROGRAM_ID
    );

このような対応関係で、送信元と送信先のATAを取得します。

なお、PublicKey は @solana/web3.js から分割代入すればよく、

  • TOKEN_PROGRAM_ID
  • ASSOCIATED_TOKEN_PROGRAM_ID

の意味は下記の通り。

  • TOKEN_PROGRAM_ID: 「トークンを管理する銀行の本店」
  • ASSOCIATED_TOKEN_PROGRAM_ID: 「銀行口座を開設する窓口」

つまり、「公開されている固定値」ということです。

トークンによって変わらない値のため、どのSPLトークンでも使いまわせます。

const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL');

これは極端な話、直書きでもいいでしょう。

トークンによって変わるのは mintAddressです。

こちらは、solscan なりで調べて、「Misc」の「Token Address」にて取得できる値をセットします。

ATAの作成

宛先のATAが存在しない場合は Phantom Wallet上でこのような警告が表示されます。

送金のシミュレーションに失敗したとのこと。
無視して実行してみると、solscan上で下記のようなエラーを観測できます。

実は、ATAが存在しないと送金を完遂することができず、このネットワーク手数料がかすめ取られることになります。

そのため、

  • ATAの存在を確認すること
  • 存在しない場合は作成のInstructionを「送金前に」追加すること

の2点が必要になります。

    const toATAInfo = await connection.getAccountInfo(toATA);
    if (!toATAInfo) {
      // ATAが存在しない場合、作成命令を追加
      const createATAIx = new TransactionInstruction({
        keys: [
          { pubkey: publicKey, isSigner: true, isWritable: true },           // 手数料支払者(Payer)
          { pubkey: toATA, isSigner: false, isWritable: true },             // 作成するATA
          { pubkey: recipientPubkey, isSigner: false, isWritable: false },  // ウォレット所有者
          { pubkey: mintAddress, isSigner: false, isWritable: false },      // ミントアドレス
          { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, // システムプログラム
          { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false },       // トークンプログラム
          { pubkey: ASSOCIATED_TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, // ATAプログラム
        ],
        programId: ASSOCIATED_TOKEN_PROGRAM_ID,
        data: Buffer.from([])
      });
      instructions.push(createATAIx);
    }
    // 以上を TransferCheckedインストラクションの「前」に行う必要がある

重要なのは下記の部分です。

   instructions.push(createATAIx);
    // 以上を TransferCheckedインストラクションの「前」に行う必要がある

instructionは追加順に実行されるため、「送金の前」に追加しておく必要があります。

ATAを作成するにはSOLが必要ですので注意しましょう。

補足: ATAが作成されるタイミング

上記の通り、プログラムでATAを作成することができます。

しかし、いちいちプログムで払い出していたら消費されるSOLが大変なことになります。

別の方法で作成する方法が存在しました。

一番信頼性が高そうな文献(コミュニティ)に下記のような記述がありました。

If this is your first time receiving or interacting with a specific token (e.g., a meme coin), the network creates an Associated Token Account for that token.

トークンを「初めて」受け取ったタイミングで、ATAが作成されるとのこと。

つまり、送金したいSPLトークンをあらかじめ少額でも受け取っておけばいいわけです。

送信する側 = 仕掛ける側からしたら、AirDropなどで事前配布しておけば、広告宣伝&ATAを払い出すSOLの節約 を兼ねられそうです。

今回の実装では「ATAがなかったら作成」というInstructionを構築しましたが、このような手法を組み合わせることも有効です。

TransferCheckedインストラクションの構築

ここまでで、「誰から」「どこへ」のATA情報が揃いました。

では、ここで具体的な送金処理の指示 = instruction を構築します。

    const transferIx = new TransactionInstruction({
      keys: [
          { pubkey: fromATA, isSigner: false, isWritable: true },        // 送金元のATA (source)
          { pubkey: mintAddress, isSigner: false, isWritable: false },   // ミントアドレス (mint)
          { pubkey: toATA, isSigner: false, isWritable: true },          // 送金先のATA (destination)
          { pubkey: publicKey, isSigner: true, isWritable: false }       // オーナー (authority)
      ],
      programId: TOKEN_PROGRAM_ID,
      data: Buffer.from([
        0x0c,
        ...new BN(parseFloat(amount) * (10 ** decimals)).toArray('le', 8),
        decimals
      ])
    });

    instructions.push(transferIx);
  }

  const transaction = new Transaction().add(...instructions);
  transaction.feePayer = publicKey;
  transaction.recentBlockhash = (await connection.getLatestBlockhash()).blockhash;

  return transaction;
};

この decimails は、solscan 上でコントラクトアドレスを入力すると確認可能です。

注意としては、transferIx.keysはこの順に実行され、solacan上で閲覧可能状態になるということです。

この順序が入れ替わったり不足したりすると、invalid account data for instruction とうエラーが出ます。

(BNはパッケージマネージャでinstallしてください)

transaction への署名

instruction が構築できたら、署名を行います。

この transaction の正当性を保証するものですね。

const signAndSendTransaction = async (
  provider,
  transaction
) => {
  try {
    const { signature } = await provider.signAndSendTransaction(transaction, {skipPreflight: false});
    return signature;
  } catch (error) {
    console.warn(error);
    throw new Error(error.message);
  }
};

export default signAndSendTransaction;

こちらは、公式sandboxをそのままいただきました。

これで十分です。

トランザクションの監視

transaction に署名ができたら、それらが無事に実行されるか polling します。

const POLLING_INTERVAL = 1000; // one second
const MAX_POLLS = 5;

const pollSignatureStatus = async (
  signature,
  connection,
) => {
  let count = 0;

  const interval = setInterval(async () => {
    // Failed to confirm transaction in time
    if (count === MAX_POLLS) {
      clearInterval(interval);
      return;
    }

    const { value } = await connection.getSignatureStatus(signature);
    const confirmationStatus = value?.confirmationStatus;

    if (confirmationStatus) {
      const hasReachedSufficientCommitment = confirmationStatus === 'confirmed' || confirmationStatus === 'finalized';

      if (hasReachedSufficientCommitment) {
        clearInterval(interval);
        return;
      }
    }

    count++;
  }, POLLING_INTERVAL);
};

export default pollSignatureStatus;

こちらも公式から拝借しました。

interval はご自身の環境によって調整ください。

全体の動作とまとめ

上記によって、下記の動作が観測されるはずです。

  1. 送信「元」のウォレットアドレスが画面に表示できる
  2. 任意のフロントFWによって送金対象のアドレス、金額がリクエストされる
  3. JSでキャッチし、ATAが特定される
  4. ATAが存在しなかったらSOLを消費して作成する
  5. 送信元・送信先のATA関係によって、指定した金額が送金される

ウォレットアドレスからATAを算出し、用意したinstructionを使って transaction を作成することでsplトークンを web3.jsのみで送金することができます。

また、ATAを作成するのに必要なSOLは、AirDropなどで事前に対象トークンを受け取っておけば消費を回避できる点も頭の片隅に置いておくといいでしょう。

Heliusを使うことによるapi key の漏洩や、JavaScript丸見え問題などはご調整ください。

Discussion