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
71 changes: 70 additions & 1 deletion contract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,73 @@ This repository uses the recommended structure for a Soroban project:
- New Soroban contracts can be put in `contracts`, each in their own directory. There is already a `hello-world` contract in there to get you started.
- If you initialized this project with any other example contracts via `--with-example`, those contracts will be in the `contracts` directory as well.
- Contracts should have their own `Cargo.toml` files that rely on the top-level `Cargo.toml` workspace for their dependencies.
- Frontend libraries can be added to the top-level directory as well. If you initialized this project with a frontend template via `--frontend-template` you will have those files already included.
- Frontend libraries can be added to the top-level directory as well. If you initialized this project with a frontend template via `--frontend-template` you will have those files already included.
---

## ABI Reference — AutoShareContract

### Notification Category

Every event carries a `NotificationCategory` as its last indexed topic so off-chain consumers can subscribe to or filter whole categories without decoding the payload.

| Variant | Value | Description |
|----------------|-------|---------------------------------------------------------|
| `Group` | 0 | AutoShare group lifecycle (created, updated, toggled) |
| `Admin` | 1 | Administrative actions (pause, unpause, admin transfer) |
| `Financial` | 2 | Fund movements (withdrawals) |
| `Notification` | 3 | Scheduled notification operations (cancellation) |

---

### Events

#### `scheduled_notification_cancelled`

Emitted whenever `cancel_notification` is called successfully.

**Topics** (in order):

| Index | Type | Description |
|-------|------------------------|----------------------------------------------------|
| 0 | `Symbol` | Event name: `"scheduled_notification_cancelled"` |
| 1 | `Address` | Address of the caller that triggered cancellation |
| 2 | `NotificationCategory` | Always `Notification` (discriminant value `3`) |

**Data** (`data_format = "single-value"`):

| Field | Type | Description |
|--------------------|--------------|---------------------------------------------------|
| `notification_id` | `BytesN<32>` | Unique identifier of the cancelled notification |

**Example** (XDR topics decoded):
```
topics[0] = Symbol("scheduled_notification_cancelled")
topics[1] = Address("G...") // caller
topics[2] = u32(3) // NotificationCategory::Notification
data = Bytes(32) // notification_id
```

---

### Functions

#### `cancel_notification(notification_id: BytesN<32>, caller: Address)`

Cancels a scheduled notification and emits a `ScheduledNotificationCancelled` event on-chain.

- **Authentication**: `caller` must authorize the invocation (`caller.require_auth()`).
- **Paused check**: returns `ContractPaused` (error code 11) when the contract is paused.
- **State**: the contract does not maintain an internal registry of scheduled notifications; the `notification_id` is recorded solely in the emitted event.

**Parameters:**

| Name | Type | Description |
|--------------------|--------------|------------------------------------------|
| `notification_id` | `BytesN<32>` | Identifier of the notification to cancel |
| `caller` | `Address` | Address authorizing the cancellation |

**Errors:**

| Code | Variant | Condition |
|------|------------------|------------------------------------|
| 11 | `ContractPaused` | Contract is currently paused |
35 changes: 35 additions & 0 deletions contract/contracts/hello-world/src/autoshare_logic.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use crate::base::errors::Error;
use crate::base::events::{
AdminTransferred, AuthorizationFailure, AutoshareCreated, AutoshareUpdated, ContractPaused,
ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory,
ScheduledNotificationCancelled, Withdrawal,
ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, NotificationPriority,
Withdrawal,
};
Expand Down Expand Up @@ -811,6 +813,39 @@ pub fn withdraw(
Ok(())
}

// ============================================================================
// Scheduled Notification Cancellation
// ============================================================================

/// 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.
///
/// 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(
env: Env,
notification_id: BytesN<32>,
caller: Address,
) -> Result<(), Error> {
caller.require_auth();

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

ScheduledNotificationCancelled {
caller,
category: NotificationCategory::Notification,
notification_id,
}
.publish(&env);

Ok(())
}

