feat!(lee): add event selector field

BREAKING!

Before: Program outputs carried and opaque vector of event blobs.

After: Outputs now bear a vector of `ProgramOutputs` struct which also
have selectors attached.

Mitigation: Apply selectors to all event emissions for current programs.
This commit is contained in:
Artem Gureev
2026-08-22 11:06:17 +00:00
parent 4ed6fcc1cd
commit aab4e5651a
9 changed files with 82 additions and 51 deletions
@@ -590,7 +590,7 @@ fn a_second_emit_at_the_same_slot_is_rejected() {
let first = lock_tx(&holder_key, holder_id, zone_b, ordinal, 0);
let diff = ValidatedStateDiff::from_public_transaction(&first, &state, 1, 0)
.expect("the first lock executes");
state.apply_state_diff(diff);
drop(state.apply_state_diff(diff));
// Same slot, fresh nonce, so the only thing that can reject it is the slot
// already holding a record. Matched on the guest's own message rather than
@@ -646,7 +646,7 @@ fn two_emitters_share_an_ordinal_without_colliding() {
let lock = lock_tx(&holder_key, holder_id, zone_b, ordinal, 0);
let diff = ValidatedStateDiff::from_public_transaction(&lock, &state, 1, 0)
.expect("the lock executes");
state.apply_state_diff(diff);
drop(state.apply_state_diff(diff));
let send = send_tx(
vec![sender_config_account_id(sender_id), send_slot],
@@ -936,7 +936,7 @@ fn the_bridge_pins_are_written_once_and_replayable() {
0,
)
.expect("the first init claims the config PDA");
state.apply_state_diff(diff);
drop(state.apply_state_diff(diff));
assert_eq!(
bridge_lock_core::read_config(&state.get_account_by_id(config_id).data.into_inner()),
Some((outbox_id, wrapped_token_id)),
@@ -1042,7 +1042,7 @@ fn the_outbox_pin_is_written_once_and_replayable() {
let first = init(outbox_id);
let diff = ValidatedStateDiff::from_public_transaction(&first, &state, 1, 0)
.expect("the first init claims the config PDA");
state.apply_state_diff(diff);
drop(state.apply_state_diff(diff));
assert_eq!(
read_outbox(&state.get_account_by_id(config_id).data.into_inner()),
Some(outbox_id),
@@ -1180,7 +1180,7 @@ fn the_token_authority_path_holds() {
0,
)
.expect("the configured authority changes sources");
state.apply_state_diff(diff);
drop(state.apply_state_diff(diff));
let cfg = wrapped_token_core::WrappedTokenConfig::from_bytes(
&state.get_account_by_id(config_id).data.into_inner(),
)
@@ -1200,7 +1200,7 @@ fn the_token_authority_path_holds() {
0,
)
.expect("the authority acts again");
state.apply_state_diff(second);
drop(state.apply_state_diff(second));
let updated_cfg = wrapped_token_core::WrappedTokenConfig::from_bytes(
&state.get_account_by_id(config_id).data.into_inner(),
)
@@ -1220,7 +1220,7 @@ fn the_token_authority_path_holds() {
let renounced =
ValidatedStateDiff::from_public_transaction(&renounce(authority, &key, 2), &state, 3, 0)
.expect("the authority renounces itself");
state.apply_state_diff(renounced);
drop(state.apply_state_diff(renounced));
let renounced_cfg = wrapped_token_core::WrappedTokenConfig::from_bytes(
&state.get_account_by_id(config_id).data.into_inner(),
)
@@ -1431,7 +1431,7 @@ fn the_receiver_authority_path_holds() {
let diff =
ValidatedStateDiff::from_public_transaction(&update(authority, &key, 0), &state, 1, 0)
.expect("the configured authority changes sources");
state.apply_state_diff(diff);
drop(state.apply_state_diff(diff));
let cfg = ping_core::ReceiverConfig::from_bytes(
&state.get_account_by_id(config_id).data.into_inner(),
)
@@ -1442,7 +1442,7 @@ fn the_receiver_authority_path_holds() {
let renounce_diff =
ValidatedStateDiff::from_public_transaction(&renounce(authority, &key, 1), &state, 2, 0)
.expect("the authority renounces itself");
state.apply_state_diff(renounce_diff);
drop(state.apply_state_diff(renounce_diff));
let renounced_cfg = ping_core::ReceiverConfig::from_bytes(
&state.get_account_by_id(config_id).data.into_inner(),
)
@@ -1555,7 +1555,7 @@ fn the_governance_path_holds() {
0,
)
.expect("the governance path changes sources");
state.apply_state_diff(first);
drop(state.apply_state_diff(first));
let cfg = wrapped_token_core::WrappedTokenConfig::from_bytes(
&state.get_account_by_id(config_id).data.into_inner(),
@@ -1570,7 +1570,7 @@ fn the_governance_path_holds() {
let second = ValidatedStateDiff::from_public_transaction(&update(vec![]), &state, 2, 0)
.expect("the governance path acts again");
state.apply_state_diff(second);
drop(state.apply_state_diff(second));
let cleared_cfg = wrapped_token_core::WrappedTokenConfig::from_bytes(
&state.get_account_by_id(config_id).data.into_inner(),
)
@@ -1583,7 +1583,7 @@ fn the_governance_path_holds() {
let renounced = ValidatedStateDiff::from_public_transaction(&renounce(), &state, 3, 0)
.expect("the governance path renounces");
state.apply_state_diff(renounced);
drop(state.apply_state_diff(renounced));
let renounced_cfg = wrapped_token_core::WrappedTokenConfig::from_bytes(
&state.get_account_by_id(config_id).data.into_inner(),
)
@@ -1723,7 +1723,7 @@ fn the_receiver_governance_path_holds() {
let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0)
.expect("the receiver governance path changes sources");
state.apply_state_diff(diff);
drop(state.apply_state_diff(diff));
let cfg = ping_core::ReceiverConfig::from_bytes(
&state.get_account_by_id(config_id).data.into_inner(),
)
@@ -1767,7 +1767,7 @@ fn a_shared_authority_survives_the_first_claim() {
);
let first = ValidatedStateDiff::from_public_transaction(&token_update, &state, 1, 0)
.expect("the token claims the shared authority");
state.apply_state_diff(first);
drop(state.apply_state_diff(first));
assert_eq!(
state.get_account_by_id(authority).program_owner,
wrapped_token_id.into(),
@@ -1786,7 +1786,7 @@ fn a_shared_authority_survives_the_first_claim() {
);
let second = ValidatedStateDiff::from_public_transaction(&receiver_update, &state, 2, 0)
.expect("the other target still acts on the token-owned authority");
state.apply_state_diff(second);
drop(state.apply_state_diff(second));
let receiver_cfg = ping_core::ReceiverConfig::from_bytes(
&state
.get_account_by_id(receiver_config_id)
@@ -1814,7 +1814,7 @@ fn a_shared_authority_survives_the_first_claim() {
);
let third = ValidatedStateDiff::from_public_transaction(&receiver_renounce, &state, 3, 0)
.expect("the other target renounces on the token-owned authority");
state.apply_state_diff(third);
drop(state.apply_state_diff(third));
let renounced_cfg = ping_core::ReceiverConfig::from_bytes(
&state
.get_account_by_id(receiver_config_id)
+12 -5
View File
@@ -468,6 +468,14 @@ impl<T> From<std::ops::RangeFull> for ValidityWindow<T> {
#[error("Invalid window")]
pub struct InvalidWindow;
#[derive(Serialize, Deserialize, Clone, BorshSerialize, BorshDeserialize)]
#[cfg_attr(any(feature = "host", test), derive(Debug, PartialEq, Eq))]
pub struct ProgramEvent {
pub selector: [u8; 8],
/// The arbitrary event-data emitted in the program output.
pub data: Vec<u8>,
}
#[derive(Serialize, Deserialize, Clone)]
#[cfg_attr(any(feature = "host", test), derive(Debug, PartialEq, Eq))]
#[must_use = "ProgramOutput does nothing unless written"]
@@ -491,7 +499,7 @@ pub struct ProgramOutput {
pub timestamp_validity_window: TimestampValidityWindow,
/// A vector of event data. Dropped for private transaction for function
/// privacy.
pub events: Vec<Vec<u8>>,
pub events: Vec<ProgramEvent>,
}
impl ProgramOutput {
@@ -524,7 +532,7 @@ impl ProgramOutput {
self
}
pub fn with_events(mut self, events: Vec<Vec<u8>>) -> Self {
pub fn with_events(mut self, events: Vec<ProgramEvent>) -> Self {
self.events = events;
self
}
@@ -582,11 +590,10 @@ impl ProgramOutput {
/// A struct holding an event-output of a program.
#[cfg(feature = "host")]
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct Event {
pub struct TransactionEvent {
/// Which program emitted the event.
pub program_id: ProgramId,
/// The arbitrary event-data emitted in the program output.
pub data: Vec<u8>,
pub event: ProgramEvent,
}
/// Representation of a number as `lo + hi * 2^128`.
+6 -5
View File
@@ -5,7 +5,7 @@ use lee_core::{
BlockId, Commitment, CommitmentSetDigest, DUMMY_COMMITMENT, MembershipProof, Nullifier,
Timestamp,
account::{Account, AccountId, Data},
program::{Event, PROGRAM_STORAGE_OWNER, ProgramId},
program::{PROGRAM_STORAGE_OWNER, ProgramId, TransactionEvent},
};
use crate::{
@@ -204,7 +204,8 @@ impl V03State {
self.public_state.insert(account_id, account);
}
pub fn apply_state_diff(&mut self, diff: ValidatedStateDiff) -> Vec<Event> {
#[must_use]
pub fn apply_state_diff(&mut self, diff: ValidatedStateDiff) -> Vec<TransactionEvent> {
let StateDiff {
signer_account_ids,
public_diff,
@@ -238,7 +239,7 @@ impl V03State {
tx: &PublicTransaction,
block_id: BlockId,
timestamp: Timestamp,
) -> Result<Vec<Event>, LeeError> {
) -> Result<Vec<TransactionEvent>, LeeError> {
let diff = ValidatedStateDiff::from_public_transaction(tx, self, block_id, timestamp)?;
Ok(self.apply_state_diff(diff))
}
@@ -251,7 +252,7 @@ impl V03State {
) -> Result<(), LeeError> {
let diff =
ValidatedStateDiff::from_privacy_preserving_transaction(tx, self, block_id, timestamp)?;
self.apply_state_diff(diff);
drop(self.apply_state_diff(diff));
Ok(())
}
@@ -260,7 +261,7 @@ impl V03State {
tx: &ProgramDeploymentTransaction,
) -> Result<(), LeeError> {
let diff = ValidatedStateDiff::from_program_deployment_transaction(tx, self)?;
self.apply_state_diff(diff);
drop(self.apply_state_diff(diff));
Ok(())
}
+27 -10
View File
@@ -12,8 +12,18 @@ fn program_transaction<T: serde::Serialize>(
PublicTransaction::new(message, witness_set)
}
fn payloads(events: &[lee_core::program::Event]) -> Vec<Vec<u8>> {
events.iter().map(|event| event.data.clone()).collect()
fn payloads(events: &[TransactionEvent]) -> Vec<Vec<u8>> {
events
.iter()
.map(|event| event.event.data.clone())
.collect()
}
fn emitted(n: u8) -> ProgramEvent {
ProgramEvent {
selector: [n; 8],
data: vec![n; 4],
}
}
#[test]
@@ -26,7 +36,7 @@ fn emitted_events_are_returned_in_order_and_attributed_to_the_emitter() {
emitter_id,
account_id,
EmitterInstruction {
events: vec![vec![0; 4], vec![1; 4]],
events: vec![emitted(0), emitted(1)],
chain: vec![],
},
);
@@ -34,6 +44,13 @@ fn emitted_events_are_returned_in_order_and_attributed_to_the_emitter() {
let events = state.transition_from_public_transaction(&tx, 1, 0).unwrap();
assert_eq!(payloads(&events), vec![vec![0; 4], vec![1; 4]]);
assert_eq!(
events
.iter()
.map(|event| event.event.selector)
.collect::<Vec<_>>(),
vec![[0; 8], [1; 8]]
);
assert!(events.iter().all(|event| event.program_id == emitter_id));
}
@@ -44,7 +61,7 @@ fn parent_events_precede_chained_callee_events() {
let emitter_id = crate::test_methods::event_emitter().id();
let callee_instruction_data = Program::serialize_instruction(EmitterInstruction {
events: vec![vec![1; 4], vec![2; 4]],
events: vec![emitted(1), emitted(2)],
chain: vec![],
})
.unwrap();
@@ -53,7 +70,7 @@ fn parent_events_precede_chained_callee_events() {
emitter_id,
account_id,
EmitterInstruction {
events: vec![vec![0; 4]],
events: vec![emitted(0)],
chain: vec![(emitter_id, callee_instruction_data)],
},
);
@@ -74,17 +91,17 @@ fn chained_events_follow_depth_first_pre_order() {
let emitter_id = crate::test_methods::event_emitter().id();
let grandchild = Program::serialize_instruction(EmitterInstruction {
events: vec![vec![2; 4]],
events: vec![emitted(2)],
chain: vec![],
})
.unwrap();
let first_callee = Program::serialize_instruction(EmitterInstruction {
events: vec![vec![1; 4]],
events: vec![emitted(1)],
chain: vec![(emitter_id, grandchild)],
})
.unwrap();
let second_callee = Program::serialize_instruction(EmitterInstruction {
events: vec![vec![3; 4]],
events: vec![emitted(3)],
chain: vec![],
})
.unwrap();
@@ -93,7 +110,7 @@ fn chained_events_follow_depth_first_pre_order() {
emitter_id,
account_id,
EmitterInstruction {
events: vec![vec![0; 4]],
events: vec![emitted(0)],
chain: vec![(emitter_id, first_callee), (emitter_id, second_callee)],
},
);
@@ -139,7 +156,7 @@ fn chained_callee_events_are_attributed_to_the_callee_not_the_caller() {
// three sibling chained calls, so the only emitting program is neither the top-level
// program nor its caller.
let callback_instruction_data = Program::serialize_instruction(EmitterInstruction {
events: vec![vec![0; 4]],
events: vec![emitted(0)],
chain: vec![],
})
.unwrap();
+3 -2
View File
@@ -14,7 +14,8 @@ use lee_core::{
encryption::ViewingPublicKey,
program::{
BlockValidityWindow, ExecutionValidationError, InstructionData, MAX_NUMBER_CHAINED_CALLS,
PdaSeed, ProgramId, TimestampValidityWindow, WrappedBalanceSum,
PdaSeed, ProgramEvent, ProgramId, TimestampValidityWindow, TransactionEvent,
WrappedBalanceSum,
},
};
@@ -183,7 +184,7 @@ enum FlashSwapInstruction {
#[derive(serde::Serialize, serde::Deserialize)]
struct EmitterInstruction {
events: Vec<Vec<u8>>,
events: Vec<ProgramEvent>,
chain: Vec<(ProgramId, InstructionData)>,
}
@@ -8,7 +8,7 @@ use lee_core::{
BlockId, Commitment, Nullifier, PrivacyPreservingCircuitOutput, PublicAction, Timestamp,
account::{Account, AccountId, AccountWithMetadata},
program::{
CallerData, ChainedCall, Claim, DEFAULT_PROGRAM_OWNER, Event,
CallerData, ChainedCall, Claim, DEFAULT_PROGRAM_OWNER, TransactionEvent,
compute_public_authorized_pdas, validate_execution,
},
};
@@ -32,7 +32,7 @@ pub struct StateDiff {
pub new_commitments: Vec<Commitment>,
pub new_nullifiers: Vec<Nullifier>,
pub program: Option<Program>,
pub events: Vec<Event>,
pub events: Vec<TransactionEvent>,
}
/// The validated output of executing or verifying a transaction, ready to be applied to the state.
@@ -90,7 +90,7 @@ impl ValidatedStateDiff {
.collect();
let mut state_diff: HashMap<AccountId, Account> = HashMap::new();
let mut events: Vec<Event> = Vec::new();
let mut events: Vec<TransactionEvent> = Vec::new();
let initial_call = ChainedCall {
program_id: message.program_id,
@@ -264,10 +264,15 @@ impl ValidatedStateDiff {
// Write all the output event data into a proper event struct,
// marking its emitter program.
events.extend(program_output.events.into_iter().map(|data| Event {
program_id: chained_call.program_id,
data,
}));
events.extend(
program_output
.events
.into_iter()
.map(|event| TransactionEvent {
program_id: chained_call.program_id,
event,
}),
);
// Source from `program_output.pre_states`, not `chained_call.pre_states`:
// the loop above already gates program_output's `is_authorized` via the
@@ -1,12 +1,12 @@
use lee_core::program::{
AccountPostState, ChainedCall, InstructionData, ProgramId, ProgramInput, ProgramOutput,
read_lee_inputs,
AccountPostState, ChainedCall, InstructionData, ProgramEvent, ProgramId, ProgramInput,
ProgramOutput, read_lee_inputs,
};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct EmitterInstruction {
pub events: Vec<Vec<u8>>,
pub events: Vec<ProgramEvent>,
pub chain: Vec<(ProgramId, InstructionData)>,
}
+2 -2
View File
@@ -133,7 +133,7 @@ impl LeeTransaction {
let diff = self
.validate_on_state(state, block_id, timestamp)
.inspect_err(|err| warn!("Error at transition {err:#?}"))?;
state.apply_state_diff(diff);
drop(state.apply_state_diff(diff));
Ok(self)
}
@@ -151,7 +151,7 @@ impl LeeTransaction {
let diff = self
.compute_state_diff(state, block_id, timestamp)
.inspect_err(|err| warn!("Error at transition {err:#?}"))?;
state.apply_state_diff(diff);
drop(state.apply_state_diff(diff));
Ok(self)
}
+1 -1
View File
@@ -922,7 +922,7 @@ impl<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
withdrawals.push(withdraw_data);
}
state.apply_state_diff(validated_diff);
drop(state.apply_state_diff(validated_diff));
}
TransactionOrigin::Sequencer => {
let LeeTransaction::Public(public_tx) = tx else {