
Token2022で作るステーブルコインに強制差押機能を実装 — PermanentDelegate【TokenExtension解説】
Solana Token2022のPermanentDelegate拡張を使い、管理者がトークンを強制Transfer/Burnできる仕組みを解説。規制対応やステーブルコイン運用に必要な差し押さえ機能を、@solana/kitによるTypeScript実装をコード付きで紹介します。
公開日2026.01.31
更新日2026.01.31
はじめに
ステーブルコインを発行・運用する際、以下のような管理権限が求められるケースがあります。
- 規制当局の要請による資産の凍結・差し押さえ
- 不正取得されたトークンの回収
- 盗まれたトークンの強制返還
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を作成します。
- UserTokenAccount —
owner: user.address。authorityとは別のユーザーが所有。差し押さえ対象 - AuthorityTokenAccount —
owner: 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ネイティブで実装できます。
実装のポイントは以下です。
PermanentDelegateExtensionのdelegateは一度設定すると変更不可- 通常の
getTransferCheckedInstructionと同じ関数で、authorityにPermanent Delegateを指定するだけで強制Transferが可能 - トークン保有者(user)の署名は不要、Permanent Delegate(authority)の署名のみで実行できる
- 強制Transferだけでなく、強制Burnも同様に実行可能
Permanent Delegateは強力な権限であるため、ユーザーとの信頼関係やガバナンスの仕組みと合わせて慎重に運用する必要があります。