fn validate_members(members: &Vec<GroupMember>) -> Result<(), Error> {
if members.is_empty() {
return Err(Error::EmptyMembers);
Expand Down
17 changes: 17 additions & 0 deletions contract/contracts/hello-world/src/base/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ pub enum NotificationCategory {
Admin = 1,
/// Movement of funds: withdrawals.
Financial = 2,
/// Scheduled notification operations: cancellation.
Notification = 3,
}

/// Severity level attached to every emitted event alongside its category.
Expand Down Expand Up @@ -165,3 +167,18 @@ pub struct AuthorizationFailure {
pub priority: NotificationPriority,
pub action: String,
}

/// Emitted when a scheduled notification is cancelled.
///
/// The `notification_id` field carries the unique identifier of the notification
/// that was cancelled, allowing off-chain consumers to correlate the on-chain
/// event back to the corresponding scheduled notification record.
#[contractevent(data_format = "single-value")]
#[derive(Clone)]
pub struct ScheduledNotificationCancelled {
#[topic]
pub caller: Address,
#[topic]
pub category: NotificationCategory,
pub notification_id: BytesN<32>,
}
13 changes: 13 additions & 0 deletions contract/contracts/hello-world/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,19 @@ impl AutoShareContract {
pub fn reduce_usage(env: Env, id: BytesN<32>) {
autoshare_logic::reduce_usage(env, id).unwrap();
}

// ============================================================================
// Scheduled Notification Management
// ============================================================================

/// Cancels a scheduled notification and emits a ScheduledNotificationCancelled event.
///
/// The `notification_id` uniquely identifies the notification being cancelled.
/// Callers must authenticate. The contract is paused-aware: cancellations are
/// rejected while the contract is paused.
pub fn cancel_notification(env: Env, notification_id: BytesN<32>, caller: Address) {
autoshare_logic::cancel_notification(env, notification_id, caller).unwrap();
}
}

#[cfg(test)]
Expand Down
170 changes: 170 additions & 0 deletions contract/contracts/hello-world/src/tests/notification_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,176 @@ fn test_events_can_be_filtered_by_category() {
assert_eq!(skipped, 1); // Admin
}

// ============================================================================
// Scheduled notification cancellation event tests
// ============================================================================

#[test]
fn test_cancellation_event_is_emitted() {
let test_env = setup_test_env();
let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract);
let caller = test_env.users.get(0).unwrap().clone();

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

client.cancel_notification(&notification_id, &caller);

assert!(
topics_of(&test_env.env, "scheduled_notification_cancelled").is_some(),
"expected scheduled_notification_cancelled event to be emitted"
);
}

#[test]
fn test_cancellation_event_has_notification_category() {
let test_env = setup_test_env();
let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract);
let caller = test_env.users.get(0).unwrap().clone();

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

client.cancel_notification(&notification_id, &caller);

assert_eq!(
category_of(&test_env.env, "scheduled_notification_cancelled"),
Some(NotificationCategory::Notification)
);
}

#[test]
fn test_cancellation_event_data_contains_notification_id() {
let test_env = setup_test_env();
let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract);
let caller = test_env.users.get(0).unwrap().clone();

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

client.cancel_notification(&notification_id, &caller);

let emitted_id = test_env
.env
.events()
.all()
.iter()
.find_map(|(_addr, topics, data)| {
let first = topics.get(0)?;
let n = Symbol::try_from_val(&test_env.env, &first).ok()?;
if n == Symbol::new(&test_env.env, "scheduled_notification_cancelled") {
Some(data)
} else {
None
}
})
.expect("scheduled_notification_cancelled event must be emitted");

let data_id = BytesN::<32>::try_from_val(&test_env.env, &emitted_id).unwrap();
assert_eq!(data_id, notification_id);
}

#[test]
fn test_cancellation_event_topic_shape() {
let test_env = setup_test_env();
let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract);
let caller = test_env.users.get(0).unwrap().clone();

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

client.cancel_notification(&notification_id, &caller);

let topics = topics_of(&test_env.env, "scheduled_notification_cancelled")
.expect("event must be emitted");

// Topics: [0] event name, [1] caller address, [2] category
assert_eq!(topics.len(), 3);

let name = Symbol::try_from_val(&test_env.env, &topics.get(0).unwrap()).unwrap();
assert_eq!(
name,
Symbol::new(&test_env.env, "scheduled_notification_cancelled")
);

let topic_caller = Address::try_from_val(&test_env.env, &topics.get(1).unwrap()).unwrap();
assert_eq!(topic_caller, caller);

let category =
NotificationCategory::try_from_val(&test_env.env, &topics.get(2).unwrap()).unwrap();
assert_eq!(category, NotificationCategory::Notification);
}

#[test]
fn test_cancellation_blocked_when_contract_paused() {
let test_env = setup_test_env();
let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract);
let caller = test_env.users.get(0).unwrap().clone();

client.pause(&test_env.admin);

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

let result = client.try_cancel_notification(&notification_id, &caller);
assert!(result.is_err(), "cancellation should be rejected while contract is paused");
}

/// Verifies that each call to `cancel_notification` emits a
/// `scheduled_notification_cancelled` event carrying the correct notification
/// identifier. The assertion runs immediately after each call so the latest
/// emitted event is always the one we just triggered.
#[test]
fn test_multiple_cancellations_emit_distinct_events() {
let test_env = setup_test_env();
let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract);
let caller = test_env.users.get(0).unwrap().clone();

let make_id = |n: u8| {
let mut bytes = [0u8; 32];
bytes[0] = n;
BytesN::from_array(&test_env.env, &bytes)
};

for n in [10u8, 20, 30] {
let expected_id = make_id(n);
client.cancel_notification(&expected_id, &caller);

// Immediately after the call, verify the latest event carries the
// notification id that was just cancelled.
let emitted_data = test_env
.env
.events()
.all()
.iter()
.find_map(|(_addr, topics, data)| {
if topics.is_empty() {
return None;
}
let first = topics.get(0)?;
let name = Symbol::try_from_val(&test_env.env, &first).ok()?;
if name == Symbol::new(&test_env.env, "scheduled_notification_cancelled") {
Some(data)
} else {
None
}
})
.expect("scheduled_notification_cancelled must be emitted");

let data_id = BytesN::<32>::try_from_val(&test_env.env, &emitted_data)
.expect("event data must be BytesN<32>");
assert_eq!(
data_id, expected_id,
"event data must carry the notification id that was cancelled (n = {n})"
);
}
}

/// 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
Loading