はじめに
対象読者
環境構築
プロジェクトの作成
パッケージのインストール
ファイルの作成とローカルバリデータの起動
実装の全体像
完成コード
コード解説
[1] RPC接続
[2] Authorityの作成とSOL付与
[3] Mintキーペア生成
[4] Transfer Fee Extensionの定義
手数料率の設定(transferFees)
TransferFeeConfigの設定
[5] SpaceとRentの計算
[6] MintAccount作成命令
[7] TokenAccount作成命令
[8] トークン発行命令
[9] トランザクション構築・署名・送信
[10] 手数料付き送金
[11] 手数料の確認
[12] 結果の出力
実行と確認
まとめ
https://s3.ap-northeast-1.amazonaws.com/hanzochang.com/_v2/1770796291024_in6kpp9eza.png

Token2022で作るステーブルコインに送金手数料機能を設定 — TransferFee【TokenExtension解説】

Solana Token2022のTransferFee拡張を使い、トークン送金時に手数料を自動徴収する仕組みを解説。basis pointsによる手数料率設定から手数料付き送金の実行まで、@solana/kitによるTypeScript実装をコード付きで紹介します。

公開日2026.01.30

更新日2026.01.30

Crypto

はじめに

ステーブルコインやDeFiトークンを運用する際、「送金のたびに手数料を徴収したい」という要件はよくあります。プロトコル運営費、発行者へのロイヤリティ、エコシステムファンドへの還元など、ユースケースは多岐にわたります。

Token2022(Token Extensions Program)のTransferFee拡張を使えば、独自プログラムなしにSolanaネイティブで送金手数料の自動徴収が可能です。

この記事では、Token2022で作成するトークンにTransferFeeを設定し、手数料付き送金を実行するまでの手順を解説します。

📖 公式ドキュメント

本記事はSolana公式ドキュメントを参考に解説しています。 Transfer Fees | Solana

対象読者

  • Solanaの基本的な概念(アカウント、トランザクション)を理解している方
  • TypeScriptの基礎知識がある方
  • トークンの送金手数料の仕組みに興味がある方

環境構築

プロジェクトの作成

mkdir token-extension-examples
cd token-extension-examples
pnpm init

パッケージのインストール

pnpm add @solana-program/system @solana-program/token-2022 @solana/kit
pnpm add -D tsx
  • @solana/kit — Solana開発の基盤。RPC接続、トランザクション作成、署名、送信などの基本機能を含むパッケージです
  • @solana-program/system — System Programのクライアント。アカウント作成に使います
  • @solana-program/token-2022 — Token-2022 Programのクライアント。Token2022やExtensionの操作で利用します
  • tsx — TypeScriptファイルの実行ツール
💡 @solana/kit について

ネット上にはweb3.jsを使用した例が多く存在しますが、現在は後継ライブラリである@solana/kitの利用が推奨されています。本記事ではkitを使用します。

ファイルの作成とローカルバリデータの起動

mkdir src
touch src/transfer-fee.ts
surfpool start

実装の全体像

この記事では以下のアカウントが登場します。

  • Authority — Mintアカウントを生成する管理者(手数料支払者も兼ねる)
  • Mint — Token2022のMintアカウント(Transfer Fee設定を含む)
  • TokenAccount — 送金元のトークンアカウント
  • TokenAccount2 — 送金先のトークンアカウント

実装は大きく4つのフェーズに分かれます。

事前準備            → RPC接続、Authority作成、Mintキーペア生成
Token2022命令作成   → Extension定義、Space/Rent計算、各種命令の作成
トランザクション実行 → 命令をまとめてトランザクションとして送信
手数料付き送金      → 別アカウントへ送金し、手数料の徴収を確認

完成コード

まず完成コードの全体像を示します。コード内の [1][12] のアンカー番号は、後続のセクションで詳しく解説します。

