From 758e7875d5a4562f8a0f6ccfbbd6fbe401a6818a Mon Sep 17 00:00:00 2001 From: dotmantissa Date: Mon, 20 Jul 2026 16:56:00 +0100 Subject: [PATCH 1/2] Add runtime event schema validation and cross checks for Issue 312 --- app/contract/Cargo.lock | 22 +- app/contract/README.md | 35 +- app/contract/contracts/Folder/build.rs | 4 +- app/contract/contracts/Folder/src/events.rs | 209 +++++++++++- .../contracts/Folder/src/events_test.rs | 303 ++++++++++++++++++ app/contract/contracts/Folder/src/lib.rs | 12 + .../contracts/Folder/src/metadata_test.rs | 7 + app/contract/contracts/Folder/src/test.rs | 3 +- 8 files changed, 552 insertions(+), 43 deletions(-) create mode 100644 app/contract/contracts/Folder/src/events_test.rs diff --git a/app/contract/Cargo.lock b/app/contract/Cargo.lock index d36c03a78..91e1813ff 100644 --- a/app/contract/Cargo.lock +++ b/app/contract/Cargo.lock @@ -2,17 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "RustAcademy" -version = "0.1.0" -dependencies = [ - "blake3", - "hex", - "proptest", - "serde_json", - "soroban-sdk", -] - [[package]] name = "ahash" version = "0.8.12" @@ -1154,6 +1143,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "rust_academy" +version = "0.1.0" +dependencies = [ + "blake3", + "hex", + "proptest", + "serde_json", + "soroban-sdk", +] + [[package]] name = "rustc_version" version = "0.4.1" diff --git a/app/contract/README.md b/app/contract/README.md index ddb85ed0e..ca43aea06 100644 --- a/app/contract/README.md +++ b/app/contract/README.md @@ -192,38 +192,20 @@ stellar contract deploy \ ## Events -### Reward Released +All events emitted by the `Folder` contract follow canonical schema definitions (`EVENT_SCHEMAS`) with stable event type IDs (`ETID_*`) and deterministic replay fields (`EVENT_REPLAY_FIELDS`: `event_type_id`, `ledger_sequence`, `schema_version`, `timestamp`). -```rust -( - "reward_released", - learner -) -``` - -### Certificate Minted - -```rust -( - "certificate_minted", - learner -) -``` - -### Reputation Updated - -```rust -( - "reputation_updated", - account -) -``` +The contract enforces full runtime schema validation (`validate_event_schemas`) to guarantee: +- Uniqueness of event names and numeric event type IDs. +- Valid domain topic namespaces (`TOPIC_ADMIN`, `TOPIC_DISPUTE`, `TOPIC_ESCROW`, `TOPIC_PRIVACY`, `TOPIC_STEALTH`). +- Alphabetically sorted payload keys without duplicates. +- Presence of all required replay fields. +- Runtime cross-checking of all emitted events against `EVENT_SCHEMAS`. --- ## Metadata API -The `Folder` contract exposes a stable, read-only metadata surface for tooling, backends, and indexers (Issue #50). All calls are non-mutating and require no authorization. +The `Folder` contract exposes a stable, read-only metadata surface for tooling, backends, and indexers (Issue #50, Issue #312). All calls are non-mutating and require no authorization. | Method | Purpose | |--------|---------| @@ -233,6 +215,7 @@ The `Folder` contract exposes a stable, read-only metadata surface for tooling, | `get_upgrade_state()` | Upgrade window and in-progress state. | | `get_supported_versions()` | Supported contract and event schema version ranges. | | `check_schema_compatibility(contract_version, event_schema_version)` | Whether a caller-supplied version pair is compatible. | +| `validate_event_schemas()` | Validate all static `EVENT_SCHEMAS` definitions against canonical schema rules. | | `get_pause_flags()` | Granular pause bitmask. | Tooling should call `check_schema_compatibility` before sending writes to avoid version mismatches. diff --git a/app/contract/contracts/Folder/build.rs b/app/contract/contracts/Folder/build.rs index 30879169a..f023da446 100644 --- a/app/contract/contracts/Folder/build.rs +++ b/app/contract/contracts/Folder/build.rs @@ -54,7 +54,7 @@ pub const BUILD_MANIFEST_SCHEMA_VERSION: u32 = {}; fn get_git_hash() -> String { let output = Command::new("git") - .args(&["rev-parse", "HEAD"]) + .args(["rev-parse", "HEAD"]) .output() .unwrap_or_else(|_| { panic!("Failed to get git hash"); @@ -84,7 +84,7 @@ fn hash_directory(hasher: &mut blake3::Hasher, path: &Path) -> std::io::Result<( for entry in fs::read_dir(path)? { let entry = entry?; let path = entry.path(); - if path.is_file() && path.extension().map_or(false, |ext| ext == "rs") { + if path.is_file() && path.extension().is_some_and(|ext| ext == "rs") { let contents = fs::read(&path)?; hasher.update(path.to_string_lossy().as_bytes()); hasher.update(&contents); diff --git a/app/contract/contracts/Folder/src/events.rs b/app/contract/contracts/Folder/src/events.rs index 3e408b546..e5af25b0d 100644 --- a/app/contract/contracts/Folder/src/events.rs +++ b/app/contract/contracts/Folder/src/events.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contractevent, Address, BytesN, Env, Symbol}; +use soroban_sdk::{contractevent, Address, BytesN, Env, Symbol, TryIntoVal, Val}; /// Canonical event schema version. /// @@ -43,6 +43,7 @@ pub const ETID_ESCROW_DISPUTED: u32 = 4; pub const ETID_ESCROW_FINALIZED: u32 = 5; pub const ETID_PARTIAL_PAYMENT: u32 = 6; pub const ETID_AUX_INDICES_CLEANED: u32 = 7; +pub const ETID_ESCROW_CLEANUP: u32 = 8; /// Dispute domain IDs (10–19) pub const ETID_ARBITER_VOTE_CAST: u32 = 10; @@ -332,7 +333,19 @@ pub const EVENT_SCHEMAS: &[EventSchema] = &[ name: "EscrowWithdrawn", event_type_id: ETID_ESCROW_WITHDRAWN, topics: &[EVENT_TOPIC_ESCROW, "EscrowWithdrawn", "escrow_id", "owner"], - payload_keys: &["amount", "event_type_id", "fee", "ledger_sequence", "schema_version", "timestamp", "token"], + payload_keys: &[ + "amount", + "arbiter_fee", + "collector_fee", + "event_type_id", + "fee", + "ledger_sequence", + "net_payout", + "platform_fee", + "schema_version", + "timestamp", + "token", + ], schema_version: EVENT_SCHEMA_VERSION, }, EventSchema { @@ -369,7 +382,20 @@ pub const EVENT_SCHEMAS: &[EventSchema] = &[ name: "PerAssetFeeSet", event_type_id: ETID_PER_ASSET_FEE_SET, topics: &[EVENT_TOPIC_ADMIN, "PerAssetFeeSet", "token"], - payload_keys: &["arbiter_bps", "event_type_id", "fee_bps", "ledger_sequence", "schema_version", "timestamp"], + payload_keys: &[ + "arbiter_bps", + "arbiter_fee_denominator", + "arbiter_fee_numerator", + "collector_fee_denominator", + "collector_fee_numerator", + "event_type_id", + "fee_bps", + "ledger_sequence", + "platform_fee_denominator", + "platform_fee_numerator", + "schema_version", + "timestamp", + ], schema_version: EVENT_SCHEMA_VERSION, }, EventSchema { @@ -471,6 +497,13 @@ pub const EVENT_SCHEMAS: &[EventSchema] = &[ payload_keys: &["event_type_id", "ledger_sequence", "schema_version", "timestamp"], schema_version: EVENT_SCHEMA_VERSION, }, + EventSchema { + name: "EscrowCleanup", + event_type_id: ETID_ESCROW_CLEANUP, + topics: &[EVENT_TOPIC_ESCROW, "EscrowCleanup", "escrow_id"], + payload_keys: &["event_type_id", "ledger_sequence", "schema_version", "timestamp"], + schema_version: EVENT_SCHEMA_VERSION, + }, ]; #[allow(dead_code)] @@ -844,6 +877,7 @@ pub(crate) fn publish_contract_migrated( .publish(env); } +#[allow(clippy::too_many_arguments)] pub(crate) fn publish_escrow_withdrawn( env: &Env, commitment: BytesN<32>, @@ -1375,6 +1409,7 @@ pub struct DisputeAutoResolvedEvent { pub recipient: Address, pub amount: i128, + pub event_type_id: u32, pub schema_version: u32, pub ledger_sequence: u32, pub timestamp: u64, @@ -1392,6 +1427,7 @@ pub(crate) fn publish_dispute_auto_resolved( action: dispute_action_symbol(env, action), recipient, amount, + event_type_id: ETID_DISPUTE_AUTO_RESOLVED, schema_version: EVENT_SCHEMA_VERSION, ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), @@ -1403,6 +1439,7 @@ pub(crate) fn publish_dispute_auto_resolved( #[derive(Clone, Debug, Eq, PartialEq)] pub struct DisputeExpiryActionSetEvent { pub action: Symbol, + pub event_type_id: u32, pub schema_version: u32, pub ledger_sequence: u32, pub timestamp: u64, @@ -1414,6 +1451,7 @@ pub(crate) fn publish_dispute_expiry_action_set( ) { DisputeExpiryActionSetEvent { action: dispute_action_symbol(env, action), + event_type_id: ETID_DISPUTE_EXPIRY_ACTION_SET, schema_version: EVENT_SCHEMA_VERSION, ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), @@ -1425,6 +1463,7 @@ pub(crate) fn publish_dispute_expiry_action_set( #[derive(Clone, Debug, Eq, PartialEq)] pub struct DisputeTimeoutConfigSetEvent { pub timeout_secs: u64, + pub event_type_id: u32, pub schema_version: u32, pub ledger_sequence: u32, pub timestamp: u64, @@ -1433,6 +1472,7 @@ pub struct DisputeTimeoutConfigSetEvent { pub(crate) fn publish_dispute_timeout_config_set(env: &Env, timeout_secs: u64) { DisputeTimeoutConfigSetEvent { timeout_secs, + event_type_id: ETID_DISPUTE_TIMEOUT_CONFIG_SET, schema_version: EVENT_SCHEMA_VERSION, ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), @@ -1448,6 +1488,7 @@ pub struct FeeCollectorRotatedEvent { #[topic] pub new_collector: Address, pub rotation_index: u32, + pub event_type_id: u32, pub schema_version: u32, pub ledger_sequence: u32, pub timestamp: u64, @@ -1461,6 +1502,7 @@ pub(crate) fn publish_fee_collector_rotated( FeeCollectorRotatedEvent { new_collector, rotation_index, + event_type_id: ETID_FEE_COLLECTOR_ROTATED, schema_version: EVENT_SCHEMA_VERSION, ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), @@ -1481,6 +1523,7 @@ pub struct PerAssetFeeSetEvent { pub platform_fee_denominator: u32, pub collector_fee_numerator: u32, pub collector_fee_denominator: u32, + pub event_type_id: u32, pub schema_version: u32, pub ledger_sequence: u32, pub timestamp: u64, @@ -1505,6 +1548,7 @@ pub(crate) fn publish_per_asset_fee_set( platform_fee_denominator: platform_fee.denominator, collector_fee_numerator: collector_fee.numerator, collector_fee_denominator: collector_fee.denominator, + event_type_id: ETID_PER_ASSET_FEE_SET, schema_version: EVENT_SCHEMA_VERSION, ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), @@ -1518,6 +1562,7 @@ pub struct HookRegisteredEvent { #[topic] pub hook_contract: Address, + pub event_type_id: u32, pub schema_version: u32, pub ledger_sequence: u32, pub timestamp: u64, @@ -1526,6 +1571,7 @@ pub struct HookRegisteredEvent { pub(crate) fn publish_hook_registered(env: &Env, hook_contract: Address) { HookRegisteredEvent { hook_contract, + event_type_id: ETID_HOOK_REGISTERED, schema_version: EVENT_SCHEMA_VERSION, ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), @@ -1539,6 +1585,7 @@ pub struct HookUnregisteredEvent { #[topic] pub hook_contract: Address, + pub event_type_id: u32, pub schema_version: u32, pub ledger_sequence: u32, pub timestamp: u64, @@ -1547,6 +1594,7 @@ pub struct HookUnregisteredEvent { pub(crate) fn publish_hook_unregistered(env: &Env, hook_contract: Address) { HookUnregisteredEvent { hook_contract, + event_type_id: ETID_HOOK_UNREGISTERED, schema_version: EVENT_SCHEMA_VERSION, ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), @@ -1560,6 +1608,7 @@ pub struct UpgradeWindowSetEvent { #[topic] pub admin: Address, + pub event_type_id: u32, pub schema_version: u32, pub ledger_sequence: u32, pub window_start: u64, @@ -1575,6 +1624,7 @@ pub(crate) fn publish_upgrade_window_set( ) { UpgradeWindowSetEvent { admin, + event_type_id: ETID_UPGRADE_WINDOW_SET, schema_version: EVENT_SCHEMA_VERSION, ledger_sequence: env.ledger().sequence(), window_start, @@ -1590,6 +1640,7 @@ pub struct PauseFlagsChangedEvent { #[topic] pub admin: Address, + pub event_type_id: u32, pub schema_version: u32, pub ledger_sequence: u32, pub flags_enabled: u64, @@ -1605,6 +1656,7 @@ pub(crate) fn publish_pause_flags_changed( ) { PauseFlagsChangedEvent { admin, + event_type_id: ETID_PAUSE_FLAGS_CHANGED, schema_version: EVENT_SCHEMA_VERSION, ledger_sequence: env.ledger().sequence(), flags_enabled, @@ -1624,15 +1676,166 @@ pub struct EscrowCleanupEvent { #[topic] pub escrow_id: BytesN<32>, + pub event_type_id: u32, pub schema_version: u32, + pub ledger_sequence: u32, pub timestamp: u64, } pub(crate) fn publish_escrow_cleanup(env: &Env, commitment: BytesN<32>) { EscrowCleanupEvent { escrow_id: commitment, + event_type_id: ETID_ESCROW_CLEANUP, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), } .publish(env); } + +// ----------------------------------------------------------------------------- +// Runtime Schema Validation & Cross-Checking (Issue #312) +// ----------------------------------------------------------------------------- + +/// Validate an individual [`EventSchema`] against canonical schema rules. +#[allow(dead_code)] +pub fn validate_event_schema_entry(schema: &EventSchema) -> Result<(), &'static str> { + if schema.name.is_empty() { + return Err("Event schema name cannot be empty"); + } + if schema.event_type_id == 0 { + return Err("Event type ID cannot be zero"); + } + if schema.schema_version != EVENT_SCHEMA_VERSION { + return Err("Event schema version mismatch"); + } + if schema.topics.len() < 2 { + return Err("Event topics must have at least 2 elements"); + } + let valid_topics = [ + EVENT_TOPIC_ADMIN, + EVENT_TOPIC_DISPUTE, + EVENT_TOPIC_ESCROW, + EVENT_TOPIC_PRIVACY, + EVENT_TOPIC_STEALTH, + ]; + if !valid_topics.contains(&schema.topics[0]) { + return Err("Invalid event topic domain namespace"); + } + if schema.topics[1] != schema.name { + return Err("Second event topic must match event name"); + } + + // Check payload keys are strictly sorted alphabetically without duplicates. + for window in schema.payload_keys.windows(2) { + if window[0] >= window[1] { + return Err("Payload keys must be strictly sorted alphabetically without duplicates"); + } + } + + // Check mandatory replay fields are present in payload_keys. + for replay_field in EVENT_REPLAY_FIELDS { + if !schema.payload_keys.contains(replay_field) { + return Err("Missing mandatory event replay field in payload keys"); + } + } + + Ok(()) +} + +/// Enforce static runtime validation over all entries in [`EVENT_SCHEMAS`]. +#[allow(dead_code)] +pub fn validate_event_schemas() -> Result<(), &'static str> { + if EVENT_SCHEMAS.is_empty() { + return Err("EVENT_SCHEMAS catalog cannot be empty"); + } + + for (i, schema) in EVENT_SCHEMAS.iter().enumerate() { + validate_event_schema_entry(schema)?; + + for other_schema in EVENT_SCHEMAS.iter().skip(i + 1) { + if schema.name == other_schema.name { + return Err("Duplicate event schema name found"); + } + if schema.event_type_id == other_schema.event_type_id { + return Err("Duplicate event type ID found"); + } + } + } + + Ok(()) +} + +/// Cross-check an emitted Soroban event against the [`EVENT_SCHEMAS`] catalog. +#[allow(dead_code)] +pub fn validate_emitted_event( + env: &Env, + topics: &soroban_sdk::Vec, + data: &Val, +) -> Result<&'static EventSchema, &'static str> { + if topics.len() < 2 { + return Err("Emitted event has fewer than 2 topics"); + } + + let topic_name_val = topics.get(1).ok_or("Missing topic[1]")?; + let topic_name: Symbol = topic_name_val + .try_into_val(env) + .map_err(|_| "Failed to parse topic[1] as Symbol")?; + + // Find matching schema by name + let schema = EVENT_SCHEMAS + .iter() + .find(|s| Symbol::new(env, s.name) == topic_name) + .ok_or("No matching EventSchema found for emitted event name")?; + + // Check topics count match + if topics.len() != schema.topics.len() as u32 { + return Err("Emitted event topics count does not match schema topics count"); + } + + // Validate topic 0 namespace + let topic_domain_val = topics.get(0).ok_or("Missing topic[0]")?; + let topic_domain: Symbol = topic_domain_val + .try_into_val(env) + .map_err(|_| "Failed to parse topic[0] as Symbol")?; + if Symbol::new(env, schema.topics[0]) != topic_domain { + return Err("Emitted event domain topic[0] mismatch"); + } + + // Validate payload fields + let data_map: soroban_sdk::Map = data + .try_into_val(env) + .map_err(|_| "Failed to convert event data payload to Map")?; + + for &key_str in schema.payload_keys { + let key_sym = Symbol::new(env, key_str); + if !data_map.contains_key(key_sym) { + return Err("Emitted event payload missing expected schema key"); + } + } + + // Validate event_type_id in payload + let etid_val = data_map + .get(Symbol::new(env, "event_type_id")) + .ok_or("Missing event_type_id in payload")?; + let etid: u32 = etid_val + .try_into_val(env) + .map_err(|_| "Failed to parse event_type_id as u32")?; + if etid != schema.event_type_id { + return Err("Emitted event_type_id does not match schema event_type_id"); + } + + // Validate schema_version in payload + let version_val = data_map + .get(Symbol::new(env, "schema_version")) + .ok_or("Missing schema_version in payload")?; + let version: u32 = version_val + .try_into_val(env) + .map_err(|_| "Failed to parse schema_version as u32")?; + if version != schema.schema_version { + return Err("Emitted schema_version does not match schema schema_version"); + } + + Ok(schema) +} + diff --git a/app/contract/contracts/Folder/src/events_test.rs b/app/contract/contracts/Folder/src/events_test.rs new file mode 100644 index 000000000..0eccbed17 --- /dev/null +++ b/app/contract/contracts/Folder/src/events_test.rs @@ -0,0 +1,303 @@ +//! Event schema validation and emitted event cross-checking tests (Issue #312). + +#[cfg(test)] +extern crate std; + +use crate::{ + events::{ + self, EventSchema, EVENT_REPLAY_FIELDS, EVENT_SCHEMAS, + EVENT_SCHEMA_VERSION, EVENT_TOPIC_ADMIN, + }, + stealth, + types::{DisputeExpiryAction, FeeRatio, PerAssetFeeConfig, StealthDepositParams}, + PauseFlag, RustAcademyContract, RustAcademyContractClient, +}; +use soroban_sdk::{ + testutils::{Address as _, Events}, + token, Address, Bytes, BytesN, Env, +}; + +fn setup() -> (Env, RustAcademyContractClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(RustAcademyContract, ()); + let client = RustAcademyContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize(&admin); + (env, client, admin) +} + +#[test] +fn test_validate_event_schemas_passes_canonical_catalog() { + let result = events::validate_event_schemas(); + assert!( + result.is_ok(), + "Canonical EVENT_SCHEMAS failed validation: {:?}", + result.err() + ); +} + +#[test] +fn test_all_event_schemas_have_unique_names_and_ids() { + assert!(!EVENT_SCHEMAS.is_empty()); + for (i, schema) in EVENT_SCHEMAS.iter().enumerate() { + assert!(!schema.name.is_empty(), "Schema at index {} has empty name", i); + assert!(schema.event_type_id > 0, "Schema {} has zero event_type_id", schema.name); + assert_eq!( + schema.schema_version, EVENT_SCHEMA_VERSION, + "Schema {} version mismatch", schema.name + ); + + for other in EVENT_SCHEMAS.iter().skip(i + 1) { + assert_ne!( + schema.name, other.name, + "Duplicate schema name: {}", schema.name + ); + assert_ne!( + schema.event_type_id, other.event_type_id, + "Duplicate event_type_id between {} and {}", schema.name, other.name + ); + } + } +} + +#[test] +fn test_event_schemas_topic_namespaces_and_formatting() { + let valid_namespaces = [ + "TOPIC_ADMIN", + "TOPIC_DISPUTE", + "TOPIC_ESCROW", + "TOPIC_PRIVACY", + "TOPIC_STEALTH", + ]; + + for schema in EVENT_SCHEMAS { + assert!( + schema.topics.len() >= 2, + "Schema {} topics must have at least 2 elements", + schema.name + ); + assert!( + valid_namespaces.contains(&schema.topics[0]), + "Schema {} has invalid domain namespace topic[0]: {}", + schema.name, + schema.topics[0] + ); + assert_eq!( + schema.topics[1], schema.name, + "Schema {} topic[1] must match schema name", + schema.name + ); + } +} + +#[test] +fn test_event_schemas_payload_keys_sorting_and_replay_fields() { + for schema in EVENT_SCHEMAS { + for window in schema.payload_keys.windows(2) { + assert!( + window[0] < window[1], + "Schema {} payload keys not strictly sorted or contain duplicates: {:?}", + schema.name, + schema.payload_keys + ); + } + + for &replay_field in EVENT_REPLAY_FIELDS { + assert!( + schema.payload_keys.contains(&replay_field), + "Schema {} missing mandatory replay field {}", + schema.name, + replay_field + ); + } + } +} + +#[test] +fn test_cross_check_emitted_events_across_contract_operations() { + let (env, client, admin) = setup(); + + let owner = Address::generate(&env); + let token_admin = Address::generate(&env); + let token = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let token_client = token::StellarAssetClient::new(&env, &token); + token_client.mint(&owner, &10000); + + let salt = Bytes::from_slice(&env, &[1u8; 32]); + + // 1. Deposit event + client.deposit(&token, &1000i128, &owner, &salt, &0u64, &None); + + // 2. Privacy toggled event + client.set_privacy(&owner, &true); + + // 3. Pause flags changed event + client.pause_features(&admin, &(PauseFlag::Deposit as u64)); + client.unpause_features(&admin, &(PauseFlag::Deposit as u64)); + + // 4. Fee collector rotated event + let new_collector = Address::generate(&env); + client.rotate_fee_collector(&admin, &new_collector); + + // 5. Per asset fee set event + let fee_cfg = PerAssetFeeConfig { + fee_bps: 100, + arbiter_bps: 50, + arbiter_fee: FeeRatio { numerator: 1, denominator: 2 }, + platform_fee: FeeRatio { numerator: 1, denominator: 4 }, + collector_fee: FeeRatio { numerator: 1, denominator: 4 }, + schema_version: 1, + }; + client.set_per_asset_fee(&admin, &token, &fee_cfg); + + // 6. Dispute expiry action set & timeout config set + client.set_dispute_expiry_action(&admin, &DisputeExpiryAction::RefundOwner); + client.set_dispute_timeout(&admin, &3600u64); + + // 7. Stealth deposit event (ephemeral key registered) + let eph_pub = BytesN::from_array(&env, &[88u8; 32]); + let spend_pub = BytesN::from_array(&env, &[99u8; 32]); + let shared = stealth::derive_shared_secret(&env, &eph_pub, &spend_pub); + let stealth_addr = stealth::derive_stealth_address(&env, &spend_pub, &shared); + + let stealth_params = StealthDepositParams { + sender: owner.clone(), + token: token.clone(), + amount_due: 500i128, + amount_paid: 500i128, + eph_pub, + spend_pub, + stealth_address: stealth_addr, + timeout_secs: 0u64, + }; + client.register_ephemeral_key(&stealth_params); + + // Validate all contract emitted events against EVENT_SCHEMAS + let all_contract_events: std::vec::Vec<_> = env + .events() + .all() + .into_iter() + .filter(|e| e.0 == client.address) + .collect(); + assert!(!all_contract_events.is_empty(), "No contract events emitted"); + + for (idx, event) in all_contract_events.iter().enumerate() { + let schema_res = events::validate_emitted_event(&env, &event.1, &event.2); + assert!( + schema_res.is_ok(), + "Emitted contract event at index {} failed validation: {:?}", + idx, + schema_res.err() + ); + } +} + +#[test] +fn test_schema_validation_error_cases() { + // Empty name + let s_empty_name = EventSchema { + name: "", + event_type_id: 100, + topics: &[EVENT_TOPIC_ADMIN, ""], + payload_keys: &["event_type_id", "ledger_sequence", "schema_version", "timestamp"], + schema_version: EVENT_SCHEMA_VERSION, + }; + assert_eq!( + events::validate_event_schema_entry(&s_empty_name), + Err("Event schema name cannot be empty") + ); + + // Zero ETID + let s_zero_etid = EventSchema { + name: "TestEvent", + event_type_id: 0, + topics: &[EVENT_TOPIC_ADMIN, "TestEvent"], + payload_keys: &["event_type_id", "ledger_sequence", "schema_version", "timestamp"], + schema_version: EVENT_SCHEMA_VERSION, + }; + assert_eq!( + events::validate_event_schema_entry(&s_zero_etid), + Err("Event type ID cannot be zero") + ); + + // Version mismatch + let s_bad_ver = EventSchema { + name: "TestEvent", + event_type_id: 100, + topics: &[EVENT_TOPIC_ADMIN, "TestEvent"], + payload_keys: &["event_type_id", "ledger_sequence", "schema_version", "timestamp"], + schema_version: 99, + }; + assert_eq!( + events::validate_event_schema_entry(&s_bad_ver), + Err("Event schema version mismatch") + ); + + // Too few topics + let s_few_topics = EventSchema { + name: "TestEvent", + event_type_id: 100, + topics: &[EVENT_TOPIC_ADMIN], + payload_keys: &["event_type_id", "ledger_sequence", "schema_version", "timestamp"], + schema_version: EVENT_SCHEMA_VERSION, + }; + assert_eq!( + events::validate_event_schema_entry(&s_few_topics), + Err("Event topics must have at least 2 elements") + ); + + // Invalid topic domain + let s_bad_domain = EventSchema { + name: "TestEvent", + event_type_id: 100, + topics: &["TOPIC_INVALID", "TestEvent"], + payload_keys: &["event_type_id", "ledger_sequence", "schema_version", "timestamp"], + schema_version: EVENT_SCHEMA_VERSION, + }; + assert_eq!( + events::validate_event_schema_entry(&s_bad_domain), + Err("Invalid event topic domain namespace") + ); + + // Topic[1] name mismatch + let s_topic1_mismatch = EventSchema { + name: "TestEvent", + event_type_id: 100, + topics: &[EVENT_TOPIC_ADMIN, "WrongName"], + payload_keys: &["event_type_id", "ledger_sequence", "schema_version", "timestamp"], + schema_version: EVENT_SCHEMA_VERSION, + }; + assert_eq!( + events::validate_event_schema_entry(&s_topic1_mismatch), + Err("Second event topic must match event name") + ); + + // Unsorted payload keys + let s_unsorted_payload = EventSchema { + name: "TestEvent", + event_type_id: 100, + topics: &[EVENT_TOPIC_ADMIN, "TestEvent"], + payload_keys: &["schema_version", "event_type_id", "ledger_sequence", "timestamp"], + schema_version: EVENT_SCHEMA_VERSION, + }; + assert_eq!( + events::validate_event_schema_entry(&s_unsorted_payload), + Err("Payload keys must be strictly sorted alphabetically without duplicates") + ); + + // Missing mandatory replay field + let s_missing_replay = EventSchema { + name: "TestEvent", + event_type_id: 100, + topics: &[EVENT_TOPIC_ADMIN, "TestEvent"], + payload_keys: &["event_type_id", "schema_version", "timestamp"], + schema_version: EVENT_SCHEMA_VERSION, + }; + assert_eq!( + events::validate_event_schema_entry(&s_missing_replay), + Err("Missing mandatory event replay field in payload keys") + ); +} diff --git a/app/contract/contracts/Folder/src/lib.rs b/app/contract/contracts/Folder/src/lib.rs index ec49c2f89..274bea380 100644 --- a/app/contract/contracts/Folder/src/lib.rs +++ b/app/contract/contracts/Folder/src/lib.rs @@ -14,6 +14,8 @@ mod escrow_id; #[cfg(test)] mod escrow_id_test; mod events; +#[cfg(test)] +mod events_test; mod fee; mod fee_router; #[cfg(test)] @@ -818,6 +820,16 @@ impl RustAcademyContract { ) } + /// Validate all static event schema definitions against canonical rules (Issue #312). + /// + /// Returns `Ok(true)` if all schemas in `EVENT_SCHEMAS` satisfy canonical + /// uniqueness, topic prefix, sorted payload keys, mandatory replay fields, and + /// versioning constraints. + pub fn validate_event_schemas(_env: Env) -> Result { + events::validate_event_schemas().map_err(|_| RustAcademyError::InternalError)?; + Ok(true) + } + /// Return the current granular pause bitmask. /// /// See [`crate::storage::PauseFlag`] for the bit definitions. A value of `0` diff --git a/app/contract/contracts/Folder/src/metadata_test.rs b/app/contract/contracts/Folder/src/metadata_test.rs index 03dcaf78f..9a1dad9a1 100644 --- a/app/contract/contracts/Folder/src/metadata_test.rs +++ b/app/contract/contracts/Folder/src/metadata_test.rs @@ -456,3 +456,10 @@ fn golden_supported_versions_schema_is_stable() { let _min_event_schema_version: u32 = versions.min_event_schema_version; let _supported_event_versions: soroban_sdk::Vec = versions.supported_event_versions; } + +#[test] +fn metadata_validate_event_schemas_succeeds() { + let (_env, client) = setup(); + let valid = client.validate_event_schemas(); + assert!(valid); +} diff --git a/app/contract/contracts/Folder/src/test.rs b/app/contract/contracts/Folder/src/test.rs index 50c232962..cbd5beff1 100644 --- a/app/contract/contracts/Folder/src/test.rs +++ b/app/contract/contracts/Folder/src/test.rs @@ -394,7 +394,8 @@ fn event_data_map(env: &Env, data: Val) -> Map { #[test] fn test_event_schema_catalog_locks_canonical_topics_and_payloads() { assert_eq!(EVENT_SCHEMA_VERSION, 2); - assert_eq!(EVENT_SCHEMAS.len(), 33); + assert_eq!(EVENT_SCHEMAS.len(), 34); + assert!(crate::events::validate_event_schemas().is_ok()); let escrow_deposited = EVENT_SCHEMAS .iter() From 8aed612d9426cf5eb1687c4cb12c1e00406c0a2b Mon Sep 17 00:00:00 2001 From: dotmantissa Date: Tue, 21 Jul 2026 14:36:26 +0100 Subject: [PATCH 2/2] Update ethnum dependency to fix transmute size error in CI --- app/contract/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/contract/Cargo.lock b/app/contract/Cargo.lock index 91e1813ff..52e49599d 100644 --- a/app/contract/Cargo.lock +++ b/app/contract/Cargo.lock @@ -617,9 +617,9 @@ checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" [[package]] name = "ethnum" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" [[package]] name = "ff"