Skip to content

Commit a85ffbc

Browse files
committed
feat: event type filtering, batch notifications, audit logging
- Add NotificationCategory and NotificationPriority as trailing topics on all emitted events so off-chain consumers can filter by type - Add batch_schedule_notifications: create up to 50 notifications in a single transaction with all-or-nothing validation and a summary event - Add AuditRecord type and append-only on-chain audit log tracking the full notification lifecycle (created, delivery attempt, delivery failed, acknowledged, cancelled, expired) - Add query endpoints: get_audit_log and get_notification_audit - Add explicit audit write helpers: record_delivery_attempt, record_delivery_failure, record_acknowledgment - Add BatchTooLarge error variant - Add AuditAction enum and AuditRecordAppended, BatchNotificationsCreated events - Add 179-test suite: payload_validation_test, batch_notification_test, audit_log_test (all passing)
1 parent ee0b34e commit a85ffbc

8 files changed

Lines changed: 1915 additions & 6 deletions

File tree

contract/contracts/hello-world/src/autoshare_logic.rs

Lines changed: 281 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
use crate::base::errors::Error;
22
use crate::base::events::{
3-
AdminTransferred, AuthorizationFailure, AutoshareCreated, AutoshareUpdated, ContractPaused,
4-
ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, NotificationExpired,
5-
NotificationPriority, NotificationScheduled, ScheduledNotificationCancelled, Withdrawal,
3+
AdminTransferred, AuditAction, AuditRecordAppended, AuthorizationFailure, AutoshareCreated,
4+
AutoshareUpdated, BatchNotificationsCreated, ContractPaused, ContractUnpaused, GroupActivated,
5+
GroupDeactivated, NotificationCategory, NotificationExpired, NotificationPriority,
6+
NotificationScheduled, ScheduledNotificationCancelled, Withdrawal,
7+
};
8+
use crate::base::types::{
9+
AuditRecord, AutoShareDetails, GroupMember, PaymentHistory, ScheduledNotification,
610
};
7-
use crate::base::types::{AutoShareDetails, GroupMember, PaymentHistory, ScheduledNotification};
811
use soroban_sdk::{contracttype, token, Address, BytesN, Env, String, Vec};
912

1013
/// Maximum allowed length for AutoShare group names.
@@ -24,6 +27,10 @@ pub enum DataKey {
2427
GroupMembers(BytesN<32>),
2528
IsPaused,
2629
ScheduledNotification(BytesN<32>),
30+
/// Monotonically increasing counter for audit record sequence numbers.
31+
AuditSeq,
32+
/// All audit records stored in a single Vec for full-scan queries.
33+
AuditLog,
2734
}
2835

2936
pub fn create_autoshare(
@@ -899,6 +906,13 @@ pub fn schedule_notification(
899906
};
900907
env.storage().persistent().set(&key, &notification);
901908

909+
append_audit_record(
910+
&env,
911+
notification_id.clone(),
912+
AuditAction::Created,
913+
creator.clone(),
914+
);
915+
902916
NotificationScheduled {
903917
creator,
904918
category: NotificationCategory::Notification,
@@ -944,6 +958,13 @@ pub fn expire_notification(env: Env, notification_id: BytesN<32>) -> Result<(),
944958

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

961+
append_audit_record(
962+
&env,
963+
notification_id.clone(),
964+
AuditAction::Expired,
965+
env.current_contract_address(),
966+
);
967+
947968
NotificationExpired {
948969
notification_id,
949970
category: NotificationCategory::Notification,
@@ -984,6 +1005,13 @@ pub fn cancel_notification(
9841005
.remove(&DataKey::ScheduledNotification(notification_id.clone()));
9851006
}
9861007

1008+
append_audit_record(
1009+
&env,
1010+
notification_id.clone(),
1011+
AuditAction::Cancelled,
1012+
caller.clone(),
1013+
);
1014+
9871015
ScheduledNotificationCancelled {
9881016
caller,
9891017
category: NotificationCategory::Notification,
@@ -994,3 +1022,252 @@ pub fn cancel_notification(
9941022

9951023
Ok(())
9961024
}
1025+
1026+
// ============================================================================
1027+
// Batch Notification Creation
1028+
// ============================================================================
1029+
1030+
/// Maximum number of notifications that can be created in a single batch call.
1031+
const MAX_BATCH_SIZE: u32 = 50;
1032+
1033+
/// Creates multiple scheduled notifications in a single transaction.
1034+
///
1035+
/// Each `ids[i]` is paired with `ttl_seconds[i]`. Both slices must have the same
1036+
/// length and must not be empty. The length must not exceed [`MAX_BATCH_SIZE`].
1037+
/// The same validation applied by [`schedule_notification`] is applied to each
1038+
/// entry; if any entry fails the entire call is rejected.
1039+
///
1040+
/// A [`NotificationScheduled`] event is emitted for every created notification,
1041+
/// followed by a single [`BatchNotificationsCreated`] summary event carrying the
1042+
/// full list of ids and the count.
1043+
pub fn batch_schedule_notifications(
1044+
env: Env,
1045+
ids: Vec<BytesN<32>>,
1046+
creator: Address,
1047+
ttl_seconds: Vec<u64>,
1048+
) -> Result<(), Error> {
1049+
creator.require_auth();
1050+
1051+
if get_paused_status(&env) {
1052+
return Err(Error::ContractPaused);
1053+
}
1054+
1055+
let count = ids.len();
1056+
1057+
// Must have at least one notification.
1058+
if count == 0 {
1059+
return Err(Error::InvalidInput);
1060+
}
1061+
1062+
// Lengths must match.
1063+
if count != ttl_seconds.len() {
1064+
return Err(Error::InvalidInput);
1065+
}
1066+
1067+
// Enforce maximum batch size.
1068+
if count > MAX_BATCH_SIZE {
1069+
return Err(Error::BatchTooLarge);
1070+
}
1071+
1072+
let created_at = env.ledger().timestamp();
1073+
1074+
// Validate all entries before persisting any (all-or-nothing semantics).
1075+
// Also track ids seen within this batch to catch intra-batch duplicates.
1076+
let mut seen_in_batch: Vec<BytesN<32>> = Vec::new(&env);
1077+
for i in 0..count {
1078+
let ttl = ttl_seconds.get(i).unwrap();
1079+
if ttl == 0 {
1080+
return Err(Error::InvalidExpirationDuration);
1081+
}
1082+
let id = ids.get(i).unwrap();
1083+
1084+
// Check for intra-batch duplicates.
1085+
for seen in seen_in_batch.iter() {
1086+
if seen == id {
1087+
return Err(Error::AlreadyExists);
1088+
}
1089+
}
1090+
seen_in_batch.push_back(id.clone());
1091+
1092+
let key = DataKey::ScheduledNotification(id.clone());
1093+
if env.storage().persistent().has(&key) {
1094+
return Err(Error::AlreadyExists);
1095+
}
1096+
// Validate ttl doesn't overflow.
1097+
created_at
1098+
.checked_add(ttl)
1099+
.ok_or(Error::InvalidExpirationDuration)?;
1100+
}
1101+
1102+
// Persist and emit per-notification events.
1103+
for i in 0..count {
1104+
let ttl = ttl_seconds.get(i).unwrap();
1105+
let id = ids.get(i).unwrap();
1106+
let expires_at = created_at + ttl;
1107+
1108+
let notification = ScheduledNotification {
1109+
id: id.clone(),
1110+
creator: creator.clone(),
1111+
created_at,
1112+
expires_at,
1113+
};
1114+
let key = DataKey::ScheduledNotification(id.clone());
1115+
env.storage().persistent().set(&key, &notification);
1116+
1117+
append_audit_record(&env, id.clone(), AuditAction::Created, creator.clone());
1118+
1119+
NotificationScheduled {
1120+
creator: creator.clone(),
1121+
category: NotificationCategory::Notification,
1122+
priority: NOTIFICATION_PRIORITY,
1123+
notification_id: id.clone(),
1124+
}
1125+
.publish(&env);
1126+
}
1127+
1128+
// Summary event.
1129+
BatchNotificationsCreated {
1130+
creator: creator.clone(),
1131+
category: NotificationCategory::Notification,
1132+
priority: NOTIFICATION_PRIORITY,
1133+
count,
1134+
ids,
1135+
}
1136+
.publish(&env);
1137+
1138+
Ok(())
1139+
}
1140+
1141+
// ============================================================================
1142+
// Audit Logging
1143+
// ============================================================================
1144+
1145+
/// Appends an immutable [`AuditRecord`] to the on-chain audit log and emits an
1146+
/// [`AuditRecordAppended`] event. The sequence number is auto-incremented.
1147+
fn append_audit_record(
1148+
env: &Env,
1149+
notification_id: BytesN<32>,
1150+
action: AuditAction,
1151+
actor: Address,
1152+
) {
1153+
// Increment sequence counter.
1154+
let seq_key = DataKey::AuditSeq;
1155+
let seq: u64 = env
1156+
.storage()
1157+
.instance()
1158+
.get(&seq_key)
1159+
.unwrap_or(0u64)
1160+
+ 1;
1161+
env.storage().instance().set(&seq_key, &seq);
1162+
1163+
let timestamp = env.ledger().timestamp();
1164+
1165+
let record = AuditRecord {
1166+
seq,
1167+
notification_id: notification_id.clone(),
1168+
action,
1169+
actor: actor.clone(),
1170+
timestamp,
1171+
};
1172+
1173+
// Append to the full log (used for full-scan / range queries).
1174+
let log_key = DataKey::AuditLog;
1175+
let mut log: Vec<AuditRecord> = env
1176+
.storage()
1177+
.persistent()
1178+
.get(&log_key)
1179+
.unwrap_or(Vec::new(env));
1180+
log.push_back(record);
1181+
env.storage().persistent().set(&log_key, &log);
1182+
1183+
AuditRecordAppended {
1184+
notification_id,
1185+
action,
1186+
category: NotificationCategory::Notification,
1187+
seq,
1188+
actor,
1189+
timestamp,
1190+
}
1191+
.publish(env);
1192+
}
1193+
1194+
/// Returns all audit records in creation order.
1195+
///
1196+
/// Records are immutable and append-only; this list can only grow over time.
1197+
pub fn get_audit_log(env: Env) -> Vec<AuditRecord> {
1198+
env.storage()
1199+
.persistent()
1200+
.get(&DataKey::AuditLog)
1201+
.unwrap_or(Vec::new(&env))
1202+
}
1203+
1204+
/// Returns all audit records for a specific notification identifier.
1205+
pub fn get_audit_records_for_notification(
1206+
env: Env,
1207+
notification_id: BytesN<32>,
1208+
) -> Vec<AuditRecord> {
1209+
let log: Vec<AuditRecord> = env
1210+
.storage()
1211+
.persistent()
1212+
.get(&DataKey::AuditLog)
1213+
.unwrap_or(Vec::new(&env));
1214+
1215+
let mut result: Vec<AuditRecord> = Vec::new(&env);
1216+
for record in log.iter() {
1217+
if record.notification_id == notification_id {
1218+
result.push_back(record);
1219+
}
1220+
}
1221+
result
1222+
}
1223+
1224+
/// Records a delivery attempt for a notification in the audit log.
1225+
///
1226+
/// This is a permissionless write so any authorised service (an off-chain
1227+
/// relay, a keeper) can record that it attempted delivery.
1228+
pub fn record_delivery_attempt(
1229+
env: Env,
1230+
notification_id: BytesN<32>,
1231+
actor: Address,
1232+
) -> Result<(), Error> {
1233+
actor.require_auth();
1234+
1235+
if get_paused_status(&env) {
1236+
return Err(Error::ContractPaused);
1237+
}
1238+
1239+
append_audit_record(&env, notification_id, AuditAction::DeliveryAttempt, actor);
1240+
Ok(())
1241+
}
1242+
1243+
/// Records a delivery failure for a notification in the audit log.
1244+
pub fn record_delivery_failure(
1245+
env: Env,
1246+
notification_id: BytesN<32>,
1247+
actor: Address,
1248+
) -> Result<(), Error> {
1249+
actor.require_auth();
1250+
1251+
if get_paused_status(&env) {
1252+
return Err(Error::ContractPaused);
1253+
}
1254+
1255+
append_audit_record(&env, notification_id, AuditAction::DeliveryFailed, actor);
1256+
Ok(())
1257+
}
1258+
1259+
/// Records that the recipient acknowledged a notification.
1260+
pub fn record_acknowledgment(
1261+
env: Env,
1262+
notification_id: BytesN<32>,
1263+
actor: Address,
1264+
) -> Result<(), Error> {
1265+
actor.require_auth();
1266+
1267+
if get_paused_status(&env) {
1268+
return Err(Error::ContractPaused);
1269+
}
1270+
1271+
append_audit_record(&env, notification_id, AuditAction::Acknowledged, actor);
1272+
Ok(())
1273+
}

contract/contracts/hello-world/src/base/errors.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,6 @@ pub enum Error {
5656
/// Triggered when attempting to expire a notification whose lifetime has not
5757
/// yet elapsed.
5858
NotificationNotExpired = 25,
59+
/// Triggered when a batch operation exceeds the maximum allowed size.
60+
BatchTooLarge = 26,
5961
}

contract/contracts/hello-world/src/base/events.rs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use soroban_sdk::{contractevent, contracttype, Address, BytesN, String};
1+
use soroban_sdk::{contractevent, contracttype, Address, BytesN, String, Vec};
22

33
/// High-level notification category attached to every emitted event.
44
///
@@ -217,3 +217,62 @@ pub struct NotificationExpired {
217217
pub priority: NotificationPriority,
218218
pub expires_at: u64,
219219
}
220+
221+
// ============================================================================
222+
// Audit Logging
223+
// ============================================================================
224+
225+
/// Discriminator for each stage in the notification lifecycle that the audit
226+
/// log tracks. Values are fixed-width integers so they serialise compactly on
227+
/// chain and can be matched exactly by off-chain indexers.
228+
#[contracttype]
229+
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
230+
pub enum AuditAction {
231+
/// A notification was created (scheduled on-chain).
232+
Created = 0,
233+
/// A delivery attempt was made for a notification.
234+
DeliveryAttempt = 1,
235+
/// A delivery attempt failed.
236+
DeliveryFailed = 2,
237+
/// The recipient acknowledged the notification.
238+
Acknowledged = 3,
239+
/// The notification was cancelled before expiry.
240+
Cancelled = 4,
241+
/// The notification expired naturally.
242+
Expired = 5,
243+
}
244+
245+
/// Emitted when a new audit record is appended to the on-chain log.
246+
///
247+
/// Off-chain indexers should key off `(notification_id, action)` to track the
248+
/// full lifecycle of each notification.
249+
#[contractevent]
250+
#[derive(Clone)]
251+
pub struct AuditRecordAppended {
252+
#[topic]
253+
pub notification_id: BytesN<32>,
254+
#[topic]
255+
pub action: AuditAction,
256+
#[topic]
257+
pub category: NotificationCategory,
258+
pub seq: u64,
259+
pub actor: Address,
260+
pub timestamp: u64,
261+
}
262+
263+
/// Emitted when a batch of notifications is created in a single transaction.
264+
///
265+
/// Each per-notification event is still emitted individually; this summary
266+
/// event additionally carries the count so consumers can verify completeness.
267+
#[contractevent]
268+
#[derive(Clone)]
269+
pub struct BatchNotificationsCreated {
270+
#[topic]
271+
pub creator: Address,
272+
#[topic]
273+
pub category: NotificationCategory,
274+
#[topic]
275+
pub priority: NotificationPriority,
276+
pub count: u32,
277+
pub ids: Vec<BytesN<32>>,
278+
}

0 commit comments

Comments
 (0)