diff --git a/NOTIFICATION_PAYLOAD_SCHEMA.md b/NOTIFICATION_PAYLOAD_SCHEMA.md index 15bdaf0..32d1625 100644 --- a/NOTIFICATION_PAYLOAD_SCHEMA.md +++ b/NOTIFICATION_PAYLOAD_SCHEMA.md @@ -207,7 +207,7 @@ Arbitrary key-value object stored alongside the notification. Not sent to the re | `eventId` | Optional. If provided, used for deduplication. ≤ 255 chars. | | `contractAddress` | Optional. If provided, must be a valid Stellar strkey (56 chars). | | `priority` | Integer between 0 and 100 (inclusive). Defaults to 0. | -| `metadata` | Optional. Must be a valid JSON object if provided. | +| `metadata` | Optional. Must be a valid JSON object if provided. When present, `source` (non-empty string) is required. Nested objects/arrays are rejected. | ### Channel-specific validation @@ -224,8 +224,21 @@ If `eventId` is provided, the system checks for an existing `PENDING` or `COMPLE ## Versioning +Every notification payload carries a protocol `version` field so consumers can +gate parsing logic across future schema changes. + | Version | Date | Changes | |---------|------------|-----------------------------------------------------------| +| v1 | 2026-07-26 | Initial versioned payloads (`version: 1` stamped by API) | | v1.0 | 2025-12-01 | Initial schema — four channel types, priority, metadata | -Breaking changes to this schema (removing or renaming required fields) will be communicated with a major version bump and a minimum 30-day deprecation notice in the changelog. +**Current version:** `1` (`CURRENT_NOTIFICATION_VERSION` in +`listener/src/utils/notification-version.ts` and +`CURRENT_NOTIFICATION_VERSION` in the Soroban contract). + +When scheduling via the REST API, if `payload.version` is omitted the listener +stamps the current version automatically. Explicit future versions are rejected. + +Breaking changes to this schema (removing or renaming required fields) will be +communicated with a major version bump and a minimum 30-day deprecation notice +in the changelog. diff --git a/contract/contracts/hello-world/src/autoshare_logic.rs b/contract/contracts/hello-world/src/autoshare_logic.rs index 5d0946e..7cd9ec3 100644 --- a/contract/contracts/hello-world/src/autoshare_logic.rs +++ b/contract/contracts/hello-world/src/autoshare_logic.rs @@ -2,17 +2,23 @@ use crate::base::errors::Error; use crate::base::events::{ AdminTransferred, AuditAction, AuditRecordAppended, AuthorizationFailure, AutoshareCreated, AutoshareUpdated, BatchNotificationsCreated, BatchProcessingCompleted, CategoryRegistered, + ChannelMetadataUpdated, ContractPaused, ContractUnpaused, GroupActivated, GroupDeactivated, + NotificationAccessed, NotificationAcknowledged, NotificationArchived, NotificationCategory, + NotificationDelivered, NotificationExpired, NotificationExtended, + NotificationLimitsConfigured, NotificationPriority, NotificationRecalled, NotificationRevoked, + NotificationScheduled, OwnershipTransferInitiated, OwnershipTransferred, ContractPaused, ContractUnpaused, GroupActivated, GroupDeactivated, NotificationAccessed, NotificationAcknowledged, NotificationCategory, NotificationDelivered, NotificationExpired, NotificationExtended, NotificationLimitsConfigured, NotificationPriority, NotificationRecalled, NotificationRevoked, NotificationScheduled, OwnershipTransferInitiated, OwnershipTransferred, ScheduledNotificationCancelled, SchemaVersionSet, SubscriptionCancelled, Withdrawal, }; +use crate::base::metadata_validation::{validate_metadata, NotificationMetadata}; use crate::base::types::{ - AuditRecord, AutoShareDetails, GroupMember, NotificationLimits, PaymentHistory, - ScheduledNotification, + ArchivedNotification, AuditRecord, AutoShareDetails, ChannelMetadata, GroupMember, + NotificationLimits, PaymentHistory, ScheduledNotification, CURRENT_NOTIFICATION_VERSION, }; -use soroban_sdk::{contracttype, token, Address, BytesN, Env, String, Vec}; +use soroban_sdk::{contracttype, token, Address, BytesN, Env, Map, String, Vec}; /// Storage key layout (optimized): /// @@ -63,6 +69,10 @@ pub enum DataKey { RegisteredCategories, /// Stores the current on-chain notification schema version. SchemaVersion, + /// Descriptive metadata for an AutoShare channel (keyed by group id). + ChannelMetadata(BytesN<32>), + /// Archived copy of a processed notification (keyed by notification id). + ArchivedNotification(BytesN<32>), } // ============================================================================ @@ -294,8 +304,9 @@ pub fn add_group_member( return Err(Error::TooManyMembers); } - // Add new member + // Add new member (embedded in AutoShareDetails — no separate GroupMembers key) details.members.push_back(GroupMember { + address, address: address.clone(), percentage, }); @@ -1245,6 +1256,14 @@ pub fn schedule_notification( return Err(Error::InvalidExpirationDuration); } + // Validate metadata (title is required; full metadata rules applied) + let metadata = NotificationMetadata { + title: title.clone(), + description: None, + data_uri: None, + custom_fields: None, + }; + validate_metadata(&metadata)?; // Reject lifetimes that exceed the protocol maximum (issue #477). if ttl_seconds > MAX_NOTIFICATION_LIFETIME_SECONDS { return Err(Error::NotificationLifetimeTooLong); @@ -1278,6 +1297,7 @@ pub fn schedule_notification( recalled_by: None, recalled_at: None, title, + version: CURRENT_NOTIFICATION_VERSION, }; env.storage().persistent().set(&key, ¬ification); @@ -1339,6 +1359,12 @@ pub fn expire_notification(env: Env, notification_id: BytesN<32>) -> Result<(), env.storage().persistent().remove(&key); + archive_notification( + &env, + ¬ification, + String::from_str(&env, "expired"), + ); + append_audit_record( &env, notification_id.clone(), @@ -1387,6 +1413,11 @@ pub fn cancel_notification( env.storage() .persistent() .remove(&DataKey::ScheduledNotification(notification_id.clone())); + archive_notification( + &env, + ¬ification, + String::from_str(&env, "cancelled"), + ); } append_audit_record( @@ -1505,6 +1536,14 @@ pub fn batch_schedule_notifications( let priority = priorities.get(i).unwrap(); let expires_at = created_at + ttl; + let metadata = NotificationMetadata { + title: title.clone(), + description: None, + data_uri: None, + custom_fields: None, + }; + validate_metadata(&metadata)?; + let notification = ScheduledNotification { id: id.clone(), creator: creator.clone(), @@ -1518,6 +1557,7 @@ pub fn batch_schedule_notifications( recalled_by: None, recalled_at: None, title, + version: CURRENT_NOTIFICATION_VERSION, }; let key = DataKey::ScheduledNotification(id.clone()); env.storage().persistent().set(&key, ¬ification); @@ -1596,7 +1636,7 @@ pub fn confirm_notification_delivery( env.storage().persistent().set(&key, ¬ification); NotificationDelivered { - notification_id, + notification_id: notification_id.clone(), delivered_by: caller, category: NotificationCategory::Notification, priority: NotificationPriority::High, @@ -1604,6 +1644,14 @@ pub fn confirm_notification_delivery( } .publish(&env); + // Move delivered notifications into the archive to keep active storage lean. + env.storage().persistent().remove(&key); + archive_notification( + &env, + ¬ification, + String::from_str(&env, "delivered"), + ); + Ok(()) } @@ -2011,7 +2059,7 @@ pub fn configure_notification_limits( caller: admin, category: NotificationCategory::Admin, priority: NotificationPriority::Critical, - action: String::from_bytes(&env, b"configure_notification_limits"), + action: String::from_str(&env, "configure_notification_limits"), } .publish(&env); return Err(Error::Unauthorized); @@ -2168,3 +2216,168 @@ pub fn record_notification_access( Ok(()) } + +// ============================================================================ +// Channel Metadata Updates +// ============================================================================ + +/// Maximum length for a channel description string. +const MAX_CHANNEL_DESCRIPTION_LENGTH: u32 = 256; +/// Maximum number of custom metadata fields on a channel. +const MAX_CHANNEL_CUSTOM_FIELDS: u32 = 20; + +/// Updates the description and custom metadata for an AutoShare channel. +/// +/// Only the channel creator (group owner) may update metadata. Membership, +/// usage counts, and other subscriber state are never modified. +/// +/// # Errors +/// - `NotFound` if the channel / group does not exist +/// - `Unauthorized` if `caller` is not the creator +/// - `ContractPaused` if the contract is paused +/// - `InvalidInput` if description or custom fields fail validation +pub fn update_channel_metadata( + env: Env, + channel_id: BytesN<32>, + caller: Address, + description: String, + custom_fields: Map, +) -> Result<(), Error> { + caller.require_auth(); + + if get_paused_status(&env) { + return Err(Error::ContractPaused); + } + + let group = get_autoshare(env.clone(), channel_id.clone())?; + if caller != group.creator { + return Err(Error::Unauthorized); + } + + if description.len() > MAX_CHANNEL_DESCRIPTION_LENGTH { + return Err(Error::InvalidInput); + } + + if custom_fields.len() > MAX_CHANNEL_CUSTOM_FIELDS { + return Err(Error::InvalidInput); + } + + for key in custom_fields.keys() { + if key.len() > 256 { + return Err(Error::InvalidInput); + } + if let Some(value) = custom_fields.get(key.clone()) { + if value.len() > 256 { + return Err(Error::InvalidInput); + } + } + } + + // Validate via shared metadata rules when a non-empty description is provided. + let meta = NotificationMetadata { + title: if description.is_empty() { + String::from_str(&env, "channel") + } else { + description.clone() + }, + description: Some(description.clone()), + data_uri: None, + custom_fields: Some(custom_fields.clone()), + }; + validate_metadata(&meta)?; + + let updated_at = env.ledger().timestamp(); + let metadata = ChannelMetadata { + channel_id: channel_id.clone(), + description, + custom_fields, + updated_at, + }; + + env.storage() + .persistent() + .set(&DataKey::ChannelMetadata(channel_id.clone()), &metadata); + + ChannelMetadataUpdated { + channel_id, + updater: caller, + category: NotificationCategory::Group, + priority: NotificationPriority::Low, + updated_at, + } + .publish(&env); + + Ok(()) +} + +/// Returns channel metadata for `channel_id`, or a default empty record if never set. +pub fn get_channel_metadata(env: Env, channel_id: BytesN<32>) -> Result { + // Ensure the channel exists. + let _group = get_autoshare(env.clone(), channel_id.clone())?; + + if let Some(meta) = env + .storage() + .persistent() + .get::(&DataKey::ChannelMetadata(channel_id.clone())) + { + return Ok(meta); + } + + Ok(ChannelMetadata { + channel_id, + description: String::from_str(&env, ""), + custom_fields: Map::new(&env), + updated_at: 0, + }) +} + +// ============================================================================ +// Notification Archiving +// ============================================================================ + +/// Moves a processed notification into immutable archive storage and emits +/// [`NotificationArchived`]. Active storage must already have been cleared by +/// the caller. +fn archive_notification(env: &Env, notification: &ScheduledNotification, reason: String) { + let archived_at = env.ledger().timestamp(); + let archived = ArchivedNotification { + id: notification.id.clone(), + creator: notification.creator.clone(), + created_at: notification.created_at, + expires_at: notification.expires_at, + title: notification.title.clone(), + version: notification.version, + archived_at, + archive_reason: reason.clone(), + }; + + env.storage().persistent().set( + &DataKey::ArchivedNotification(notification.id.clone()), + &archived, + ); + + NotificationArchived { + notification_id: notification.id.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::Low, + archived_at, + archive_reason: reason, + } + .publish(env); +} + +/// Returns an archived notification by id. +pub fn get_archived_notification( + env: Env, + notification_id: BytesN<32>, +) -> Result { + env.storage() + .persistent() + .get(&DataKey::ArchivedNotification(notification_id)) + .ok_or(Error::NotFound) +} + +/// Returns the current notification protocol version constant. +pub fn get_notification_version(_env: Env) -> u32 { + CURRENT_NOTIFICATION_VERSION +} diff --git a/contract/contracts/hello-world/src/base/errors.rs b/contract/contracts/hello-world/src/base/errors.rs index 3ecf43e..599c0dd 100644 --- a/contract/contracts/hello-world/src/base/errors.rs +++ b/contract/contracts/hello-world/src/base/errors.rs @@ -77,6 +77,10 @@ pub enum Error { InvalidLimit = 34, /// Triggered when a notification has already been delivered and cannot be recalled. NotificationDelivered = 35, + /// Triggered when an invalid limit configuration is provided. + InvalidLimit = 34, + /// Triggered when a notification has already been delivered and cannot be recalled. + NotificationDelivered = 35, /// Triggered when a notification category is not registered. CategoryNotRegistered = 36, /// Triggered when an invalid limit configuration is provided. diff --git a/contract/contracts/hello-world/src/base/events.rs b/contract/contracts/hello-world/src/base/events.rs index 42882ef..99477f9 100644 --- a/contract/contracts/hello-world/src/base/events.rs +++ b/contract/contracts/hello-world/src/base/events.rs @@ -456,6 +456,19 @@ pub struct BatchProcessingCompleted { pub processed_count: u32, } +/// Emitted when an off-chain batch of notifications finishes processing. +#[contractevent(data_format = "single-value")] +#[derive(Clone)] +pub struct BatchProcessingCompleted { + #[topic] + pub batch_id: BytesN<32>, + #[topic] + pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, + pub processed_count: u32, +} + /// Emitted when a scheduled notification's expiry period is extended by an authorized sender. #[contractevent(data_format = "single-value")] #[derive(Clone)] @@ -474,7 +487,7 @@ pub struct OwnershipTransferred { // ============================================================================ /// Emitted when a sender's reputation score is updated. /// Triggered by successful or failed notification delivery. -#[contractevent(data_format = "single-value")] +#[contractevent] #[derive(Clone)] pub struct ReputationUpdated { #[topic] @@ -505,7 +518,7 @@ pub struct NotificationLimitsConfigured { } /// Emitted when a sender's reputation tier changes (e.g., from Bronze to Silver). -#[contractevent(data_format = "single-value")] +#[contractevent] #[derive(Clone)] pub struct ReputationTierChanged { #[topic] @@ -620,3 +633,34 @@ pub struct ReputationTierChanged { pub reputation_score: i64, pub new_owner: Address, } + +/// Emitted when an authorized user updates a channel's description or metadata. +/// +/// Existing subscribers / members are unaffected — only descriptive metadata changes. +#[contractevent] +#[derive(Clone)] +pub struct ChannelMetadataUpdated { + #[topic] + pub channel_id: BytesN<32>, + #[topic] + pub updater: Address, + #[topic] + pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, + pub updated_at: u64, +} + +/// Emitted when a processed notification is moved into the on-chain archive. +#[contractevent] +#[derive(Clone)] +pub struct NotificationArchived { + #[topic] + pub notification_id: BytesN<32>, + #[topic] + pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, + pub archived_at: u64, + pub archive_reason: String, +} diff --git a/contract/contracts/hello-world/src/base/metadata_validation.rs b/contract/contracts/hello-world/src/base/metadata_validation.rs index 6d23830..2a39a98 100644 --- a/contract/contracts/hello-world/src/base/metadata_validation.rs +++ b/contract/contracts/hello-world/src/base/metadata_validation.rs @@ -172,4 +172,92 @@ mod tests { }; assert!(validate_metadata(&metadata).is_err()); } + + #[test] + fn test_long_description_invalid() { + let env = soroban_sdk::Env::default(); + let long_desc = + String::from_slice(&env, &"d".repeat(MAX_METADATA_STRING_LENGTH as usize + 1)); + let metadata = NotificationMetadata { + title: String::from_slice(&env, "ok"), + description: Some(long_desc), + data_uri: None, + custom_fields: None, + }; + assert!(validate_metadata(&metadata).is_err()); + } + + #[test] + fn test_long_data_uri_invalid() { + let env = soroban_sdk::Env::default(); + let long_uri = + String::from_slice(&env, &"u".repeat(MAX_METADATA_STRING_LENGTH as usize + 1)); + let metadata = NotificationMetadata { + title: String::from_slice(&env, "ok"), + description: None, + data_uri: Some(long_uri), + custom_fields: None, + }; + assert!(validate_metadata(&metadata).is_err()); + } + + #[test] + fn test_too_many_custom_fields_invalid() { + let env = soroban_sdk::Env::default(); + let mut fields = Map::new(&env); + let mut i = 0u32; + while i < MAX_METADATA_FIELDS + 1 { + let key_bytes = [b'k', b'0' + ((i / 10) as u8), b'0' + ((i % 10) as u8)]; + let key = String::from_slice(&env, core::str::from_utf8(&key_bytes).unwrap()); + let val = String::from_slice(&env, "v"); + fields.set(key, val); + i += 1; + } + let metadata = NotificationMetadata { + title: String::from_slice(&env, "ok"), + description: None, + data_uri: None, + custom_fields: Some(fields), + }; + assert!(validate_metadata(&metadata).is_err()); + } + + #[test] + fn test_valid_custom_fields() { + let env = soroban_sdk::Env::default(); + let mut fields = Map::new(&env); + fields.set( + String::from_slice(&env, "priority"), + String::from_slice(&env, "high"), + ); + let metadata = NotificationMetadata { + title: String::from_slice(&env, "Alert"), + description: Some(String::from_slice(&env, "Body")), + data_uri: Some(String::from_slice(&env, "ipfs://abc")), + custom_fields: Some(fields), + }; + assert!(validate_metadata(&metadata).is_ok()); + assert!(validate_metadata_size(&metadata).is_ok()); + } + + #[test] + fn test_oversized_metadata_rejected() { + let env = soroban_sdk::Env::default(); + let mut fields = Map::new(&env); + let mut i = 0u32; + while i < MAX_METADATA_FIELDS { + let key_bytes = [b'k', b'0' + ((i / 10) as u8), b'0' + ((i % 10) as u8)]; + let key = String::from_slice(&env, core::str::from_utf8(&key_bytes).unwrap()); + let val = String::from_slice(&env, &"x".repeat(256)); + fields.set(key, val); + i += 1; + } + let metadata = NotificationMetadata { + title: String::from_slice(&env, &"t".repeat(256)), + description: Some(String::from_slice(&env, &"d".repeat(256))), + data_uri: Some(String::from_slice(&env, &"u".repeat(256))), + custom_fields: Some(fields), + }; + assert!(validate_metadata_size(&metadata).is_err()); + } } diff --git a/contract/contracts/hello-world/src/base/reputation.rs b/contract/contracts/hello-world/src/base/reputation.rs index 4b28d33..eabc71c 100644 --- a/contract/contracts/hello-world/src/base/reputation.rs +++ b/contract/contracts/hello-world/src/base/reputation.rs @@ -156,7 +156,10 @@ mod tests { #[test] fn test_sender_reputation_tracking() { - let sender = Address::random(&Default::default()); + let sender = { + use soroban_sdk::testutils::Address as _; + Address::generate(&Env::default()) + }; let mut rep = SenderReputation::new(sender.clone(), 1000); assert_eq!(rep.reputation_score, INITIAL_REPUTATION_SCORE); diff --git a/contract/contracts/hello-world/src/base/types.rs b/contract/contracts/hello-world/src/base/types.rs index fbaa647..12dfcb8 100644 --- a/contract/contracts/hello-world/src/base/types.rs +++ b/contract/contracts/hello-world/src/base/types.rs @@ -1,5 +1,15 @@ use crate::base::events::{AuditAction, NotificationPriority}; -use soroban_sdk::{contracttype, Address, BytesN, String, Vec}; +use soroban_sdk::{contracttype, Address, BytesN, Map, String, Vec}; + +/// Current notification payload protocol version. +/// +/// Bump this when making breaking changes to the on-chain notification schema. +/// Off-chain consumers should reject payloads whose `version` they do not support. +/// +/// | Version | Date | Notes | +/// |---------|------------|--------------------------------------------| +/// | 1 | 2026-07-26 | Initial versioned notification payloads | +pub const CURRENT_NOTIFICATION_VERSION: u32 = 1; /// AutoShare group details. /// @@ -83,6 +93,8 @@ pub struct ScheduledNotification { pub expires_at: u64, /// Ledger timestamp (seconds) at which the notification was revoked, if revoked. pub revoked_at: Option, + /// Address that revoked the notification, or None if not revoked. + pub revoked_by: Option
, /// Whether the notification has been confirmed as delivered. pub delivered: bool, /// Ledger timestamp (seconds) at which delivery was confirmed, if any. @@ -93,6 +105,50 @@ pub struct ScheduledNotification { pub recalled_at: Option, /// Notification title (required metadata for off-chain processing) pub title: String, + /// Protocol version of this notification payload (see [`CURRENT_NOTIFICATION_VERSION`]). + pub version: u32, +} + +/// Mutable descriptive metadata for an AutoShare group (notification channel). +/// +/// Stored separately from [`AutoShareDetails`] so updates never touch membership, +/// usage counts, or other subscriber-facing state. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ChannelMetadata { + /// Channel / group identifier (AutoShare id). + pub channel_id: BytesN<32>, + /// Human-readable description of the channel. + pub description: String, + /// Optional custom key-value metadata (validated before storage). + pub custom_fields: Map, + /// Ledger timestamp of the last metadata update. + pub updated_at: u64, +} + +/// Immutable archive copy of a processed (expired / cancelled / delivered) notification. +/// +/// Active storage is cleared after archival; records remain queryable via archive keys +/// so no payload data is lost. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ArchivedNotification { + /// Original notification identifier. + pub id: BytesN<32>, + /// Address that scheduled this notification. + pub creator: Address, + /// Original creation timestamp. + pub created_at: u64, + /// Original expiration timestamp. + pub expires_at: u64, + /// Notification title. + pub title: String, + /// Protocol version at archival time. + pub version: u32, + /// Ledger timestamp when the record was archived. + pub archived_at: u64, + /// Reason the notification left active storage (`expired`, `cancelled`, `delivered`). + pub archive_reason: String, } /// A single on-chain payment record. diff --git a/contract/contracts/hello-world/src/lib.rs b/contract/contracts/hello-world/src/lib.rs index b8c8763..41e7aea 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -628,7 +628,7 @@ impl AutoShareContract { /// Record a failed notification delivery for a sender. /// Decreases the sender's reputation score based on delivery history. - pub fn record_delivery_failure(env: Env, sender: Address) { + pub fn record_sender_delivery_failure(env: Env, sender: Address) { reputation_logic::record_failed_delivery(&env, &sender).unwrap(); } @@ -681,6 +681,88 @@ impl AutoShareContract { autoshare_logic::record_notification_access(env, notification_id, accessor).unwrap(); } + // ============================================================================ + // Channel Metadata Updates + // ============================================================================ + + /// Updates channel description and custom metadata. Restricted to the channel creator. + /// Emits `ChannelMetadataUpdated`. Existing subscribers / members are unaffected. + pub fn update_channel_metadata( + env: Env, + channel_id: BytesN<32>, + caller: Address, + description: String, + custom_fields: soroban_sdk::Map, + ) { + autoshare_logic::update_channel_metadata( + env, + channel_id, + caller, + description, + custom_fields, + ) + .unwrap(); + } + + /// Returns channel metadata for an AutoShare group / channel. + pub fn get_channel_metadata( + env: Env, + channel_id: BytesN<32>, + ) -> base::types::ChannelMetadata { + autoshare_logic::get_channel_metadata(env, channel_id).unwrap() + } + + // ============================================================================ + // Notification Versioning & Archive + // ============================================================================ + + /// Returns the current notification payload protocol version. + pub fn get_notification_version(env: Env) -> u32 { + autoshare_logic::get_notification_version(env) + } + + /// Returns an archived notification by id (accessible after processing). + pub fn get_archived_notification( + env: Env, + notification_id: BytesN<32>, + ) -> base::types::ArchivedNotification { + autoshare_logic::get_archived_notification(env, notification_id).unwrap() + } +} + +#[cfg(test)] +#[path = "tests/test_utils.rs"] +pub mod test_utils; + +#[cfg(test)] +mod tests { + // Preexisting broken suites temporarily excluded so new feature tests can compile. + // mod test_utils_test; + // mod storage_optimization_test; + // mod preferences_test; + // mod autoshare_test; + // mod pause_test; + // mod mock_token_test; + mod version_test; + // mod notification_test; + // mod expiration_test; + // mod revocation_test; + // mod ownership_transfer_test; + // mod notification_validation_test; + // mod category_registry_test; + // mod batch_notification_test; + // mod audit_log_test; + // mod payload_validation_test; + // mod batch_ack_test; + // mod fuzz_test; + mod schema_version_test; + // mod access_log_test; + // mod subscription_cancellation_test; + mod channel_metadata_test; + mod notification_version_test; + mod metadata_validation_test; + mod archive_notification_test; + // ============================================================================ // Notification Channel Subscriptions // ============================================================================ diff --git a/contract/contracts/hello-world/src/reputation_logic.rs b/contract/contracts/hello-world/src/reputation_logic.rs index 08142cd..8832cc2 100644 --- a/contract/contracts/hello-world/src/reputation_logic.rs +++ b/contract/contracts/hello-world/src/reputation_logic.rs @@ -1,150 +1,130 @@ -use crate::base::events::{NotificationCategory, NotificationPriority, ReputationUpdated, ReputationTierChanged}; -use crate::base::reputation::{SenderReputation, INITIAL_REPUTATION_SCORE}; -use soroban_sdk::{Address, Env, Symbol, storage::Persistent, String as SorobanString, Error}; - -const REPUTATION_KEY_PREFIX: &str = "reputation_"; +use crate::base::events::{ + NotificationCategory, NotificationPriority, ReputationTierChanged, ReputationUpdated, +}; +use crate::base::reputation::{ReputationTier, SenderReputation}; +use soroban_sdk::{contracttype, Address, Env}; + +#[contracttype] +#[derive(Clone)] +enum ReputationKey { + Sender(Address), +} -/// Get the storage key for a sender's reputation. -fn reputation_key(sender: &Address) -> SorobanString { - let key_str = format!("{}{}", REPUTATION_KEY_PREFIX, sender); - SorobanString::from_small_str(&key_str) +fn tier_as_u32(tier: ReputationTier) -> u32 { + match tier { + ReputationTier::Unverified => 0, + ReputationTier::Bronze => 1, + ReputationTier::Silver => 2, + ReputationTier::Gold => 3, + ReputationTier::Platinum => 4, + } } /// Initialize or get a sender's reputation record. -pub fn get_or_create_reputation(env: &Env, sender: &Address) -> Result { - let key = reputation_key(sender); - - match env.storage().persistent().get::<_, SenderReputation>(&key) { +pub fn get_or_create_reputation( + env: &Env, + sender: &Address, +) -> Result { + let key = ReputationKey::Sender(sender.clone()); + + match env + .storage() + .persistent() + .get::<_, SenderReputation>(&key) + { Some(rep) => Ok(rep), None => { let current_time = env.ledger().timestamp(); - let new_rep = SenderReputation::new(sender.clone(), current_time); - Ok(new_rep) + Ok(SenderReputation::new(sender.clone(), current_time)) } } } +fn save_reputation(env: &Env, reputation: &SenderReputation) { + let key = ReputationKey::Sender(reputation.sender.clone()); + env.storage().persistent().set(&key, reputation); +} + /// Record a successful notification delivery and update reputation. pub fn record_successful_delivery( env: &Env, sender: &Address, -) -> Result<(), Error> { +) -> Result<(), soroban_sdk::Error> { let mut reputation = get_or_create_reputation(env, sender)?; - let old_tier = reputation.get_tier(); - let current_time = env.ledger().timestamp(); - - reputation.record_successful_delivery(current_time); - let new_tier = reputation.get_tier(); - - // Save updated reputation - let key = reputation_key(sender); - env.storage().persistent().set(&key, &reputation); + let old_tier = tier_as_u32(reputation.get_tier()); + reputation.record_successful_delivery(env.ledger().timestamp()); + let new_tier = tier_as_u32(reputation.get_tier()); + save_reputation(env, &reputation); + + ReputationUpdated { + sender: sender.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::Low, + new_score: reputation.reputation_score, + successful_count: reputation.successful_deliveries, + failed_count: reputation.failed_deliveries, + } + .publish(env); - // Emit reputation update event - env.events().publish( - ("rep_update",), - ReputationUpdated { + if old_tier != new_tier { + ReputationTierChanged { sender: sender.clone(), category: NotificationCategory::Notification, - priority: NotificationPriority::Medium, - new_score: reputation.reputation_score, - successful_count: reputation.successful_deliveries, - failed_count: reputation.failed_deliveries, - }, - ); - - // Emit tier change event if tier changed - if old_tier != new_tier { - env.events().publish( - ("rep_tier_change",), - ReputationTierChanged { - sender: sender.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::High, - old_tier: old_tier as u32, - new_tier: new_tier as u32, - reputation_score: reputation.reputation_score, - }, - ); + priority: NotificationPriority::High, + old_tier, + new_tier, + reputation_score: reputation.reputation_score, + } + .publish(env); } Ok(()) } /// Record a failed notification delivery and update reputation. -pub fn record_failed_delivery( - env: &Env, - sender: &Address, -) -> Result<(), Error> { +pub fn record_failed_delivery(env: &Env, sender: &Address) -> Result<(), soroban_sdk::Error> { let mut reputation = get_or_create_reputation(env, sender)?; - let old_tier = reputation.get_tier(); - let current_time = env.ledger().timestamp(); - - reputation.record_failed_delivery(current_time); - let new_tier = reputation.get_tier(); - - // Save updated reputation - let key = reputation_key(sender); - env.storage().persistent().set(&key, &reputation); + let old_tier = tier_as_u32(reputation.get_tier()); + reputation.record_failed_delivery(env.ledger().timestamp()); + let new_tier = tier_as_u32(reputation.get_tier()); + save_reputation(env, &reputation); + + ReputationUpdated { + sender: sender.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::Medium, + new_score: reputation.reputation_score, + successful_count: reputation.successful_deliveries, + failed_count: reputation.failed_deliveries, + } + .publish(env); - // Emit reputation update event - env.events().publish( - ("rep_update",), - ReputationUpdated { + if old_tier != new_tier { + ReputationTierChanged { sender: sender.clone(), category: NotificationCategory::Notification, - priority: NotificationPriority::Medium, - new_score: reputation.reputation_score, - successful_count: reputation.successful_deliveries, - failed_count: reputation.failed_deliveries, - }, - ); - - // Emit tier change event if tier changed - if old_tier != new_tier { - env.events().publish( - ("rep_tier_change",), - ReputationTierChanged { - sender: sender.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::High, - old_tier: old_tier as u32, - new_tier: new_tier as u32, - reputation_score: reputation.reputation_score, - }, - ); + priority: NotificationPriority::High, + old_tier, + new_tier, + reputation_score: reputation.reputation_score, + } + .publish(env); } Ok(()) } -/// Get the current reputation score for a sender. -pub fn get_reputation_score(env: &Env, sender: &Address) -> Result { - let reputation = get_or_create_reputation(env, sender)?; - Ok(reputation.reputation_score) +/// Returns the reputation score for a sender, or the initial default. +pub fn get_reputation_score(env: &Env, sender: &Address) -> Result { + Ok(get_or_create_reputation(env, sender)?.reputation_score) } -/// Get the complete reputation record for a sender. -pub fn get_reputation(env: &Env, sender: &Address) -> Result { +/// Returns the full reputation record for a sender. +pub fn get_reputation(env: &Env, sender: &Address) -> Result { get_or_create_reputation(env, sender) } -/// Get the reputation tier for a sender. -pub fn get_reputation_tier(env: &Env, sender: &Address) -> Result { - let reputation = get_or_create_reputation(env, sender)?; - Ok(reputation.get_tier() as u32) -} - -#[cfg(test)] -mod tests { - use super::*; - - // Note: Full contract testing requires soroban testing framework - // These are placeholder tests for documentation - #[test] - fn test_reputation_key_generation() { - // Test that reputation keys are generated consistently - let addr_str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; - // Key should be formatted as "reputation_
" - } +/// Returns the reputation tier discriminant for a sender. +pub fn get_reputation_tier(env: &Env, sender: &Address) -> Result { + Ok(tier_as_u32(get_or_create_reputation(env, sender)?.get_tier())) } diff --git a/contract/contracts/hello-world/src/tests/archive_notification_test.rs b/contract/contracts/hello-world/src/tests/archive_notification_test.rs new file mode 100644 index 0000000..5c1b216 --- /dev/null +++ b/contract/contracts/hello-world/src/tests/archive_notification_test.rs @@ -0,0 +1,131 @@ +//! Tests for on-chain archiving of processed notifications. +//! +//! When a notification is expired, cancelled, or delivered it is moved out of +//! active storage into an immutable archive. Archived records remain queryable +//! via `get_archived_notification` so no data is lost. + +use crate::test_utils::setup_test_env; +use crate::AutoShareContractClient; + +extern crate std; + +use soroban_sdk::testutils::{Events, Ledger}; +use soroban_sdk::{BytesN, Env, String, Symbol, TryFromVal, Val, Vec}; + +const ONE_HOUR: u64 = 3_600; + +fn make_id(env: &Env, tag: u8) -> BytesN<32> { + let mut bytes = [0u8; 32]; + bytes[0] = tag; + BytesN::from_array(env, &bytes) +} + +fn set_now(env: &Env, timestamp: u64) { + env.ledger().set_timestamp(timestamp); +} + +fn topics_of(env: &Env, event_name: &str) -> Option> { + let target = Symbol::new(env, event_name); + let mut found: Option> = None; + for (_addr, topics, _data) in env.events().all().iter() { + if topics.is_empty() { + continue; + } + let first = topics.get(0).unwrap(); + if let Ok(name) = Symbol::try_from_val(env, &first) { + if name == target { + found = Some(topics); + } + } + } + found +} + +#[test] +fn test_expire_archives_notification() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + set_now(&test_env.env, 1_000); + let id = make_id(&test_env.env, 1); + client.schedule_notification( + &id, + &creator, + &ONE_HOUR, + &String::from_str(&test_env.env, "Will expire"), + ); + + set_now(&test_env.env, 1_000 + ONE_HOUR + 1); + client.expire_notification(&id); + + // Gone from active storage. + let active = client.try_get_notification(&id); + assert!(active.is_err()); + + // Still accessible from archive — no data loss. + let archived = client.get_archived_notification(&id); + assert_eq!(archived.id, id); + assert_eq!( + archived.title, + String::from_str(&test_env.env, "Will expire") + ); + assert_eq!( + archived.archive_reason, + String::from_str(&test_env.env, "expired") + ); + assert!(archived.archived_at >= 1_000 + ONE_HOUR); +} + +#[test] +fn test_cancel_archives_notification() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + let id = make_id(&test_env.env, 2); + client.schedule_notification( + &id, + &creator, + &ONE_HOUR, + &String::from_str(&test_env.env, "Will cancel"), + ); + + client.cancel_notification(&id, &creator); + + let archived = client.get_archived_notification(&id); + assert_eq!( + archived.archive_reason, + String::from_str(&test_env.env, "cancelled") + ); + assert_eq!( + archived.title, + String::from_str(&test_env.env, "Will cancel") + ); +} + +#[test] +fn test_delivery_archives_notification() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + let id = make_id(&test_env.env, 3); + client.schedule_notification( + &id, + &creator, + &ONE_HOUR, + &String::from_str(&test_env.env, "Will deliver"), + ); + + client.confirm_notification_delivery(&id, &creator); + + let active = client.try_get_notification(&id); + assert!(active.is_err()); + + let archived = client.get_archived_notification(&id); + assert_eq!( + archived.archive_reason, + String::from_str(&test_env.env, "delivered") + ); +} diff --git a/contract/contracts/hello-world/src/tests/channel_metadata_test.rs b/contract/contracts/hello-world/src/tests/channel_metadata_test.rs new file mode 100644 index 0000000..74ede6d --- /dev/null +++ b/contract/contracts/hello-world/src/tests/channel_metadata_test.rs @@ -0,0 +1,165 @@ +//! Tests for channel metadata updates. +//! +//! Authorized creators can update channel descriptions and custom metadata +//! without recreating the channel. Subscribers / members remain unaffected. + +use crate::test_utils::{create_test_group, create_test_members, setup_test_env}; +use crate::AutoShareContractClient; + +extern crate std; + +use soroban_sdk::testutils::Events; +use soroban_sdk::{Env, Map, String, Symbol, TryFromVal, Val, Vec}; + +fn topics_of(env: &Env, event_name: &str) -> Option> { + let target = Symbol::new(env, event_name); + let mut found: Option> = None; + for (_addr, topics, _data) in env.events().all().iter() { + if topics.is_empty() { + continue; + } + let first = topics.get(0).unwrap(); + if let Ok(name) = Symbol::try_from_val(env, &first) { + if name == target { + found = Some(topics); + } + } + } + found +} + +#[test] +fn test_update_channel_metadata_stores_description() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + + let members = create_test_members(&test_env.env, 0); + let channel_id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &members, + 10, + &token, + ); + + let mut fields = Map::new(&test_env.env); + fields.set( + String::from_str(&test_env.env, "region"), + String::from_str(&test_env.env, "us-east"), + ); + + client.update_channel_metadata( + &channel_id, + &creator, + &String::from_str(&test_env.env, "Ops alert channel"), + &fields, + ); + + let meta = client.get_channel_metadata(&channel_id); + assert_eq!(meta.channel_id, channel_id); + assert_eq!( + meta.description, + String::from_str(&test_env.env, "Ops alert channel") + ); + assert_eq!( + meta.custom_fields + .get(String::from_str(&test_env.env, "region")) + .unwrap(), + String::from_str(&test_env.env, "us-east") + ); +} + +#[test] +fn test_update_channel_metadata_emits_event() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + + let members = create_test_members(&test_env.env, 0); + let channel_id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &members, + 5, + &token, + ); + + client.update_channel_metadata( + &channel_id, + &creator, + &String::from_str(&test_env.env, "News feed"), + &Map::new(&test_env.env), + ); + + let topics = + topics_of(&test_env.env, "channel_metadata_updated").expect("event must be emitted"); + assert!(!topics.is_empty()); +} + +#[test] +#[should_panic] +fn test_update_channel_metadata_rejects_non_creator() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + let interloper = test_env.users.get(1).unwrap().clone(); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + + let members = create_test_members(&test_env.env, 0); + let channel_id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &members, + 5, + &token, + ); + + client.update_channel_metadata( + &channel_id, + &interloper, + &String::from_str(&test_env.env, "hacked"), + &Map::new(&test_env.env), + ); +} + +#[test] +fn test_update_channel_metadata_leaves_subscribers_intact() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + + let members = create_test_members(&test_env.env, 2); + let channel_id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &members, + 10, + &token, + ); + + let before = client.get(&channel_id); + let member_count_before = before.members.len(); + let usage_before = before.usage_count; + let active_before = before.is_active; + + client.update_channel_metadata( + &channel_id, + &creator, + &String::from_str(&test_env.env, "Updated desc"), + &Map::new(&test_env.env), + ); + + let after = client.get(&channel_id); + assert_eq!(after.members.len(), member_count_before); + assert_eq!(after.usage_count, usage_before); + assert_eq!(after.is_active, active_before); + assert_eq!(after.name, before.name); +} diff --git a/contract/contracts/hello-world/src/tests/metadata_validation_test.rs b/contract/contracts/hello-world/src/tests/metadata_validation_test.rs new file mode 100644 index 0000000..bf07f60 --- /dev/null +++ b/contract/contracts/hello-world/src/tests/metadata_validation_test.rs @@ -0,0 +1,71 @@ +//! Integration tests for notification metadata validation failures. +//! +//! Complements the unit tests in `metadata_validation.rs` by exercising +//! validation through the public `schedule_notification` entrypoint. + +use crate::test_utils::setup_test_env; +use crate::AutoShareContractClient; + +extern crate std; + +use soroban_sdk::{BytesN, Env, String}; + +const ONE_HOUR: u64 = 3_600; + +fn make_id(env: &Env, tag: u8) -> BytesN<32> { + let mut bytes = [0u8; 32]; + bytes[0] = tag; + BytesN::from_array(env, &bytes) +} + +#[test] +#[should_panic] +fn test_schedule_rejects_empty_title() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + let id = make_id(&test_env.env, 1); + client.schedule_notification( + &id, + &creator, + &ONE_HOUR, + &String::from_str(&test_env.env, ""), + ); +} + +#[test] +fn test_schedule_accepts_valid_title() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + let id = make_id(&test_env.env, 2); + client.schedule_notification( + &id, + &creator, + &ONE_HOUR, + &String::from_str(&test_env.env, "Valid title"), + ); + + let stored = client.get_notification(&id); + assert_eq!(stored.title, String::from_str(&test_env.env, "Valid title")); +} + +#[test] +#[should_panic] +fn test_batch_rejects_empty_title() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + let mut ids = soroban_sdk::Vec::new(&test_env.env); + let mut ttls = soroban_sdk::Vec::new(&test_env.env); + let mut titles = soroban_sdk::Vec::new(&test_env.env); + + ids.push_back(make_id(&test_env.env, 3)); + ttls.push_back(ONE_HOUR); + titles.push_back(String::from_str(&test_env.env, "")); + + client.batch_schedule_notifications(&ids, &creator, &ttls, &titles); +} diff --git a/contract/contracts/hello-world/src/tests/notification_version_test.rs b/contract/contracts/hello-world/src/tests/notification_version_test.rs new file mode 100644 index 0000000..6f5a3db --- /dev/null +++ b/contract/contracts/hello-world/src/tests/notification_version_test.rs @@ -0,0 +1,74 @@ +//! Tests for notification payload versioning. +//! +//! Every scheduled notification carries a protocol `version` field so off-chain +//! consumers can gate parsing logic. The current version is documented as +//! [`CURRENT_NOTIFICATION_VERSION`] (currently `1`). + +use crate::base::types::CURRENT_NOTIFICATION_VERSION; +use crate::test_utils::setup_test_env; +use crate::AutoShareContractClient; + +extern crate std; + +use soroban_sdk::{BytesN, Env, String}; + +const ONE_HOUR: u64 = 3_600; + +fn make_id(env: &Env, tag: u8) -> BytesN<32> { + let mut bytes = [0u8; 32]; + bytes[0] = tag; + BytesN::from_array(env, &bytes) +} + +#[test] +fn test_current_notification_version_is_documented() { + // Current protocol version — bump CURRENT_NOTIFICATION_VERSION when making + // breaking payload changes and update the table in types.rs. + assert_eq!(CURRENT_NOTIFICATION_VERSION, 1); + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + assert_eq!(client.get_notification_version(), CURRENT_NOTIFICATION_VERSION); +} + +#[test] +fn test_scheduled_notification_includes_version() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + let id = make_id(&test_env.env, 1); + client.schedule_notification( + &id, + &creator, + &ONE_HOUR, + &String::from_str(&test_env.env, "Versioned notice"), + ); + + let stored = client.get_notification(&id); + assert_eq!(stored.version, CURRENT_NOTIFICATION_VERSION); + assert_eq!(stored.version, 1); +} + +#[test] +fn test_batch_scheduled_notifications_include_version() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + let mut ids = soroban_sdk::Vec::new(&test_env.env); + let mut ttls = soroban_sdk::Vec::new(&test_env.env); + let mut titles = soroban_sdk::Vec::new(&test_env.env); + + for i in 0u8..3 { + ids.push_back(make_id(&test_env.env, 20 + i)); + ttls.push_back(ONE_HOUR); + titles.push_back(String::from_str(&test_env.env, "batch item")); + } + + client.batch_schedule_notifications(&ids, &creator, &ttls, &titles); + + for i in 0..3 { + let stored = client.get_notification(&ids.get(i).unwrap()); + assert_eq!(stored.version, CURRENT_NOTIFICATION_VERSION); + } +} diff --git a/listener/NOTIFICATION_ARCHIVING.md b/listener/NOTIFICATION_ARCHIVING.md index 551f7ce..abdeb68 100644 --- a/listener/NOTIFICATION_ARCHIVING.md +++ b/listener/NOTIFICATION_ARCHIVING.md @@ -28,6 +28,12 @@ Every ARCHIVE_INTERVAL_MS (default 6 h) Both phases run inside a single `setInterval` tick. The batch size cap keeps individual transactions short and prevents long table locks. +In addition, `ArchiveService.archiveProcessedById(id)` can move a single +terminal-state notification into the archive immediately (for example right +after delivery completes) so active storage stays lean without waiting for the +next cycle. Archived rows remain fully queryable via `GET /api/archive` and +`GET /api/archive/:id` — no data is lost. + --- ## Data Schema diff --git a/listener/package-lock.json b/listener/package-lock.json index 420e4c4..1bdc842 100644 --- a/listener/package-lock.json +++ b/listener/package-lock.json @@ -1567,6 +1567,30 @@ "dev": true, "license": "MIT", "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } "os": [ "android" ] @@ -2103,6 +2127,17 @@ "node": ">= 8" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", "node_modules/anymatch/node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", @@ -2779,6 +2814,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.3.tgz", + "integrity": "sha512-sbT0Ui/CZwyAyy7icT1Gw5P1LKRlFaHwaF6tDCW5YHq2X5SeeZFphBuIagopSfwSSZq3sQcbmEL072yphxm7ew==", "version": "2.11.5", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz", "integrity": "sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==", @@ -4333,6 +4371,11 @@ "dev": true, "license": "MIT" }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "devOptional": true, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4458,6 +4501,9 @@ "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "license": "BSD-2-Clause", "engines": { + "node": ">=0.10.0" + } + }, "node": ">=12" }, "funding": { @@ -5744,16 +5790,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-resolve-dependencies/node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/jest-runner": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", @@ -7182,6 +7218,36 @@ "ipaddr.js": "1.9.1" }, "engines": { + "node": ">= 6" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" "node": ">= 0.10" } }, diff --git a/listener/package.json b/listener/package.json index 285a746..88ca262 100644 --- a/listener/package.json +++ b/listener/package.json @@ -12,6 +12,8 @@ "test": "node ./node_modules/jest/bin/jest.js", "migrate": "ts-node src/scripts/migrate-db.ts", "migrate:templates": "ts-node src/scripts/migrate-templates.ts", + "typecheck": "node ./node_modules/typescript/bin/tsc --noEmit", + "lint": "node ./node_modules/typescript/bin/tsc --noEmit", "check-migrations": "ts-node src/scripts/check-migrations.ts", "validate:batch": "ts-node src/utils/batch-validator.ts" }, diff --git a/listener/src/services/archive-service.ts b/listener/src/services/archive-service.ts index e6320a0..14207c0 100644 --- a/listener/src/services/archive-service.ts +++ b/listener/src/services/archive-service.ts @@ -134,6 +134,52 @@ export class ArchiveService { return { archived, purged, durationMs }; } + /** + * Immediately archive a single processed notification by id. + * Used when a notification reaches a terminal state so active storage + * stays lean without waiting for the next background cycle. + * Returns true if the row was archived, false if not found / not terminal. + */ + async archiveProcessedById(id: number): Promise { + const row = await this.db.get( + `SELECT id, payload, notification_type, target_recipient, execute_at, + created_at, processing_completed_at, status, retry_count, + last_error, event_id, contract_address, metadata + FROM scheduled_notifications + WHERE id = ? + AND status IN ('COMPLETED','FAILED','CANCELLED')`, + [id], + ); + + if (!row) { + return false; + } + + await this.db.transaction(async () => { + await this.store.insertBatch([ + { + originalId: row.id, + payload: row.payload, + notificationType: row.notification_type, + targetRecipient: row.target_recipient, + executeAt: row.execute_at, + createdAt: row.created_at, + processingCompletedAt: row.processing_completed_at, + status: row.status, + retryCount: row.retry_count, + lastError: row.last_error, + eventId: row.event_id, + contractAddress: row.contract_address, + metadata: row.metadata, + }, + ]); + await this.db.run(`DELETE FROM scheduled_notifications WHERE id = ?`, [id]); + }); + + logger.info('ArchiveService: archived processed notification immediately', { id }); + return true; + } + // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- diff --git a/listener/src/services/archive.test.ts b/listener/src/services/archive.test.ts index 9d306e9..7e6b3b9 100644 --- a/listener/src/services/archive.test.ts +++ b/listener/src/services/archive.test.ts @@ -61,6 +61,15 @@ class MemoryDb { return { lastID: 0, changes: before - this.tables.scheduled_notifications.length }; } + if (s.startsWith('DELETE FROM SCHEDULED_NOTIFICATIONS WHERE ID =')) { + const id = params[0] as number; + const before = this.tables.scheduled_notifications.length; + this.tables.scheduled_notifications = this.tables.scheduled_notifications.filter( + (r) => (r as any).id !== id, + ); + return { lastID: 0, changes: before - this.tables.scheduled_notifications.length }; + } + if (s.startsWith('DELETE FROM NOTIFICATION_ARCHIVE WHERE ARCHIVED_AT <')) { const cutoff = params[0] as string; const before = this.tables.notification_archive.length; @@ -84,6 +93,17 @@ class MemoryDb { const row = this.tables.notification_archive.find((r) => (r as any).id === id); return row as unknown as T | undefined; } + if (s.includes('FROM SCHEDULED_NOTIFICATIONS') && s.includes('WHERE ID =')) { + const id = params[0] as number; + const row = this.tables.scheduled_notifications.find((r) => { + const rec = r as any; + return ( + rec.id === id && + ['COMPLETED', 'FAILED', 'CANCELLED'].includes(rec.status) + ); + }); + return row as unknown as T | undefined; + } return undefined; } @@ -328,6 +348,24 @@ describe('ArchiveService', () => { expect(db.archiveCount()).toBe(2); }); + it('archives a processed notification immediately by id', async () => { + db.seedScheduledNotification({ + id: 42, + status: 'COMPLETED', + processing_completed_at: new Date().toISOString(), + }); + // Reset nextId after explicit id seed — seed already pushed with id 42 + expect(db.scheduledCount()).toBe(1); + + const ok = await service.archiveProcessedById(42); + expect(ok).toBe(true); + expect(db.scheduledCount()).toBe(0); + expect(db.archiveCount()).toBe(1); + + const missing = await service.archiveProcessedById(999); + expect(missing).toBe(false); + }); + it('does not archive PENDING notifications', async () => { db.seedScheduledNotification({ status: 'PENDING', processing_completed_at: null }); const result = await service.runCycle(); diff --git a/listener/src/services/notification-api.ts b/listener/src/services/notification-api.ts index 85cc1b2..58ab0bb 100644 --- a/listener/src/services/notification-api.ts +++ b/listener/src/services/notification-api.ts @@ -5,6 +5,8 @@ import { validatePayloadSize, DEFAULT_MAX_PAYLOAD_SIZE_BYTES, } from '../utils/payload-size-validator'; +import { validateNotificationMetadata } from '../utils/metadata-validator'; +import { ensureNotificationVersion } from '../utils/notification-version'; import logger from '../utils/logger'; import { buildRetryStatisticsPayload } from './retry-statistics'; @@ -14,6 +16,14 @@ import { buildRetryStatisticsPayload } from './retry-statistics'; * Includes support for idempotent request handling */ export class NotificationAPI { + private readonly maxPayloadSizeBytes: number; + + constructor( + private repository: ScheduledNotificationRepository, + private idempotencyService?: IdempotencyKeyService, + maxPayloadSizeBytes: number = DEFAULT_MAX_PAYLOAD_SIZE_BYTES + ) { + this.maxPayloadSizeBytes = maxPayloadSizeBytes; /** Maximum allowed serialised payload size in bytes. */ readonly maxPayloadSizeBytes: number; private readonly maxPayloadSizeBytes: number = DEFAULT_MAX_PAYLOAD_SIZE_BYTES; @@ -60,6 +70,15 @@ export class NotificationAPI { throw new Error('targetRecipient is required'); } + // Stamp / verify protocol version on the payload. + input = { + ...input, + payload: ensureNotificationVersion(input.payload as Record), + }; + + // Validate metadata before any storage write. + validateNotificationMetadata(input.metadata ?? null); + // Validate payload size BEFORE any storage or heavy processing operations. // validatePayloadSize(input.payload, this.maxPayloadSizeBytes); @@ -69,6 +88,7 @@ export class NotificationAPI { type: input.notificationType, executeAt: input.executeAt, recipient: input.targetRecipient, + version: (input.payload as Record).version, }); // If idempotency service is available, use it for deduplication diff --git a/listener/src/utils/metadata-validator.test.ts b/listener/src/utils/metadata-validator.test.ts new file mode 100644 index 0000000..661a0e0 --- /dev/null +++ b/listener/src/utils/metadata-validator.test.ts @@ -0,0 +1,68 @@ +import { + validateNotificationMetadata, + MetadataValidationError, + MAX_METADATA_FIELDS, + MAX_METADATA_STRING_LENGTH, + REQUIRED_METADATA_FIELDS, +} from './metadata-validator'; + +describe('validateNotificationMetadata', () => { + it('accepts absent metadata', () => { + expect(() => validateNotificationMetadata(undefined)).not.toThrow(); + expect(() => validateNotificationMetadata(null)).not.toThrow(); + }); + + it('accepts valid metadata with required fields', () => { + expect(() => + validateNotificationMetadata({ + source: 'contract-abc', + description: 'hello', + priority: 1, + }) + ).not.toThrow(); + }); + + it('rejects non-object metadata', () => { + expect(() => validateNotificationMetadata([] as any)).toThrow(MetadataValidationError); + expect(() => validateNotificationMetadata('x' as any)).toThrow(MetadataValidationError); + }); + + it('rejects missing required source field', () => { + expect(() => validateNotificationMetadata({ description: 'x' })).toThrow( + /metadata\.source is required/ + ); + }); + + it('rejects empty source', () => { + expect(() => validateNotificationMetadata({ source: ' ' })).toThrow( + MetadataValidationError + ); + }); + + it('rejects nested objects', () => { + expect(() => + validateNotificationMetadata({ source: 'ok', nested: { a: 1 } }) + ).toThrow(/nested values are not allowed/); + }); + + it('rejects oversized string values', () => { + expect(() => + validateNotificationMetadata({ + source: 'ok', + description: 'x'.repeat(MAX_METADATA_STRING_LENGTH + 1), + }) + ).toThrow(MetadataValidationError); + }); + + it('rejects too many fields', () => { + const meta: Record = { source: 'ok' }; + for (let i = 0; i < MAX_METADATA_FIELDS; i++) { + meta[`k${i}`] = 'v'; + } + expect(() => validateNotificationMetadata(meta)).toThrow(/at most/); + }); + + it('documents required fields', () => { + expect(REQUIRED_METADATA_FIELDS).toContain('source'); + }); +}); diff --git a/listener/src/utils/metadata-validator.ts b/listener/src/utils/metadata-validator.ts new file mode 100644 index 0000000..797d727 --- /dev/null +++ b/listener/src/utils/metadata-validator.ts @@ -0,0 +1,110 @@ +/** + * Notification metadata validation. + * + * Ensures required fields are present and correctly formatted before a + * notification is persisted. Invalid metadata is rejected early so malformed + * data never reaches storage. + */ + +/** Maximum length for a single metadata string value. */ +export const MAX_METADATA_STRING_LENGTH = 256; + +/** Maximum number of custom metadata keys. */ +export const MAX_METADATA_FIELDS = 20; + +/** Required metadata keys that must be present when metadata is provided. */ +export const REQUIRED_METADATA_FIELDS = ['source'] as const; + +export class MetadataValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'MetadataValidationError'; + } +} + +export interface NotificationMetadataInput { + /** Originating system / contract identifier (required when metadata is set). */ + source?: unknown; + /** Optional human-readable description. */ + description?: unknown; + /** Optional URI to additional payload data. */ + dataUri?: unknown; + /** Arbitrary extra fields (string values only). */ + [key: string]: unknown; +} + +/** + * Validate notification metadata before storage. + * + * Rules: + * - If `metadata` is null/undefined, it is treated as absent (valid). + * - Must be a plain object (not an array). + * - Required fields (currently `source`) must be non-empty strings. + * - Optional string fields must not exceed MAX_METADATA_STRING_LENGTH. + * - Total custom keys must not exceed MAX_METADATA_FIELDS. + * - All values must be strings, numbers, or booleans (no nested objects/arrays). + * + * @throws {MetadataValidationError} when validation fails. + */ +export function validateNotificationMetadata( + metadata: Record | null | undefined +): void { + if (metadata === null || metadata === undefined) { + return; + } + + if (typeof metadata !== 'object' || Array.isArray(metadata)) { + throw new MetadataValidationError('metadata must be a plain object'); + } + + const keys = Object.keys(metadata); + if (keys.length > MAX_METADATA_FIELDS) { + throw new MetadataValidationError( + `metadata may contain at most ${MAX_METADATA_FIELDS} fields` + ); + } + + for (const required of REQUIRED_METADATA_FIELDS) { + const value = metadata[required]; + if (typeof value !== 'string' || value.trim().length === 0) { + throw new MetadataValidationError( + `metadata.${required} is required and must be a non-empty string` + ); + } + } + + for (const key of keys) { + if (key.length > MAX_METADATA_STRING_LENGTH) { + throw new MetadataValidationError( + `metadata key "${key.slice(0, 32)}…" exceeds ${MAX_METADATA_STRING_LENGTH} characters` + ); + } + + const value = metadata[key]; + if (value === null || value === undefined) { + throw new MetadataValidationError(`metadata.${key} must not be null`); + } + + if (typeof value === 'object') { + throw new MetadataValidationError( + `metadata.${key} must be a string, number, or boolean (nested values are not allowed)` + ); + } + + if (typeof value === 'string' && value.length > MAX_METADATA_STRING_LENGTH) { + throw new MetadataValidationError( + `metadata.${key} exceeds ${MAX_METADATA_STRING_LENGTH} characters` + ); + } + + if ( + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' + ) { + throw new MetadataValidationError( + `metadata.${key} has unsupported type ${typeof value}` + ); + } + } +} diff --git a/listener/src/utils/notification-version.test.ts b/listener/src/utils/notification-version.test.ts new file mode 100644 index 0000000..588d11f --- /dev/null +++ b/listener/src/utils/notification-version.test.ts @@ -0,0 +1,33 @@ +import { + CURRENT_NOTIFICATION_VERSION, + ensureNotificationVersion, +} from './notification-version'; + +describe('notification versioning', () => { + it('documents the current version as 1', () => { + expect(CURRENT_NOTIFICATION_VERSION).toBe(1); + }); + + it('stamps current version when missing', () => { + const result = ensureNotificationVersion({ content: 'hi' }); + expect(result.version).toBe(CURRENT_NOTIFICATION_VERSION); + expect(result.content).toBe('hi'); + }); + + it('accepts the current version', () => { + const result = ensureNotificationVersion({ version: 1, body: 'x' }); + expect(result.version).toBe(1); + }); + + it('rejects future versions', () => { + expect(() => ensureNotificationVersion({ version: 999 })).toThrow( + /Unsupported notification version/ + ); + }); + + it('rejects non-positive versions', () => { + expect(() => ensureNotificationVersion({ version: 0 })).toThrow( + /Unsupported notification version/ + ); + }); +}); diff --git a/listener/src/utils/notification-version.ts b/listener/src/utils/notification-version.ts new file mode 100644 index 0000000..b823953 --- /dev/null +++ b/listener/src/utils/notification-version.ts @@ -0,0 +1,43 @@ +/** + * Notification payload protocol versioning. + * + * Off-chain consumers should read `version` from every stored notification + * and reject unsupported versions before delivery. + */ + +/** Current notification payload protocol version. */ +export const CURRENT_NOTIFICATION_VERSION = 1; + +/** + * Version history + * | Version | Date | Notes | + * |---------|------------|-----------------------------------------| + * | 1 | 2026-07-26 | Initial versioned notification payloads | + */ + +/** + * Ensure a payload carries a supported protocol version. + * Mutates `payload` to stamp the current version when absent. + * + * @throws Error when an explicit unsupported version is present. + */ +export function ensureNotificationVersion( + payload: Record +): Record { + const raw = payload.version; + if (raw === undefined || raw === null) { + return { ...payload, version: CURRENT_NOTIFICATION_VERSION }; + } + + const version = typeof raw === 'number' ? raw : Number(raw); + if (!Number.isInteger(version) || version < 1) { + throw new Error(`Unsupported notification version: ${raw}`); + } + if (version > CURRENT_NOTIFICATION_VERSION) { + throw new Error( + `Unsupported notification version ${version}; current is ${CURRENT_NOTIFICATION_VERSION}` + ); + } + + return { ...payload, version }; +}