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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -692,3 +692,4 @@ To run the staging environment locally:
1. Export environment variables: `export $(cat listener/.env.staging | xargs)`
2. Build and run listener: `cd listener && npm ci && npm run build && npm start`
3. Verify the service is up: `curl http://localhost:8787/health`

1 change: 1 addition & 0 deletions TEMPLATE_QUICK_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,4 @@ npm test -- -t "should render simple variable"
---

**Quick Help**: `TEMPLATE_SYSTEM_GUIDE.md` | **Status**: ✅ Production Ready

27 changes: 21 additions & 6 deletions contract/contracts/hello-world/src/autoshare_logic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@ use crate::base::events::{
NotificationDelivered, NotificationExpired, NotificationExtended,
NotificationLimitsConfigured, NotificationPriority, NotificationRecalled, NotificationRevoked,
NotificationScheduled, OwnershipTransferInitiated, OwnershipTransferred,
ContractPaused, ContractUnpaused, GroupActivated, GroupDeactivated, NotificationAccessed,
NotificationAcknowledged, NotificationCategory, NotificationDelivered, NotificationExpired,
NotificationExtended, NotificationLimitsConfigured, NotificationPriority, NotificationRecalled,
NotificationRevoked, NotificationScheduled, OwnershipTransferInitiated, OwnershipTransferred,
ScheduledNotificationCancelled, SchemaVersionSet, SubscriptionCancelled, Withdrawal,
};
use crate::base::metadata_validation::{validate_metadata, NotificationMetadata};
Expand Down Expand Up @@ -307,7 +303,6 @@ pub fn add_group_member(
// Add new member (embedded in AutoShareDetails — no separate GroupMembers key)
details.members.push_back(GroupMember {
address,
address: address.clone(),
percentage,
});

Expand Down Expand Up @@ -1878,6 +1873,8 @@ pub fn record_delivery_attempt(
return Err(Error::ContractPaused);
}

guard_auditable_notification(&env, &notification_id)?;

append_audit_record(&env, notification_id, AuditAction::DeliveryAttempt, actor);
Ok(())
}
Expand All @@ -1894,6 +1891,8 @@ pub fn record_delivery_failure(
return Err(Error::ContractPaused);
}

guard_auditable_notification(&env, &notification_id)?;

append_audit_record(&env, notification_id, AuditAction::DeliveryFailed, actor);
Ok(())
}
Expand All @@ -1910,10 +1909,27 @@ pub fn record_acknowledgment(
return Err(Error::ContractPaused);
}

guard_auditable_notification(&env, &notification_id)?;

append_audit_record(&env, notification_id, AuditAction::Acknowledged, actor);
Ok(())
}

/// Shared precondition check for the audit-log helpers
/// ([`record_delivery_attempt`], [`record_delivery_failure`],
/// [`record_acknowledgment`]): the referenced notification must exist, must not
/// have been revoked, and must not have expired.
fn guard_auditable_notification(env: &Env, notification_id: &BytesN<32>) -> Result<(), Error> {
let 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);
}
Ok(())
}

/// Checks if a notification has been revoked.
///
/// Returns [`Error::NotFound`] if the notification is not tracked.
Expand Down Expand Up @@ -1974,7 +1990,6 @@ pub fn emit_batch_completed(
batch_id: BytesN<32>,
processed_count: u32,
) -> Result<(), Error> {
pub fn emit_batch_completed(env: Env, batch_id: BytesN<32>, processed_count: u32) -> Result<(), Error> {
BatchProcessingCompleted {
batch_id,
category: NotificationCategory::Notification,
Expand Down
22 changes: 4 additions & 18 deletions contract/contracts/hello-world/src/base/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,29 +77,15 @@ pub enum Error {
InvalidLimit = 34,
/// Triggered when a notification has already been delivered and cannot be recalled.
NotificationDelivered = 35,
/// Triggered when an invalid limit configuration is provided.
InvalidLimit = 34,
/// Triggered when a notification has already been delivered and cannot be recalled.
NotificationDelivered = 35,
/// Triggered when a notification category is not registered.
CategoryNotRegistered = 36,
/// Triggered when an invalid limit configuration is provided.
InvalidLimit = 34,
/// Triggered when a notification has already been delivered and cannot be recalled.
NotificationDelivered = 35,
/// Triggered when a notification lifetime exceeds the protocol maximum.
/// See `MAX_NOTIFICATION_LIFETIME_SECONDS` in autoshare_logic.
NotificationLifetimeTooLong = 36,
NotAuthorizedToAcknowledge = 29,
/// Triggered when an invalid limit configuration is provided.
InvalidLimit = 32,
/// Triggered when a notification has already been delivered and cannot be recalled.
NotificationDelivered = 30,
InvalidLimit = 30,
NotificationLifetimeTooLong = 37,
/// Triggered when referencing a template ID that does not exist in the registry.
TemplateNotFound = 31,
TemplateNotFound = 38,
/// Triggered when a template name exceeds the maximum allowed length.
TemplateNameTooLong = 32,
TemplateNameTooLong = 39,
/// Triggered when a template content field is empty.
TemplateContentEmpty = 33,
TemplateContentEmpty = 40,
}
93 changes: 0 additions & 93 deletions contract/contracts/hello-world/src/base/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,6 @@ pub struct CategoryRegistered {
pub priority: NotificationPriority,
}

/// Emitted when the contract is paused by the admin.
#[contractevent]
/// Emitted when a recipient updates a delivery channel preference.
///
/// Off-chain consumers can filter on `recipient` and inspect `channel` /
Expand Down Expand Up @@ -443,33 +441,6 @@ pub struct OwnershipTransferInitiated {
}

/// Emitted when a two-step ownership transfer is completed.
/// Emitted when an off-chain batch of notifications finishes processing.
#[contractevent(data_format = "single-value")]
#[derive(Clone)]
pub struct BatchProcessingCompleted {
#[topic]
pub batch_id: BytesN<32>,
#[topic]
pub category: NotificationCategory,
#[topic]
pub priority: NotificationPriority,
pub processed_count: u32,
}

/// Emitted when an off-chain batch of notifications finishes processing.
#[contractevent(data_format = "single-value")]
#[derive(Clone)]
pub struct BatchProcessingCompleted {
#[topic]
pub batch_id: BytesN<32>,
#[topic]
pub category: NotificationCategory,
#[topic]
pub priority: NotificationPriority,
pub processed_count: u32,
}

/// Emitted when a scheduled notification's expiry period is extended by an authorized sender.
#[contractevent(data_format = "single-value")]
#[derive(Clone)]
pub struct OwnershipTransferred {
Expand Down Expand Up @@ -570,70 +541,6 @@ pub struct NotificationAccessed {
pub accessed_at: u64,
}

// ============================================================================
// Reputation events
// ============================================================================

/// Emitted when a sender's reputation score is updated.
/// Emitted when a subscriber cancels an active notification subscription.
///
/// Off-chain consumers can key off `(group_id, subscriber)` to track the full
/// subscription lifecycle. The `group_id` identifies the AutoShare group whose
/// subscription was cancelled; `subscriber` is the address that initiated the
/// cancellation.
#[contractevent(data_format = "single-value")]
#[derive(Clone)]
pub struct SubscriptionCancelled {
/// The group whose subscription was cancelled.
#[topic]
pub group_id: BytesN<32>,
/// The address that cancelled the subscription.
#[topic]
pub subscriber: Address,
#[topic]
pub category: NotificationCategory,
#[topic]
pub priority: NotificationPriority,
/// Ledger timestamp (seconds) when the cancellation occurred.
pub cancelled_at: u64,
}

/// Emitted when the current owner initiates a two-step ownership transfer by
/// nominating a `pending_owner`. The transfer is not final until the pending
/// owner calls `accept_ownership`.
///
/// This mirrors the OpenZeppelin `Ownable2Step` `OwnershipTransferStarted` event
/// and lets off-chain consumers track in-progress transfers before they settle.
#[contractevent(data_format = "single-value")]
#[derive(Clone)]
pub struct ReputationUpdated {
#[topic]
pub sender: Address,
#[topic]
pub category: NotificationCategory,
#[topic]
pub priority: NotificationPriority,
pub new_score: i64,
pub successful_count: u32,
pub failed_count: u32,
}

/// Emitted when a sender's reputation tier changes.
#[contractevent(data_format = "single-value")]
#[derive(Clone)]
pub struct ReputationTierChanged {
#[topic]
pub sender: Address,
#[topic]
pub category: NotificationCategory,
#[topic]
pub priority: NotificationPriority,
pub old_tier: u32,
pub new_tier: u32,
pub reputation_score: i64,
pub new_owner: Address,
}

/// Emitted when an authorized user updates a channel's description or metadata.
///
/// Existing subscribers / members are unaffected — only descriptive metadata changes.
Expand Down
40 changes: 20 additions & 20 deletions contract/contracts/hello-world/src/base/metadata_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ mod tests {
#[test]
fn test_valid_metadata() {
let metadata = NotificationMetadata {
title: String::from_slice(&soroban_sdk::Env::default(), "Test"),
title: String::from_str(&soroban_sdk::Env::default(), "Test"),
description: None,
data_uri: None,
custom_fields: None,
Expand All @@ -151,7 +151,7 @@ mod tests {
#[test]
fn test_empty_title_invalid() {
let metadata = NotificationMetadata {
title: String::from_slice(&soroban_sdk::Env::default(), ""),
title: String::from_str(&soroban_sdk::Env::default(), ""),
description: None,
data_uri: None,
custom_fields: None,
Expand All @@ -163,7 +163,7 @@ mod tests {
fn test_long_title_invalid() {
let env = soroban_sdk::Env::default();
let long_string =
String::from_slice(&env, &"a".repeat(MAX_METADATA_STRING_LENGTH as usize + 1));
String::from_str(&env, &"a".repeat(MAX_METADATA_STRING_LENGTH as usize + 1));
let metadata = NotificationMetadata {
title: long_string,
description: None,
Expand All @@ -177,9 +177,9 @@ mod tests {
fn test_long_description_invalid() {
let env = soroban_sdk::Env::default();
let long_desc =
String::from_slice(&env, &"d".repeat(MAX_METADATA_STRING_LENGTH as usize + 1));
String::from_str(&env, &"d".repeat(MAX_METADATA_STRING_LENGTH as usize + 1));
let metadata = NotificationMetadata {
title: String::from_slice(&env, "ok"),
title: String::from_str(&env, "ok"),
description: Some(long_desc),
data_uri: None,
custom_fields: None,
Expand All @@ -191,9 +191,9 @@ mod tests {
fn test_long_data_uri_invalid() {
let env = soroban_sdk::Env::default();
let long_uri =
String::from_slice(&env, &"u".repeat(MAX_METADATA_STRING_LENGTH as usize + 1));
String::from_str(&env, &"u".repeat(MAX_METADATA_STRING_LENGTH as usize + 1));
let metadata = NotificationMetadata {
title: String::from_slice(&env, "ok"),
title: String::from_str(&env, "ok"),
description: None,
data_uri: Some(long_uri),
custom_fields: None,
Expand All @@ -208,13 +208,13 @@ mod tests {
let mut i = 0u32;
while i < MAX_METADATA_FIELDS + 1 {
let key_bytes = [b'k', b'0' + ((i / 10) as u8), b'0' + ((i % 10) as u8)];
let key = String::from_slice(&env, core::str::from_utf8(&key_bytes).unwrap());
let val = String::from_slice(&env, "v");
let key = String::from_str(&env, core::str::from_utf8(&key_bytes).unwrap());
let val = String::from_str(&env, "v");
fields.set(key, val);
i += 1;
}
let metadata = NotificationMetadata {
title: String::from_slice(&env, "ok"),
title: String::from_str(&env, "ok"),
description: None,
data_uri: None,
custom_fields: Some(fields),
Expand All @@ -227,13 +227,13 @@ mod tests {
let env = soroban_sdk::Env::default();
let mut fields = Map::new(&env);
fields.set(
String::from_slice(&env, "priority"),
String::from_slice(&env, "high"),
String::from_str(&env, "priority"),
String::from_str(&env, "high"),
);
let metadata = NotificationMetadata {
title: String::from_slice(&env, "Alert"),
description: Some(String::from_slice(&env, "Body")),
data_uri: Some(String::from_slice(&env, "ipfs://abc")),
title: String::from_str(&env, "Alert"),
description: Some(String::from_str(&env, "Body")),
data_uri: Some(String::from_str(&env, "ipfs://abc")),
custom_fields: Some(fields),
};
assert!(validate_metadata(&metadata).is_ok());
Expand All @@ -247,15 +247,15 @@ mod tests {
let mut i = 0u32;
while i < MAX_METADATA_FIELDS {
let key_bytes = [b'k', b'0' + ((i / 10) as u8), b'0' + ((i % 10) as u8)];
let key = String::from_slice(&env, core::str::from_utf8(&key_bytes).unwrap());
let val = String::from_slice(&env, &"x".repeat(256));
let key = String::from_str(&env, core::str::from_utf8(&key_bytes).unwrap());
let val = String::from_str(&env, &"x".repeat(256));
fields.set(key, val);
i += 1;
}
let metadata = NotificationMetadata {
title: String::from_slice(&env, &"t".repeat(256)),
description: Some(String::from_slice(&env, &"d".repeat(256))),
data_uri: Some(String::from_slice(&env, &"u".repeat(256))),
title: String::from_str(&env, &"t".repeat(256)),
description: Some(String::from_str(&env, &"d".repeat(256))),
data_uri: Some(String::from_str(&env, &"u".repeat(256))),
custom_fields: Some(fields),
};
assert!(validate_metadata_size(&metadata).is_err());
Expand Down
27 changes: 16 additions & 11 deletions contract/contracts/hello-world/src/base/reputation.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use soroban_sdk::{contracttype, Address, Env};
use soroban_sdk::{contracttype, Address};

/// Sender reputation score and metrics.
///
Expand Down Expand Up @@ -52,23 +52,27 @@ pub fn reputation_tier_from_score(score: i64) -> ReputationTier {
}

/// Calculate reputation score based on delivery history.
///
/// The score maps the sender's success rate onto `[MIN_REPUTATION_SCORE,
/// MAX_REPUTATION_SCORE]` using a convex (squared) curve, so that a perfect
/// record earns the maximum, an all-failure record earns the minimum, and a
/// mixed record is penalised more heavily than a linear map would (a 50%
/// success rate scores ~25, not 50). A sender with no delivery history yet
/// starts at `INITIAL_REPUTATION_SCORE`.
///
/// Integer arithmetic only — the contract must not depend on floating point.
pub fn calculate_reputation_score(successful: u32, failed: u32) -> i64 {
let total = successful.saturating_add(failed);
if total == 0 {
return INITIAL_REPUTATION_SCORE;
}

let success_rate = (successful as f64 / total as f64) * 100.0;
let score = (success_rate / 2.0) as i64 + 25;
// Success rate as a whole percentage in `0..=100`.
let success_rate = (successful as u64).saturating_mul(100) / total as u64;
// Convex map onto the score range: rate² / 100.
let score = (success_rate.saturating_mul(success_rate) / 100) as i64;

// Clamp score to valid range
if score > MAX_REPUTATION_SCORE {
MAX_REPUTATION_SCORE
} else if score < MIN_REPUTATION_SCORE {
MIN_REPUTATION_SCORE
} else {
score
}
score.clamp(MIN_REPUTATION_SCORE, MAX_REPUTATION_SCORE)
}

impl SenderReputation {
Expand Down Expand Up @@ -126,6 +130,7 @@ impl SenderReputation {
#[cfg(test)]
mod tests {
use super::*;
use soroban_sdk::Env;

#[test]
fn test_reputation_tier_classification() {
Expand Down
2 changes: 1 addition & 1 deletion contract/contracts/hello-world/src/channel_logic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ pub fn unsubscribe(env: Env, channel_id: BytesN<32>, subscriber: Address) -> Res

set_subscribed(&env, &channel_id, &subscriber, false);

let mut subscribers = load_subscribers(&env, &channel_id);
let subscribers = load_subscribers(&env, &channel_id);
let mut next = Vec::new(&env);
for addr in subscribers.iter() {
if addr != subscriber {
Expand Down
Loading