From 78fa2b6ce66b11ab75aab49a1ed5e60662329923 Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Thu, 11 Jun 2026 13:10:50 -0400 Subject: [PATCH] test: add real-deployment coverage for inner instruction events Add an events-caller program that CPIs into the events program so events are emitted from inner instructions (invoke depth 2), and test that both the TS client (addEventListener, EventParser.parseLogs) and the Rust client (Program::on) detect them against a live validator instead of synthetic log data. Covers the fix from #4451. Closes #4656. Co-authored-by: Cursor --- .github/workflows/reusable-tests.yaml | 6 +++ client/example/Cargo.toml | 1 + client/example/run-test.sh | 5 ++ client/example/setup.tx | 5 ++ client/example/src/blocking.rs | 49 ++++++++++++++++- client/example/src/main.rs | 2 + client/example/src/nonblocking.rs | 47 ++++++++++++++++- tests/events/Anchor.toml | 1 + .../events/programs/events-caller/Cargo.toml | 19 +++++++ .../events/programs/events-caller/src/lib.rs | 33 ++++++++++++ tests/events/tests/events.ts | 52 +++++++++++++++++++ 11 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 tests/events/programs/events-caller/Cargo.toml create mode 100644 tests/events/programs/events-caller/src/lib.rs diff --git a/.github/workflows/reusable-tests.yaml b/.github/workflows/reusable-tests.yaml index d5aedd07ad..8e0b00cbbe 100644 --- a/.github/workflows/reusable-tests.yaml +++ b/.github/workflows/reusable-tests.yaml @@ -184,6 +184,8 @@ jobs: name: optional.so - path: tests/events/ name: events.so + - path: tests/events/ + name: events_caller.so - path: examples/tutorial/basic-4/ name: basic_4.so - path: examples/tutorial/basic-2/ @@ -236,6 +238,10 @@ jobs: with: name: events.so path: tests/events/target/deploy/ + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: events_caller.so + path: tests/events/target/deploy/ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: basic_4.so diff --git a/client/example/Cargo.toml b/client/example/Cargo.toml index 92cba05c22..ebac428b2e 100644 --- a/client/example/Cargo.toml +++ b/client/example/Cargo.toml @@ -16,6 +16,7 @@ basic-4 = { path = "../../examples/tutorial/basic-4/programs/basic-4", features composite = { path = "../../tests/composite/programs/composite", features = ["no-entrypoint"] } optional = { path = "../../tests/optional/programs/optional", features = ["no-entrypoint"] } events = { path = "../../tests/events/programs/events", features = ["no-entrypoint"] } +events-caller = { path = "../../tests/events/programs/events-caller", features = ["no-entrypoint"] } anyhow = "1.0.32" clap = { version = "4.2.4", features = ["derive"] } shellexpand = "2.1.0" diff --git a/client/example/run-test.sh b/client/example/run-test.sh index eae3257a3e..5bcee593ae 100755 --- a/client/example/run-test.sh +++ b/client/example/run-test.sh @@ -26,6 +26,7 @@ main() { local basic_2_pid="Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS" local basic_4_pid="CwrqeMj2U8tFr1Rhkgwc84tpAsqbt9pTt2a4taoTADPr" local events_pid="2dhGsWUzy5YKUsjZdLHLmkNpUDAXkNa9MYWsPc4Ziqzy" + local events_caller_pid="9Cjn1bYn2naaf4JCHSSEfMGcnUsLGSdKHpYX3wc6NvwU" local optional_pid="FNqz6pqLAwvMSds2FYjR4nKV3moVpPNtvkfGFrqLKrgG" cd ../../tests/composite && anchor build --skip-lint --ignore-keys && cd - @@ -52,6 +53,7 @@ main() { --basic-2-pid $basic_2_pid \ --basic-4-pid $basic_4_pid \ --events-pid $events_pid \ + --events-caller-pid $events_caller_pid \ --optional-pid $optional_pid # @@ -68,6 +70,7 @@ main() { --basic-2-pid $basic_2_pid \ --basic-4-pid $basic_4_pid \ --events-pid $events_pid \ + --events-caller-pid $events_caller_pid \ --optional-pid $optional_pid \ --multithreaded @@ -85,6 +88,7 @@ main() { --basic-2-pid $basic_2_pid \ --basic-4-pid $basic_4_pid \ --events-pid $events_pid \ + --events-caller-pid $events_caller_pid \ --optional-pid $optional_pid \ --multithreaded @@ -143,6 +147,7 @@ start_surfpool() { --input basic_2_pid=$basic_2_pid \ --input basic_4_pid=$basic_4_pid \ --input events_pid=$events_pid \ + --input events_caller_pid=$events_caller_pid \ --input optional_pid=$optional_pid \ >&2 diff --git a/client/example/setup.tx b/client/example/setup.tx index b6ab4c0e3a..d3ba79fd46 100644 --- a/client/example/setup.tx +++ b/client/example/setup.tx @@ -32,6 +32,11 @@ action "setup" "svm::setup_surfnet" { binary_path = "../../tests/events/target/deploy/events.so" authority = signer.authority.public_key } + deploy_program { + program_id = input.events_caller_pid + binary_path = "../../tests/events/target/deploy/events_caller.so" + authority = signer.authority.public_key + } deploy_program { program_id = input.optional_pid binary_path = "../../tests/optional/target/deploy/optional.so" diff --git a/client/example/src/blocking.rs b/client/example/src/blocking.rs index ddd9355344..538ed7036c 100644 --- a/client/example/src/blocking.rs +++ b/client/example/src/blocking.rs @@ -11,7 +11,9 @@ use basic_2::accounts as basic_2_accounts; use basic_2::instruction as basic_2_instruction; use basic_2::Counter; use events::instruction as events_instruction; -use events::MyEvent; +use events::{MyEvent, MyOtherEvent}; +use events_caller::accounts as events_caller_accounts; +use events_caller::instruction as events_caller_instruction; use optional::accounts::Initialize as OptionalInitialize; use optional::instruction as optional_instruction; // The `accounts` and `instructions` modules are generated by the framework. @@ -59,6 +61,7 @@ pub fn main() -> Result<()> { let payer: &Keypair = &payer; let client = Client::new_with_options(url, payer, CommitmentConfig::processed()); events(&client, opts.events_pid)?; + events_cpi(&client, opts.events_caller_pid, opts.events_pid)?; optional(&client, opts.optional_pid)?; } else { // Client. @@ -81,6 +84,11 @@ pub fn main() -> Result<()> { let local_client = Arc::clone(&client); handles.push(std::thread::spawn(move || test(&local_client, arg))); } + let local_client = Arc::clone(&client); + let (events_caller_pid, events_pid) = (opts.events_caller_pid, opts.events_pid); + handles.push(std::thread::spawn(move || { + events_cpi(&local_client, events_caller_pid, events_pid) + })); for handle in handles { assert!(handle.join().unwrap().is_ok()); } @@ -230,6 +238,45 @@ pub fn events + Clone>( Ok(()) } +// Tests that events emitted by inner instructions (CPI, invoke depth >= 2) +// are detected when subscribed to the emitting program. Regression test for +// the log parsing fix in #4451 against a real deployment (#4656). +pub fn events_cpi + Clone>( + client: &Client, + caller_pid: Pubkey, + events_pid: Pubkey, +) -> Result<()> { + let events_program = client.program(events_pid)?; + let caller_program = client.program(caller_pid)?; + + let (sender, receiver) = std::sync::mpsc::channel(); + let event_unsubscriber = events_program.on(move |_, event: MyOtherEvent| { + if sender.send(event).is_err() { + println!("Error while transferring the event.") + } + })?; + + sleep(Duration::from_millis(1000)); + + caller_program + .request() + .accounts(events_caller_accounts::CpiEvent { + events_program: events_pid, + }) + .args(events_caller_instruction::CpiEvent {}) + .send()?; + + let event = receiver.recv().unwrap(); + assert_eq!(event.data, 6); + assert_eq!(event.label, "bye".to_string()); + + event_unsubscriber.unsubscribe(); + + println!("Events CPI success!"); + + Ok(()) +} + pub fn basic_4 + Clone>( client: &Client, pid: Pubkey, diff --git a/client/example/src/main.rs b/client/example/src/main.rs index abc0048558..16781ba3bf 100644 --- a/client/example/src/main.rs +++ b/client/example/src/main.rs @@ -19,6 +19,8 @@ pub struct Opts { #[clap(long)] events_pid: Pubkey, #[clap(long)] + events_caller_pid: Pubkey, + #[clap(long)] optional_pid: Pubkey, #[clap(long, default_value = "false")] multithreaded: bool, diff --git a/client/example/src/nonblocking.rs b/client/example/src/nonblocking.rs index 9026afd110..4d19004a06 100644 --- a/client/example/src/nonblocking.rs +++ b/client/example/src/nonblocking.rs @@ -11,7 +11,9 @@ use basic_2::accounts as basic_2_accounts; use basic_2::instruction as basic_2_instruction; use basic_2::Counter; use events::instruction as events_instruction; -use events::MyEvent; +use events::{MyEvent, MyOtherEvent}; +use events_caller::accounts as events_caller_accounts; +use events_caller::instruction as events_caller_instruction; use optional::accounts::Initialize as OptionalInitialize; use optional::instruction as optional_instruction; // The `accounts` and `instructions` modules are generated by the framework. @@ -56,6 +58,7 @@ pub async fn main() -> Result<()> { let payer: &Keypair = &payer; let client = Client::new_with_options(url, payer, CommitmentConfig::processed()); events(&client, opts.events_pid).await?; + events_cpi(&client, opts.events_caller_pid, opts.events_pid).await?; optional(&client, opts.optional_pid).await?; // Success. Ok(()) @@ -243,6 +246,48 @@ pub async fn events + Clone>( Ok(()) } +// Tests that events emitted by inner instructions (CPI, invoke depth >= 2) +// are detected when subscribed to the emitting program. Regression test for +// the log parsing fix in #4451 against a real deployment (#4656). +pub async fn events_cpi + Clone>( + client: &Client, + caller_pid: Pubkey, + events_pid: Pubkey, +) -> Result<()> { + let events_program = client.program(events_pid)?; + let caller_program = client.program(caller_pid)?; + + let (sender, mut receiver) = mpsc::unbounded_channel(); + let event_unsubscriber = events_program + .on(move |_, event: MyOtherEvent| { + if sender.send(event).is_err() { + println!("Error while transferring the event.") + } + }) + .await?; + + sleep(Duration::from_millis(1000)).await; + + caller_program + .request() + .accounts(events_caller_accounts::CpiEvent { + events_program: events_pid, + }) + .args(events_caller_instruction::CpiEvent {}) + .send() + .await?; + + let event = receiver.recv().await.unwrap(); + assert_eq!(event.data, 6); + assert_eq!(event.label, "bye".to_string()); + + event_unsubscriber.unsubscribe().await; + + println!("Events CPI success!"); + + Ok(()) +} + pub async fn basic_4 + Clone>( client: &Client, pid: Pubkey, diff --git a/tests/events/Anchor.toml b/tests/events/Anchor.toml index 18b7cf65a0..cf39b008be 100644 --- a/tests/events/Anchor.toml +++ b/tests/events/Anchor.toml @@ -4,6 +4,7 @@ wallet = "~/.config/solana/id.json" [programs.localnet] events = "2dhGsWUzy5YKUsjZdLHLmkNpUDAXkNa9MYWsPc4Ziqzy" +events_caller = "9Cjn1bYn2naaf4JCHSSEfMGcnUsLGSdKHpYX3wc6NvwU" [scripts] test = "yarn run ts-mocha -t 1000000 -p ./tsconfig.json tests/**/*.ts" diff --git a/tests/events/programs/events-caller/Cargo.toml b/tests/events/programs/events-caller/Cargo.toml new file mode 100644 index 0000000000..3646bb6482 --- /dev/null +++ b/tests/events/programs/events-caller/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "events-caller" +version = "0.1.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "events_caller" + +[features] +no-entrypoint = [] +cpi = ["no-entrypoint"] +default = [] +idl-build = ["anchor-lang/idl-build"] + +[dependencies] +anchor-lang = { path = "../../../../lang" } +events = { path = "../events", features = ["no-entrypoint"] } diff --git a/tests/events/programs/events-caller/src/lib.rs b/tests/events/programs/events-caller/src/lib.rs new file mode 100644 index 0000000000..aaa48c8e61 --- /dev/null +++ b/tests/events/programs/events-caller/src/lib.rs @@ -0,0 +1,33 @@ +//! This program CPIs into the `events` program so that events are +//! emitted from an inner instruction (invoke depth >= 2). Clients +//! subscribed to the `events` program must still detect them, which +//! is the scenario from #4450 fixed in #4451. + +use anchor_lang::{ + prelude::*, + solana_program::{instruction::Instruction, program::invoke}, + InstructionData, +}; +use events::program::Events; + +declare_id!("9Cjn1bYn2naaf4JCHSSEfMGcnUsLGSdKHpYX3wc6NvwU"); + +#[program] +pub mod events_caller { + use super::*; + + pub fn cpi_event(ctx: Context) -> Result<()> { + let ix = Instruction { + program_id: ctx.accounts.events_program.key(), + accounts: vec![], + data: events::instruction::TestEvent.data(), + }; + invoke(&ix, &[ctx.accounts.events_program.to_account_info()])?; + Ok(()) + } +} + +#[derive(Accounts)] +pub struct CpiEvent<'info> { + pub events_program: Program<'info, Events>, +} diff --git a/tests/events/tests/events.ts b/tests/events/tests/events.ts index d8c5237953..7b3b0bf60f 100644 --- a/tests/events/tests/events.ts +++ b/tests/events/tests/events.ts @@ -2,11 +2,14 @@ import * as anchor from "@anchor-lang/core"; import { assert } from "chai"; import { Events } from "../target/types/events"; +import { EventsCaller } from "../target/types/events_caller"; describe("Events", () => { // Configure the client to use the local cluster. anchor.setProvider(anchor.AnchorProvider.env()); const program = anchor.workspace.Events as anchor.Program; + const eventsCaller = anchor.workspace + .EventsCaller as anchor.Program; const confirmOptions: anchor.web3.ConfirmOptions = { commitment: "confirmed", preflightCommitment: "confirmed", @@ -119,4 +122,53 @@ describe("Events", () => { throw new Error("Was able to invoke the self-CPI instruction"); }); }); + + // The `events-caller` program CPIs into the `events` program, so the + // event is emitted from an inner instruction (invoke depth 2). These + // tests verify the log parsing fix from #4451 against a real + // deployment instead of synthetic log data (#4656, #4450). + describe("Inner instruction event", () => { + it("Is delivered to event listeners", async () => { + let listenerId: number; + const eventPromise = new Promise((res) => { + listenerId = program.addEventListener("myOtherEvent", (event) => { + res(event); + }); + }); + // Give the log subscription time to become active before sending + // the transaction, otherwise the only emission can be missed. + await new Promise((res) => setTimeout(res, 500)); + await eventsCaller.methods + .cpiEvent() + .accounts({ eventsProgram: program.programId }) + .rpc(confirmOptions); + const event = await eventPromise; + await program.removeEventListener(listenerId); + + assert.strictEqual(event.data.toNumber(), 6); + assert.strictEqual(event.label, "bye"); + }); + + it("Is detected by the event parser in on-chain transaction logs", async () => { + const txHash = await eventsCaller.methods + .cpiEvent() + .accounts({ eventsProgram: program.programId }) + .rpc(confirmOptions); + const txResult = await program.provider.connection.getTransaction( + txHash, + { + commitment: "confirmed", + maxSupportedTransactionVersion: 0, + } + ); + + const parser = new anchor.EventParser(program.programId, program.coder); + const events = [...parser.parseLogs(txResult.meta.logMessages)]; + + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].name, "myOtherEvent"); + assert.strictEqual(events[0].data.label, "bye"); + assert.strictEqual((events[0].data.data as anchor.BN).toNumber(), 6); + }); + }); });