T10 · Solana Program
保存領域のないプログラムは、どう状態を記憶する?
- 練習する能力
- Builder
- 実践
- Anchor でカウンタープログラムを書き、Devnet にデプロイしてクライアントから呼び出し、状態を読み戻す。
- AI Lab
- AI にアカウント検証ロジックを調べさせ、指摘された問題が本当に存在するか自分で確認する。
現実の問題
T7 の保証金コントラクトを Solana へ移そうとして、最初の行で止まります。
balanceOf[ユーザー] += 金額 と書きたいのに、プログラムに保存領域がありません。状態変数も mapping もなく、文書を探しても専用データの保存先が見つかりません。
第二の驚きは、取引を送る時に読み書きする全アカウントを先に列挙することです。プログラムが探すのでなく、呼び出し側がデータを渡します。
第三の驚きも続きます。相手が間違ったアカウントを渡したら、他人のアカウントを渡してそれを変更させたら、どうなるでしょう。
三つは同じ点を示します。データの帰属はチェーンの保証でなく、自分で証明するものです。この章は保存場所と正当性の証明を扱います。
思考実験
ディスクのない純粋関数と考えます。
コードだけで専用保存はありません。呼び出し側からフォルダー群(アカウント)を受け取り、読み、変え、終了します。フォルダーは関数のものではなく、変更を許可されるだけです。
各フォルダーには四情報があります。
アドレス フォルダーの場所
owner 内容を変更できるプログラム
data 中身のバイト列
lamports 金額。保存用の預託金でもあるowner のプログラムだけが内容を変更できます。 他は読めても変えられません。これが基礎です。
二つを考えます。
第一の問い:違うフォルダーを渡せますか。
できます。自分のカウンターを増やすつもりの命令に他人のものを渡せば、関数にはバイト列しか見えず、そのまま変更します。
期待したフォルダーだと自分で検証する必要があります。 EVM の balanceOf[msg.sender] は自分のマスを指定しますが、ここでは保証を実装します。
第二の問い:専用フォルダーをどう作りますか。
プログラム ID と独自ラベルからアドレスを導きます。誰でも計算できクライアントが指定できますが、誰も秘密鍵を持ちません。導出したプログラムだけが代理で動きます。
balanceOf[ユーザー] はラベルとユーザーから導くアカウントへ変わります。mapping の各マスが、独立して指定できるアカウントです。
- 呼び出し側がアカウントを渡す
- プログラム自身が検証する
- 導出アドレスで帰属を計算し検証する
あなたが決める
各ユーザーの increment で増えるカウンターを、自分のものだけ増やせるようにします。
結果を観察する
モデルが安全性と性能を同時に決めます。
| 方法 | 帰属の保証 | 移転 | 並列性 | 用途 |
|---|---|---|---|---|
| 検証なし | なし | — | — | 事故報告 |
| data の authority | 実行時照合 | 可 | 高い | 移転可能な資源 |
| 導出アドレス | 導出規則 | 不可 | 高い | 個別帳簿、設定、金庫 |
| 全体の単一アカウント | 区別しない | — | 低い、書き込みが競合 | 全体設定、統計 |
習慣にすべき結論です。
EVM が式で与える「自分のマスだけを操作する」保証は、Solana では自分の検証行になります。
優劣でなく取捨選択です。事前宣言で並列性を得る代わりに、検証責任を作者へ移します。
監査の重点も違います。EVM は再入、オーバーフロー、権限、Solana は各種アカウント検証の漏れが中心です。
モデルを作る
五フィールド
Account
├─ address 32 バイトのアドレス
├─ owner data の変更権限者。通常ウォレットはシステムプログラム
├─ data 任意バイト。プログラムが解釈する
├─ lamports 残高。レント免除の閾値も満たす
└─ executable プログラムかデータか三種と役割です。
| 種類 | owner | data | 変更者 |
|---|---|---|---|
| ウォレット | システムプログラム | 空 | 鍵で署名する人 |
| データ | 自社プログラム | 独自構造 | 自社プログラムだけ |
| プログラム | ローダー | 実行コード | 更新権限者 |
アカウント検証表
画面に貼るべき一覧です。渡される全アカウントで確認します。
- owner は正しいか。 自分のプログラムでなければ信頼できません。
- アドレスは正しいか。 派生なら同じラベルで再導出し比較します。
- 署名したか。 アドレスだけでなく、本取引の署名が必要です。
- 型は正しいか。 同じ長さの別構造を型マーカーで区別します。
- 同じものではないか。 別であるべき二アカウントの不一致を確認します。
- 書き込み可能か。 変更先を呼び出し側が writable にしていますか。
すべてに実際の攻撃が対応し、署名と owner は特に多く悪用されました。
導出とプログラム間呼び出し
導出アドレスはプログラム ID とラベルから曲線外へ計算し、数学的に対応する秘密鍵がありません。人は鍵で署名できず、プログラムが代理署名します。
二つの用途があります。
- mapping のマス:
[b"counter", ユーザーアドレス]はその人のカウンターです。 - 金庫:鍵を持つ人でなく、プログラムのロジックだけが出金できます。
プログラム間呼び出しでは導出ラベルで自分の許可を証明します。送金、アカウント作成、他プロトコルとの連携に使います。
検証を宣言にする
六検査の手書きは長く漏れやすいため、Rust のフレームワークはアカウント構造の宣言へ変えます。
use anchor_lang::prelude::*;
// `anchor keys list` の実 ID で置き換える。コピーしたまま使わない
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod counter {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.authority = ctx.accounts.user.key();
counter.count = 0;
Ok(())
}
pub fn increment(ctx: Context<Increment>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
// 本番では checked_add で、オーバーフローを巻き戻りでなくエラーにする
counter.count = counter.count.checked_add(1).unwrap();
Ok(())
}
}
#[account]
pub struct Counter {
pub authority: Pubkey, // 32 バイト
pub count: u64, // 8 バイト
}
#[derive(Accounts)]
pub struct Initialize<'info> {
// init 本プログラムが作成し owner になる
// payer レント免除預託金の支払者
// space 8 バイトの型マーカー + 32 + 8
// seeds/bump ラベルと user から導出。誰でも計算でき、本プログラムだけが書く
#[account(
init,
payer = user,
space = 8 + 32 + 8,
seeds = [b"counter", user.key().as_ref()],
bump
)]
pub counter: Account<'info, Counter>,
#[account(mut)]
pub user: Signer<'info>, // Signer が署名を強制
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct Increment<'info> {
// seeds/bump アドレスを再導出し比較
// has_one data の authority と下のアカウントを照合
#[account(
mut,
seeds = [b"counter", authority.key().as_ref()],
bump,
has_one = authority
)]
pub counter: Account<'info, Counter>,
pub authority: Signer<'info>,
}六つのうち五つを委ねます。
Accountはowner が自分か、型マーカーが一致するか確認します。seedsとbumpは再導出と照合です。Signerは署名です。mutは書き込み可能です。has_oneはフィールドとアカウントの照合です。
委ねるのは書いた制約だけで、忘れた制約は扱いません。 increment の has_one と seeds を外してもコンパイルと正常テストは合格し、他人のカウンターを変えられます。AI Lab で試します。
何と呼ぶか
ウォレット、残高、プログラム、設定という全データの器です。
アドレス、owner、data、lamports が重要です。owner だけが data を変えると覚えれば、混乱が半分減ります。
Solana のコントラクトで、コードだけの状態なしの存在です。
状態は所有する独立データアカウントにあり、呼び出し側が明示します。
ID とラベルから曲線外へ導く、秘密鍵のないアドレスです。
mapping の代わりの保存と金庫に使い、導出プログラムが代理署名できます。
送金、作成、連携で他プログラムを呼ぶことです。
ラベルで自分の許可を証明できますが、呼び出す先は外部コードで、信頼しません。
渡されたものが期待したものだと証明します。
owner、導出、署名、型、別物、書き込みの六点です。安全性の中心で、事故の多くはその欠落です。
サイズ比例の閾値を満たす lamports が保存に必要で、不足なら回収対象です。
保存には預託が必要で、閉鎖時に返還できます。 T11 では支払者の問題になります。
実践
複雑さより、開発の全過程と検証の働きを実体験します。
ツールを確認し初期化します。
solana --version
anchor --version
anchor init counter版は変わるので、固定版の古い教程よりインストール済み版の文書を使います。
プログラムを入力して自分の ID を得ます。
anchor build
anchor keys list # このプロジェクトのプログラム IDdeclare_id! と設定へ反映し再ビルドします。ほぼ誰でも一度忘れ、配備後に ID 不一致になります。
ローカルで試します。 正常な初期化と増加、別ウォレットから元のカウンターの increment を書きます。
第二は拒否される必要があります。 通れば制約が誤りで、正常ケースより重要です。
Devnet に切り替え、取得して配備します。
solana config set --url devnet
solana airdrop 2
anchor deploy取得には頻度制限があります。実資産を持つウォレットを使いません。
呼んで状態を読みます。 同ラベルでアドレスを計算し、initialize、複数の increment の後に取得して復号し、count を確認します。
クライアントは照会でなく計算で見つけます。 これが工程上の価値です。
CLI でも読みます。
solana account <計算したアカウントアドレス>owner は自分の ID、data は先頭 8 バイトの型、次の 32 の authority、最後の 8 の count です。手で照合すると理解できます。
壊す実験をします。 Increment の has_one と seeds を外し、再配備して第三の実験を繰り返します。
今度は成功します。 他人の変更を見れば、検証を単なる定型コードとは考えなくなります。
AI Lab
全コードを渡して三段階で問います。
第一段階:
命令ごと、アカウントごとに、期待するもの、実際の検査、欠けた検査を
表にしてください。修正案は不要です。
第二段階:
最重大な一点の攻撃を、使用アカウント、命令順、結果まで具体化してください。
第三段階:
現在は攻撃に成功し、修正後は拒否されるテストを書いてください。一覧は整っても、フレームワークが済ませた owner などを欠落と断定する危険があります。誤報で冗長なコードを増やし、本当の漏れを見逃します。
逆に見落としもあります。別のはずが同じアカウント、書くのに writable なしが典型です。
壊れたコードで成功する攻撃テストが、実在を証明します。 前章に続けて繰り返すのは、ここで特に重要だからです。書けなければ主張を疑います。
全制約名も文書検索します。創作する確率は予想より高く、意味が自然なので気づきにくいものです。コンパイル不可は止められても、意味の違う実在名は危険です。
AI の回答後、自分で必ず検証すること
- 各脆弱性に実際に成功する攻撃取引を作り、証明できないものを疑う
- 制約名とマクロを公式文書で確認する。もっともらしい創作がある
- owner や型など、フレームワーク済みの検査を欠陥と誤報しない
- 同一アカウントと writable の確認を漏らさない
- 修正がコンパイルでき、攻撃ケースが成功から拒否へ変わる
- コンパイル、テスト合格、安全性の三つを同一視しない
実例
管理者アドレスとの一致だけを確認し、署名を調べない、単純でもよく悪用された欠陥です。
誰でも公開アドレスを取引に列挙でき、それだけで通ります。
署名型を使うフレームワークなら防ぎやすくても、手書きでは多発しました。名前の一致は本人の出席ではありません。
二用途のデータ長が同じで、型を見ずに期待構造として復号しました。
設定を金庫として渡すと、違う意味で読み書きされます。
8 バイト型マーカーの理由です。手書きでは追加も検証も自分でします。
取引内の署名確認命令を調べるため、ランタイム管理の特殊アカウントを読みます。
本物のアドレスか確認せず、攻撃者の偽検証結果を読まされました。
裏付けなしの資産を発行し、億ドル規模の損失になりました。
第二のアドレス検証の欠落です。各行に実際の損失が対応します。
A から B への移動で、別アドレスか確認しませんでした。
同じものを渡すと読み、減らして書き、また読み増やす処理で第二の書き込みが第一を上書きし、資金を増やす場合があります。
EVM にもありますが、外から渡すため、別物という仮定の根拠がないここでは特に多い問題です。
変数を一つ変える
攻撃者が同レイアウト、自分の authority のアカウントを自分のプログラムで作ります。
構造、authority、署名は正常に見えても、攻撃者が全制御する状態を受け入れます。
内容より先に、データの信頼性を確認します。 第一項の理由です。
一人一つから全体一つへ変わります。
帰属がなく誰でも増やせ、全書き込みは待って直列です。
分割粒度が安全境界と並列度を同時に決めます。 EVM の配置以上に後から直しにくい設計です。
リストも取引サイズを使い、上限に入らなくなります。
書く先が多いほどロック競合も増えて並列性を失います。
命令分割、ルックアップテーブル、構造再設計が対処です。多くの引数を要するなら、状態分割を見直します。
CPI で導出ラベルを渡し、トークンプログラムへ許可を証明します。
その呼び出しへ到達できるコード経路が、金庫の安全性そのものです。一つでも検査不足なら開放されます。
監査では署名付き CPI を全部探し、入口までさかのぼります。
持ち帰る問い
対象データの事前宣言で並列実行できる、という工学上の交換です。スループットと作者の検証責任を交換します。チェーン理解はこうした選択の理解です。
EVM が防ぐ種類を自分で防ぐため、リスクは作者へ集中します。一覧は推奨でなく納品前の必須項目です。
AI の脆弱性は不存在かもしれず、制約名も創作かもしれません。レビューの価値は結論より、各主張を自分で検証する段階にあります。
章末チェック
owner がプログラムの独立アカウントにあり、各住所と残高を持ちます。
クライアントが全対象を列挙し、プログラムは専用メモリでなく参照群を受け取ります。
外から渡すので、期待したものか検証します。
誰もロジックを迂回して操作できないからです。
通常は鍵の所有者が支配しますが、曲線外なら対応鍵がなく、公開コードのプログラムだけが動かします。
金庫の支配を一つの鍵から、誰もが監査できるコードへ移します。
いいえ。宣言に変えただけで、書かなかった宣言は検査しません。
owner と型は自動でも、業務上の帰属がなければ正常テストは合格します。
六項目で保証者を問います。答えられなければ欠陥です。
公開アドレスは誰でもリストへ書けます。
証明はその取引への署名です。住所だけなら名前で通しているだけです。
署名型を使うか、手書きで署名フラグを確認します。
唯一の正解はありません。確認します。
- owner は包装型か手書きで保証するか。
- 導出を実行時に再計算するか。
- アドレスだけでなく署名を要求するか。
- 別のはずが同じになれないか。
- そのアカウントである理由を説明できるか。
- 署名付き CPI を起動する入口を図にする。
説明できないアカウントは未整理の設計で、明白な検査ミス以上に危険です。
要点
PDA はプログラム固有のアドレス空間、CPI はプログラム間の呼び出し方法だ。