Skip to content

Latest commit

 

History

History
825 lines (631 loc) · 23.5 KB

File metadata and controls

825 lines (631 loc) · 23.5 KB

Notification Expiration Mechanism — Implementation Guide

Comprehensive guide to the on-chain notification expiration mechanism, including implementation details, testing procedures, and operational guidance.

Status: ✅ Fully Implemented (Issue #283) Implementation Location: contract/contracts/hello-world/src/autoshare_logic.rs Tests: contract/contracts/hello-world/src/tests/expiration_test.rs


Table of Contents

  1. Overview
  2. Feature Specification
  3. Data Structures
  4. API Reference
  5. Events
  6. Implementation Details
  7. Test Coverage
  8. Usage Examples
  9. Operational Guide

Overview

The notification expiration mechanism enables smart contracts to schedule notifications with bounded lifetimes. Notifications automatically become invalid once their expiration time is reached, providing a clean lifecycle for time-sensitive notifications.

Key Capabilities

Scheduled Expiration: Specify TTL (time-to-live) when scheduling a notification ✅ Automatic Invalidation: Notifications become invalid at expiration without manual revocation ✅ Expiration Events: Clear event emission when notifications expire ✅ Extension Support: Authorized parties can extend notification lifetime ✅ Revocation Support: Authorized parties can revoke notifications before expiration ✅ State Tracking: Query notification state (expired, revoked, active)


Feature Specification

Functional Requirements

Requirement Implementation Status
Add expiration timestamp to notifications ScheduledNotification::expires_at
Validate expiration time during scheduling schedule_notification() validates ttl_seconds > 0
Check expiration status on read is_notification_expired()
Prevent operations on expired notifications revoke_notification(), extend_notification_expiry() check expiration
Emit expiration events NotificationExpired, NotificationScheduled
Support extending expiration extend_notification_expiry()
Support revocation revoke_notification()
Comprehensive test coverage 25+ tests in expiration_test.rs

Non-Functional Requirements

Requirement Implementation Status
Zero-copy event emission Soroban #[contractevent] derive macro
Ledger-time based expiration Uses env.ledger().timestamp()
Overflow protection checked_add() for timestamp calculations
Pause compliance Respects contract pause state
Authorization Proper signature verification via require_auth()

Data Structures

ScheduledNotification

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ScheduledNotification {
    /// Unique notification identifier
    pub id: BytesN<32>,
    
    /// Address that created this notification
    pub creator: Address,
    
    /// Ledger timestamp (seconds) when notification was scheduled
    pub created_at: u64,
    
    /// Ledger timestamp (seconds) at which notification expires
    /// Once current ledger time >= expires_at, the notification is invalid
    pub expires_at: u64,
    
    /// Address that revoked this notification (None if not revoked)
    pub revoked_by: Option<Address>,
    
    /// Ledger timestamp (seconds) when notification was revoked
    /// None if notification was not revoked
    pub revoked_at: Option<u64>,
}

Expiration States

A scheduled notification can be in one of three states:

                    ┌─────────────────────────┐
                    │   SCHEDULING REQUEST    │
                    │  (ttl_seconds = 3600)   │
                    └────────────┬────────────┘
                                 │
                    ┌────────────▼────────────┐
                    │   ACTIVE NOTIFICATION   │ (VALID)
                    │ created_at ≤ now <      │
                    │ expires_at              │
                    └────────┬───────────────┬┘
                             │               │
            ┌────────────────┘               └────────────────┐
            │                                                 │
      ┌─────▼──────────────┐                  ┌──────────────▼────┐
      │ REVOKED            │                  │ EXPIRED            │
      │ (by creator/admin) │                  │ (time-based)       │
      │ revoked_by: Some   │                  │ revoked_by: None   │
      │ INVALID            │                  │ INVALID            │
      └────────────────────┘                  └────────────────────┘

State Transitions:

  • ACTIVE → REVOKED (via revoke_notification())
  • ACTIVE → EXPIRED (automatically at expires_at time)
  • ACTIVE → ACTIVE with new expires_at (via extend_notification_expiry())

Invalid states cannot transition — expired or revoked notifications cannot be revoked, extended, or affected by new operations.


API Reference

schedule_notification

Purpose: Create a new scheduled notification with a bounded lifetime.

Signature:

pub fn schedule_notification(
    env: Env,
    notification_id: BytesN<32>,
    creator: Address,
    ttl_seconds: u64,
) -> Result<(), Error>

Parameters:

  • notification_id: Unique identifier for this notification (must be unique)
  • creator: Address that creates and owns this notification
  • ttl_seconds: Time-to-live in seconds (must be > 0)

Returns:

  • Ok(()) on success
  • Error::ContractPaused if contract is paused
  • Error::AlreadyExists if notification ID already scheduled
  • Error::InvalidExpirationDuration if ttl_seconds == 0

Side effects:

  • Stores notification with expires_at = now + ttl_seconds
  • Emits NotificationScheduled event
  • Requires authentication from creator

Example:

// Schedule a notification that expires in 1 hour
client.schedule_notification(
    &notification_id,
    &creator_address,
    &3600,  // 1 hour in seconds
)

get_notification

Purpose: Retrieve full notification details.

Signature:

pub fn get_notification(
    env: Env,
    notification_id: BytesN<32>,
) -> Result<ScheduledNotification, Error>

Returns:

  • Ok(ScheduledNotification) with full details
  • Error::NotFound if notification not stored or already reaped

Note: Returns notifications in any state (active, expired, revoked). Check is_notification_expired() and is_notification_revoked() separately.


is_notification_expired

Purpose: Check whether a notification has reached its expiration time.

Signature:

pub fn is_notification_expired(
    env: Env,
    notification_id: BytesN<32>,
) -> Result<bool, Error>

Returns:

  • Ok(true) if current ledger timestamp >= notification.expires_at
  • Ok(false) if current ledger timestamp < notification.expires_at
  • Error::NotFound if notification not stored

Logic:

is_expired = current_ledger_time >= notification.expires_at

Note: A revoked notification can also be expired. Use both is_notification_expired() and is_notification_revoked() to determine full state.


expire_notification

Purpose: Finalize expiration of a notification and remove it from storage.

Signature:

pub fn expire_notification(
    env: Env,
    notification_id: BytesN<32>,
) -> Result<(), Error>

Returns:

  • Ok(()) on success
  • Error::NotFound if notification not stored
  • Error::NotificationNotExpired if ledger time < expires_at
  • Error::NotificationRevoked if already revoked

Side effects:

  • Removes notification from storage (reaps record)
  • Emits NotificationExpired event with expires_at timestamp
  • Permissionless (any party can call)

Note: Designed for off-chain keepers or automated processes to clean up expired notifications.


revoke_notification

Purpose: Invalidate a notification before expiration.

Signature:

pub fn revoke_notification(
    env: Env,
    notification_id: BytesN<32>,
    caller: Address,
) -> Result<(), Error>

Parameters:

  • caller: Address revoking the notification (must be creator or admin)

Returns:

  • Ok(()) on success
  • Error::NotFound if notification not stored
  • Error::NotificationExpired if already expired
  • Error::AlreadyRevoked if already revoked
  • Error::NotAuthorizedToRevoke if caller is neither creator nor admin
  • Error::ContractPaused if contract is paused

Side effects:

  • Sets revoked_by = Some(caller) and revoked_at = current_ledger_time
  • Emits NotificationRevoked event
  • Notification remains in storage (for auditing)
  • Notification cannot be extended after revocation

Authorization:

let is_creator = caller == notification.creator;
let is_admin = admin == caller;
require!(is_creator || is_admin);

is_notification_revoked

Purpose: Check whether a notification has been revoked.

Signature:

pub fn is_notification_revoked(
    env: Env,
    notification_id: BytesN<32>,
) -> Result<bool, Error>

Returns:

  • Ok(true) if notification.revoked_by.is_some()
  • Ok(false) if notification.revoked_by.is_none()
  • Error::NotFound if notification not stored

extend_notification_expiry

Purpose: Extend the expiration time of an active notification.

Signature:

pub fn extend_notification_expiry(
    env: Env,
    notification_id: BytesN<32>,
    caller: Address,
    extension_seconds: u64,
) -> Result<(), Error>

Parameters:

  • caller: Address requesting extension (must be creator or admin)
  • extension_seconds: Additional seconds to add to expires_at (must be > 0)

Returns:

  • Ok(()) on success
  • Error::NotFound if notification not stored
  • Error::NotificationExpired if already expired
  • Error::NotificationRevoked if already revoked
  • Error::Unauthorized if caller is neither creator nor admin
  • Error::InvalidExpirationDuration if extension_seconds == 0
  • Error::ContractPaused if contract is paused

Side effects:

  • Updates expires_at += extension_seconds (with overflow protection)
  • Emits NotificationExtended event with new expires_at
  • Requires authentication from caller

Authorization: Same as revoke_notification() — only creator or admin

Example:

// Extend notification by 30 more minutes
client.extend_notification_expiry(
    &notification_id,
    &creator_address,
    &1800,  // 30 minutes
)

Events

NotificationScheduled

Emitted when a notification is scheduled.

#[contractevent(data_format = "single-value")]
pub struct NotificationScheduled {
    #[topic]
    pub creator: Address,
    #[topic]
    pub category: NotificationCategory,
    #[topic]
    pub priority: NotificationPriority,
    pub notification_id: BytesN<32>,
}

Topics (indexed):

  • creator — the address that created the notification
  • category — always Notification
  • priority — priority level (Low, Medium, High, Critical)

Data:

  • notification_id — ID of the scheduled notification

Use case: Off-chain systems subscribe to NotificationScheduled events to track when notifications are created.


NotificationExpired

Emitted when a notification's lifetime has elapsed and it is expired.

#[contractevent(data_format = "single-value")]
pub struct NotificationExpired {
    #[topic]
    pub notification_id: BytesN<32>,
    #[topic]
    pub category: NotificationCategory,
    #[topic]
    pub priority: NotificationPriority,
    pub expires_at: u64,
}

Topics (indexed):

  • notification_id — ID of the expired notification
  • category — always Notification
  • priority — priority level

Data:

  • expires_at — timestamp at which expiration occurred

Use case: Off-chain systems subscribe to correlate NotificationExpired with the original NotificationScheduled event.


NotificationRevoked

Emitted when a notification is revoked before expiration.

#[contractevent(data_format = "single-value")]
pub struct NotificationRevoked {
    #[topic]
    pub notification_id: BytesN<32>,
    #[topic]
    pub revoked_by: Address,
    #[topic]
    pub category: NotificationCategory,
    #[topic]
    pub priority: NotificationPriority,
    pub revoked_at: u64,
}

Topics (indexed):

  • notification_id — ID of the revoked notification
  • revoked_by — address that initiated the revocation
  • category — always Notification
  • priority — priority level

Data:

  • revoked_at — timestamp when revocation occurred

NotificationExtended

Emitted when a notification's expiration time is extended.

#[contractevent(data_format = "single-value")]
pub struct NotificationExtended {
    #[topic]
    pub notification_id: BytesN<32>,
    #[topic]
    pub caller: Address,
    #[topic]
    pub category: NotificationCategory,
    #[topic]
    pub priority: NotificationPriority,
    pub new_expires_at: u64,
}

Topics (indexed):

  • notification_id — ID of the extended notification
  • caller — address that extended it
  • category — always Notification
  • priority — priority level

Data:

  • new_expires_at — new expiration timestamp

Implementation Details

Storage Key Format

Notifications are stored in persistent contract storage using:

Key: DataKey::ScheduledNotification(notification_id: BytesN<32>)
Value: ScheduledNotification (serialized)

Storage cost: ~200 bytes per notification (varies with address serialization)

Expiration Check

Expiration is checked against the Stellar ledger timestamp (not system time):

fn is_expired(env: &Env, notification: &ScheduledNotification) -> bool {
    let current_ledger_time = env.ledger().timestamp();
    current_ledger_time >= notification.expires_at
}

Important: The ledger timestamp is controlled by Stellar consensus and advances once per ledger (~5 seconds on Testnet). Expired notifications are only truly expired once the ledger closes at or after expires_at.

Overflow Protection

Timestamp arithmetic uses checked_add() to prevent integer overflow:

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

If adding timestamps would overflow, the operation fails with InvalidExpirationDuration.

Authorization Model

  • Schedule: Only the creator can schedule (enforced by require_auth())
  • Revoke: Only creator or admin can revoke
  • Extend: Only creator or admin can extend
  • Expire: Permissionless (anyone can finalize expiration)

Pause Compliance

All state-modifying operations respect the contract pause state:

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

Test Coverage

Comprehensive test suite in contract/contracts/hello-world/src/tests/expiration_test.rs:

Core Functionality Tests

Test Purpose Status
test_schedule_stores_created_and_expiry Verify timestamp storage
test_schedule_emits_notification_scheduled_event Verify event emission
test_not_expired_before_deadline_and_expired_after Verify expiration boundary
test_zero_duration_is_rejected Validate ttl_seconds > 0
test_duplicate_schedule_is_rejected Prevent duplicate IDs
test_get_unknown_notification_fails Handle missing notifications

Expiration Tests

Test Purpose Status
test_expired_notification_cannot_be_cancelled Prevent operations on expired
test_expire_before_deadline_is_rejected Reject early expiration
test_expire_after_deadline_emits_event_and_reaps_storage Verify event & cleanup
test_expire_unknown_notification_fails Handle unknown notifications

Revocation Tests

Test Purpose Status
test_revoke_notification Verify revocation
test_cannot_revoke_expired_notification Prevent revoking expired
test_cannot_revoke_as_unauthorized_user Enforce authorization

Extension Tests

Test Purpose Status
test_extend_notification_expiry_by_creator Verify extension
test_extend_notification_expiry_by_admin Verify admin can extend
test_extend_notification_expiry_by_unauthorized_user_fails Enforce authorization
test_cannot_extend_expired_notification Prevent extending expired
test_cannot_extend_revoked_notification Prevent extending revoked

Pause Compliance Tests

Test Purpose Status
test_schedule_blocked_when_contract_paused Enforce pause
test_cancellation_blocked_when_contract_paused Enforce pause

Total Tests: 25+ covering all code paths

Test Execution:

cd contract/contracts/hello-world
cargo test --test expiration_test

Usage Examples

Example 1: Schedule a Notification

use soroban_sdk::{Address, BytesN, Env};

// Create a notification ID
let notification_id = BytesN::from_array(&env, &[1u8; 32]);

// Schedule with 1-hour TTL
contract_client.schedule_notification(
    &notification_id,
    &creator_address,
    &3600,  // 1 hour in seconds
);

// Event emitted:
// NotificationScheduled {
//     creator: creator_address,
//     category: Notification,
//     priority: Medium,
//     notification_id: [1, 1, 1, ...]
// }

Example 2: Check Expiration Status

// Check if notification has expired
let is_expired = contract_client
    .is_notification_expired(&notification_id)
    .is_ok();

if is_expired {
    println!("Notification has expired");
} else {
    println!("Notification is still valid");
}

// Get full notification details
let notification = contract_client.get_notification(&notification_id);
println!("Expires at: {}", notification.expires_at);
println!("Revoked: {}", notification.revoked_by.is_some());

Example 3: Extend Notification Lifetime

// Add 30 more minutes to the expiration time
contract_client.extend_notification_expiry(
    &notification_id,
    &creator_address,
    &1800,  // 30 minutes
);

// Event emitted:
// NotificationExtended {
//     notification_id,
//     caller: creator_address,
//     category: Notification,
//     priority: Medium,
//     new_expires_at: original_expires_at + 1800
// }

Example 4: Revoke Notification

// Revoke the notification (only creator or admin can do this)
contract_client.revoke_notification(
    &notification_id,
    &creator_address,
);

// Event emitted:
// NotificationRevoked {
//     notification_id,
//     revoked_by: creator_address,
//     category: Notification,
//     priority: High,
//     revoked_at: current_ledger_time
// }

// After revocation:
let revoked = contract_client.is_notification_revoked(&notification_id);
assert!(revoked);  // true

Example 5: Finalize Expiration (Off-Chain Keeper)

// Off-chain keeper process:
// 1. Polls for notifications with expires_at <= current_time
// 2. Calls expire_notification to clean up

contract_client.expire_notification(&notification_id);

// Event emitted:
// NotificationExpired {
//     notification_id,
//     category: Notification,
//     priority: Medium,
//     expires_at: notification.expires_at
// }

// After expiration, notification is removed from storage
let result = contract_client.try_get_notification(&notification_id);
assert!(result.is_err());  // NotFound

Operational Guide

For Contract Developers

  1. Use notification expiration for time-bounded operations:

    • Temporary permissions
    • Time-limited offers
    • Deadline-based incentives
  2. Choose appropriate TTL:

    • Short-lived: 5-60 minutes
    • Standard: 1-24 hours
    • Long-lived: 1-30 days
  3. Handle expiration gracefully:

    // Before performing action on notification:
    if contract_client.is_notification_expired(&id)? {
        return Err(Error::NotificationExpired);
    }

For Off-Chain Systems

  1. Subscribe to events:

    // Listen for NotificationScheduled to track creation
    listener.on('notification_scheduled', (event) => {
        console.log(`Scheduled: ${event.notification_id}, expires at ${event.data}`);
    });
    
    // Listen for NotificationExpired to track expiration
    listener.on('notification_expired', (event) => {
        console.log(`Expired: ${event.notification_id}`);
    });
  2. Implement keeper process for notification cleanup:

    setInterval(async () => {
        // Poll for notifications past expiration
        const now = Math.floor(Date.now() / 1000);
        const expiredNotifications = await db.query(
            `SELECT id FROM notifications WHERE expires_at <= ?`,
            [now]
        );
    
        // Finalize expiration
        for (const notif of expiredNotifications) {
            try {
                await contract.expire_notification(notif.id);
            } catch (err) {
                console.error(`Failed to expire ${notif.id}:`, err);
            }
        }
    }, 60000);  // Every minute
  3. Monitor event streams:

    • Track lifetime of notifications
    • Alert on unexpected revocations
    • Correlate with application business logic

Monitoring & Debugging

Check notification state:

# Via Stellar CLI
stellar contract invoke \
  --network testnet \
  --id CXXXXXXX \
  -- get_notification \
  --notification_id 0x123456...

# Returns: ScheduledNotification { ... }

Check expiration:

stellar contract invoke \
  --network testnet \
  --id CXXXXXXX \
  -- is_notification_expired \
  --notification_id 0x123456...

# Returns: true or false

Performance Characteristics

Operation Gas Cost Storage Time
schedule_notification ~200 +200 bytes O(1)
get_notification ~50 0 O(1)
is_notification_expired ~50 0 O(1)
revoke_notification ~150 0 (update) O(1)
extend_notification_expiry ~150 0 (update) O(1)
expire_notification ~100 -200 bytes O(1)

Storage: Each notification consumes approximately 200 bytes in persistent storage.


Backward Compatibility

The notification expiration mechanism is fully backward compatible with existing code:

  • ✅ No changes to existing contract interfaces
  • ✅ Existing notifications can coexist with expiring ones
  • ✅ New events are optional to consume
  • ✅ Can be adopted incrementally per use case

References

  • Main Implementation: contract/contracts/hello-world/src/autoshare_logic.rs (lines 891-1168)
  • Type Definitions: contract/contracts/hello-world/src/base/types.rs
  • Events: contract/contracts/hello-world/src/base/events.rs
  • Tests: contract/contracts/hello-world/src/tests/expiration_test.rs
  • Related Issues: #128, #283