From 30a606bb813e1193fb95c7671f5ab089b2fd4f16 Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 18 Mar 2026 16:52:27 +0000 Subject: [PATCH 1/7] fix: handle mixed batch/non-batch inputs in create_nullifier_queue_indices When a transaction mixes batch (v2) and legacy/concurrent (v1) input accounts, the nullifier queue index assignment was using the raw position in input_compressed_accounts as the write index into nullifier_queue_indices. This caused an out-of-bounds panic when a non-batch account appeared between batch accounts (e.g. [batchA, legacy, batchB, batchA]). Fix by walking input_compressed_accounts in order and using a compact batch_idx counter that only advances for accounts with a matching sequence number entry. Non-batch accounts have no sequence number entry and are skipped without consuming a slot. --- Cargo.lock | 2 ++ sdk-libs/event/src/parse.rs | 24 +++++++++++++----------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e293fd2b8..cd908461ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12547,6 +12547,7 @@ dependencies = [ "ark-bn254 0.5.0", "ark-ff 0.5.0", "base64 0.13.1", + "bs58", "chrono", "clap 4.5.60", "dirs", @@ -12556,6 +12557,7 @@ dependencies = [ "light-compressed-account", "light-compressible", "light-concurrent-merkle-tree", + "light-event", "light-hash-set", "light-hasher", "light-indexed-merkle-tree", diff --git a/sdk-libs/event/src/parse.rs b/sdk-libs/event/src/parse.rs index f034eaebef..c524f9307b 100644 --- a/sdk-libs/event/src/parse.rs +++ b/sdk-libs/event/src/parse.rs @@ -738,18 +738,20 @@ fn create_nullifier_queue_indices( .insert_into_queues_instruction .input_sequence_numbers .to_vec(); - // For every sequence number: - // 1. Find every input compressed account - // 2. assign sequence number as nullifier queue index - // 3. increment the sequence number - internal_input_sequence_numbers.iter_mut().for_each(|seq| { - for (i, merkle_tree_pubkey) in input_merkle_tree_pubkeys.iter().enumerate() { - if *merkle_tree_pubkey == seq.tree_pubkey { - nullifier_queue_indices[i] = seq.seq.into(); - seq.seq += 1; - } + // Walk input_compressed_accounts in order, assigning sequence numbers to batch + // accounts using a compact write index. Non-batch (legacy/concurrent) accounts + // have no matching sequence number entry and are skipped. + let mut batch_idx = 0usize; + for merkle_tree_pubkey in input_merkle_tree_pubkeys.iter() { + if let Some(seq) = internal_input_sequence_numbers + .iter_mut() + .find(|s| s.tree_pubkey == *merkle_tree_pubkey) + { + nullifier_queue_indices[batch_idx] = seq.seq.into(); + seq.seq += 1; + batch_idx += 1; } - }); + } nullifier_queue_indices } From 25967bec2fa65ea06b89777f0dd859e313d1e506 Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 18 Mar 2026 16:52:34 +0000 Subject: [PATCH 2/7] feat: add xtask fetch-block-events subcommand Fetches a configurable number of blocks starting at a given slot, parses every transaction using event_from_light_transaction, and prints a structured summary of all Light Protocol events found. Usage: cargo xtask fetch-block-events --start-slot --network mainnet cargo xtask fetch-block-events --start-slot --network devnet --num-blocks 5 --- xtask/Cargo.toml | 2 + xtask/src/fetch_block_events.rs | 222 ++++++++++++++++++++++++++++++++ xtask/src/main.rs | 5 + 3 files changed, 229 insertions(+) create mode 100644 xtask/src/fetch_block_events.rs diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 31e51dd641..a5a195fc51 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -17,6 +17,8 @@ light-hash-set = { workspace = true } light-hasher = { workspace = true, features = ["poseidon"] } light-indexed-merkle-tree = { workspace = true } light-compressed-account = { workspace = true, features = ["std"] } +light-event = { workspace = true } +bs58 = { workspace = true } light-merkle-tree-metadata = { workspace = true } num-bigint = { workspace = true } rand = { workspace = true } diff --git a/xtask/src/fetch_block_events.rs b/xtask/src/fetch_block_events.rs new file mode 100644 index 0000000000..b33600774c --- /dev/null +++ b/xtask/src/fetch_block_events.rs @@ -0,0 +1,222 @@ +use anyhow::Result; +use clap::Parser; +use light_compressed_account::Pubkey as LightPubkey; +use light_event::parse::event_from_light_transaction; +use solana_client::{rpc_client::RpcClient, rpc_config::RpcBlockConfig}; +use solana_sdk::commitment_config::CommitmentConfig; +use solana_transaction_status::{ + option_serializer::OptionSerializer, EncodedTransactionWithStatusMeta, TransactionDetails, + UiInstruction, UiTransactionEncoding, +}; + +#[derive(Debug, Parser)] +pub struct Options { + /// Starting slot + #[clap(long)] + start_slot: u64, + /// Number of blocks to fetch (default: 10) + #[clap(long, default_value_t = 10)] + num_blocks: usize, + /// Network: mainnet, devnet, testnet, local, or RPC URL + #[clap(long, default_value = "mainnet")] + network: String, + /// Custom RPC URL (overrides --network) + #[clap(long)] + rpc_url: Option, +} + +fn network_to_url(network: &str) -> String { + match network { + "mainnet" => "https://api.mainnet-beta.solana.com".to_string(), + "devnet" => "https://api.devnet.solana.com".to_string(), + "testnet" => "https://api.testnet.solana.com".to_string(), + "local" | "localnet" => "http://localhost:8899".to_string(), + custom => custom.to_string(), + } +} + +pub async fn fetch_block_events(opts: Options) -> Result<()> { + let rpc_url = opts + .rpc_url + .unwrap_or_else(|| network_to_url(&opts.network)); + let client = RpcClient::new_with_commitment(rpc_url, CommitmentConfig::confirmed()); + + let slots = client.get_blocks_with_limit(opts.start_slot, opts.num_blocks)?; + + let mut total_txs: usize = 0; + let mut total_events: usize = 0; + + for slot in &slots { + let config = RpcBlockConfig { + encoding: Some(UiTransactionEncoding::Base64), + transaction_details: Some(TransactionDetails::Full), + rewards: None, + commitment: Some(CommitmentConfig::confirmed()), + max_supported_transaction_version: Some(0), + }; + let block = match client.get_block_with_config(*slot, config) { + Ok(b) => b, + Err(e) => { + eprintln!("slot {slot}: {e}"); + continue; + } + }; + + let transactions = block.transactions.unwrap_or_default(); + let tx_count = transactions.len(); + total_txs += tx_count; + + println!("Slot {slot} -- {tx_count} transactions"); + + for encoded_tx_with_meta in transactions { + parse_and_print_tx(encoded_tx_with_meta, &mut total_events); + } + } + + println!( + "\nSummary: {} blocks, {total_txs} transactions, {total_events} light events", + slots.len() + ); + + Ok(()) +} + +fn parse_and_print_tx(encoded: EncodedTransactionWithStatusMeta, total_events: &mut usize) { + let EncodedTransactionWithStatusMeta { + transaction, meta, .. + } = encoded; + + let versioned_tx = match transaction.decode() { + Some(tx) => tx, + None => return, + }; + + let sig = versioned_tx + .signatures + .first() + .map(|s| s.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let meta = match meta { + Some(m) => m, + None => return, + }; + + // Build full account list: static keys + loaded ALT addresses + let mut sdk_accounts: Vec = + versioned_tx.message.static_account_keys().to_vec(); + + if versioned_tx.message.address_table_lookups().is_some() { + if let OptionSerializer::Some(loaded) = &meta.loaded_addresses { + for addr_str in loaded.writable.iter().chain(loaded.readonly.iter()) { + match addr_str.parse::() { + Ok(pk) => sdk_accounts.push(pk), + Err(e) => { + eprintln!(" {sig}: bad ALT address {addr_str}: {e}"); + } + } + } + } + } + + let accounts: Vec = sdk_accounts + .iter() + .map(|pk| LightPubkey::new_from_array(pk.to_bytes())) + .collect(); + + // Build inner instruction map: outer_ix_index -> [(program_id, data, accounts)] + let outer_count = versioned_tx.message.instructions().len(); + let mut inner_map: Vec, Vec)>> = + vec![Vec::new(); outer_count]; + + if let OptionSerializer::Some(inner_ixs_vec) = &meta.inner_instructions { + for inner_ixs in inner_ixs_vec { + let idx = inner_ixs.index as usize; + if idx >= inner_map.len() { + continue; + } + for ui_ix in &inner_ixs.instructions { + if let UiInstruction::Compiled(c) = ui_ix { + let program_id = accounts[c.program_id_index as usize]; + let data = match bs58::decode(&c.data).into_vec() { + Ok(d) => d, + Err(e) => { + eprintln!(" {sig}: inner ix decode error: {e}"); + continue; + } + }; + let ix_accounts: Vec = c + .accounts + .iter() + .map(|i| accounts[*i as usize]) + .collect(); + inner_map[idx].push((program_id, data, ix_accounts)); + } + } + } + } + + // For each instruction group (outer + its inners), call event_from_light_transaction + let mut tx_event_count = 0usize; + let mut tx_event_lines: Vec = Vec::new(); + + for (outer_idx, compiled_ix) in versioned_tx.message.instructions().iter().enumerate() { + let outer_program_id = accounts[compiled_ix.program_id_index as usize]; + let outer_data: Vec = compiled_ix.data.clone(); + let outer_accounts: Vec = compiled_ix + .accounts + .iter() + .map(|i| accounts[*i as usize]) + .collect(); + + let mut program_ids = vec![outer_program_id]; + let mut data = vec![outer_data]; + let mut ix_accounts = vec![outer_accounts]; + + for (pid, d, accs) in &inner_map[outer_idx] { + program_ids.push(*pid); + data.push(d.clone()); + ix_accounts.push(accs.clone()); + } + + match event_from_light_transaction(&program_ids, &data, ix_accounts) { + Ok(Some(events)) => { + for (i, event) in events.iter().enumerate() { + let tx_hash = bs58::encode(&event.tx_hash).into_string(); + let inputs = event.event.input_compressed_account_hashes.len(); + let outputs = event.event.output_compressed_account_hashes.len(); + let new_addrs = event.new_addresses.len(); + let compress_info = + if event.event.is_compress || event.event.compress_or_decompress_lamports.is_some() { + let dir = if event.event.is_compress { + "compress" + } else { + "decompress" + }; + if let Some(lamports) = event.event.compress_or_decompress_lamports { + format!(" ({dir}: {lamports} lamports)") + } else { + format!(" ({dir})") + } + } else { + String::new() + }; + tx_event_lines.push(format!( + " event[{i}] tx_hash={tx_hash} inputs={inputs} outputs={outputs} new_addresses={new_addrs}{compress_info}" + )); + } + tx_event_count += events.len(); + } + Ok(None) => {} + Err(e) => eprintln!(" {sig} parse error: {e:?}"), + } + } + + if tx_event_count > 0 { + println!(" {sig} -- {tx_event_count} light event(s)"); + for line in &tx_event_lines { + println!("{line}"); + } + *total_events += tx_event_count; + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 5c30f4045c..d845f314fa 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -12,6 +12,7 @@ mod create_vkeyrs_from_gnark_key; mod export_photon_test_data; mod fee; mod fetch_accounts; +mod fetch_block_events; mod fetch_failed_txs; mod hash_set; mod new_deployment; @@ -82,6 +83,9 @@ enum Command { /// cargo xtask fetch-failed-txs --minutes 10 --network mainnet /// cargo xtask fetch-failed-txs --minutes 30 --network devnet FetchFailedTxs(fetch_failed_txs::Options), + /// Fetch the last N blocks from a start slot and parse Light Protocol events + /// Example: cargo xtask fetch-block-events --start-slot 300000000 --network mainnet + FetchBlockEvents(fetch_block_events::Options), /// Create compressible config (config counter + compressible config) /// Creates the config counter PDA and a compressible config with default RentConfig. /// Example: cargo xtask create-compressible-config --network devnet @@ -130,6 +134,7 @@ async fn main() -> Result<(), anyhow::Error> { Command::ReinitCpiAccounts(opts) => reinit_cpi_accounts::reinit_cpi_accounts(opts).await, Command::FetchAccounts(opts) => fetch_accounts::fetch_accounts(opts).await, Command::FetchFailedTxs(opts) => fetch_failed_txs::fetch_failed_txs(opts).await, + Command::FetchBlockEvents(opts) => fetch_block_events::fetch_block_events(opts).await, Command::CreateCompressibleConfig(opts) => { create_compressible_config::create_compressible_config(opts).await } From a5b1c81184ce90cb7e7dfe6a7fe488fb08f654a7 Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 18 Mar 2026 17:08:14 +0000 Subject: [PATCH 3/7] fix: format --- xtask/src/fetch_block_events.rs | 34 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/xtask/src/fetch_block_events.rs b/xtask/src/fetch_block_events.rs index b33600774c..ce46b16298 100644 --- a/xtask/src/fetch_block_events.rs +++ b/xtask/src/fetch_block_events.rs @@ -145,11 +145,8 @@ fn parse_and_print_tx(encoded: EncodedTransactionWithStatusMeta, total_events: & continue; } }; - let ix_accounts: Vec = c - .accounts - .iter() - .map(|i| accounts[*i as usize]) - .collect(); + let ix_accounts: Vec = + c.accounts.iter().map(|i| accounts[*i as usize]).collect(); inner_map[idx].push((program_id, data, ix_accounts)); } } @@ -186,21 +183,22 @@ fn parse_and_print_tx(encoded: EncodedTransactionWithStatusMeta, total_events: & let inputs = event.event.input_compressed_account_hashes.len(); let outputs = event.event.output_compressed_account_hashes.len(); let new_addrs = event.new_addresses.len(); - let compress_info = - if event.event.is_compress || event.event.compress_or_decompress_lamports.is_some() { - let dir = if event.event.is_compress { - "compress" - } else { - "decompress" - }; - if let Some(lamports) = event.event.compress_or_decompress_lamports { - format!(" ({dir}: {lamports} lamports)") - } else { - format!(" ({dir})") - } + let compress_info = if event.event.is_compress + || event.event.compress_or_decompress_lamports.is_some() + { + let dir = if event.event.is_compress { + "compress" } else { - String::new() + "decompress" }; + if let Some(lamports) = event.event.compress_or_decompress_lamports { + format!(" ({dir}: {lamports} lamports)") + } else { + format!(" ({dir})") + } + } else { + String::new() + }; tx_event_lines.push(format!( " event[{i}] tx_hash={tx_hash} inputs={inputs} outputs={outputs} new_addresses={new_addrs}{compress_info}" )); From 17325e0520092a01783ef8bca63a0d1c8f989d73 Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 18 Mar 2026 17:17:21 +0000 Subject: [PATCH 4/7] chore: bump light-event 0.23.0 -> 0.23.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- sdk-libs/event/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cd908461ff..1ee0fbcb66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3975,7 +3975,7 @@ dependencies = [ [[package]] name = "light-event" -version = "0.23.0" +version = "0.23.1" dependencies = [ "borsh 0.10.4", "light-compressed-account", diff --git a/Cargo.toml b/Cargo.toml index da89f33888..1a9c0a3136 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -201,7 +201,7 @@ light-indexed-merkle-tree = { version = "5.0.0", path = "program-libs/indexed-me light-concurrent-merkle-tree = { version = "5.0.0", path = "program-libs/concurrent-merkle-tree" } light-sparse-merkle-tree = { version = "0.3.0", path = "sparse-merkle-tree" } light-client = { path = "sdk-libs/client", version = "0.23.0" } -light-event = { path = "sdk-libs/event", version = "0.23.0" } +light-event = { path = "sdk-libs/event", version = "0.23.1" } light-hasher = { path = "program-libs/hasher", version = "5.0.0", default-features = false } light-macros = { path = "program-libs/macros", version = "2.2.0" } light-merkle-tree-reference = { path = "program-tests/merkle-tree", version = "4.0.0" } diff --git a/sdk-libs/event/Cargo.toml b/sdk-libs/event/Cargo.toml index dbdfdf6ef4..7b9ab31c38 100644 --- a/sdk-libs/event/Cargo.toml +++ b/sdk-libs/event/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "light-event" -version = "0.23.0" +version = "0.23.1" description = "Event types and utilities for Light Protocol" repository = "https://github.com/Lightprotocol/light-protocol" license = "Apache-2.0" From 1071d5602857e712c66259a1e88cc69a9cc901de Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 18 Mar 2026 17:30:05 +0000 Subject: [PATCH 5/7] fix: extract ParsedInstruction struct to satisfy clippy::type_complexity --- xtask/src/fetch_block_events.rs | 36 +++++++++++++++++---------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/xtask/src/fetch_block_events.rs b/xtask/src/fetch_block_events.rs index ce46b16298..0e25ca2db1 100644 --- a/xtask/src/fetch_block_events.rs +++ b/xtask/src/fetch_block_events.rs @@ -9,6 +9,13 @@ use solana_transaction_status::{ UiInstruction, UiTransactionEncoding, }; +#[derive(Clone)] +struct ParsedInstruction { + program_id: LightPubkey, + data: Vec, + accounts: Vec, +} + #[derive(Debug, Parser)] pub struct Options { /// Starting slot @@ -124,10 +131,9 @@ fn parse_and_print_tx(encoded: EncodedTransactionWithStatusMeta, total_events: & .map(|pk| LightPubkey::new_from_array(pk.to_bytes())) .collect(); - // Build inner instruction map: outer_ix_index -> [(program_id, data, accounts)] + // Build inner instruction map: outer_ix_index -> [ParsedInstruction] let outer_count = versioned_tx.message.instructions().len(); - let mut inner_map: Vec, Vec)>> = - vec![Vec::new(); outer_count]; + let mut inner_map: Vec> = vec![Vec::new(); outer_count]; if let OptionSerializer::Some(inner_ixs_vec) = &meta.inner_instructions { for inner_ixs in inner_ixs_vec { @@ -145,9 +151,9 @@ fn parse_and_print_tx(encoded: EncodedTransactionWithStatusMeta, total_events: & continue; } }; - let ix_accounts: Vec = + let ix_accounts = c.accounts.iter().map(|i| accounts[*i as usize]).collect(); - inner_map[idx].push((program_id, data, ix_accounts)); + inner_map[idx].push(ParsedInstruction { program_id, data, accounts: ix_accounts }); } } } @@ -158,22 +164,18 @@ fn parse_and_print_tx(encoded: EncodedTransactionWithStatusMeta, total_events: & let mut tx_event_lines: Vec = Vec::new(); for (outer_idx, compiled_ix) in versioned_tx.message.instructions().iter().enumerate() { - let outer_program_id = accounts[compiled_ix.program_id_index as usize]; - let outer_data: Vec = compiled_ix.data.clone(); - let outer_accounts: Vec = compiled_ix + let mut program_ids = vec![accounts[compiled_ix.program_id_index as usize]]; + let mut data = vec![compiled_ix.data.clone()]; + let mut ix_accounts = vec![compiled_ix .accounts .iter() .map(|i| accounts[*i as usize]) - .collect(); - - let mut program_ids = vec![outer_program_id]; - let mut data = vec![outer_data]; - let mut ix_accounts = vec![outer_accounts]; + .collect::>()]; - for (pid, d, accs) in &inner_map[outer_idx] { - program_ids.push(*pid); - data.push(d.clone()); - ix_accounts.push(accs.clone()); + for ix in &inner_map[outer_idx] { + program_ids.push(ix.program_id); + data.push(ix.data.clone()); + ix_accounts.push(ix.accounts.clone()); } match event_from_light_transaction(&program_ids, &data, ix_accounts) { From 5d1c6dacbe628f47ae90834bea69d0d6ade2c882 Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 18 Mar 2026 17:37:48 +0000 Subject: [PATCH 6/7] fix: rustfmt fetch_block_events --- xtask/src/fetch_block_events.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/xtask/src/fetch_block_events.rs b/xtask/src/fetch_block_events.rs index 0e25ca2db1..43c8532f20 100644 --- a/xtask/src/fetch_block_events.rs +++ b/xtask/src/fetch_block_events.rs @@ -151,9 +151,12 @@ fn parse_and_print_tx(encoded: EncodedTransactionWithStatusMeta, total_events: & continue; } }; - let ix_accounts = - c.accounts.iter().map(|i| accounts[*i as usize]).collect(); - inner_map[idx].push(ParsedInstruction { program_id, data, accounts: ix_accounts }); + let ix_accounts = c.accounts.iter().map(|i| accounts[*i as usize]).collect(); + inner_map[idx].push(ParsedInstruction { + program_id, + data, + accounts: ix_accounts, + }); } } } From bfa7c87b5bac660553a9a46ed1076098bb2dd9cd Mon Sep 17 00:00:00 2001 From: ananas Date: Wed, 18 Mar 2026 18:28:50 +0000 Subject: [PATCH 7/7] test(light-event): add regression tests for mixed batch/legacy nullifier OOB panic Transaction 3ybts1eFSC7QN6aU4ao6NJCgn7xTbtBVyzeLDZJf9eVN93vHZWupX4TXqHHgV18xf17eit7Uw5T135uabnpToKK4 at slot 407265372 panicked with "index out of bounds: len is 3 but index is 3" in create_nullifier_queue_indices when inputs mix batch and legacy trees. Adds two tests: - src/regression_test.rs: real mainnet instruction bytes decoded via bs58 - tests/parse_test.rs: synthetic test verifying exact nullifier_queue_indices [6, 3, 7] Also adds light-event to sdk-libs/justfile so it runs in CI. --- Cargo.lock | 1 + sdk-libs/event/Cargo.toml | 1 + sdk-libs/event/src/lib.rs | 2 + sdk-libs/event/src/regression_test.rs | 110 +++++++++++++ sdk-libs/event/tests/parse_test.rs | 215 ++++++++++++++++++++++++++ sdk-libs/justfile | 1 + 6 files changed, 330 insertions(+) create mode 100644 sdk-libs/event/src/regression_test.rs diff --git a/Cargo.lock b/Cargo.lock index 1ee0fbcb66..38a9bdf137 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3978,6 +3978,7 @@ name = "light-event" version = "0.23.1" dependencies = [ "borsh 0.10.4", + "bs58", "light-compressed-account", "light-hasher", "light-token-interface", diff --git a/sdk-libs/event/Cargo.toml b/sdk-libs/event/Cargo.toml index 7b9ab31c38..9ed14708d6 100644 --- a/sdk-libs/event/Cargo.toml +++ b/sdk-libs/event/Cargo.toml @@ -15,4 +15,5 @@ light-zero-copy = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +bs58 = { workspace = true } rand = { workspace = true } diff --git a/sdk-libs/event/src/lib.rs b/sdk-libs/event/src/lib.rs index 6a78baffe8..1955c3911d 100644 --- a/sdk-libs/event/src/lib.rs +++ b/sdk-libs/event/src/lib.rs @@ -11,3 +11,5 @@ pub mod error; pub mod event; pub mod parse; +#[cfg(test)] +mod regression_test; diff --git a/sdk-libs/event/src/regression_test.rs b/sdk-libs/event/src/regression_test.rs new file mode 100644 index 0000000000..9c46046c11 --- /dev/null +++ b/sdk-libs/event/src/regression_test.rs @@ -0,0 +1,110 @@ +/// Regression test for index-out-of-bounds panic in create_nullifier_queue_indices. +/// Transaction: 3ybts1eFSC7QN6aU4ao6NJCgn7xTbtBVyzeLDZJf9eVN93vHZWupX4TXqHHgV18xf17eit7Uw5T135uabnpToKK4 +/// Slot: 407265372 (mainnet) +/// This transaction crashed photon's indexer because `len` passed to +/// `create_nullifier_queue_indices` didn't match the number of input accounts. +#[cfg(test)] +mod tests { + use light_compressed_account::Pubkey; + + use crate::parse::event_from_light_transaction; + + fn pubkey(s: &str) -> Pubkey { + let bytes: [u8; 32] = bs58::decode(s).into_vec().unwrap().try_into().unwrap(); + Pubkey::from(bytes) + } + + fn ix_data(s: &str) -> Vec { + bs58::decode(s).into_vec().unwrap() + } + + // Account addresses used in the transaction + const USER: &str = "33X2Tg3gdxTwouaVSxpcNwVHJt2ZYxo3Hm7UjH2i8M3r"; + const REGISTERED_PDA: &str = "35hkDgaAKwMCaxRz2ocSZ6NaUrtKkyNqU6c4RV3tYJRh"; + const NOOP: &str = "noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV"; + const CPI_CONTEXT: &str = "HwXnGK3tPkkVY6P439H2p68AxpeuWXd5PcrAxFpbmfbA"; + const ACCOUNT_COMPRESSION: &str = "compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq"; + const SOL_POOL: &str = "CHK57ywWSDncAoRu1F8QgwYJeXuAJyyBYT4LixLXvMZ1"; + const SYSTEM_PROGRAM: &str = "11111111111111111111111111111111"; + const BMT3_TREE: &str = "bmt3ccLd4bqSVZVeCJnH1F6C8jNygAhaDfxDwePyyGb"; + const OQ3_QUEUE: &str = "oq3AxjekBWgo64gpauB6QtuZNesuv19xrhaC1ZM1THQ"; + const SMT5_TREE: &str = "smt5uPaQT9n6b1qAkgyonmzRxtuazA53Rddwntqistc"; + const NFQ5_QUEUE: &str = "nfq5b5xEguPtdD6uPetZduyrB5EUqad7gcUE46rALau"; + const BMT2_TREE: &str = "bmt2UxoBxB9xWev4BkLvkGdapsz6sZGkzViPNph7VFi"; + const OQ2_QUEUE: &str = "oq2UkeMsJLfXt2QHzim242SUi3nvjJs8Pn7Eac9H9vg"; + + // Program IDs + const LIGHT_SYSTEM: &str = "SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7"; + const COMPUTE_BUDGET: &str = "ComputeBudget111111111111111111111111111111"; + + #[test] + fn test_mainnet_tx_407265372_no_panic() { + // Tx 3ybts1eFSC7QN...ToKK4, slot 407265372 + // Before fix: panicked with "index out of bounds: the len is 3 but the index is 3" + let program_ids = vec![ + pubkey(COMPUTE_BUDGET), // SetComputeUnitLimit + pubkey(COMPUTE_BUDGET), // SetComputeUnitPrice + pubkey(LIGHT_SYSTEM), // Light system invoke + pubkey(SYSTEM_PROGRAM), // SOL transfer (inner) + pubkey(SYSTEM_PROGRAM), // SOL transfer (inner) + pubkey(SYSTEM_PROGRAM), // SOL transfer (inner) + pubkey(ACCOUNT_COMPRESSION), // InsertIntoQueues (inner) + ]; + + let instructions: Vec> = vec![ + ix_data("K1FDJ7"), + ix_data("3cDeqiGMb6md"), + ix_data("7Xu3JKNhcxBjvH52amHsaGu55uKzfsGvVjkBKAcEAAByDYGHt2TQQRq8aam17wkuH3Vtu2xuLyh8nZRxaqTEZPKM88CTs2e9MMiHW1ZA2NmFwbtgeHLFSRvW2DCayZMqHZWGEPjKwXnEJjFfKCTiJDXLeHbqirZeH4M3rYpeudpPnbNH9F9vLchjWs73hKJ9aSLVJKnJNXyr6ZW4hZd8YKVk3jaS11oW2ndPQT8CzYAF79wu8uishgqpLN42RwytWTDpMNUq7mKRFj1LkKKnpv8ya9WRxDCCKHfp1zn8sc1YviTcMyFsDRBvnE7kibyhcvd6hY9PvojPZNWABNDHxMGZoUL8xoUNRiD8Fxk7DyWQBvtqwyoPSjgFmKA97yEp4Kvj8btYDP24t51GYYXZyKjfFHnShcmdoKxuGohShW1UdjAhSWMVySZ92KRXjVJm6uv7CD5uXRy5Kuqco9ZHwASTv6HE1fQCEWKDdvq8Nx8SBMZF9jPM8JKJEarj"), + ix_data("3Bxs4HHMpGEM2775"), + ix_data("3Bxs4PckVVt51W8w"), + ix_data("3Bxs4PckVVt51W8w"), + ix_data("42NS6uhgPkAU4qDJGz54pXVoPKYL4VENq5jdLryg8pPRKsdthWiNYkaBQEimb4SSscjPZ2uYSXD7TjANLcaUdRMjh7Hid94o5GpGTxM3Pg2ALYdg8Qps6w2Sn6FXc1cp2vWVaXFQicExxLSTUNSSZwKH2M2XiqDxZBSekyELNcXkJCji9heVWqiB48zJX1YDBMYKLgXu3MoFvUgGjpYRteuuw44rBYUSfrs5tNh5CdfMtNkUJVCEvr5LSWeRUYwwXT8shx53iYb186vE3Gm2qY1Up7PfHdqGH1KZmzNz6ZjU2oC2r6zUHxoAA4v7HhMiC2cgwFXMrVGnw2nfKunjEP7Xm2Q62G4uJHGH3aMucTrSKCiwc55czqV9RaUDZUrvtfbLUjwG7XcPwwaY9JusFs21sZNveGE9xm1groM6uGn8ERCc6oBtFhouRKpfQiGoWxKeSrS6K5KWEq5aJ7XsZcXkNSdNGsGtgGu4nDXDtGhbamhXUtVmXcEfMMsMfoSzm1Cj1HCP89thHHC6P52Wert8XAfeei8X8bfwRHw6SzVFTBKkP7W8vjE2PgjwD5rVprBxS5owL4HPEnuTdSoawLA5JEqucpqgvXv7qihuJZ5aEQ8q2JhayJx3hqDriN6g1Vc2br8MtGRPXuwQYAd84jJoS6puMoanPnyFccv35jaxkEwUi5vY8J88ejut9W4uP7JVBivLBXYgDyLteffxA5a6rhJtFZ"), + ]; + + let accounts: Vec> = vec![ + vec![], // ComputeBudget + vec![], // ComputeBudget + // Light system invoke: user, registered PDA, noop, CPI context, account compression, + // SOL pool, system program, V2 trees (bmt3, oq3), V1 trees (smt5, nfq5), V2 trees (bmt2, oq2) + vec![ + pubkey(USER), + pubkey(USER), + pubkey(REGISTERED_PDA), + pubkey(NOOP), + pubkey(CPI_CONTEXT), + pubkey(ACCOUNT_COMPRESSION), + pubkey(SOL_POOL), + pubkey(USER), + pubkey(SYSTEM_PROGRAM), + pubkey(BMT3_TREE), + pubkey(OQ3_QUEUE), + pubkey(SMT5_TREE), + pubkey(NFQ5_QUEUE), + pubkey(BMT2_TREE), + pubkey(OQ2_QUEUE), + ], + // SOL transfers (inner) + vec![pubkey(SOL_POOL), pubkey(USER)], + vec![pubkey(USER), pubkey(BMT3_TREE)], + vec![pubkey(USER), pubkey(SMT5_TREE)], + // InsertIntoQueues (inner): CPI context, registered PDA, queues and trees + vec![ + pubkey(CPI_CONTEXT), + pubkey(REGISTERED_PDA), + pubkey(OQ3_QUEUE), + pubkey(BMT3_TREE), + pubkey(NFQ5_QUEUE), + pubkey(SMT5_TREE), + pubkey(OQ2_QUEUE), + pubkey(BMT2_TREE), + ], + ]; + + let result = event_from_light_transaction(&program_ids, &instructions, accounts); + assert!( + result.is_ok(), + "event_from_light_transaction failed: {:?}", + result.err() + ); + } +} diff --git a/sdk-libs/event/tests/parse_test.rs b/sdk-libs/event/tests/parse_test.rs index 8d4b0abbc9..5c780b3284 100644 --- a/sdk-libs/event/tests/parse_test.rs +++ b/sdk-libs/event/tests/parse_test.rs @@ -1285,3 +1285,218 @@ fn test_wrap_program_ids_account_compression_insufficient_accounts() { "AccountCompression with insufficient accounts should be Unknown" ); } + +// ========================================================================== +// Regression test: mixed batch / legacy input accounts in one transaction +// ========================================================================== + +/// Regression test for OOB panic in create_nullifier_queue_indices. +/// +/// Transaction 3ybts1eFSC7QN6aU4ao6NJCgn7xTbtBVyzeLDZJf9eVN93vHZWupX4TXqHHgV18xf17eit7Uw5T135uabnpToKK4 +/// at slot 407265372 triggered "index out of bounds: len is 3 but index is 3" +/// because the system instruction had 4 input accounts mixing batch and +/// legacy/concurrent trees: [batchA, legacy, batchB, batchA]. +/// +/// The InsertIntoQueues instruction also had 4 nullifiers. After filtering out +/// the legacy nullifier, batch_input_accounts.len() == 3. The old code used the +/// raw loop index i from input_compressed_accounts (4 elements) to write into +/// nullifier_queue_indices (len 3), causing the OOB on i==3. +/// +/// The fix walks input_compressed_accounts in order and uses a compact +/// batch_idx counter that only advances when a batch tree is found. +#[test] +fn test_mixed_batch_legacy_nullifier_queue_indices_no_oob() { + use light_compressed_account::{ + compressed_account::{ + CompressedAccount, PackedCompressedAccountWithMerkleContext, PackedMerkleContext, + }, + constants::LIGHT_SYSTEM_PROGRAM_ID, + discriminators::DISCRIMINATOR_INVOKE, + instruction_data::{ + data::InstructionDataInvoke, + insert_into_queues::{ + InsertIntoQueuesInstructionDataMut, InsertNullifierInput, + MerkleTreeSequenceNumber as IxSeqNum, + }, + }, + }; + use light_event::parse::event_from_light_transaction; + + let tree_a = Pubkey::new_from_array([1u8; 32]); + let legacy_tree = Pubkey::new_from_array([2u8; 32]); + let tree_b = Pubkey::new_from_array([3u8; 32]); + + // --- Build the LightSystem instruction --- + // 4 input accounts: batchA (index 0), legacy (index 1), batchB (index 2), batchA (index 0) + let system_invoke_data = InstructionDataInvoke { + input_compressed_accounts_with_merkle_context: vec![ + PackedCompressedAccountWithMerkleContext { + compressed_account: CompressedAccount::default(), + merkle_context: PackedMerkleContext { + merkle_tree_pubkey_index: 0, // treeA + queue_pubkey_index: 0, + leaf_index: 100, + prove_by_index: false, + }, + root_index: 0, + read_only: false, + }, + PackedCompressedAccountWithMerkleContext { + compressed_account: CompressedAccount::default(), + merkle_context: PackedMerkleContext { + merkle_tree_pubkey_index: 1, // legacyTree + queue_pubkey_index: 1, + leaf_index: 200, + prove_by_index: false, + }, + root_index: 0, + read_only: false, + }, + PackedCompressedAccountWithMerkleContext { + compressed_account: CompressedAccount::default(), + merkle_context: PackedMerkleContext { + merkle_tree_pubkey_index: 2, // treeB + queue_pubkey_index: 2, + leaf_index: 300, + prove_by_index: false, + }, + root_index: 0, + read_only: false, + }, + PackedCompressedAccountWithMerkleContext { + compressed_account: CompressedAccount::default(), + merkle_context: PackedMerkleContext { + merkle_tree_pubkey_index: 0, // treeA again + queue_pubkey_index: 0, + leaf_index: 400, + prove_by_index: false, + }, + root_index: 0, + read_only: false, + }, + ], + ..InstructionDataInvoke::default() + }; + // Format: [discriminator: 8][Anchor prefix: 4][borsh InstructionDataInvoke] + let mut system_ix_data = Vec::new(); + system_ix_data.extend_from_slice(&DISCRIMINATOR_INVOKE); + system_ix_data.extend_from_slice(&[0u8; 4]); + system_ix_data.extend(system_invoke_data.try_to_vec().unwrap()); + + // First 9 are system accounts; accounts[9..] are the tree accounts referenced + // by merkle_tree_pubkey_index in each input compressed account. + let mut system_accounts = vec![Pubkey::default(); 9]; + system_accounts.push(tree_a); // index 0 + system_accounts.push(legacy_tree); // index 1 + system_accounts.push(tree_b); // index 2 + + // --- Solana system instruction (required for the CPI pattern match) --- + let solana_system_ix_data = vec![0u8; 12]; + let solana_system_accounts: Vec = vec![]; + + // --- Build the AccountCompression (InsertIntoQueues) instruction --- + // 4 nullifiers matching the 4 system inputs: batchA, legacy, batchB, batchA. + // 2 input sequence numbers: treeA seq=6, treeB seq=3. + let size = InsertIntoQueuesInstructionDataMut::required_size_for_capacity( + 0, // leaves + 4, // nullifiers + 0, // addresses + 0, // output trees + 2, // input trees (treeA, treeB) + 0, // address trees + ); + let mut insert_queue_buf = vec![0u8; size]; + { + let (mut data_mut, _) = + InsertIntoQueuesInstructionDataMut::new_at(&mut insert_queue_buf, 0, 4, 0, 0, 2, 0) + .unwrap(); + + data_mut.tx_hash = [42u8; 32]; + + // nullifiers: tree_index is an index into ac_accounts[2..] = [treeA, legacyTree, treeB] + data_mut.nullifiers[0] = InsertNullifierInput { + account_hash: [11u8; 32], + leaf_index: 100u32.into(), + prove_by_index: 1, + tree_index: 0, // treeA + queue_index: 0, + }; + data_mut.nullifiers[1] = InsertNullifierInput { + account_hash: [22u8; 32], + leaf_index: 200u32.into(), + prove_by_index: 0, + tree_index: 1, // legacyTree — no sequence number entry + queue_index: 1, + }; + data_mut.nullifiers[2] = InsertNullifierInput { + account_hash: [33u8; 32], + leaf_index: 300u32.into(), + prove_by_index: 1, + tree_index: 2, // treeB + queue_index: 2, + }; + data_mut.nullifiers[3] = InsertNullifierInput { + account_hash: [44u8; 32], + leaf_index: 400u32.into(), + prove_by_index: 1, + tree_index: 0, // treeA again + queue_index: 0, + }; + + data_mut.input_sequence_numbers[0] = IxSeqNum { + tree_pubkey: tree_a, + queue_pubkey: Pubkey::default(), + tree_type: 3u64.into(), // StateV2 + seq: 6u64.into(), + }; + data_mut.input_sequence_numbers[1] = IxSeqNum { + tree_pubkey: tree_b, + queue_pubkey: Pubkey::default(), + tree_type: 3u64.into(), // StateV2 + seq: 3u64.into(), + }; + } + + // Format: [discriminator: 8][prefix: 4][zero-copy data][empty cpi_context_outputs: 4] + let mut ac_ix_data = Vec::new(); + ac_ix_data.extend_from_slice(&DISCRIMINATOR_INSERT_INTO_QUEUES); + ac_ix_data.extend_from_slice(&[0u8; 4]); + ac_ix_data.extend_from_slice(&insert_queue_buf); + ac_ix_data.extend_from_slice(&[0u8; 4]); // borsh-encoded empty Vec (u32 len = 0) + + // accounts[0] = signer, accounts[1] = REGISTERED_PROGRAM_PDA, accounts[2..] = trees + let ac_accounts = vec![ + Pubkey::default(), + Pubkey::from(REGISTERED_PROGRAM_PDA), + tree_a, + legacy_tree, + tree_b, + ]; + + // --- Assemble and invoke --- + let program_ids = vec![ + Pubkey::new_from_array(LIGHT_SYSTEM_PROGRAM_ID), + Pubkey::default(), // SolanaSystem + Pubkey::new_from_array(ACCOUNT_COMPRESSION_PROGRAM_ID), + ]; + let instructions = vec![system_ix_data, solana_system_ix_data, ac_ix_data]; + let accounts = vec![system_accounts, solana_system_accounts, ac_accounts]; + + // Before the fix this panicked: "index out of bounds: len is 3 but index is 3" + let result = event_from_light_transaction(&program_ids, &instructions, accounts); + let events = result + .expect("should parse without error") + .expect("should find events"); + assert_eq!(events.len(), 1); + + let event = &events[0]; + // 3 batch inputs: batchA, batchB, batchA (legacy is filtered out) + assert_eq!(event.batch_input_accounts.len(), 3); + // nullifier_queue_indices: batchA->seq=6, batchB->seq=3, batchA->seq=7 (incremented) + let queue_indices: Vec = event + .batch_input_accounts + .iter() + .map(|c| c.nullifier_queue_index) + .collect(); + assert_eq!(queue_indices, vec![6, 3, 7]); +} diff --git a/sdk-libs/justfile b/sdk-libs/justfile index 81bfe7d042..b0480fd32a 100644 --- a/sdk-libs/justfile +++ b/sdk-libs/justfile @@ -15,3 +15,4 @@ test: cargo test -p light-token --all-features cargo test -p light-token-client cargo test -p light-token-client --all-features + cargo test -p light-event