import { getCreateAccountInstruction } from '@solana-program/system'
import {
  extension,
  fetchToken,
  getInitializeAccountInstruction,
  getInitializeMintInstruction,
  getInitializeTransferFeeConfigInstruction,
  getMintSize,
  getMintToInstruction,
  getTokenSize,
  getTransferCheckedWithFeeInstruction,
  TOKEN_2022_PROGRAM_ADDRESS,
} from '@solana-program/token-2022'
import {
  airdropFactory,
  appendTransactionMessageInstructions,
  assertIsTransactionWithBlockhashLifetime,
  createSolanaRpc,
  createSolanaRpcSubscriptions,
  createTransactionMessage,
  generateKeyPairSigner,
  getSignatureFromTransaction,
  lamports,
  pipe,
  sendAndConfirmTransactionFactory,
  setTransactionMessageFeePayerSigner,
  setTransactionMessageLifetimeUsingBlockhash,
  signTransactionMessageWithSigners,
} from '@solana/kit'

// --- [1] RPC接続 ---
const rpc = createSolanaRpc('http://localhost:8899')
const rpcSubscriptions = createSolanaRpcSubscriptions('ws://localhost:8900')

// --- [2] Authorityの作成とSOL付与 ---
const authority = await generateKeyPairSigner()
await airdropFactory({ rpc, rpcSubscriptions })({
  recipientAddress: authority.address,
  lamports: lamports(5_000_000_000n), // 5 SOL
  commitment: 'confirmed',
})

// --- [3] Mintキーペア生成 ---
const mint = await generateKeyPairSigner()

// --- [4] Transfer Fee Extensionの定義 ---
const transferFees = {
  epoch: 0n,
  maximumFee: 1_000_000n,
  transferFeeBasisPoints: 100, // 1%
}

const transferFeeConfigExtension = extension('TransferFeeConfig', {
  transferFeeConfigAuthority: authority.address,
  withdrawWithheldAuthority: authority.address,
  withheldAmount: 0n,
  newerTransferFee: transferFees,
  olderTransferFee: transferFees,
})

// --- [5] SpaceとRentの計算 ---
const space = BigInt(getMintSize([transferFeeConfigExtension]))
const rent = await rpc.getMinimumBalanceForRentExemption(space).send()

// --- [6] MintAccount作成命令 ---
const createMintAccountInstruction = getCreateAccountInstruction({
  payer: authority,
  newAccount: mint,
  lamports: rent,
  space,
  programAddress: TOKEN_2022_PROGRAM_ADDRESS,
})

const initializeTransferFeeConfigInstruction = getInitializeTransferFeeConfigInstruction({
  mint: mint.address,
  transferFeeConfigAuthority: authority.address,
  withdrawWithheldAuthority: authority.address,
  transferFeeBasisPoints: 100,
  maximumFee: 1_000_000n,
})

const initializeMintInstruction = getInitializeMintInstruction({
  mint: mint.address,
  decimals: 6,
  mintAuthority: authority.address,
  freezeAuthority: authority.address,
})

// --- [7] TokenAccount作成命令(送金元・送金先の2つ) ---
const tokenAccount = await generateKeyPairSigner()

const transferFeeAmountExtension = extension('TransferFeeAmount', {
  withheldAmount: 0n,
})
const tokenAccountLen = BigInt(getTokenSize([transferFeeAmountExtension]))
const tokenAccountRent = await rpc.getMinimumBalanceForRentExemption(tokenAccountLen).send()

const createTokenAccountInstruction = getCreateAccountInstruction({
  payer: authority,
  newAccount: tokenAccount,
  lamports: tokenAccountRent,
  space: tokenAccountLen,
  programAddress: TOKEN_2022_PROGRAM_ADDRESS,
})

const initializeTokenAccountInstruction = getInitializeAccountInstruction({
  account: tokenAccount.address,
  mint: mint.address,
  owner: authority.address,
})

// 送金先
const tokenAccount2 = await generateKeyPairSigner()
const createTokenAccount2Instruction = getCreateAccountInstruction({
  payer: authority,
  newAccount: tokenAccount2,
  lamports: tokenAccountRent,
  space: tokenAccountLen,
  programAddress: TOKEN_2022_PROGRAM_ADDRESS,
})
const initializeTokenAccount2Instruction = getInitializeAccountInstruction({
  account: tokenAccount2.address,
  mint: mint.address,
  owner: authority.address,
})

// --- [8] トークン発行命令 ---
const mintAmount = 1_000_000_000n // 1,000 tokens (decimals=6)
const mintToInstruction = getMintToInstruction({
  mint: mint.address,
  token: tokenAccount.address,
  mintAuthority: authority,
  amount: mintAmount,
})

