Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions NOTIFICATION_PAYLOAD_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
225 changes: 219 additions & 6 deletions contract/contracts/hello-world/src/autoshare_logic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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):
///
Expand Down Expand Up @@ -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>),
}

// ============================================================================
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1278,6 +1297,7 @@ pub fn schedule_notification(
recalled_by: None,
recalled_at: None,
title,
version: CURRENT_NOTIFICATION_VERSION,
};
env.storage().persistent().set(&key, &notification);

Expand Down Expand Up @@ -1339,6 +1359,12 @@ pub fn expire_notification(env: Env, notification_id: BytesN<32>) -> Result<(),

env.storage().persistent().remove(&key);

archive_notification(
&env,
&notification,
String::from_str(&env, "expired"),
);

append_audit_record(
&env,
notification_id.clone(),
Expand Down Expand Up @@ -1387,6 +1413,11 @@ pub fn cancel_notification(
env.storage()
.persistent()
.remove(&DataKey::ScheduledNotification(notification_id.clone()));
archive_notification(
&env,
&notification,
String::from_str(&env, "cancelled"),
);
}

append_audit_record(
Expand Down Expand Up @@ -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(),
Expand All @@ -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, &notification);
Expand Down Expand Up @@ -1596,14 +1636,22 @@ pub fn confirm_notification_delivery(
env.storage().persistent().set(&key, &notification);

NotificationDelivered {
notification_id,
notification_id: notification_id.clone(),
delivered_by: caller,
category: NotificationCategory::Notification,
priority: NotificationPriority::High,
delivered_at,
}
.publish(&env);

// Move delivered notifications into the archive to keep active storage lean.
env.storage().persistent().remove(&key);
archive_notification(
&env,
&notification,
String::from_str(&env, "delivered"),
);

Ok(())
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<String, String>,
) -> 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<ChannelMetadata, Error> {
// Ensure the channel exists.
let _group = get_autoshare(env.clone(), channel_id.clone())?;

if let Some(meta) = env
.storage()
.persistent()
.get::<DataKey, ChannelMetadata>(&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<ArchivedNotification, Error> {
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
}
4 changes: 4 additions & 0 deletions contract/contracts/hello-world/src/base/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading