zk-nullifier について
Solana の ZK プログラムで nullifier を実装する開発者向けに、実装パターンと参考資料をまとめたスキルです。圧縮 PDA、証明検証、クライアント側のアドレス導出を扱います。
主な機能
- 01重複操作を防ぐ nullifier の解説
- 02PDA と圧縮 PDA の保存コスト比較
- 03アドレス導出とプログラム実装例
- 04Rust と TypeScript のテストコマンド
活用シーン
- 01Solana プログラムで二重支払いを防止
- 02圧縮 PDA による nullifier 作成の参照
- 03Rust または TypeScript クライアントのテスト
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ lightprotocol/ skills --skill zk-nullifierインストール後、Claude Code で/zk-nullifierを入力して読み込まれたか確認します。
詳細
ZK Nullifiers
概要
ZK Solana プログラムを構築するには、以下が必要です。
- 二重支払いを防ぐヌリファイア
- 証明の検証
- 状態を保存するマークルツリー
- マークル証明を提供するインデクサー
- 暗号化された状態
ZK ではないアプリケーションでヌリファイアを使用する場合は、skills/payments のスキルを参照してください。
ワークフロー
- 意図を明確にする
- プランモードが有効になっていない場合は、有効にすることを推奨します
AskUserQuestionを使って不明点を解消します- 実行を開始する前に、すべての質問を解決する必要があります
- 参考資料とスキルを特定する
- タスクに合った参考資料を以下のリソースから選びます
- 関連するドキュメントとサンプルを探します
- 計画ファイルを作成する(YAML タスク形式)
- 不明な点があれば
AskUserQuestionを使います。推測や思い込みは避けてください - ブロッカーを特定します。権限、依存関係、不明点などです
- 実行を開始する前に、計画を完成させる必要があります
- 不明な点があれば
- 実行する
Taskツールでサブエージェントを使い、並行して調査します- サブエージェントは
Skillツールを使ってスキルを読み込みます TodoWriteで進捗を管理します
- 行き詰まった場合:
Read、Glob、Grep、DeepWiki MCP アクセスを使用し、skills/ask-mcpを読み込む読み取り専用サブエージェントの起動を依頼します。読み取り対象は、スキルの参考資料、サンプルリポジトリ、ドキュメントに限定してください。
Solana のヌリファイア
ヌリファイアは、あるアクションを一度だけ実行できるようにするために決定論的に導出されたハッシュです。ヌリファイアからアクションやユーザーを特定することはできません。たとえば、Zcash は二重支払いを防ぐためにヌリファイアを使用しています。
ヌリファイアを実装するには、それぞれのヌリファイアが一度だけ作成され、削除されないことを保証するデータ構造が必要です。Solana では、ヌリファイアをシードとして PDA アカウントを作成するのが、ヌリファイアを実装する簡単な方法です。
PDA アカウントは閉じることができず、890,880 lamports が恒久的にロックされます(ヌリファイアごとに必要な最低残高)。 圧縮 PDA は Solana の PDA と同様に導出され、作成コストは 15,000 lamports です(最低残高は不要)。
| ストレージ | ヌリファイアあたりのコスト |
|---|---|
| PDA | 890,880 lamports |
| 圧縮 PDA | 15,000 lamports |
テスト
# Rust tests
cargo test-sbf -p nullifier
# TypeScript tests (requires light test-validator)
light test-validator # separate terminal
npm run test:ts
パターンの概要
1. Client computes nullifier = hash(secret, context)
2. Client fetches validity proof for derived address (proves it does not exist)
3. Client calls create_nullifier with nullifier values and proof
4. Program derives address from nullifier, creates compressed account via CPI
5. Light system program rejects CPI if address already exists
リソース
- 完全なサンプル:program-examples/zk/nullifier
- ZK の概要:zkcompression.com/zk/overview
- その他の ZK サンプル:program-examples/zk(nullifier、zk-id、mixer、shielded-pool)
参照実装
ソース:program-examples/zk/nullifier
アカウント構造
#[derive(Clone, Debug, Default, BorshSerialize, BorshDeserialize, LightDiscriminator)]
pub struct NullifierAccount {}
存在することだけでヌリファイアが使用済みであると証明できるため、空の構造体です。
アドレスの導出
pub const NULLIFIER_PREFIX: &[u8] = b"nullifier";
let (address, address_seed) = derive_address(
&[NULLIFIER_PREFIX, nullifier.as_slice()], // seeds
&address_tree_pubkey, // address tree
&program_id, // program ID
);
アドレスは以下から決定論的に導出されます。
- 定数プレフィックス(他のアカウント型との衝突を防止)
- ヌリファイア値(32 バイト)
- アドレスツリーの公開鍵
- プログラム ID
インストラクションデータ
#[derive(Clone, Debug, AnchorSerialize, AnchorDeserialize)]
pub struct NullifierInstructionData {
pub proof: ValidityProof, // ZK proof that addresses don't exist
pub address_tree_info: PackedAddressTreeInfo,
pub output_state_tree_index: u8,
pub system_accounts_offset: u8,
}
ヌリファイア作成関数
pub fn create_nullifiers<'info>(
nullifiers: &[[u8; 32]],
data: NullifierInstructionData,
signer: &AccountInfo<'info>,
remaining_accounts: &[AccountInfo<'info>],
) -> Result<()> {
let light_cpi_accounts = CpiAccounts::new(
signer,
&remaining_accounts[data.system_accounts_offset as usize..],
LIGHT_CPI_SIGNER,
);
let address_tree_pubkey = data
.address_tree_info
.get_tree_pubkey(&light_cpi_accounts)
.map_err(|_| ErrorCode::AccountNotEnoughKeys)?;
let mut cpi_builder = LightSystemProgramCpi::new_cpi(LIGHT_CPI_SIGNER, data.proof);
let mut new_address_params: Vec<NewAddressParamsAssignedPacked> =
Vec::with_capacity(nullifiers.len());
for (i, nullifier) in nullifiers.iter().enumerate() {
let (address, address_seed) = derive_address(
&[NULLIFIER_PREFIX, nullifier.as_slice()],
&address_tree_pubkey,
&crate::ID,
);
let nullifier_account = LightAccount::<NullifierAccount>::new_init(
&crate::ID,
Some(address),
data.output_state_tree_index,
);
cpi_builder = cpi_builder.with_light_account(nullifier_account)?;
new_address_params.push(
data.address_tree_info
.into_new_address_params_assigned_packed(address_seed, Some(i as u8)),
);
}
cpi_builder
.with_new_addresses(&new_address_params)
.invoke(light_cpi_accounts)?;
Ok(())
}
プログラムのエントリーポイント
#[program]
pub mod nullifier {
pub fn create_nullifier<'info>(
ctx: Context<'_, '_, '_, 'info, CreateNullifierAccounts<'info>>,
data: NullifierInstructionData,
nullifiers: Vec<[u8; 32]>,
) -> Result<()> {
// Verify your ZK proof here. Use nullifiers as public inputs.
// Example:
// let public_inputs = [...nullifiers, ...your_other_inputs];
// Groth16Verifier::new(...).verify()?;
create_nullifiers(
&nullifiers,
data,
ctx.accounts.signer.as_ref(),
ctx.remaining_accounts,
)
}
}
#[derive(Accounts)]
pub struct CreateNullifierAccounts<'info> {
#[account(mut)]
pub signer: Signer<'info>,
}
クライアント実装(TypeScript)
const NULLIFIER_PREFIX = Buffer.from("nullifier");
const addressTree = new web3.PublicKey(batchAddressTree);
// Derive addresses for each nullifier
const addressesWithTree = nullifiers.map((nullifier) => {
const seed = deriveAddressSeedV2([NULLIFIER_PREFIX, nullifier]);
const address = deriveAddressV2(seed, addressTree, programId);
return { tree: addressTree, queue: addressTree, address: bn(address.toBytes()) };
});
// Get validity proof (proves addresses don't exist)
const proofResult = await rpc.getValidityProofV0([], addressesWithTree);
// Build remaining accounts
const remainingAccounts = new PackedAccounts();
remainingAccounts.addSystemAccountsV2(SystemAccountMetaConfig.new(programId));
const addressMerkleTreeIndex = remainingAccounts.insertOrGet(addressTree);
const outputStateTreeIndex = remainingAccounts.insertOrGet(outputStateTree);
// Build instruction data
const data = {
proof: { 0: proofResult.compressedProof },
addressTreeInfo: {
addressMerkleTreePubkeyIndex: addressMerkleTreeIndex,
addressQueuePubkeyIndex: addressMerkleTreeIndex,
rootIndex: proofResult.rootIndices[0],
},
outputStateTreeIndex,
systemAccountsOffset: systemStart,
};
// Call program
const ix = await program.methods
.createNullifier(data, nullifiers.map((n) => Array.from(n)))
.accounts({ signer: signer.publicKey })
.remainingAccounts(remainingAccounts)
.instruction();
クライアント実装(Rust)
use light_sdk::address::v2::derive_address;
let address_tree_info = rpc.get_address_tree_v2();
// Derive addresses
let address_with_trees: Vec<AddressWithTree> = nullifiers
.iter()
.map(|n| {
let (address, _) = derive_address(
&[NULLIFIER_PREFIX, n.as_slice()],
&address_tree_info.tree,
&program_id,
);
AddressWithTree {
address,
tree: address_tree_info.tree,
}
})
.collect();
// Get validity proof (empty hashes = non-inclusion proof)
let rpc_result = rpc
.get_validity_proof(vec![], address_with_trees, None)
.await?
.value;
// Build accounts
let mut remaining_accounts = PackedAccounts::default();
let config = SystemAccountMetaConfig::new(program_id);
remaining_accounts.add_system_accounts_v2(config)?;
let packed_address_tree_accounts = rpc_result
.pack_tree_infos(&mut remaining_accounts)
.address_trees;
let output_state_tree_index = rpc
.get_random_state_tree_info()?
.pack_output_tree_index(&mut remaining_accounts)?;
SDK の参考資料
| パッケージ | リンク |
|---|---|
light-sdk | docs.rs |
@lightprotocol/stateless.js | API ドキュメント |
light-client | docs.rs |
@lightprotocol/nullifier-program | npm |
light-nullifier-program | crates.io |
light-program-test | docs.rs |
セキュリティ
このスキルでは、コードパターンとドキュメントへの参照のみを提供します。
- 認証情報は使用しません。 このスキルでは、API キー、秘密鍵、署名シークレットは必要ありません。
env: []が明示的に宣言されています。 - サブエージェントの範囲。 行き詰まった場合、このスキルは、スキルの参考資料、サンプルリポジトリ、ドキュメントに範囲を限定して、
Read、Glob、Grepを使用する読み取り専用サブエージェントの起動を求めます。 - インストール元。 Lightprotocol/skills から
npx skills add Lightprotocol/skillsを実行します。 - 監査済みプロトコル。 Light Protocol のスマートコントラクトは、独立した監査を受けています。監査レポートは github.com/Lightprotocol/light-protocol/tree/main/audits で公開されています。