// --- [9] トランザクション構築・署名・送信 ---
const instructions = [
  createMintAccountInstruction,
  initializeTransferFeeConfigInstruction,
  initializeMintInstruction,
  createTokenAccountInstruction,
  initializeTokenAccountInstruction,
  mintToInstruction,
  createTokenAccount2Instruction,
  initializeTokenAccount2Instruction,
]

const { value: latestBlockhash } = await rpc.getLatestBlockhash().send()

const transactionMessage = pipe(
  createTransactionMessage({ version: 0 }),
  (tx) => setTransactionMessageFeePayerSigner(authority, tx),
  (tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
  (tx) => appendTransactionMessageInstructions(instructions, tx),
)

const signedTransaction = await signTransactionMessageWithSigners(transactionMessage)
assertIsTransactionWithBlockhashLifetime(signedTransaction)

await sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions })(signedTransaction, {
  commitment: 'confirmed',
  skipPreflight: true,
})

// --- [10] 手数料付き送金 ---
const transferAmount = 100_000_000n // 100 tokens
const transferFee = 1_000_000n // 1 token (1% of 100)

const transferInstruction = getTransferCheckedWithFeeInstruction({
  source: tokenAccount.address,
  mint: mint.address,
  destination: tokenAccount2.address,
  authority: authority,
  amount: transferAmount,
  decimals: 6,
  fee: transferFee,
})

const { value: latestBlockhash2 } = await rpc.getLatestBlockhash().send()

const transferTransactionMessage = pipe(
  createTransactionMessage({ version: 0 }),
  (tx) => setTransactionMessageFeePayerSigner(authority, tx),
  (tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash2, tx),
  (tx) => appendTransactionMessageInstructions([transferInstruction], tx),
)

const signedTransferTransaction = await signTransactionMessageWithSigners(
  transferTransactionMessage,
)
assertIsTransactionWithBlockhashLifetime(signedTransferTransaction)

await sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions })(signedTransferTransaction, {
  commitment: 'confirmed',
  skipPreflight: true,
})

// --- [11] 手数料の確認 ---
const tokenAccount2Data = await fetchToken(rpc, tokenAccount2.address)
let withheldAmount = 0n
if (tokenAccount2Data.data.extensions.__option === 'Some') {
  for (const ext of tokenAccount2Data.data.extensions.value) {
    if (ext.__kind === 'TransferFeeAmount') {
      withheldAmount = ext.withheldAmount
      break
    }
  }
}

// --- [12] 結果の出力 ---
const transactionSignature = getSignatureFromTransaction(signedTransaction)
const transferSignature = getSignatureFromTransaction(signedTransferTransaction)

console.log('【Explorer URL】')
console.log(
  `Authority: https://explorer.solana.com/address/${authority.address}?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899`,
)
console.log(
  `Mint: https://explorer.solana.com/address/${mint.address}?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899`,
)
console.log(
  `送金元TokenAccount: https://explorer.solana.com/address/${tokenAccount.address}?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899`,
)
console.log(
  `送金先TokenAccount: https://explorer.solana.com/address/${tokenAccount2.address}?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899`,
)
console.log(
  `Mint作成TX: https://explorer.solana.com/tx/${transactionSignature}?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899`,
)
console.log(
  `TransferTX: https://explorer.solana.com/tx/${transferSignature}?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899`,
)

コード解説

以降、各アンカー番号に沿って解説していきます。

[1] RPC接続

const rpc = createSolanaRpc('http://localhost:8899')
const rpcSubscriptions = createSolanaRpcSubscriptions('ws://localhost:8900')

@solana/kitの2つの関数でローカルバリデータに接続します。

  • createSolanaRpc — HTTPでトランザクション送信やデータ取得に使用
  • createSolanaRpcSubscriptions — WebSocketでトランザクション監視に使用。ポーリング不要でトランザクションの完了を検知できます

[2] Authorityの作成とSOL付与

const authority = await generateKeyPairSigner()
await airdropFactory({ rpc, rpcSubscriptions })({
  recipientAddress: authority.address,
  lamports: lamports(5_000_000_000n),
  commitment: 'confirmed',
})

