Skip to content
Open
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
113 changes: 111 additions & 2 deletions contract/contracts/hello-world/src/autoshare_logic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ use crate::base::errors::Error;
use crate::base::events::{
AdminTransferred, AuthorizationFailure, AutoshareCreated, AutoshareUpdated, CategoryRegistered,
ContractPaused, ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory,
NotificationExpired, NotificationExtended, NotificationLimitsConfigured, NotificationPriority,
NotificationRevoked, NotificationScheduled, ScheduledNotificationCancelled, Withdrawal,
NotificationDelivered, NotificationExpired, NotificationExtended, NotificationLimitsConfigured,
NotificationPriority, NotificationRecalled, NotificationRevoked, NotificationScheduled,
ScheduledNotificationCancelled, Withdrawal,
};
use crate::base::types::{
AutoShareDetails, GroupMember, NotificationLimits, PaymentHistory, ScheduledNotification,
Expand Down Expand Up @@ -996,6 +997,10 @@ pub fn schedule_notification(
expires_at,
revoked_by: None,
revoked_at: None,
delivered: false,
delivered_at: None,
recalled_by: None,
recalled_at: None,
title,
};
env.storage().persistent().set(&key, &notification);
Expand Down Expand Up @@ -1114,6 +1119,110 @@ pub fn cancel_notification(
///
/// Revoked notifications maintain their state for transparency and auditing:
/// they can still be queried but cannot be cancelled or expired.
pub fn confirm_notification_delivery(
env: Env,
notification_id: BytesN<32>,
caller: Address,
) -> Result<(), Error> {
caller.require_auth();

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

let key = DataKey::ScheduledNotification(notification_id.clone());
let mut notification = load_notification(&env, &notification_id).ok_or(Error::NotFound)?;

if is_revoked(&notification) {
return Err(Error::NotificationRevoked);
}

if is_expired(&env, &notification) {
return Err(Error::NotificationExpired);
}

if notification.delivered {
return Err(Error::NotificationDelivered);
}

let admin = get_admin(env.clone()).ok();
let is_creator = caller == notification.creator;
let is_admin = admin.as_ref().map_or(false, |a| caller == *a);

if !is_creator && !is_admin {
return Err(Error::Unauthorized);
}

let delivered_at = env.ledger().timestamp();
notification.delivered = true;
notification.delivered_at = Some(delivered_at);

env.storage().persistent().set(&key, &notification);

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

Ok(())
}

pub fn recall_notification(
env: Env,
notification_id: BytesN<32>,
caller: Address,
) -> Result<(), Error> {
caller.require_auth();

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

let key = DataKey::ScheduledNotification(notification_id.clone());
let mut notification = load_notification(&env, &notification_id).ok_or(Error::NotFound)?;

if is_revoked(&notification) {
return Err(Error::NotificationRevoked);
}

if is_expired(&env, &notification) {
return Err(Error::NotificationExpired);
}

if notification.delivered {
return Err(Error::NotificationDelivered);
}

let admin = get_admin(env.clone()).ok();
let is_creator = caller == notification.creator;
let is_admin = admin.as_ref().map_or(false, |a| caller == *a);

if !is_creator && !is_admin {
return Err(Error::Unauthorized);
}

let recalled_at = env.ledger().timestamp();
notification.recalled_by = Some(caller.clone());
notification.recalled_at = Some(recalled_at);

env.storage().persistent().set(&key, &notification);

NotificationRecalled {
notification_id,
recalled_by: caller,
category: NotificationCategory::Notification,
priority: NotificationPriority::High,
recalled_at,
}
.publish(&env);

Ok(())
}

pub fn revoke_notification(
env: Env,
notification_id: BytesN<32>,
Expand Down
2 changes: 2 additions & 0 deletions contract/contracts/hello-world/src/base/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,6 @@ pub enum Error {
AlreadyRevoked = 28,
/// Triggered when an invalid limit configuration is provided.
InvalidLimit = 29,
/// Triggered when a notification has already been delivered and cannot be recalled.
NotificationDelivered = 30,
}
30 changes: 30 additions & 0 deletions contract/contracts/hello-world/src/base/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,36 @@ pub struct ScheduledNotificationCancelled {
pub notification_id: BytesN<32>,
}

/// Emitted when a notification is confirmed as delivered to its intended recipient.
#[contractevent(data_format = "single-value")]
#[derive(Clone)]
pub struct NotificationDelivered {
#[topic]
pub notification_id: BytesN<32>,
#[topic]
pub delivered_by: Address,
#[topic]
pub category: NotificationCategory,
#[topic]
pub priority: NotificationPriority,
pub delivered_at: u64,
}

/// Emitted when a sender recalls a scheduled notification before delivery confirmation.
#[contractevent(data_format = "single-value")]
#[derive(Clone)]
pub struct NotificationRecalled {
#[topic]
pub notification_id: BytesN<32>,
#[topic]
pub recalled_by: Address,
#[topic]
pub category: NotificationCategory,
#[topic]
pub priority: NotificationPriority,
pub recalled_at: u64,
}

/// 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
Expand Down
8 changes: 8 additions & 0 deletions contract/contracts/hello-world/src/base/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ pub struct ScheduledNotification {
pub revoked_by: Option<Address>,
/// Ledger timestamp (seconds) at which the notification was revoked, if revoked.
pub revoked_at: Option<u64>,
/// Whether the notification has been confirmed as delivered.
pub delivered: bool,
/// Ledger timestamp (seconds) at which delivery was confirmed, if any.
pub delivered_at: Option<u64>,
/// Address that recalled the notification, or None if not recalled.
pub recalled_by: Option<Address>,
/// Ledger timestamp (seconds) at which the notification was recalled, if recalled.
pub recalled_at: Option<u64>,
/// Notification title (required metadata for off-chain processing)
pub title: String,
}
Expand Down
16 changes: 16 additions & 0 deletions contract/contracts/hello-world/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,22 @@ impl AutoShareContract {
autoshare_logic::expire_notification(env, notification_id).unwrap();
}

/// Confirms delivery of a scheduled notification.
///
/// Only the notification creator or the contract admin can confirm delivery.
/// The notification must exist, not already be revoked or expired, and not yet be marked delivered.
pub fn confirm_notification_delivery(env: Env, notification_id: BytesN<32>, caller: Address) {
autoshare_logic::confirm_notification_delivery(env, notification_id, caller).unwrap();
}

/// Recalls a scheduled notification before delivery confirmation.
///
/// Only the notification creator or the contract admin can recall a notification.
/// The notification must exist, not already be revoked or expired, and not yet be delivered.
pub fn recall_notification(env: Env, notification_id: BytesN<32>, caller: Address) {
autoshare_logic::recall_notification(env, notification_id, caller).unwrap();
}

/// Revokes a scheduled notification, preventing any further interaction with it.
///
/// Only the notification creator or the contract admin can revoke a notification.
Expand Down
74 changes: 73 additions & 1 deletion contract/contracts/hello-world/src/tests/notification_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@
//! - the change is backward compatible: the event name remains the first topic
//! and the previously defined topics/data are unchanged.

use crate::base::errors::Error;
use crate::base::events::{NotificationCategory, NotificationPriority};
use crate::test_utils::{create_test_group, setup_test_env};
use crate::AutoShareContractClient;

use soroban_sdk::testutils::{Address as _, Events};
use soroban_sdk::{Address, BytesN, Symbol, TryFromVal, Val, Vec};
use soroban_sdk::{Address, BytesN, String, Symbol, TryFromVal, Val, Vec};

/// Returns the topic list of the most recently emitted event whose first topic
/// matches `event_name` (the snake_case event name produced by `#[contractevent]`).
Expand Down Expand Up @@ -542,6 +543,77 @@ fn test_multiple_cancellations_emit_distinct_events() {
}
}

#[test]
fn test_recall_notification_emits_event_for_sender() {
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 id_bytes = [0u8; 32];
id_bytes[0] = 40;
let notification_id = BytesN::from_array(&test_env.env, &id_bytes);

client.schedule_notification(
&notification_id,
&creator,
&3600u64,
&String::from_str(&test_env.env, "Recall me"),
);
client.recall_notification(&notification_id, &creator);

assert!(topics_of(&test_env.env, "notification_recalled").is_some());
}

#[test]
fn test_recall_notification_rejects_unauthorized_sender() {
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 other = test_env.users.get(1).unwrap().clone();

let mut id_bytes = [0u8; 32];
id_bytes[0] = 41;
let notification_id = BytesN::from_array(&test_env.env, &id_bytes);

client.schedule_notification(
&notification_id,
&creator,
&3600u64,
&String::from_str(&test_env.env, "Nope"),
);

let result = client.try_recall_notification(&notification_id, &other);
assert!(
result.is_err(),
"recall should fail for an unauthorized caller"
);
}

#[test]
fn test_recall_notification_rejects_after_delivery_confirmation() {
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 id_bytes = [0u8; 32];
id_bytes[0] = 42;
let notification_id = BytesN::from_array(&test_env.env, &id_bytes);

client.schedule_notification(
&notification_id,
&creator,
&3600u64,
&String::from_str(&test_env.env, "Delivered"),
);
client.confirm_notification_delivery(&notification_id, &creator);

let result = client.try_recall_notification(&notification_id, &creator);
assert!(
result.is_err(),
"recall should fail after delivery confirmation"
);
}

/// Backward compatibility: the event name is still the first topic, the
/// pre-existing `creator` topic is unchanged, the category is appended as the
/// trailing topic, and the data payload (`id`) is preserved.
Expand Down
45 changes: 0 additions & 45 deletions dashboard/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dashboard/src/components/ActivityFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export function ActivityFeed() {

// Clear stale activity and re-fetch from page 1 whenever the connected
// wallet address changes (switch or disconnect). This is the fix for issue #175.
useWalletAccountSync((_nextAddress) => {
useWalletAccountSync(() => {
setEvents([]);
setLiveEvents([]);
setTotal(0);
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/pages/EventExplorerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export function EventExplorerPage() {

// Clear stale events and re-fetch whenever the connected wallet address
// changes (switch or disconnect). This is the fix for issue #175.
useWalletAccountSync((_nextAddress) => {
useWalletAccountSync(() => {
setEvents([]);
setError(null);
setPage(1);
Expand Down
Loading