diff --git a/contract/contracts/hello-world/src/autoshare_logic.rs b/contract/contracts/hello-world/src/autoshare_logic.rs index d392945..69c27e1 100644 --- a/contract/contracts/hello-world/src/autoshare_logic.rs +++ b/contract/contracts/hello-world/src/autoshare_logic.rs @@ -1,7 +1,8 @@ use crate::base::errors::Error; use crate::base::events::{ AdminTransferred, AuthorizationFailure, AutoshareCreated, AutoshareUpdated, ContractPaused, - ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, Withdrawal, + ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, NotificationPriority, + Withdrawal, }; use crate::base::types::{AutoShareDetails, GroupMember, PaymentHistory}; use soroban_sdk::{contracttype, token, Address, BytesN, Env, String, Vec}; @@ -109,6 +110,7 @@ pub fn create_autoshare( AutoshareCreated { creator: creator.clone(), category: NotificationCategory::Group, + priority: NotificationPriority::Medium, id: id.clone(), } .publish(&env); @@ -259,6 +261,7 @@ fn publish_authorization_failure(env: &Env, caller: &Address, action: &str) { AuthorizationFailure { caller: caller.clone(), category: NotificationCategory::Admin, + priority: NotificationPriority::Critical, action: String::from_str(env, action), } .publish(env); @@ -295,6 +298,7 @@ pub fn transfer_admin(env: Env, current_admin: Address, new_admin: Address) -> R AdminTransferred { old_admin: current_admin, category: NotificationCategory::Admin, + priority: NotificationPriority::Critical, new_admin, } .publish(&env); @@ -319,6 +323,7 @@ pub fn pause(env: Env, admin: Address) -> Result<(), Error> { env.storage().persistent().set(&pause_key, &true); ContractPaused { category: NotificationCategory::Admin, + priority: NotificationPriority::High, } .publish(&env); Ok(()) @@ -338,6 +343,7 @@ pub fn unpause(env: Env, admin: Address) -> Result<(), Error> { env.storage().persistent().set(&pause_key, &false); ContractUnpaused { category: NotificationCategory::Admin, + priority: NotificationPriority::High, } .publish(&env); Ok(()) @@ -675,6 +681,7 @@ pub fn update_members( AutoshareUpdated { updater: caller, category: NotificationCategory::Group, + priority: NotificationPriority::Medium, id: id.clone(), } .publish(&env); @@ -711,6 +718,7 @@ pub fn deactivate_group(env: Env, id: BytesN<32>, caller: Address) -> Result<(), GroupDeactivated { creator: caller, category: NotificationCategory::Group, + priority: NotificationPriority::Low, id: id.clone(), } .publish(&env); @@ -747,6 +755,7 @@ pub fn activate_group(env: Env, id: BytesN<32>, caller: Address) -> Result<(), E GroupActivated { creator: caller, category: NotificationCategory::Group, + priority: NotificationPriority::Low, id: id.clone(), } .publish(&env); @@ -795,6 +804,7 @@ pub fn withdraw( token, recipient, category: NotificationCategory::Financial, + priority: NotificationPriority::High, amount, } .publish(&env); diff --git a/contract/contracts/hello-world/src/base/events.rs b/contract/contracts/hello-world/src/base/events.rs index 9c97285..6ad736e 100644 --- a/contract/contracts/hello-world/src/base/events.rs +++ b/contract/contracts/hello-world/src/base/events.rs @@ -25,6 +25,34 @@ pub enum NotificationCategory { Financial = 2, } +/// Severity level attached to every emitted event alongside its category. +/// +/// Off-chain consumers (alerting, dashboards, paging) often route notifications +/// by priority rather than (or in addition to) category. Each event carries its +/// priority as a trailing, indexed event topic so consumers can subscribe to — +/// or page on — high-priority notifications without decoding the payload. +/// +/// # Backward compatibility +/// +/// The priority is published as the *last* topic of every event, after the +/// event name, the previously defined topics, and the category. Existing +/// listeners that only read the event name (the first topic), the prior topics, +/// or the category will continue to work unchanged: the extra trailing topic is +/// simply ignored by consumers that don't look for it. +#[contracttype] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum NotificationPriority { + /// Informational: routine lifecycle events. No action required. + Low = 0, + /// Standard: day-to-day operational events worth tracking. + Medium = 1, + /// Elevated: events the operator should review promptly. + High = 2, + /// Urgent: security-relevant or funds-moving events that demand + /// immediate attention (e.g. admin transfer, authorization failure). + Critical = 3, +} + /// Emitted when a new AutoShare group is created. #[contractevent(data_format = "single-value")] #[derive(Clone)] @@ -33,6 +61,8 @@ pub struct AutoshareCreated { pub creator: Address, #[topic] pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, pub id: BytesN<32>, } @@ -42,6 +72,8 @@ pub struct AutoshareCreated { pub struct ContractPaused { #[topic] pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, } /// Emitted when the contract is unpaused by the admin. @@ -50,6 +82,8 @@ pub struct ContractPaused { pub struct ContractUnpaused { #[topic] pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, } /// Emitted when an AutoShare group's member list is updated. @@ -60,6 +94,8 @@ pub struct AutoshareUpdated { pub updater: Address, #[topic] pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, pub id: BytesN<32>, } @@ -71,6 +107,8 @@ pub struct GroupDeactivated { pub creator: Address, #[topic] pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, pub id: BytesN<32>, } @@ -82,6 +120,8 @@ pub struct GroupActivated { pub creator: Address, #[topic] pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, pub id: BytesN<32>, } @@ -93,6 +133,8 @@ pub struct AdminTransferred { pub old_admin: Address, #[topic] pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, pub new_admin: Address, } @@ -106,6 +148,8 @@ pub struct Withdrawal { pub recipient: Address, #[topic] pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, pub amount: i128, } @@ -117,5 +161,7 @@ pub struct AuthorizationFailure { pub caller: Address, #[topic] pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, pub action: String, } diff --git a/contract/contracts/hello-world/src/tests/notification_test.rs b/contract/contracts/hello-world/src/tests/notification_test.rs index 607ce0c..81f8a9a 100644 --- a/contract/contracts/hello-world/src/tests/notification_test.rs +++ b/contract/contracts/hello-world/src/tests/notification_test.rs @@ -7,7 +7,7 @@ //! - the change is backward compatible: the event name remains the first topic //! and the previously defined topics/data are unchanged. -use crate::base::events::NotificationCategory; +use crate::base::events::{NotificationCategory, NotificationPriority}; use crate::test_utils::{create_test_group, setup_test_env}; use crate::AutoShareContractClient; @@ -34,20 +34,43 @@ fn topics_of(env: &soroban_sdk::Env, event_name: &str) -> Option> { found } -/// Extracts the notification category (the trailing topic) for the latest event -/// named `event_name`. +/// Extracts the notification category for the latest event named `event_name`. +/// +/// Events now carry **two** trailing topics: the category (the previously-trailing +/// topic) followed by the priority (added with the `NotificationPriority` change). +/// This helper reads the *second-to-last* topic, i.e. the category, so existing +/// subscribers keyed off category keep working unchanged. fn category_of(env: &soroban_sdk::Env, event_name: &str) -> Option { + let topics = topics_of(env, event_name)?; + let n = topics.len(); + if n < 2 { + return None; + } + let category_topic = topics.get(n - 2)?; + NotificationCategory::try_from_val(env, &category_topic).ok() +} + +/// Extracts the notification priority (the trailing topic) for the latest event +/// named `event_name`. +fn priority_of(env: &soroban_sdk::Env, event_name: &str) -> Option { let topics = topics_of(env, event_name)?; let last = topics.last()?; - NotificationCategory::try_from_val(env, &last).ok() + NotificationPriority::try_from_val(env, &last).ok() } /// Returns the category of the most recently emitted event — i.e. the metadata a /// streaming consumer would read off the event as it arrives. +/// +/// Categories are now the **second-to-last** topic (priority is the trailing +/// topic), so we read from the back accordingly. fn latest_category(env: &soroban_sdk::Env) -> Option { let (_addr, topics, _data) = env.events().all().last()?; - let last = topics.last()?; - NotificationCategory::try_from_val(env, &last).ok() + let n = topics.len(); + if n < 2 { + return None; + } + let category_topic = topics.get(n - 2)?; + NotificationCategory::try_from_val(env, &category_topic).ok() } #[test] @@ -69,6 +92,10 @@ fn test_created_event_has_group_category() { category_of(&test_env.env, "autoshare_created"), Some(NotificationCategory::Group) ); + assert_eq!( + priority_of(&test_env.env, "autoshare_created"), + Some(NotificationPriority::Medium) + ); } #[test] @@ -98,6 +125,10 @@ fn test_updated_event_has_group_category() { category_of(&test_env.env, "autoshare_updated"), Some(NotificationCategory::Group) ); + assert_eq!( + priority_of(&test_env.env, "autoshare_updated"), + Some(NotificationPriority::Medium) + ); } #[test] @@ -121,12 +152,20 @@ fn test_deactivate_and_activate_events_have_group_category() { category_of(&test_env.env, "group_deactivated"), Some(NotificationCategory::Group) ); + assert_eq!( + priority_of(&test_env.env, "group_deactivated"), + Some(NotificationPriority::Low) + ); client.activate_group(&id, &creator); assert_eq!( category_of(&test_env.env, "group_activated"), Some(NotificationCategory::Group) ); + assert_eq!( + priority_of(&test_env.env, "group_activated"), + Some(NotificationPriority::Low) + ); } #[test] @@ -139,12 +178,20 @@ fn test_pause_and_unpause_events_have_admin_category() { category_of(&test_env.env, "contract_paused"), Some(NotificationCategory::Admin) ); + assert_eq!( + priority_of(&test_env.env, "contract_paused"), + Some(NotificationPriority::High) + ); client.unpause(&test_env.admin); assert_eq!( category_of(&test_env.env, "contract_unpaused"), Some(NotificationCategory::Admin) ); + assert_eq!( + priority_of(&test_env.env, "contract_unpaused"), + Some(NotificationPriority::High) + ); } #[test] @@ -158,6 +205,10 @@ fn test_admin_transfer_event_has_admin_category() { category_of(&test_env.env, "admin_transferred"), Some(NotificationCategory::Admin) ); + assert_eq!( + priority_of(&test_env.env, "admin_transferred"), + Some(NotificationPriority::Critical) + ); } #[test] @@ -183,6 +234,10 @@ fn test_withdrawal_event_has_financial_category() { category_of(&test_env.env, "withdrawal"), Some(NotificationCategory::Financial) ); + assert_eq!( + priority_of(&test_env.env, "withdrawal"), + Some(NotificationPriority::High) + ); } /// Models an off-chain subscriber that only wants a subset of categories. As @@ -268,8 +323,9 @@ fn test_created_event_backward_compatible_shape() { ); let topics = topics_of(&test_env.env, "autoshare_created").expect("event emitted"); - // [0] event name, [1] creator (unchanged), [2] category (new trailing topic) - assert_eq!(topics.len(), 3); + // [0] event name, [1] creator (unchanged), [2] category (now second-to-last), + // [3] priority (new trailing topic). + assert_eq!(topics.len(), 4); let name = Symbol::try_from_val(&test_env.env, &topics.get(0).unwrap()).unwrap(); assert_eq!(name, Symbol::new(&test_env.env, "autoshare_created")); @@ -281,6 +337,11 @@ fn test_created_event_backward_compatible_shape() { NotificationCategory::try_from_val(&test_env.env, &topics.get(2).unwrap()).unwrap(); assert_eq!(category, NotificationCategory::Group); + // The newly added trailing topic is the priority. + let priority = + NotificationPriority::try_from_val(&test_env.env, &topics.get(3).unwrap()).unwrap(); + assert_eq!(priority, NotificationPriority::Medium); + // Data payload is still the group id. let data = test_env .env