Skip to content

Commit bfa7c87

Browse files
committed
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.
1 parent 5d1c6da commit bfa7c87

6 files changed

Lines changed: 330 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sdk-libs/event/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ light-zero-copy = { workspace = true }
1515
thiserror = { workspace = true }
1616

1717
[dev-dependencies]
18+
bs58 = { workspace = true }
1819
rand = { workspace = true }

sdk-libs/event/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@
1111
pub mod error;
1212
pub mod event;
1313
pub mod parse;
14+
#[cfg(test)]
15+
mod regression_test;
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/// Regression test for index-out-of-bounds panic in create_nullifier_queue_indices.
2+
/// Transaction: 3ybts1eFSC7QN6aU4ao6NJCgn7xTbtBVyzeLDZJf9eVN93vHZWupX4TXqHHgV18xf17eit7Uw5T135uabnpToKK4
3+
/// Slot: 407265372 (mainnet)
4+
/// This transaction crashed photon's indexer because `len` passed to
5+
/// `create_nullifier_queue_indices` didn't match the number of input accounts.
6+
#[cfg(test)]
7+
mod tests {
8+
use light_compressed_account::Pubkey;
9+
10+
use crate::parse::event_from_light_transaction;
11+
12+
fn pubkey(s: &str) -> Pubkey {
13+
let bytes: [u8; 32] = bs58::decode(s).into_vec().unwrap().try_into().unwrap();
14+
Pubkey::from(bytes)
15+
}
16+
17+
fn ix_data(s: &str) -> Vec<u8> {
18+
bs58::decode(s).into_vec().unwrap()
19+
}
20+
21+
// Account addresses used in the transaction
22+
const USER: &str = "33X2Tg3gdxTwouaVSxpcNwVHJt2ZYxo3Hm7UjH2i8M3r";
23+
const REGISTERED_PDA: &str = "35hkDgaAKwMCaxRz2ocSZ6NaUrtKkyNqU6c4RV3tYJRh";
24+
const NOOP: &str = "noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV";
25+
const CPI_CONTEXT: &str = "HwXnGK3tPkkVY6P439H2p68AxpeuWXd5PcrAxFpbmfbA";
26+
const ACCOUNT_COMPRESSION: &str = "compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq";
27+
const SOL_POOL: &str = "CHK57ywWSDncAoRu1F8QgwYJeXuAJyyBYT4LixLXvMZ1";
28+
const SYSTEM_PROGRAM: &str = "11111111111111111111111111111111";
29+
const BMT3_TREE: &str = "bmt3ccLd4bqSVZVeCJnH1F6C8jNygAhaDfxDwePyyGb";
30+
const OQ3_QUEUE: &str = "oq3AxjekBWgo64gpauB6QtuZNesuv19xrhaC1ZM1THQ";
31+
const SMT5_TREE: &str = "smt5uPaQT9n6b1qAkgyonmzRxtuazA53Rddwntqistc";
32+
const NFQ5_QUEUE: &str = "nfq5b5xEguPtdD6uPetZduyrB5EUqad7gcUE46rALau";
33+
const BMT2_TREE: &str = "bmt2UxoBxB9xWev4BkLvkGdapsz6sZGkzViPNph7VFi";
34+
const OQ2_QUEUE: &str = "oq2UkeMsJLfXt2QHzim242SUi3nvjJs8Pn7Eac9H9vg";
35+
36+
// Program IDs
37+
const LIGHT_SYSTEM: &str = "SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7";
38+
const COMPUTE_BUDGET: &str = "ComputeBudget111111111111111111111111111111";
39+
40+
#[test]
41+
fn test_mainnet_tx_407265372_no_panic() {
42+
// Tx 3ybts1eFSC7QN...ToKK4, slot 407265372
43+
// Before fix: panicked with "index out of bounds: the len is 3 but the index is 3"
44+
let program_ids = vec![
45+
pubkey(COMPUTE_BUDGET), // SetComputeUnitLimit
46+
pubkey(COMPUTE_BUDGET), // SetComputeUnitPrice
47+
pubkey(LIGHT_SYSTEM), // Light system invoke
48+
pubkey(SYSTEM_PROGRAM), // SOL transfer (inner)
49+
pubkey(SYSTEM_PROGRAM), // SOL transfer (inner)
50+
pubkey(SYSTEM_PROGRAM), // SOL transfer (inner)
51+
pubkey(ACCOUNT_COMPRESSION), // InsertIntoQueues (inner)
52+
];
53+
54+
let instructions: Vec<Vec<u8>> = vec![
55+
ix_data("K1FDJ7"),
56+
ix_data("3cDeqiGMb6md"),
57+
ix_data("7Xu3JKNhcxBjvH52amHsaGu55uKzfsGvVjkBKAcEAAByDYGHt2TQQRq8aam17wkuH3Vtu2xuLyh8nZRxaqTEZPKM88CTs2e9MMiHW1ZA2NmFwbtgeHLFSRvW2DCayZMqHZWGEPjKwXnEJjFfKCTiJDXLeHbqirZeH4M3rYpeudpPnbNH9F9vLchjWs73hKJ9aSLVJKnJNXyr6ZW4hZd8YKVk3jaS11oW2ndPQT8CzYAF79wu8uishgqpLN42RwytWTDpMNUq7mKRFj1LkKKnpv8ya9WRxDCCKHfp1zn8sc1YviTcMyFsDRBvnE7kibyhcvd6hY9PvojPZNWABNDHxMGZoUL8xoUNRiD8Fxk7DyWQBvtqwyoPSjgFmKA97yEp4Kvj8btYDP24t51GYYXZyKjfFHnShcmdoKxuGohShW1UdjAhSWMVySZ92KRXjVJm6uv7CD5uXRy5Kuqco9ZHwASTv6HE1fQCEWKDdvq8Nx8SBMZF9jPM8JKJEarj"),
58+
ix_data("3Bxs4HHMpGEM2775"),
59+
ix_data("3Bxs4PckVVt51W8w"),
60+
ix_data("3Bxs4PckVVt51W8w"),
61+
ix_data("42NS6uhgPkAU4qDJGz54pXVoPKYL4VENq5jdLryg8pPRKsdthWiNYkaBQEimb4SSscjPZ2uYSXD7TjANLcaUdRMjh7Hid94o5GpGTxM3Pg2ALYdg8Qps6w2Sn6FXc1cp2vWVaXFQicExxLSTUNSSZwKH2M2XiqDxZBSekyELNcXkJCji9heVWqiB48zJX1YDBMYKLgXu3MoFvUgGjpYRteuuw44rBYUSfrs5tNh5CdfMtNkUJVCEvr5LSWeRUYwwXT8shx53iYb186vE3Gm2qY1Up7PfHdqGH1KZmzNz6ZjU2oC2r6zUHxoAA4v7HhMiC2cgwFXMrVGnw2nfKunjEP7Xm2Q62G4uJHGH3aMucTrSKCiwc55czqV9RaUDZUrvtfbLUjwG7XcPwwaY9JusFs21sZNveGE9xm1groM6uGn8ERCc6oBtFhouRKpfQiGoWxKeSrS6K5KWEq5aJ7XsZcXkNSdNGsGtgGu4nDXDtGhbamhXUtVmXcEfMMsMfoSzm1Cj1HCP89thHHC6P52Wert8XAfeei8X8bfwRHw6SzVFTBKkP7W8vjE2PgjwD5rVprBxS5owL4HPEnuTdSoawLA5JEqucpqgvXv7qihuJZ5aEQ8q2JhayJx3hqDriN6g1Vc2br8MtGRPXuwQYAd84jJoS6puMoanPnyFccv35jaxkEwUi5vY8J88ejut9W4uP7JVBivLBXYgDyLteffxA5a6rhJtFZ"),
62+
];
63+
64+
let accounts: Vec<Vec<Pubkey>> = vec![
65+
vec![], // ComputeBudget
66+
vec![], // ComputeBudget
67+
// Light system invoke: user, registered PDA, noop, CPI context, account compression,
68+
// SOL pool, system program, V2 trees (bmt3, oq3), V1 trees (smt5, nfq5), V2 trees (bmt2, oq2)
69+
vec![
70+
pubkey(USER),
71+
pubkey(USER),
72+
pubkey(REGISTERED_PDA),
73+
pubkey(NOOP),
74+
pubkey(CPI_CONTEXT),
75+
pubkey(ACCOUNT_COMPRESSION),
76+
pubkey(SOL_POOL),
77+
pubkey(USER),
78+
pubkey(SYSTEM_PROGRAM),
79+
pubkey(BMT3_TREE),
80+
pubkey(OQ3_QUEUE),
81+
pubkey(SMT5_TREE),
82+
pubkey(NFQ5_QUEUE),
83+
pubkey(BMT2_TREE),
84+
pubkey(OQ2_QUEUE),
85+
],
86+
// SOL transfers (inner)
87+
vec![pubkey(SOL_POOL), pubkey(USER)],
88+
vec![pubkey(USER), pubkey(BMT3_TREE)],
89+
vec![pubkey(USER), pubkey(SMT5_TREE)],
90+
// InsertIntoQueues (inner): CPI context, registered PDA, queues and trees
91+
vec![
92+
pubkey(CPI_CONTEXT),
93+
pubkey(REGISTERED_PDA),
94+
pubkey(OQ3_QUEUE),
95+
pubkey(BMT3_TREE),
96+
pubkey(NFQ5_QUEUE),
97+
pubkey(SMT5_TREE),
98+
pubkey(OQ2_QUEUE),
99+
pubkey(BMT2_TREE),
100+
],
101+
];
102+
103+
let result = event_from_light_transaction(&program_ids, &instructions, accounts);
104+
assert!(
105+
result.is_ok(),
106+
"event_from_light_transaction failed: {:?}",
107+
result.err()
108+
);
109+
}
110+
}

sdk-libs/event/tests/parse_test.rs

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,3 +1285,218 @@ fn test_wrap_program_ids_account_compression_insufficient_accounts() {
12851285
"AccountCompression with insufficient accounts should be Unknown"
12861286
);
12871287
}
1288+
1289+
// ==========================================================================
1290+
// Regression test: mixed batch / legacy input accounts in one transaction
1291+
// ==========================================================================
1292+
1293+
/// Regression test for OOB panic in create_nullifier_queue_indices.
1294+
///
1295+
/// Transaction 3ybts1eFSC7QN6aU4ao6NJCgn7xTbtBVyzeLDZJf9eVN93vHZWupX4TXqHHgV18xf17eit7Uw5T135uabnpToKK4
1296+
/// at slot 407265372 triggered "index out of bounds: len is 3 but index is 3"
1297+
/// because the system instruction had 4 input accounts mixing batch and
1298+
/// legacy/concurrent trees: [batchA, legacy, batchB, batchA].
1299+
///
1300+
/// The InsertIntoQueues instruction also had 4 nullifiers. After filtering out
1301+
/// the legacy nullifier, batch_input_accounts.len() == 3. The old code used the
1302+
/// raw loop index i from input_compressed_accounts (4 elements) to write into
1303+
/// nullifier_queue_indices (len 3), causing the OOB on i==3.
1304+
///
1305+
/// The fix walks input_compressed_accounts in order and uses a compact
1306+
/// batch_idx counter that only advances when a batch tree is found.
1307+
#[test]
1308+
fn test_mixed_batch_legacy_nullifier_queue_indices_no_oob() {
1309+
use light_compressed_account::{
1310+
compressed_account::{
1311+
CompressedAccount, PackedCompressedAccountWithMerkleContext, PackedMerkleContext,
1312+
},
1313+
constants::LIGHT_SYSTEM_PROGRAM_ID,
1314+
discriminators::DISCRIMINATOR_INVOKE,
1315+
instruction_data::{
1316+
data::InstructionDataInvoke,
1317+
insert_into_queues::{
1318+
InsertIntoQueuesInstructionDataMut, InsertNullifierInput,
1319+
MerkleTreeSequenceNumber as IxSeqNum,
1320+
},
1321+
},
1322+
};
1323+
use light_event::parse::event_from_light_transaction;
1324+
1325+
let tree_a = Pubkey::new_from_array([1u8; 32]);
1326+
let legacy_tree = Pubkey::new_from_array([2u8; 32]);
1327+
let tree_b = Pubkey::new_from_array([3u8; 32]);
1328+
1329+
// --- Build the LightSystem instruction ---
1330+
// 4 input accounts: batchA (index 0), legacy (index 1), batchB (index 2), batchA (index 0)
1331+
let system_invoke_data = InstructionDataInvoke {
1332+
input_compressed_accounts_with_merkle_context: vec![
1333+
PackedCompressedAccountWithMerkleContext {
1334+
compressed_account: CompressedAccount::default(),
1335+
merkle_context: PackedMerkleContext {
1336+
merkle_tree_pubkey_index: 0, // treeA
1337+
queue_pubkey_index: 0,
1338+
leaf_index: 100,
1339+
prove_by_index: false,
1340+
},
1341+
root_index: 0,
1342+
read_only: false,
1343+
},
1344+
PackedCompressedAccountWithMerkleContext {
1345+
compressed_account: CompressedAccount::default(),
1346+
merkle_context: PackedMerkleContext {
1347+
merkle_tree_pubkey_index: 1, // legacyTree
1348+
queue_pubkey_index: 1,
1349+
leaf_index: 200,
1350+
prove_by_index: false,
1351+
},
1352+
root_index: 0,
1353+
read_only: false,
1354+
},
1355+
PackedCompressedAccountWithMerkleContext {
1356+
compressed_account: CompressedAccount::default(),
1357+
merkle_context: PackedMerkleContext {
1358+
merkle_tree_pubkey_index: 2, // treeB
1359+
queue_pubkey_index: 2,
1360+
leaf_index: 300,
1361+
prove_by_index: false,
1362+
},
1363+
root_index: 0,
1364+
read_only: false,
1365+
},
1366+
PackedCompressedAccountWithMerkleContext {
1367+
compressed_account: CompressedAccount::default(),
1368+
merkle_context: PackedMerkleContext {
1369+
merkle_tree_pubkey_index: 0, // treeA again
1370+
queue_pubkey_index: 0,
1371+
leaf_index: 400,
1372+
prove_by_index: false,
1373+
},
1374+
root_index: 0,
1375+
read_only: false,
1376+
},
1377+
],
1378+
..InstructionDataInvoke::default()
1379+
};
1380+
// Format: [discriminator: 8][Anchor prefix: 4][borsh InstructionDataInvoke]
1381+
let mut system_ix_data = Vec::new();
1382+
system_ix_data.extend_from_slice(&DISCRIMINATOR_INVOKE);
1383+
system_ix_data.extend_from_slice(&[0u8; 4]);
1384+
system_ix_data.extend(system_invoke_data.try_to_vec().unwrap());
1385+
1386+
// First 9 are system accounts; accounts[9..] are the tree accounts referenced
1387+
// by merkle_tree_pubkey_index in each input compressed account.
1388+
let mut system_accounts = vec![Pubkey::default(); 9];
1389+
system_accounts.push(tree_a); // index 0
1390+
system_accounts.push(legacy_tree); // index 1
1391+
system_accounts.push(tree_b); // index 2
1392+
1393+
// --- Solana system instruction (required for the CPI pattern match) ---
1394+
let solana_system_ix_data = vec![0u8; 12];
1395+
let solana_system_accounts: Vec<Pubkey> = vec![];
1396+
1397+
// --- Build the AccountCompression (InsertIntoQueues) instruction ---
1398+
// 4 nullifiers matching the 4 system inputs: batchA, legacy, batchB, batchA.
1399+
// 2 input sequence numbers: treeA seq=6, treeB seq=3.
1400+
let size = InsertIntoQueuesInstructionDataMut::required_size_for_capacity(
1401+
0, // leaves
1402+
4, // nullifiers
1403+
0, // addresses
1404+
0, // output trees
1405+
2, // input trees (treeA, treeB)
1406+
0, // address trees
1407+
);
1408+
let mut insert_queue_buf = vec![0u8; size];
1409+
{
1410+
let (mut data_mut, _) =
1411+
InsertIntoQueuesInstructionDataMut::new_at(&mut insert_queue_buf, 0, 4, 0, 0, 2, 0)
1412+
.unwrap();
1413+
1414+
data_mut.tx_hash = [42u8; 32];
1415+
1416+
// nullifiers: tree_index is an index into ac_accounts[2..] = [treeA, legacyTree, treeB]
1417+
data_mut.nullifiers[0] = InsertNullifierInput {
1418+
account_hash: [11u8; 32],
1419+
leaf_index: 100u32.into(),
1420+
prove_by_index: 1,
1421+
tree_index: 0, // treeA
1422+
queue_index: 0,
1423+
};
1424+
data_mut.nullifiers[1] = InsertNullifierInput {
1425+
account_hash: [22u8; 32],
1426+
leaf_index: 200u32.into(),
1427+
prove_by_index: 0,
1428+
tree_index: 1, // legacyTree — no sequence number entry
1429+
queue_index: 1,
1430+
};
1431+
data_mut.nullifiers[2] = InsertNullifierInput {
1432+
account_hash: [33u8; 32],
1433+
leaf_index: 300u32.into(),
1434+
prove_by_index: 1,
1435+
tree_index: 2, // treeB
1436+
queue_index: 2,
1437+
};
1438+
data_mut.nullifiers[3] = InsertNullifierInput {
1439+
account_hash: [44u8; 32],
1440+
leaf_index: 400u32.into(),
1441+
prove_by_index: 1,
1442+
tree_index: 0, // treeA again
1443+
queue_index: 0,
1444+
};
1445+
1446+
data_mut.input_sequence_numbers[0] = IxSeqNum {
1447+
tree_pubkey: tree_a,
1448+
queue_pubkey: Pubkey::default(),
1449+
tree_type: 3u64.into(), // StateV2
1450+
seq: 6u64.into(),
1451+
};
1452+
data_mut.input_sequence_numbers[1] = IxSeqNum {
1453+
tree_pubkey: tree_b,
1454+
queue_pubkey: Pubkey::default(),
1455+
tree_type: 3u64.into(), // StateV2
1456+
seq: 3u64.into(),
1457+
};
1458+
}
1459+
1460+
// Format: [discriminator: 8][prefix: 4][zero-copy data][empty cpi_context_outputs: 4]
1461+
let mut ac_ix_data = Vec::new();
1462+
ac_ix_data.extend_from_slice(&DISCRIMINATOR_INSERT_INTO_QUEUES);
1463+
ac_ix_data.extend_from_slice(&[0u8; 4]);
1464+
ac_ix_data.extend_from_slice(&insert_queue_buf);
1465+
ac_ix_data.extend_from_slice(&[0u8; 4]); // borsh-encoded empty Vec (u32 len = 0)
1466+
1467+
// accounts[0] = signer, accounts[1] = REGISTERED_PROGRAM_PDA, accounts[2..] = trees
1468+
let ac_accounts = vec![
1469+
Pubkey::default(),
1470+
Pubkey::from(REGISTERED_PROGRAM_PDA),
1471+
tree_a,
1472+
legacy_tree,
1473+
tree_b,
1474+
];
1475+
1476+
// --- Assemble and invoke ---
1477+
let program_ids = vec![
1478+
Pubkey::new_from_array(LIGHT_SYSTEM_PROGRAM_ID),
1479+
Pubkey::default(), // SolanaSystem
1480+
Pubkey::new_from_array(ACCOUNT_COMPRESSION_PROGRAM_ID),
1481+
];
1482+
let instructions = vec![system_ix_data, solana_system_ix_data, ac_ix_data];
1483+
let accounts = vec![system_accounts, solana_system_accounts, ac_accounts];
1484+
1485+
// Before the fix this panicked: "index out of bounds: len is 3 but index is 3"
1486+
let result = event_from_light_transaction(&program_ids, &instructions, accounts);
1487+
let events = result
1488+
.expect("should parse without error")
1489+
.expect("should find events");
1490+
assert_eq!(events.len(), 1);
1491+
1492+
let event = &events[0];
1493+
// 3 batch inputs: batchA, batchB, batchA (legacy is filtered out)
1494+
assert_eq!(event.batch_input_accounts.len(), 3);
1495+
// nullifier_queue_indices: batchA->seq=6, batchB->seq=3, batchA->seq=7 (incremented)
1496+
let queue_indices: Vec<u64> = event
1497+
.batch_input_accounts
1498+
.iter()
1499+
.map(|c| c.nullifier_queue_index)
1500+
.collect();
1501+
assert_eq!(queue_indices, vec![6, 3, 7]);
1502+
}

sdk-libs/justfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ test:
1515
cargo test -p light-token --all-features
1616
cargo test -p light-token-client
1717
cargo test -p light-token-client --all-features
18+
cargo test -p light-event

0 commit comments

Comments
 (0)