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

Token2022で作るステーブルコインに強制差押機能を実装 — PermanentDelegate【TokenExtension解説】

Solana Token2022のPermanentDelegate拡張を使い、管理者がトークンを強制Transfer/Burnできる仕組みを解説。規制対応やステーブルコイン運用に必要な差し押さえ機能を、@solana/kitによるTypeScript実装をコード付きで紹介します。

公開日2026.01.31

更新日2026.01.31

Crypto

はじめに

ステーブルコインを発行・運用する際、以下のような管理権限が求められるケースがあります。

  • 規制当局の要請による資産の凍結・差し押さえ
  • 不正取得されたトークンの回収
  • 盗まれたトークンの強制返還

Token2022(Token Extensions Program)のPermanentDelegate拡張を使えば、Mintに設定した管理者が任意のトークンアカウントから強制的にTransfer/Burnできる仕組みを、Solanaネイティブで実装できます。保有者の署名は不要です。

この記事では、Token2022で作成するトークンにPermanentDelegateを設定し、管理者による強制差し押さえを実行するまでの手順を解説します。

📖 公式ドキュメント

本記事はSolana公式ドキュメントを参考に解説しています。 Permanent Delegate | 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/permanent-delegate.ts
surfpool start

実装の全体像

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

  • Authority — Mintの管理者かつPermanent Delegate(手数料支払者も兼ねる)
  • User — トークンを保有する一般ユーザー(差し押さえ対象)
  • Mint — Token2022のMintアカウント(Permanent Delegate設定を含む)
  • UserTokenAccount — ユーザーが所有するトークンアカウント
  • AuthorityTokenAccount — 権限者が所有するトークンアカウント(差し押さえ先)

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

事前準備            → RPC接続、Authority作成、Mintキーペア生成
Token2022命令作成   → Extension定義、Space/Rent計算、各種命令の作成
トランザクション実行 → 命令をまとめてトランザクションとして送信
差し押さえ実行      → ユーザーの署名なしで管理者がトークンを強制Transfer

完成コード

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

import { getCreateAccountInstruction } from '@solana-program/system'
import {
  extension,
  fetchToken,
  getInitializeAccountInstruction,
  getInitializeMintInstruction,
  getInitializePermanentDelegateInstruction,
  getMintSize,
  getMintToInstruction,
  getTokenSize,
  getTransferCheckedInstruction,
  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] Permanent Delegate Extensionの定義 ---
const permanentDelegateExtension = extension('PermanentDelegate', {
  delegate: authority.address,
})

// --- [5] SpaceとRentの計算 ---
const space = BigInt(getMintSize([permanentDelegateExtension]))
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 initializePermanentDelegateInstruction = getInitializePermanentDelegateInstruction({
  mint: mint.address,
  delegate: authority.address,
})

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

// --- [7] TokenAccount作成命令(ユーザー用 + 権限者用) ---
const user = await generateKeyPairSigner()
const userTokenAccount = await generateKeyPairSigner()

const tokenAccountLen = BigInt(getTokenSize([]))
const tokenAccountRent = await rpc.getMinimumBalanceForRentExemption(tokenAccountLen).send()

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

const initializeUserTokenAccountInstruction = getInitializeAccountInstruction({
  account: userTokenAccount.address,
  mint: mint.address,
  owner: user.address, // ユーザーが所有者
})

const authorityTokenAccount = await generateKeyPairSigner()

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

const initializeAuthorityTokenAccountInstruction = getInitializeAccountInstruction({
  account: authorityTokenAccount.address,
  mint: mint.address,
  owner: authority.address,
})

// --- [8] トークン発行命令 ---
const mintAmount = 1_000_000_000_000n // 1,000 tokens (decimals=9)
const mintToInstruction = getMintToInstruction({
  mint: mint.address,
  token: userTokenAccount.address, // ユーザーのアカウントに発行
  mintAuthority: authority,
  amount: mintAmount,
})

// --- [9] トランザクション構築・署名・送信 ---
const instructions = [
  createMintAccountInstruction,
  initializePermanentDelegateInstruction,
  initializeMintInstruction,
  createUserTokenAccountInstruction,
  initializeUserTokenAccountInstruction,
  createAuthorityTokenAccountInstruction,
  initializeAuthorityTokenAccountInstruction,
  mintToInstruction,
]

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 userBalanceBefore = (await fetchToken(rpc, userTokenAccount.address)).data.amount
const authorityBalanceBefore = (await fetchToken(rpc, authorityTokenAccount.address)).data.amount

