第6章 RWAトークンの設計と発行(Token-2022)#
本章では、コンプライアンス要件を満たす私募債トークンのミント(mint)を、Token-2022(Token Extensions)で実際に作成します。第3章で定義した要件——許可済み投資家間のみの移転、発行体による凍結・強制回収——をトークン規格のレベルで支える拡張を選定し、正しい順序で初期化する手順を示します。
本章のコードは、リポジトリの検証済みスクリプト scripts/create-mint.ts からの転記です。このスクリプトは devnet で実機検証済みであり(6.6節)、説明の都合でコードを変更することはしていません。
6.1 Token-2022の基礎#
Token-2022(Token Extensions Program)は、標準SPL Tokenの上位互換として設計された、別プログラムIDを持つトークンプログラムです。標準SPL Tokenの機能に加え、ミントやトークンアカウントに「拡張(extension)」を付与できます。
RWAトークンに Token-2022 を採用する理由は、コンプライアンス機能をトークン規格自身で表現できる点にあります。移転制限・強制措置・メタデータ格納といった機能を、外部プログラムに頼らずに実現できます。第3章のコンプライアンス設計パターン比較で示したとおり、本書は「Transfer Hook + オンチェーン許可リスト」を採用します。
実装にあたっては、次の点に注意してください。
- プログラムIDが標準SPL Tokenとは異なります。 Token-2022 のミントを扱うには、クライアント側で Token-2022 のプログラムアドレスを明示的に指定します。標準SPL Token 向けに作られた既存dAppが、そのままでは Token-2022 のトークンを扱えない場合があります。
- トークンアカウントは Associated Token Account(ATA)として作成できます。 ただし ATA の導出にも Token-2022 のプログラムIDを用います。
- 既存エコシステムとの互換性は、統合先の対応状況に依存します。上場・連携の前に、対象dApp・取引所の Token-2022 対応状況を確認することを推奨します。
6.2 使用する4拡張の仕様(何を保証し、何を保証しないか)#
本書のミントには、次の4つの拡張を初期化します。各拡張が「何を保証し、何を保証しないか」を正確に把握することが、コンプライアンス設計の前提になります。
| 拡張 | 保証すること | 保証しないこと(留意点) |
|---|---|---|
| Transfer Hook | 転送のたびに指定プログラムを呼び出し、その失敗で転送を失敗させる | Hook自身は転送を実行しない。検証ロジックの正しさは実装者の責任 |
| Permanent Delegate | 発行体が投資家の承認なしに移転・回収できる権限を持つ | 権限の濫用を防ぐ仕組みはトークン規格側にはない(社内統制で担保) |
| Default Account State(Frozen既定) | 新規トークンアカウントを既定で凍結し、thaw まで受領・移転を止める | 既存アカウントの状態は既定変更後も変わらない。freeze authority が必須 |
| Metadata Pointer + Token Metadata | ミント自身を指すメタデータ格納先を確定し、資産参照情報を格納する | メタデータの内容が真正であること自体は保証しない |
以下、各拡張の役割を確認します。
6.2.1 Transfer Hook#
Transfer Hook は、トークン転送のたびに、指定した外部プログラム(本書では第7章で実装する transfer_hook プログラム)を呼び出す拡張です。
重要な点として、Hook プログラムは転送そのものを実行しません。 転送は Token-2022 本体が行い、Hook はそのコンテキストで呼ばれる検証ロジックです。公式ドキュメントは、この呼び出しが「他のすべての転送ロジックの後に発生し、アカウントは転送の終了状態を反映する」と説明しています(確認日: 2026-07-23、出典: solana-program.com/docs/token-2022/extensions)。Hook の内部で Err を返すと、その失敗が Token-2022 に伝播し、転送全体が失敗します。この「Hook の失敗が転送を止める」という動作が、移転制限の技術的な土台になります。詳細は第7章7.1で扱います。
6.2.2 Permanent Delegate#
Permanent Delegate は、ミント作成時に「恒久的な委任先」を1つ設定する拡張です。この委任先は、いずれのトークンアカウントに対しても、保有者の承認なしに移転・burn を行えます(確認日: 2026-07-23、出典: 同上)。
本書では、この権限を発行体による強制回収(第7章5.)に用います。トークン規格側には権限の濫用を防ぐ仕組みはないため、この鍵の保持者・発動条件・承認プロセスは社内統制で担保する必要があります(第10・11章)。実運用ではマルチシグに設定することを推奨します。
6.2.3 Default Account State(Frozen既定)#
Default Account State は、新規に作成されるトークンアカウントの既定状態を指定する拡張です。本書では既定を Frozen(凍結) とします。これにより、KYC が未完了の投資家がトークンアカウントを作成しても、そのアカウントは凍結状態で始まり、受領・移転ができません。
この拡張を使うには、ミントに freeze authority が有効である必要があります(確認日: 2026-07-23、出典: 同上)。KYC 完了後、発行体(freeze authority 保持者)がそのアカウントを thaw(凍結解除)して初めて配布・移転が可能になります(6.5節)。なお、既定状態を後から変更しても、既存のアカウントの状態は変わりません。
6.2.4 Metadata Pointer + Token Metadata#
Metadata Pointer は、ミントを説明するメタデータの正規の格納先アドレスを指す拡張です。本書ではミント自身をメタデータの格納先とし、Token Metadata 拡張でミントアカウント内に直接メタデータを格納します。
Token Metadata は、名称・シンボル・URI・任意の追加フィールドを保持できます(確認日: 2026-07-23、出典: 同上)。RWA トークンでは、この URI・追加フィールドに資産参照情報や目論見書等のハッシュを記載します(6.4節)。Token Metadata 本体は、ミント初期化後に別命令で初期化する点に注意してください(6.3.4)。
コラム:Confidential Transfer は本編に含めません。 秘匿転送(Confidential Transfer)は要件次第の発展項目です。本書のリファレンス実装では採用しません。Permanent Delegate は秘匿残高アカウントに対しては機能しないという制約もあり(確認日: 2026-07-23、出典: 同上)、コンプライアンス設計との組み合わせは別途の検討を要します。採用する場合は要件・法域の整理を先行してください。
6.3 ミント作成手順(順序が最重要)#
ここからは scripts/create-mint.ts を転記しながら、ミント作成の手順を追います。このスクリプトは、拡張領域のサイズ計算 → アカウント作成 → 各拡張の初期化 → ミント初期化 → メタデータ本体の初期化、という順序で命令を組み立てます。この順序が最重要です。
6.3.1 使用SDKと依存パッケージ#
クライアントは TypeScript の @solana/kit 系を基準とします(設計書0.3節)。使用するパッケージとバージョンは、第1章の動作確認済みバージョン表と一致します。
| パッケージ | 固定バージョン | 用途 |
|---|---|---|
@solana/kit |
7.0.0 | クライアント基準SDK |
@solana-program/token-2022 |
0.13.0 | Token-2022 命令ビルダー |
@solana-program/system |
0.13.0 | createAccount 命令 |
これらの発行元 org はいずれも公式(anza-xyz / solana-program)であることを確認済みです(確認方法は第1章バージョン表を参照)。
スクリプト冒頭の import と設定は次のとおりです。
// scripts/create-mint.ts
import {
appendTransactionMessageInstructions,
createKeyPairSignerFromBytes,
createSolanaRpc,
createSolanaRpcSubscriptions,
createTransactionMessage,
generateKeyPairSigner,
getSignatureFromTransaction,
pipe,
sendAndConfirmTransactionFactory,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
signTransactionMessageWithSigners,
type Address,
type KeyPairSigner,
} from '@solana/kit';
import { getCreateAccountInstruction } from '@solana-program/system';
import { readFileSync } from 'node:fs';
import {
AccountState,
TOKEN_2022_PROGRAM_ADDRESS,
getInitializeDefaultAccountStateInstruction,
getInitializeMetadataPointerInstruction,
getInitializeMintInstruction,
getInitializePermanentDelegateInstruction,
getInitializeTokenMetadataInstruction,
getInitializeTransferHookInstruction,
getMintSize,
type ExtensionArgs,
} from '@solana-program/token-2022';
各拡張ごとに getInitialize...Instruction ビルダーが用意されている点に注目してください。これらを正しい順序で並べることが、本節の主眼です。
6.3.2 拡張領域のサイズ計算(二段方式)#
ミント用アカウントは、拡張を格納できるだけのサイズを確保し、レント免除に必要な lamports を積む必要があります。ここで注意が必要なのは、Token Metadata 本体は可変長で、ミント初期化後に realloc(再割り当て)で追加されるという点です。
そこで本スクリプトは、サイズを二段構えで扱います。
- アカウント確保サイズ(
space): メタデータ本体を「抜いた」拡張一覧から算出します。 - レント算定サイズ(
rentSpace): メタデータ本体を「含めた」一覧から算出し、この額で支払います。
こうすると、realloc でアカウントが後から大きくなっても、レント免除水準を割り込みません。これは @solana-program/token-2022 の公式 createMint 実装と同じ方式です。
// scripts/create-mint.ts
// メタデータ「抜き」の拡張一覧。これがアカウント確保サイズ(space)になる。
// メタデータ本体は InitializeMint 後に realloc で追加されるため、ここには含めない。
const preInitExtensions: ExtensionArgs[] = [
{
__kind: 'TransferHook',
authority: payer.address,
programId: TRANSFER_HOOK_PROGRAM_ADDRESS,
},
{ __kind: 'PermanentDelegate', delegate: payer.address },
{ __kind: 'DefaultAccountState', state: AccountState.Frozen },
{ __kind: 'MetadataPointer', authority: payer.address, metadataAddress: mint.address },
];
// メタデータ「込み」の一覧。レント支払額はこちらで計算し、realloc 後も
// 免除水準を満たすよう前倒しで資金を積む(公式 createMint と同方式)。
const rentExtensions: ExtensionArgs[] = [
...preInitExtensions,
{
__kind: 'TokenMetadata',
updateAuthority: payer.address,
mint: mint.address,
name: TOKEN_NAME,
symbol: TOKEN_SYMBOL,
uri: TOKEN_URI,
additionalMetadata: new Map<string, string>(),
},
];
const space = BigInt(getMintSize(preInitExtensions));
const rentSpace = BigInt(getMintSize(rentExtensions));
const lamports = await rpc.getMinimumBalanceForRentExemption(rentSpace).send();
getMintSize に拡張一覧を渡すと、その拡張を格納するのに必要なミントアカウントのサイズが返ります。space にはメタデータ抜きのサイズを、lamports の算定にはメタデータ込みのサイズ(rentSpace)を用いる、という使い分けが要点です。
6.3.3 命令の組み立て順序#
命令は、次の順序で組み立てます。この順序が動作の成否を分けます。
// scripts/create-mint.ts
// --- 命令の組み立て(順序が最重要)---
// createAccount → 拡張初期化(4種)→ InitializeMint → InitializeTokenMetadata
const instructions = [
// 1. ミント用アカウントを確保し、所有者を Token-2022 にする。
getCreateAccountInstruction({
payer,
newAccount: mint,
lamports,
space,
programAddress: TOKEN_2022_PROGRAM_ADDRESS,
}),
// 2. Transfer Hook 拡張(InitializeMint より前)。
getInitializeTransferHookInstruction({
mint: mint.address,
authority: payer.address,
programId: TRANSFER_HOOK_PROGRAM_ADDRESS,
}),
// 3. Permanent Delegate 拡張。強制移転・回収の権限者を設定する。
getInitializePermanentDelegateInstruction({
mint: mint.address,
delegate: payer.address,
}),
// 4. Default Account State = Frozen。新規トークンアカウントは既定で凍結。
getInitializeDefaultAccountStateInstruction({
mint: mint.address,
state: AccountState.Frozen,
}),
// 5. Metadata Pointer。ミント自身をメタデータ格納先として指す。
getInitializeMetadataPointerInstruction({
mint: mint.address,
authority: payer.address,
metadataAddress: mint.address,
}),
// 6. InitializeMint。ここでミントが確定する。
// Default Account State=Frozen のため freezeAuthority は必須。
getInitializeMintInstruction({
mint: mint.address,
decimals: DECIMALS,
mintAuthority: payer.address,
freezeAuthority: payer.address,
}),
// 7. Token Metadata 本体(InitializeMint 後)。mintAuthority は署名者が必要。
getInitializeTokenMetadataInstruction({
metadata: mint.address,
updateAuthority: payer.address,
mint: mint.address,
mintAuthority: payer,
name: TOKEN_NAME,
symbol: TOKEN_SYMBOL,
uri: TOKEN_URI,
}),
];
命令1で確保したアカウントに対し、命令2〜5で4つの拡張を初期化し、命令6でミントを確定させます。命令7のメタデータ本体だけが、ミント初期化の「後」に来ます。
なお命令6では、Default Account State を Frozen にしているため freezeAuthority の指定が必須です(6.2.3)。この freeze authority が、後の thaw・再 freeze の操作主体になります。
6.3.4 【間違えやすいポイント】拡張初期化は InitializeMint より前#
Token-2022 の拡張初期化には、「拡張初期化命令はミント初期化(InitializeMint)より前に置く」という順序上の制約があります。これは設計書の地雷リストにも挙げられている典型的な誤りです。
- 命令2〜5(4拡張)は
InitializeMint(命令6)より前に置きます。 順序を誤るとミント作成が失敗します。 - 例外は Token Metadata 本体(命令7)です。 メタデータ本体は可変長で、ミント初期化後に別命令で初期化する必要があります(確認日: 2026-07-23、出典: solana-program.com/docs/token-2022/extensions)。6.3.2の二段サイズ計算は、この後追い初期化に備えるためのものです。
言い換えると、拡張の「宣言・設定」はミント確定前、可変長メタデータの「本体書き込み」はミント確定後、という2段階になります。この区別を押さえておけば、順序の誤りは避けられます。
トランザクションの送信部分は次のとおりです。命令列を1つのトランザクションにまとめて送ります。
// scripts/create-mint.ts
const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();
const transactionMessage = pipe(
createTransactionMessage({ version: 0 }),
(tx) => setTransactionMessageFeePayerSigner(payer, tx),
(tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
(tx) => appendTransactionMessageInstructions(instructions, tx),
);
const signedTransaction = await signTransactionMessageWithSigners(transactionMessage);
const sendAndConfirm = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions });
// --- 省略 ---
await sendAndConfirm(
signedTransaction as Parameters<typeof sendAndConfirm>[0],
{ commitment: 'confirmed' },
);
const signature = getSignatureFromTransaction(signedTransaction);
console.log('ミント作成トランザクション署名:', signature);
console.log('作成したミントアドレス :', mint.address);
6.4 メタデータ設計#
RWA トークンのメタデータには、資産の参照情報を記載します。本スクリプトでは、名称・シンボル・URI を設定しています。
// scripts/create-mint.ts
const TOKEN_NAME = process.env.TOKEN_NAME ?? 'RWA Private Bond (Reference)';
const TOKEN_SYMBOL = process.env.TOKEN_SYMBOL ?? 'RWAB';
const TOKEN_URI = process.env.TOKEN_URI ?? 'https://example.com/rwa/private-bond.json';
RWA として記載を検討すべき項目は、次のとおりです。
- 資産参照情報: どのオフチェーン資産(私募債・貸付債権等)に対応するかの識別情報。
- ドキュメントのハッシュ/URI: 目論見書・契約書等のドキュメントの所在(URI)と、改ざん検知のためのハッシュ。
改ざん検知のためのドキュメントハッシュは、Token Metadata の追加フィールド(additionalMetadata)に含めることを検討してください。本スクリプトの初期状態では additionalMetadata は空ですが、要件に応じて拡張できます。なお、メタデータに記載した内容が真正であること自体をトークン規格が保証するわけではない点に留意してください(6.2.4)。オンチェーン残高やメタデータが法的権利の唯一の根拠になるとは限らず、法的ストラクチャに依存します(第8章)。
6.5 発行(mint)と投資家への配布フロー#
Default Account State を Frozen にしているため、投資家への配布は「凍結を解く」手順を挟みます。標準的な運用フローは次のとおりです。
- 投資家のKYC完了。 オフチェーンの KYC プロバイダで審査を完了します(第8章)。
- レジストリへの登録。 管理者が投資家を許可リスト(registry)に Approved で登録します(第7章2.)。
- トークンアカウントの thaw。 発行体(freeze authority 保持者)が、その投資家のトークンアカウントを凍結解除します。
- 配布(mint / transfer)。 thaw 済みの Approved 投資家に対して発行・移転します。
この順序により、「KYC 未完了の投資家が受領・移転できない」状態が、既定(Frozen)によって担保されます。thaw と registry 登録は独立した操作である点に注意してください。両方が揃って初めて、移転が成立します(移転時に Transfer Hook が registry の Approved を検証するため。第7章3.)。凍結・thaw・強制回収の実装とテストは第7章・第9章で扱います。
6.6 検証(Explorer/コードでの拡張状態の読み取り)#
作成したミントが意図した拡張を持つことは、RPC の getAccountInfo(jsonParsed エンコード)で確認できます。これは Explorer が表示する内容に相当します。
本スクリプトは devnet で実機検証済みです。検証結果は次のとおりです(確認日: 2026-07-23、出典: docs/test-cases-ch9.md 4.3、devnet 実機 T-40)。
- 作成したミント:
5fcLh2uW9uRwizuWgKCNmdkpHmKGD23jXV2LaHuWHcAQ(devnet) - ミント作成TX署名:
4HYo78DDmujE6efkLMvvJQMRKMk3aZzkUgqsAdHBn4rc2PWF319KeQahVBW66MSQeV7tgY7kLZujSxBnprqiYA3n getSignatureStatuses→confirmationStatus: finalized、err: null(成功)。getAccountInfo(jsonParsed)で確認した内容:- owner = Token-2022、decimals = 0、mint / freeze authority = payer。
transferHook(programId = 本 Hook、authority = payer)permanentDelegate(delegate = payer)defaultAccountState(state = frozen)metadataPointer(metadataAddress = ミント自身)tokenMetadata(name =RWA Private Bond (Reference)/ symbol =RWAB/ uri =https://example.com/rwa/private-bond.json/ updateAuthority = payer)
4拡張に加え、可変長の Token Metadata 本体(tokenMetadata)も devnet で正常に realloc・初期化されました。6.3.2の二段レント方式が実クラスタで正しく機能することを実証しています。
本スクリプトの鍵の扱いについて。
scripts/create-mint.tsは、ローカル生成のテスト鍵のみを扱います。devnet 実機検証では、環境変数PAYER_KEYPAIRに事前に airdrop 済みのテスト鍵を指定します。本番鍵・シードフレーズは一切扱いません(設計書のAI利用統制。第5章)。実運用では、各種 authority をマルチシグに置き換えることを推奨します(第10・11章)。
6.7 本章の自己チェック表#
| 観点 | 結果 |
|---|---|
| スタイル規則違反 | なし(です・ます調、一文概ね80字以内、コードにファイルパス付与、バージョンは第1章表と一致、価格・投資リターン・マーケティング語彙・法的判断示唆なし) |
[要検証] の残数 |
0 件 |
| バージョン表との不一致 | なし(@solana/kit 7.0.0 / @solana-program/token-2022 0.13.0 / @solana-program/system 0.13.0。本文中に「latest」「最新版」の記載なし) |
| コードの出所 | すべて scripts/create-mint.ts からの転記(整形以外の変更なし)。devnet 実機検証済み(T-40) |