From 2f290f89e5129a131d6e7c48e3232b9c4e8de0c8 Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 18 Feb 2026 17:25:00 +0000 Subject: [PATCH 1/3] fix: address creation Entire-Checkpoint: e8de3c68866c --- Cargo.lock | 1 + program-tests/system-test/Cargo.toml | 1 + .../system-test/tests/v2_failing_tests.rs | 327 +++++++++++++++++- .../src/processor/create_outputs_cpi_data.rs | 7 +- 4 files changed, 327 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b70ab73b37..af5725380a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10928,6 +10928,7 @@ version = "1.1.0" dependencies = [ "account-compression", "anchor-lang", + "create-address-test-program", "light-account-checks", "light-batched-merkle-tree", "light-bloom-filter", diff --git a/program-tests/system-test/Cargo.toml b/program-tests/system-test/Cargo.toml index 9faa09405e..6d82a9feae 100644 --- a/program-tests/system-test/Cargo.toml +++ b/program-tests/system-test/Cargo.toml @@ -49,3 +49,4 @@ light-bloom-filter = { workspace = true } pinocchio = { workspace = true } light-account-checks = { workspace = true, features = ["test-only", "pinocchio"] } light-zero-copy = { workspace = true } +create-address-test-program = { path = "../create-address-test-program", features = ["cpi"] } diff --git a/program-tests/system-test/tests/v2_failing_tests.rs b/program-tests/system-test/tests/v2_failing_tests.rs index d9ba0ec11e..18f9d7a544 100644 --- a/program-tests/system-test/tests/v2_failing_tests.rs +++ b/program-tests/system-test/tests/v2_failing_tests.rs @@ -1,4 +1,3 @@ -#![cfg(feature = "test-sbf")] //! Test for CPI context address owner derivation. //! //! When creating new addresses via CPI context, the owner should always be the @@ -19,19 +18,28 @@ //! } //! ``` -use anchor_lang::AnchorSerialize; +use anchor_lang::{AnchorSerialize, Discriminator}; +use create_address_test_program::create_invoke_cpi_instruction; use light_account_checks::account_info::test_account_info::pinocchio::get_account_info; use light_compressed_account::{ compressed_account::{ - CompressedAccount, PackedCompressedAccountWithMerkleContext, PackedMerkleContext, + CompressedAccount, CompressedAccountData, PackedCompressedAccountWithMerkleContext, + PackedMerkleContext, }, instruction_data::{ cpi_context::CompressedCpiContext, data::{NewAddressParamsPacked, OutputCompressedAccountWithPackedContext}, invoke_cpi::InstructionDataInvokeCpi, + with_readonly::{InAccount, InstructionDataInvokeCpiWithReadOnly}, zero_copy::ZInstructionDataInvokeCpi, }, }; +use light_program_test::indexer::TestIndexerExtensions; +use light_program_test::{ + utils::assert::assert_rpc_error, AddressWithTree, LightProgramTest, ProgramTestConfig, +}; +use light_program_test::{Indexer, Rpc}; +use light_sdk::{address::v2::derive_address, address::NewAddressParamsAssigned}; use light_system_program_pinocchio::{ context::WrappedInstructionData, cpi_context::{ @@ -42,9 +50,17 @@ use light_system_program_pinocchio::{ }, ID, }; +use light_test_utils::{ + e2e_test_env::to_account_metas_light, + pack::{ + pack_compressed_accounts, pack_new_address_params_assigned, pack_output_compressed_accounts, + }, +}; use light_zero_copy::traits::ZeroCopyAt; use pinocchio::pubkey::Pubkey as PinocchioPubkey; use solana_sdk::pubkey::Pubkey; +use solana_sdk::signature::Signer; +use std::collections::HashMap; /// Creates a test CPI context account with the given associated merkle tree. fn create_test_cpi_context_account( @@ -277,3 +293,308 @@ fn test_cpi_context_new_address_uses_invoking_program_owner_with_inputs() { "Owner should NOT be input account's owner" ); } + +/// Test that duplicate addresses are rejected in V2 batched address trees. +/// +/// This test verifies that the bloom filter catches duplicate address +/// insertions within the same transaction. +#[tokio::test] +async fn test_duplicate_address_in_v2_batched_tree() { + let mut rpc = LightProgramTest::new({ + let mut config = ProgramTestConfig::default_with_batched_trees(true); + config.additional_programs = Some(vec![( + "create_address_test_program", + create_address_test_program::ID, + )]); + config + }) + .await + .expect("Failed to setup test programs"); + + let env = rpc.test_accounts.clone(); + let payer = rpc.get_payer().insecure_clone(); + + let v2_address_tree = env.v2_address_trees[0]; + let address_seed = [42u8; 32]; + + let (derived_address, address_seed) = derive_address( + &[address_seed.as_slice()], + &v2_address_tree, + &create_address_test_program::ID, + ); + + // Request proof for the same address twice + let addresses_with_tree = vec![ + AddressWithTree { + address: derived_address, + tree: v2_address_tree, + }, + AddressWithTree { + address: derived_address, + tree: v2_address_tree, + }, + ]; + + let proof_result = rpc + .get_validity_proof(Vec::new(), addresses_with_tree, None) + .await + .expect("Failed to get validity proof") + .value; + + // Two new_address_params with the SAME seed + let new_address_params = vec![ + NewAddressParamsAssigned { + seed: address_seed.into(), + address_queue_pubkey: v2_address_tree.into(), + address_merkle_tree_pubkey: v2_address_tree.into(), + address_merkle_tree_root_index: proof_result.get_address_root_indices()[0], + assigned_account_index: None, + }, + NewAddressParamsAssigned { + seed: address_seed.into(), + address_queue_pubkey: v2_address_tree.into(), + address_merkle_tree_pubkey: v2_address_tree.into(), + address_merkle_tree_root_index: proof_result.get_address_root_indices()[0], + assigned_account_index: None, + }, + ]; + + let mut remaining_accounts = HashMap::::new(); + let packed_new_address_params = + pack_new_address_params_assigned(new_address_params.as_slice(), &mut remaining_accounts); + + let ix_data = InstructionDataInvokeCpiWithReadOnly { + mode: 0, + bump: 255, + with_cpi_context: false, + invoking_program_id: create_address_test_program::ID.into(), + proof: proof_result.proof.0, + new_address_params: packed_new_address_params, + is_compress: false, + compress_or_decompress_lamports: 0, + output_compressed_accounts: vec![], + input_compressed_accounts: vec![], + with_transaction_hash: true, + ..Default::default() + }; + + let remaining_accounts_light: HashMap = + remaining_accounts + .into_iter() + .map(|(k, v)| (k.into(), v)) + .collect(); + let remaining_accounts = to_account_metas_light(remaining_accounts_light); + + let instruction = create_invoke_cpi_instruction( + payer.pubkey(), + [ + light_system_program::instruction::InvokeCpiWithReadOnly::DISCRIMINATOR.to_vec(), + ix_data.try_to_vec().unwrap(), + ] + .concat(), + remaining_accounts, + None, + ); + + let result = rpc + .create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) + .await; + + // Expected: BloomFilterError::Full (14201) + assert_rpc_error(result, 0, 14201).unwrap(); +} + +#[tokio::test] +async fn test_address_position_with_none_in_context_addresses() { + let mut rpc = LightProgramTest::new({ + let mut config = ProgramTestConfig::default_with_batched_trees(true); + config.additional_programs = Some(vec![( + "create_address_test_program", + create_address_test_program::ID, + )]); + config + }) + .await + .expect("Failed to setup test programs"); + + let env = rpc.test_accounts.clone(); + let payer = rpc.get_payer().insecure_clone(); + let v2_state_tree = env.v2_state_trees[0]; + let v2_address_tree = env.v2_address_trees[0]; + let output_queue = v2_state_tree.output_queue; + + // ------------------------------------------------------------------- + // Transaction 1: Create a compressed account WITHOUT an address. + // Owner = create_address_test_program so it can be consumed via CPI. + // Data is set to zero discriminator/hash so InAccount can reproduce the + // same account hash when consuming it. + // ------------------------------------------------------------------- + { + let mut remaining_accounts = HashMap::::new(); + let packed_outputs = pack_output_compressed_accounts( + &[CompressedAccount { + owner: create_address_test_program::ID.into(), + lamports: 0, + address: None, + data: Some(CompressedAccountData { + discriminator: [0u8; 8], + data: vec![], + data_hash: [0u8; 32], + }), + }], + &[output_queue], + &mut remaining_accounts, + ); + + let ix_data = InstructionDataInvokeCpiWithReadOnly { + mode: 0, + bump: 255, + with_cpi_context: false, + invoking_program_id: create_address_test_program::ID.into(), + output_compressed_accounts: packed_outputs, + with_transaction_hash: true, + ..Default::default() + }; + + let remaining_accounts_light: HashMap = + remaining_accounts + .into_iter() + .map(|(k, v)| (k.into(), v)) + .collect(); + let remaining_accounts_vec = to_account_metas_light(remaining_accounts_light); + + let instruction = create_invoke_cpi_instruction( + payer.pubkey(), + [ + light_system_program::instruction::InvokeCpiWithReadOnly::DISCRIMINATOR.to_vec(), + ix_data.try_to_vec().unwrap(), + ] + .concat(), + remaining_accounts_vec, + None, + ); + + rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) + .await + .expect("Transaction 1 (create account without address) should succeed"); + } + + // ------------------------------------------------------------------- + // Transaction 2: Consume the addressless account as input, create ONE + // ------------------------------------------------------------------- + { + let accounts = rpc + .get_compressed_accounts_with_merkle_context_by_owner(&create_address_test_program::ID); + assert_eq!( + accounts.len(), + 1, + "Should have exactly 1 compressed account" + ); + let input_account = accounts[0].clone(); + let input_hash = input_account.hash().unwrap(); + + // Derive a single address. + let (addr, seed) = derive_address( + &[&[1u8; 32]], + &v2_address_tree, + &create_address_test_program::ID, + ); + + // Proof covers the input account inclusion and addr non-inclusion. + let proof_result = rpc + .get_validity_proof( + vec![input_hash], + vec![AddressWithTree { + address: addr, + tree: v2_address_tree, + }], + None, + ) + .await + .expect("Failed to get validity proof") + .value; + + let mut remaining_accounts = HashMap::::new(); + + // Pack the input account. + let root_indices = proof_result.get_root_indices(); + let packed_inputs = + pack_compressed_accounts(&[input_account], &root_indices, &mut remaining_accounts); + let in_accounts: Vec = packed_inputs.into_iter().map(InAccount::from).collect(); + + // Single new address param, assigned to output[0]. + // context.addresses will be [None, Some(addr)] when outputs are processed. + let address_root_indices = proof_result.get_address_root_indices(); + let new_address_params = vec![NewAddressParamsAssigned { + seed: seed.into(), + address_queue_pubkey: v2_address_tree.into(), + address_merkle_tree_pubkey: v2_address_tree.into(), + address_merkle_tree_root_index: address_root_indices[0], + assigned_account_index: Some(0), + }]; + let packed_new_address_params = + pack_new_address_params_assigned(&new_address_params, &mut remaining_accounts); + + // Both outputs claim the same address - the exploit. + let zero_data = Some(CompressedAccountData { + discriminator: [0u8; 8], + data: vec![], + data_hash: [0u8; 32], + }); + let packed_outputs = pack_output_compressed_accounts( + &[ + CompressedAccount { + owner: create_address_test_program::ID.into(), + lamports: 0, + address: Some(addr), + data: zero_data.clone(), + }, + CompressedAccount { + owner: create_address_test_program::ID.into(), + lamports: 0, + address: Some(addr), + data: zero_data, + }, + ], + &[output_queue, output_queue], + &mut remaining_accounts, + ); + + let ix_data = InstructionDataInvokeCpiWithReadOnly { + mode: 0, + bump: 255, + with_cpi_context: false, + invoking_program_id: create_address_test_program::ID.into(), + proof: proof_result.proof.0, + new_address_params: packed_new_address_params, + input_compressed_accounts: in_accounts, + output_compressed_accounts: packed_outputs, + with_transaction_hash: true, + ..Default::default() + }; + + let remaining_accounts_light: HashMap = + remaining_accounts + .into_iter() + .map(|(k, v)| (k.into(), v)) + .collect(); + let remaining_accounts_vec = to_account_metas_light(remaining_accounts_light); + + let instruction = create_invoke_cpi_instruction( + payer.pubkey(), + [ + light_system_program::instruction::InvokeCpiWithReadOnly::DISCRIMINATOR.to_vec(), + ix_data.try_to_vec().unwrap(), + ] + .concat(), + remaining_accounts_vec, + None, + ); + + // Correctly rejects duplicate address usage with InvalidAddress (6006). + let result = rpc + .create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer]) + .await; + assert_rpc_error(result, 0, 6006).unwrap(); + } +} diff --git a/programs/system/src/processor/create_outputs_cpi_data.rs b/programs/system/src/processor/create_outputs_cpi_data.rs index eb06460432..6eb739130a 100644 --- a/programs/system/src/processor/create_outputs_cpi_data.rs +++ b/programs/system/src/processor/create_outputs_cpi_data.rs @@ -171,12 +171,7 @@ pub fn create_outputs_cpi_data<'a, 'info, T: InstructionData<'a>>( // Check 3. if let Some(address) = account.address() { - if let Some(position) = context - .addresses - .iter() - .filter(|x| x.is_some()) - .position(|&x| x.unwrap() == address) - { + if let Some(position) = context.addresses.iter().position(|&x| x == Some(address)) { context.addresses.remove(position); } else { msg!(format!("context.addresses: {:?}", context.addresses).as_str()); From 2d01f9df7c45b29290d47057028ae9fcf06d600a Mon Sep 17 00:00:00 2001 From: ananas-block Date: Wed, 18 Feb 2026 21:35:54 +0100 Subject: [PATCH 2/3] fix feature gate --- programs/system/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/programs/system/src/lib.rs b/programs/system/src/lib.rs index f452c9eb8d..2d678bc661 100644 --- a/programs/system/src/lib.rs +++ b/programs/system/src/lib.rs @@ -53,6 +53,7 @@ pub enum InstructionDiscriminator { InvokeCpi, InvokeCpiWithReadOnly, InvokeCpiWithAccountInfo, + #[cfg(feature = "reinit")] ReInitCpiContextAccount, } #[cfg(not(feature = "no-entrypoint"))] From 014cdce6e0b21ab4c3ca4fdddbdb4ea6b8a1df84 Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 18 Feb 2026 22:05:40 +0000 Subject: [PATCH 3/3] fmt tests Entire-Checkpoint: 5a3de7e68923 --- program-tests/system-test/tests/v2_failing_tests.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/program-tests/system-test/tests/v2_failing_tests.rs b/program-tests/system-test/tests/v2_failing_tests.rs index 18f9d7a544..3f59ca0051 100644 --- a/program-tests/system-test/tests/v2_failing_tests.rs +++ b/program-tests/system-test/tests/v2_failing_tests.rs @@ -18,6 +18,8 @@ //! } //! ``` +use std::collections::HashMap; + use anchor_lang::{AnchorSerialize, Discriminator}; use create_address_test_program::create_invoke_cpi_instruction; use light_account_checks::account_info::test_account_info::pinocchio::get_account_info; @@ -34,12 +36,11 @@ use light_compressed_account::{ zero_copy::ZInstructionDataInvokeCpi, }, }; -use light_program_test::indexer::TestIndexerExtensions; use light_program_test::{ - utils::assert::assert_rpc_error, AddressWithTree, LightProgramTest, ProgramTestConfig, + indexer::TestIndexerExtensions, utils::assert::assert_rpc_error, AddressWithTree, Indexer, + LightProgramTest, ProgramTestConfig, Rpc, }; -use light_program_test::{Indexer, Rpc}; -use light_sdk::{address::v2::derive_address, address::NewAddressParamsAssigned}; +use light_sdk::address::{v2::derive_address, NewAddressParamsAssigned}; use light_system_program_pinocchio::{ context::WrappedInstructionData, cpi_context::{ @@ -58,9 +59,7 @@ use light_test_utils::{ }; use light_zero_copy::traits::ZeroCopyAt; use pinocchio::pubkey::Pubkey as PinocchioPubkey; -use solana_sdk::pubkey::Pubkey; -use solana_sdk::signature::Signer; -use std::collections::HashMap; +use solana_sdk::{pubkey::Pubkey, signature::Signer}; /// Creates a test CPI context account with the given associated merkle tree. fn create_test_cpi_context_account(