// --- [11] 差し押さえ実行(Permanent Delegateによる強制Transfer) ---
const seizeAmount = userBalanceBefore // 全額差し押さえ

const seizeInstruction = getTransferCheckedInstruction({
  source: userTokenAccount.address,
  mint: mint.address,
  destination: authorityTokenAccount.address,
  authority: authority, // Permanent Delegateとして署名(userの署名は不要)
  amount: seizeAmount,
  decimals: 9,
})

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

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

const signedSeizeTransaction = await signTransactionMessageWithSigners(seizeTransactionMessage)
assertIsTransactionWithBlockhashLifetime(signedSeizeTransaction)

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

// --- [12] 結果の出力 ---
const userBalanceAfter = (await fetchToken(rpc, userTokenAccount.address)).data.amount
const authorityBalanceAfter = (await fetchToken(rpc, authorityTokenAccount.address)).data.amount

const setupSignature = getSignatureFromTransaction(signedTransaction)
const seizeSignature = getSignatureFromTransaction(signedSeizeTransaction)

console.log('【差し押さえ前の残高】')
console.log(`  ユーザー: ${userBalanceBefore}`)
console.log(`  権限者:   ${authorityBalanceBefore}`)
console.log('')
console.log('【差し押さえ後の残高】')
console.log(`  ユーザー: ${userBalanceAfter}`)
console.log(`  権限者:   ${authorityBalanceAfter}`)
console.log('')
console.log('【Explorer URL】')
console.log(
  `Mint: https://explorer.solana.com/address/${mint.address}?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899`,
)
console.log(
  `権限者(Permanent Delegate): https://explorer.solana.com/address/${authority.address}?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899`,
)
console.log(
  `ユーザーTokenAccount: https://explorer.solana.com/address/${userTokenAccount.address}?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899`,
)
console.log(
  `権限者TokenAccount: https://explorer.solana.com/address/${authorityTokenAccount.address}?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899`,
)
console.log(
  `セットアップTX: https://explorer.solana.com/tx/${setupSignature}?cluster=custom&customUrl=http%3A%2F%2Flocalhost%3A8899`,
)
console.log(
  `差し押さえTX: https://explorer.solana.com/tx/${seizeSignature}?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はMintの管理者、トランザクション手数料の支払者、そしてPermanent Delegateの3つの役割を兼ねます。

[3] Mintキーペア生成

const mint = await generateKeyPairSigner()

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

[4] Permanent Delegate Extensionの定義

const permanentDelegateExtension = extension('PermanentDelegate', {
  delegate: authority.address,
})

delegateには、強制Transfer/Burnの権限を持つアカウントのアドレスを指定します。

⚠️ delegateは変更不可

delegateは一度設定すると変更できません。誰にPermanent Delegateの権限を与えるかは、Mint作成前に慎重に検討してください。

[5] SpaceとRentの計算

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

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

[6] MintAccount作成命令

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

const initializePermanentDelegateInstruction = getInitializePermanentDelegateInstruction({
  mint: mint.address,
  delegate: authority.address,
})

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

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

  • getCreateAccountInstruction — Mintアカウントを作成
  • getInitializePermanentDelegateInstruction — Permanent Delegateを初期化。差し押さえ権限を持つアカウントを設定します
  • getInitializeMintInstruction — Mintの基本情報を設定

[7] TokenAccount作成命令

// ユーザー(差し押さえ対象)
const user = await generateKeyPairSigner();
const userTokenAccount = await generateKeyPairSigner();

const createUserTokenAccountInstruction = getCreateAccountInstruction({ ... });
const initializeUserTokenAccountInstruction = getInitializeAccountInstruction({
  account: userTokenAccount.address,
  mint: mint.address,
  owner: user.address, // ← ユーザーが所有者
});

// 権限者(差し押さえ先)
const authorityTokenAccount = await generateKeyPairSigner();

const createAuthorityTokenAccountInstruction = getCreateAccountInstruction({ ... });
const initializeAuthorityTokenAccountInstruction = getInitializeAccountInstruction({
  account: authorityTokenAccount.address,
  mint: mint.address,
  owner: authority.address,
});

今回は2種類のTokenAccountを作成します。

  • UserTokenAccountowner: user.address。authorityとは別のユーザーが所有。差し押さえ対象
  • AuthorityTokenAccountowner: authority.address。差し押さえで没収したトークンの転送先

ownerがそれぞれ異なるアカウントになっている点がポイントです。

[8] トークン発行命令

const mintAmount = 1_000_000_000_000n // 1,000 tokens
const mintToInstruction = getMintToInstruction({
  mint: mint.address,
  token: userTokenAccount.address, // ユーザーのアカウントに発行
  mintAuthority: authority,
  amount: mintAmount,
})

ユーザーのTokenAccountに1,000トークンを発行します。この後、Permanent Delegateの権限でこのトークンを強制的に移動します。

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

const instructions = [
  createMintAccountInstruction,
  initializePermanentDelegateInstruction,
  initializeMintInstruction,
  createUserTokenAccountInstruction,
  initializeUserTokenAccountInstruction,
  createAuthorityTokenAccountInstruction,
  initializeAuthorityTokenAccountInstruction,
  mintToInstruction,
]

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

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

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

[10] 差し押さえ前の残高確認

const userBalanceBefore = (await fetchToken(rpc, userTokenAccount.address)).data.amount
const authorityBalanceBefore = (await fetchToken(rpc, authorityTokenAccount.address)).data.amount

差し押さえ前後の変化を確認するために、両方のTokenAccountの残高を取得します。この時点でユーザーの残高は1,000トークン(1,000,000,000,000 lamports)、権限者の残高は0です。

[11] 差し押さえ実行

const seizeAmount = userBalanceBefore // 全額差し押さえ

const seizeInstruction = getTransferCheckedInstruction({
  source: userTokenAccount.address,
  mint: mint.address,
  destination: authorityTokenAccount.address,
  authority: authority, // Permanent Delegateとして署名
  amount: seizeAmount,
  decimals: 9,
})

ここがPermanent Delegateの核心部分です。

通常のgetTransferCheckedInstructionと同じ関数を使います。違いは、authorityにトークンの所有者(user)ではなく**Permanent Delegate(authority)**を指定している点です。

userの署名は一切不要で、authorityの署名のみでユーザーのトークンを強制的に移動できます。

差し押さえトランザクションはMint作成トランザクションとは別に実行するため、新しいブロックハッシュを取得します。

[12] 結果の出力

console.log('【差し押さえ前の残高】')
console.log(`  ユーザー: ${userBalanceBefore}`) // 1000000000000
console.log(`  権限者:   ${authorityBalanceBefore}`) // 0

console.log('【差し押さえ後の残高】')
console.log(`  ユーザー: ${userBalanceAfter}`) // 0
console.log(`  権限者:   ${authorityBalanceAfter}`) // 1000000000000

ユーザーの残高が0になり、権限者に全額が移動していることが確認できます。

Explorerでは以下が確認できます。

  • Mintアカウント — ExtensionタブにPermanent Delegateと権限アカウントが表示される
  • 差し押さえTX — ユーザーとは異なるアカウント(authority)が、ユーザーのトークンを移動している
  • UserTokenAccount — 残高が0、2つのトランザクション(MintとTransfer)が記録されている

実行と確認

pnpm tsx src/permanent-delegate.ts

まとめ

Permanent Delegate拡張を使うことで、管理者による強制Transfer/Burnの仕組みをSolanaネイティブで実装できます。

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

  • PermanentDelegate Extensionのdelegateは一度設定すると変更不可
  • 通常のgetTransferCheckedInstructionと同じ関数で、authorityにPermanent Delegateを指定するだけで強制Transferが可能
  • トークン保有者(user)の署名は不要、Permanent Delegate(authority)の署名のみで実行できる
  • 強制Transferだけでなく、強制Burnも同様に実行可能

Permanent Delegateは強力な権限であるため、ユーザーとの信頼関係やガバナンスの仕組みと合わせて慎重に運用する必要があります。

picture
hanzochang - 半澤勇大
慶應義塾大学卒業後、Webプランナーとして勤務。 ナショナルクライアントのキャンペーンサイトの企画・演出を担当。 その後開発会社に創業メンバーとして参加。 Fintech案件や大手企業のDXプロジェクトに関わり、その後個人事業主として独立し、 2023年にWeb3に特化した開発会社として法人化しました。 現在はWeb3アプリ開発を中心にAI開発フローの整備を行っています。
また、趣味で2017年ごろより匿名アカウントでCryptoの調査等を行い、 ブロックチェーンメディアやSNSでビットコイン論文等の図解等を発信していました。
X (Twitter)

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

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