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
191 changes: 156 additions & 35 deletions contract/contracts/hello-world/src/autoshare_logic.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
use crate::base::errors::Error;
use crate::base::events::{
AdminTransferred, AuthorizationFailure, AutoshareCreated, AutoshareUpdated, ContractPaused,
ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, NotificationPriority,
ScheduledNotificationCancelled, Withdrawal,
ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, NotificationExpired,
NotificationPriority, NotificationScheduled, ScheduledNotificationCancelled, Withdrawal,
};
use crate::base::types::{AutoShareDetails, GroupMember, PaymentHistory};
use crate::base::types::{AutoShareDetails, GroupMember, PaymentHistory, ScheduledNotification};
use soroban_sdk::{contracttype, token, Address, BytesN, Env, String, Vec};

/// Maximum allowed length for AutoShare group names.
Expand All @@ -23,6 +23,7 @@ pub enum DataKey {
GroupPaymentHistory(BytesN<32>),
GroupMembers(BytesN<32>),
IsPaused,
ScheduledNotification(BytesN<32>),
}

pub fn create_autoshare(
Expand Down Expand Up @@ -812,64 +813,184 @@ pub fn withdraw(
Ok(())
}

fn validate_members(members: &Vec<GroupMember>) -> Result<(), Error> {
if members.is_empty() {
return Err(Error::EmptyMembers);
}
// Validate member count limit
if members.len() > MAX_MEMBERS {
return Err(Error::TooManyMembers);
}
let env = members.env();
let mut total_percentage: u32 = 0;
let mut seen_addresses = Vec::new(env);

for member in members.iter() {
total_percentage += member.percentage;
for seen in seen_addresses.iter() {
if seen == member.address {
return Err(Error::DuplicateMember);
}
}
seen_addresses.push_back(member.address.clone());
}

if total_percentage != 100 {
return Err(Error::InvalidTotalPercentage);
}
Ok(())
}

// ============================================================================
// Scheduled Notification Cancellation
// Notification Scheduling & Expiration
// ============================================================================

/// Cancels a scheduled notification identified by `notification_id` and emits
/// a [`ScheduledNotificationCancelled`] event so off-chain consumers can track
/// the lifecycle of every scheduled notification in real time.
/// Default priority attached to notification lifecycle events.
const NOTIFICATION_PRIORITY: NotificationPriority = NotificationPriority::Medium;

/// Reads a scheduled notification from storage, if one is tracked for `id`.
fn load_notification(env: &Env, id: &BytesN<32>) -> Option<ScheduledNotification> {
env.storage()
.persistent()
.get(&DataKey::ScheduledNotification(id.clone()))
}

/// Returns true if `notification` has reached or passed its expiry instant.
fn is_expired(env: &Env, notification: &ScheduledNotification) -> bool {
env.ledger().timestamp() >= notification.expires_at
}

/// Schedules a notification on-chain that becomes invalid after `ttl_seconds`.
///
/// The contract does not keep a registry of scheduled notifications; callers are
/// responsible for submitting the correct identifier. Any authenticated address
/// may cancel a notification — access control beyond authentication is left to
/// the application layer.
pub fn cancel_notification(
/// The notification is stored with an `expires_at` of `now + ttl_seconds`. A
/// zero duration (or one that overflows the ledger clock) is rejected, as is a
/// duplicate identifier. Emits [`NotificationScheduled`].
pub fn schedule_notification(
env: Env,
notification_id: BytesN<32>,
caller: Address,
creator: Address,
ttl_seconds: u64,
) -> Result<(), Error> {
caller.require_auth();
creator.require_auth();

if get_paused_status(&env) {
return Err(Error::ContractPaused);
}

ScheduledNotificationCancelled {
caller,
if ttl_seconds == 0 {
return Err(Error::InvalidExpirationDuration);
}

let key = DataKey::ScheduledNotification(notification_id.clone());
if env.storage().persistent().has(&key) {
return Err(Error::AlreadyExists);
}

let created_at = env.ledger().timestamp();
let expires_at = created_at
.checked_add(ttl_seconds)
.ok_or(Error::InvalidExpirationDuration)?;

let notification = ScheduledNotification {
id: notification_id.clone(),
creator: creator.clone(),
created_at,
expires_at,
};
env.storage().persistent().set(&key, &notification);

NotificationScheduled {
creator,
category: NotificationCategory::Notification,
priority: NotificationPriority::Low,
priority: NOTIFICATION_PRIORITY,
notification_id,
}
.publish(&env);

Ok(())
}

fn validate_members(members: &Vec<GroupMember>) -> Result<(), Error> {
if members.is_empty() {
return Err(Error::EmptyMembers);
/// Retrieves a scheduled notification. Returns [`Error::NotFound`] if no
/// notification is tracked for `notification_id` (including one already expired
/// and reaped via [`expire_notification`]).
pub fn get_notification(
env: Env,
notification_id: BytesN<32>,
) -> Result<ScheduledNotification, Error> {
load_notification(&env, &notification_id).ok_or(Error::NotFound)
}

/// Returns whether a tracked notification has expired. Errors with
/// [`Error::NotFound`] if the notification is not tracked.
pub fn is_notification_expired(env: Env, notification_id: BytesN<32>) -> Result<bool, Error> {
let notification = get_notification(env.clone(), notification_id)?;
Ok(is_expired(&env, &notification))
}

/// Expires a notification whose lifetime has elapsed: removes it from storage
/// and emits [`NotificationExpired`].
///
/// Permissionless by design — any party (e.g. an off-chain keeper) may finalize
/// the expiry of an elapsed notification. A notification that has not yet
/// reached its expiry is rejected with [`Error::NotificationNotExpired`]; an
/// unknown one with [`Error::NotFound`].
pub fn expire_notification(env: Env, notification_id: BytesN<32>) -> Result<(), Error> {
let key = DataKey::ScheduledNotification(notification_id.clone());
let notification = load_notification(&env, &notification_id).ok_or(Error::NotFound)?;

if !is_expired(&env, &notification) {
return Err(Error::NotificationNotExpired);
}
// Validate member count limit
if members.len() > MAX_MEMBERS {
return Err(Error::TooManyMembers);

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

NotificationExpired {
notification_id,
category: NotificationCategory::Notification,
priority: NOTIFICATION_PRIORITY,
expires_at: notification.expires_at,
}
let env = members.env();
let mut total_percentage: u32 = 0;
let mut seen_addresses = Vec::new(env);
.publish(&env);

for member in members.iter() {
total_percentage += member.percentage;
for seen in seen_addresses.iter() {
if seen == member.address {
return Err(Error::DuplicateMember);
}
Ok(())
}

/// Cancels a scheduled notification identified by `notification_id` and emits a
/// [`ScheduledNotificationCancelled`] event so off-chain consumers can track the
/// lifecycle of every scheduled notification in real time.
///
/// If the notification is tracked on-chain, cancelling reaps its storage entry —
/// but an **expired** notification is invalid and cannot be cancelled; such an
/// attempt is rejected with [`Error::NotificationExpired`]. Identifiers that are
/// not tracked on-chain are accepted (and simply emit the event) so callers can
/// signal cancellation of notifications managed entirely off-chain.
pub fn cancel_notification(
env: Env,
notification_id: BytesN<32>,
caller: Address,
) -> Result<(), Error> {
caller.require_auth();

if get_paused_status(&env) {
return Err(Error::ContractPaused);
}

if let Some(notification) = load_notification(&env, &notification_id) {
if is_expired(&env, &notification) {
return Err(Error::NotificationExpired);
}
seen_addresses.push_back(member.address.clone());
env.storage()
.persistent()
.remove(&DataKey::ScheduledNotification(notification_id.clone()));
}

if total_percentage != 100 {
return Err(Error::InvalidTotalPercentage);
ScheduledNotificationCancelled {
caller,
category: NotificationCategory::Notification,
priority: NotificationPriority::Low,
notification_id,
}
.publish(&env);

Ok(())
}
8 changes: 8 additions & 0 deletions contract/contracts/hello-world/src/base/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,12 @@ pub enum Error {
NameTooLong = 21,
/// Triggered when the number of members exceeds the maximum allowed.
TooManyMembers = 22,
/// Triggered when interacting with a notification that has already expired.
NotificationExpired = 23,
/// Triggered when an invalid expiration duration is provided (e.g., zero or
/// one that overflows the ledger clock).
InvalidExpirationDuration = 24,
/// Triggered when attempting to expire a notification whose lifetime has not
/// yet elapsed.
NotificationNotExpired = 25,
}
39 changes: 36 additions & 3 deletions contract/contracts/hello-world/src/base/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use soroban_sdk::{contractevent, contracttype, Address, BytesN, String};
///
/// Off-chain consumers (listeners, indexers, dashboards) often only care about a
/// subset of the events the contract emits. Each event carries its category as a
/// trailing, indexed event topic so consumers can subscribe to or filter out
/// trailing, indexed event topic so consumers can subscribe to or filter out
/// whole categories without having to decode the event payload first.
///
/// # Backward compatibility
Expand All @@ -23,15 +23,15 @@ pub enum NotificationCategory {
Admin = 1,
/// Movement of funds: withdrawals.
Financial = 2,
/// Scheduled notification operations: cancellation.
/// Scheduled notification operations: scheduling, expiry, cancellation.
Notification = 3,
}

/// 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
/// priority as a trailing, indexed event topic so consumers can subscribe to
/// or page on high-priority notifications without decoding the payload.
///
/// # Backward compatibility
Expand Down Expand Up @@ -184,3 +184,36 @@ pub struct ScheduledNotificationCancelled {
pub priority: NotificationPriority,
pub notification_id: BytesN<32>,
}

/// Emitted when a notification is scheduled on-chain with a bounded lifetime.
///
/// Off-chain consumers can use this to track the notification's existence and
/// know when to expect an accompanying [`NotificationExpired`] event.
#[contractevent(data_format = "single-value")]
#[derive(Clone)]
pub struct NotificationScheduled {
#[topic]
pub creator: Address,
#[topic]
pub category: NotificationCategory,
#[topic]
pub priority: NotificationPriority,
pub notification_id: BytesN<32>,
}

/// Emitted when a scheduled notification's lifetime elapses and it is expired.
///
/// The `notification_id` is published as an indexed topic so consumers can
/// subscribe to the expiry of a specific notification; the `expires_at`
/// timestamp at which it became invalid is carried as the event data.
#[contractevent(data_format = "single-value")]
#[derive(Clone)]
pub struct NotificationExpired {
#[topic]
pub notification_id: BytesN<32>,
#[topic]
pub category: NotificationCategory,
#[topic]
pub priority: NotificationPriority,
pub expires_at: u64,
}
15 changes: 15 additions & 0 deletions contract/contracts/hello-world/src/base/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ pub struct GroupMember {
pub percentage: u32,
}

/// A notification stored on-chain with a bounded lifetime.
///
/// The notification is considered **expired** — and therefore invalid for any
/// further interaction — once the ledger timestamp reaches `expires_at`.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ScheduledNotification {
pub id: BytesN<32>,
pub creator: Address,
/// Ledger timestamp (seconds) at which the notification was scheduled.
pub created_at: u64,
/// Ledger timestamp (seconds) at or after which the notification is expired.
pub expires_at: u64,
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PaymentHistory {
Expand Down
39 changes: 39 additions & 0 deletions contract/contracts/hello-world/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,42 @@ impl AutoShareContract {
pub fn cancel_notification(env: Env, notification_id: BytesN<32>, caller: Address) {
autoshare_logic::cancel_notification(env, notification_id, caller).unwrap();
}

// ============================================================================
// Notification Expiration
// ============================================================================

/// Schedules a notification on-chain that expires after `ttl_seconds`.
///
/// The notification becomes invalid once the ledger timestamp reaches
/// `created_at + ttl_seconds`. Emits a `NotificationScheduled` event.
pub fn schedule_notification(
env: Env,
notification_id: BytesN<32>,
creator: Address,
ttl_seconds: u64,
) {
autoshare_logic::schedule_notification(env, notification_id, creator, ttl_seconds).unwrap();
}

/// Returns the stored details for a scheduled notification.
pub fn get_notification(
env: Env,
notification_id: BytesN<32>,
) -> base::types::ScheduledNotification {
autoshare_logic::get_notification(env, notification_id).unwrap()
}

/// Returns whether a scheduled notification has expired.
pub fn is_notification_expired(env: Env, notification_id: BytesN<32>) -> bool {
autoshare_logic::is_notification_expired(env, notification_id).unwrap()
}

/// Finalizes the expiry of a notification whose lifetime has elapsed,
/// emitting a `NotificationExpired` event. Callable by anyone.
pub fn expire_notification(env: Env, notification_id: BytesN<32>) {
autoshare_logic::expire_notification(env, notification_id).unwrap();
}
}

#[cfg(test)]
Expand All @@ -278,4 +314,7 @@ mod tests {

#[path = "../tests/notification_test.rs"]
mod notification_test;

#[path = "../tests/expiration_test.rs"]
mod expiration_test;
}
Loading
Loading