generateKeyPairSignerで署名オブジェクト(秘密鍵と公開鍵のペア)を生成します。Authorityはトークンの管理者であり、トランザクション手数料の支払者も兼ねます。

lamportsはSOLの最小単位で、1 SOL = 1,000,000,000 lamports。5 SOLを付与しています。

[3] Mintキーペア生成

const mint = await generateKeyPairSigner()

Mintアカウントのアドレスとして使うキーペアを生成します。

[4] Transfer Fee Extensionの定義

const transferFees = {
  epoch: 0n,
  maximumFee: 1_000_000n,
  transferFeeBasisPoints: 100,
}

const transferFeeConfigExtension = extension('TransferFeeConfig', {
  transferFeeConfigAuthority: authority.address,
  withdrawWithheldAuthority: authority.address,
  withheldAmount: 0n,
  newerTransferFee: transferFees,
  olderTransferFee: transferFees,
})

手数料率の設定(transferFees)

  • epoch — この設定が有効になるエポック。0nは即時有効を意味します
  • transferFeeBasisPoints — 手数料率をbasis pointsで指定。1 basis point = 0.01%なので、100 = 1%です
  • maximumFee — 手数料の上限。どれだけ大きな金額を送っても、この上限を超えません。decimals=6の場合、1_000_000は1トークンに相当します
💡 EpochとSlotについて

Solanaでは時間の単位としてSlotとEpochがあります。Slotはブロック番号のようなもので、約400msごとに1つ増えます。1 Epoch = 432,000スロット(約2日)です。epoch: 0nと設定すると、現在のエポック番号に自動変換されます。

TransferFeeConfigの設定

  • transferFeeConfigAuthority — 手数料設定を変更できる権限者
  • withdrawWithheldAuthority — 蓄積した手数料を回収できる権限者
  • withheldAmount — 蓄積された手数料。初期化時は0n
  • newerTransferFee / olderTransferFee — 手数料率の変更を安全に行うための二重構造です。初期化時は両方同じ値を設定します。今後更新するときはnewerTransferFeeに新しい定義を入れます

[5] SpaceとRentの計算

const space = BigInt(getMintSize([transferFeeConfigExtension]))
const rent = await rpc.getMinimumBalanceForRentExemption(space).send()

Transfer Fee Extensionは固定長なので、計算はシンプルです。SpaceとRentで同じ値を使えます。

[6] MintAccount作成命令

const createMintAccountInstruction = getCreateAccountInstruction({
  payer: authority,
  newAccount: mint,
  lamports: rent,
  space,
  programAddress: TOKEN_2022_PROGRAM_ADDRESS,
})

const initializeTransferFeeConfigInstruction = getInitializeTransferFeeConfigInstruction({
  mint: mint.address,
  transferFeeConfigAuthority: authority.address,
  withdrawWithheldAuthority: authority.address,
  transferFeeBasisPoints: 100,
  maximumFee: 1_000_000n,
})

const initializeMintInstruction = getInitializeMintInstruction({
  mint: mint.address,
  decimals: 6,
  mintAuthority: authority.address,
  freezeAuthority: authority.address,
})

3つの命令を順に作成します。

  • getCreateAccountInstruction — Mintアカウントを作成
  • getInitializeTransferFeeConfigInstruction — Transfer Feeの設定を初期化。Extension固有の命令です
  • getInitializeMintInstruction — Mintの基本情報を設定。今回はdecimals: 6を使用します(USDCやDeFi系トークンでは6が一般的)

[7] TokenAccount作成命令

const transferFeeAmountExtension = extension('TransferFeeAmount', {
  withheldAmount: 0n,
})
const tokenAccountLen = BigInt(getTokenSize([transferFeeAmountExtension]))

Transfer Fee付きトークンのTokenAccountには、TransferFeeAmount拡張用のスペースが追加で必要です。この拡張は送金を受けたTokenAccountに手数料を蓄積するための領域です。

送金元・送金先の2つのTokenAccountを同じサイズ・同じRentで作成します。

[8] トークン発行命令

const mintAmount = 1_000_000_000n // 1,000 tokens (decimals=6)

1,000トークンを発行します。decimals=6なので、1,000トークン = 1,000 × 10^6 = 1_000_000_000nです。

[9] トランザクション構築・署名・送信

const instructions = [
  createMintAccountInstruction,
  initializeTransferFeeConfigInstruction,
  initializeMintInstruction,
  createTokenAccountInstruction,
  initializeTokenAccountInstruction,
  mintToInstruction,
  createTokenAccount2Instruction,
  initializeTokenAccount2Instruction,
]

8つの命令を配列にまとめてトランザクションとして送信します。

pipeでトランザクションメッセージを構築し、ブロックハッシュで有効期限を設定して署名・送信する流れは、Token2022の基本パターンです。

assertIsTransactionWithBlockhashLifetimeは、@solana/kit v4から必要になったTypeScriptの型アサーション関数で、ブロックハッシュ方式の有効期限を持つことを保証します。

[10] 手数料付き送金

const transferAmount = 100_000_000n // 100 tokens
const transferFee = 1_000_000n // 1 token (1% of 100)

const transferInstruction = getTransferCheckedWithFeeInstruction({
  source: tokenAccount.address,
  mint: mint.address,
  destination: tokenAccount2.address,
  authority: authority,
  amount: transferAmount,
  decimals: 6,
  fee: transferFee,
})

ここがTransfer Feeの核心部分です。getTransferCheckedWithFeeInstructionを使い、手数料付きで送金します。

feeは送信者が明示的に指定する必要があります。 もしオンチェーンで自動計算される仕組みだと、トランザクション送信中に運営者が手数料率を変更した場合、送信者は予期しない手数料を取られてしまいます。送信者がfeeを指定し、実際の設定と一致しなければトランザクションが失敗する仕組みにすることで、ユーザーが保護されています。

送金トランザクションは最初のMint作成トランザクションとは別に実行します。前のトランザクション後の新しいブロックハッシュを取得する点に注意してください。

[11] 手数料の確認

const tokenAccount2Data = await fetchToken(rpc, tokenAccount2.address)
let withheldAmount = 0n
if (tokenAccount2Data.data.extensions.__option === 'Some') {
  for (const ext of tokenAccount2Data.data.extensions.value) {
    if (ext.__kind === 'TransferFeeAmount') {
      withheldAmount = ext.withheldAmount
      break
    }
  }
}

送金先TokenAccountのextensionsからTransferFeeAmountを探し、withheldAmount(蓄積された手数料)を取得します。

extensionsはOption型で、拡張の有無をチェックしてからforループで該当する拡張を探します。

[12] 結果の出力

Explorer URLを出力します。?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899を付けることで、ローカルバリデータの結果をSolana Explorer上で確認できます。

Explorerの送金先TokenAccountで「Extensions」タブを開くと、withheldAmountに蓄積された手数料が表示されます。

実行と確認

pnpm tsx src/transfer-fee.ts

まとめ

Transfer Fee拡張を使うことで、独自プログラムなしにSolanaネイティブで送金手数料の自動徴収が実現できます。

実装のポイントは以下です。

  • TransferFeeConfig Extensionで手数料率と上限を設定する
  • newerTransferFee / olderTransferFeeの二重構造で、手数料率変更を安全に行える
  • TokenAccountにもTransferFeeAmount拡張のスペースが必要
  • 送金時はgetTransferCheckedWithFeeInstructionを使い、feeを送信者が明示的に指定する(ユーザー保護のため)
  • 蓄積された手数料はwithheldAmountで確認できる
picture
hanzochang - 半澤勇大
慶應義塾大学卒業後、Webプランナーとして勤務。 ナショナルクライアントのキャンペーンサイトの企画・演出を担当。 その後開発会社に創業メンバーとして参加。 Fintech案件や大手企業のDXプロジェクトに関わり、その後個人事業主として独立し、 2023年にWeb3に特化した開発会社として法人化しました。 現在はWeb3アプリ開発を中心にAI開発フローの整備を行っています。
また、趣味で2017年ごろより匿名アカウントでCryptoの調査等を行い、 ブロックチェーンメディアやSNSでビットコイン論文等の図解等を発信していました。
X (Twitter)

お問い合わせはこちらから

内容によっては返信ができない場合や、お時間をいただく場合がございます。あらかじめご了承ください。