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
616 changes: 616 additions & 0 deletions CONTRACT_EVENT_REFERENCE.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ The project enables developers to build reactive decentralized applications with
10. [License](#license)

> **Listener service docs**: [Notification Failure Recovery](NOTIFICATION_FAILURE_RECOVERY.md) — retry lifecycle, configuration, and troubleshooting.
>
> **Event reference**: [Smart Contract Event Reference Guide](CONTRACT_EVENT_REFERENCE.md) — all emitted events, parameters, data types, and usage recommendations for indexers and listeners.

---

Expand Down
96 changes: 96 additions & 0 deletions contract/contracts/hello-world/src/autoshare_logic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use crate::base::events::{
Withdrawal, BatchProcessingCompleted,
NotificationExtended, NotificationLimitsConfigured, NotificationPriority, NotificationRevoked,
NotificationScheduled, ScheduledNotificationCancelled, Withdrawal,
SchemaVersionSet, NotificationAccessed,
};
use crate::base::types::{
AuditRecord, AutoShareDetails, GroupMember, NotificationLimits, PaymentHistory,
Expand Down Expand Up @@ -53,6 +54,8 @@ pub enum DataKey {
NotificationRevokers(BytesN<32>),
NotificationLimits,
RegisteredCategories,
/// Stores the current on-chain notification schema version.
SchemaVersion,
}

// ============================================================================
Expand Down Expand Up @@ -1623,3 +1626,96 @@ pub fn get_notification_limits(env: Env) -> NotificationLimits {
max_batch_size: 1000,
})
}

// ============================================================================
// Schema Version Tracking (Issue #309)
// ============================================================================

/// The minimum schema version this contract supports.
const MIN_SUPPORTED_SCHEMA_VERSION: u32 = 1;
/// The maximum (current) schema version this contract supports.
const MAX_SUPPORTED_SCHEMA_VERSION: u32 = 1;

/// Sets the on-chain notification schema version. Only the admin can call this.
///
/// Emits a [`SchemaVersionSet`] event so off-chain consumers can detect protocol
/// upgrades and reject payloads whose version they cannot handle.
///
/// # Errors
/// - [`Error::Unauthorized`] – caller is not the admin.
/// - [`Error::InvalidInput`] – `schema_version` is outside the supported range.
pub fn set_schema_version(env: Env, admin: Address, schema_version: u32) -> Result<(), Error> {
admin.require_auth();

let stored_admin = get_admin(env.clone())?;
if admin != stored_admin {
return Err(Error::Unauthorized);
}

if schema_version < MIN_SUPPORTED_SCHEMA_VERSION || schema_version > MAX_SUPPORTED_SCHEMA_VERSION {
return Err(Error::InvalidInput);
}

let key = DataKey::SchemaVersion;
let previous_version: u32 = env
.storage()
.persistent()
.get::<DataKey, u32>(&key)
.unwrap_or(0);

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

SchemaVersionSet {
admin,
category: NotificationCategory::Admin,
priority: NotificationPriority::Medium,
schema_version,
previous_version,
}
.publish(&env);

Ok(())
}

/// Returns the current on-chain schema version (0 if never set).
pub fn get_schema_version(env: Env) -> u32 {
env.storage()
.persistent()
.get::<DataKey, u32>(&DataKey::SchemaVersion)
.unwrap_or(0)
}

/// Returns whether `version` is within the supported range.
pub fn is_version_supported(_env: Env, version: u32) -> bool {
version >= MIN_SUPPORTED_SCHEMA_VERSION && version <= MAX_SUPPORTED_SCHEMA_VERSION
}

// ============================================================================
// Access Logging (Issue #312)
// ============================================================================

/// Emits a [`NotificationAccessed`] event for the given notification.
///
/// Call this whenever a protected notification record is read so that
/// off-chain indexers can build an immutable access trail for compliance.
pub fn record_notification_access(
env: Env,
notification_id: BytesN<32>,
accessor: Address,
) -> Result<(), Error> {
// Verify the notification exists.
let key = DataKey::ScheduledNotification(notification_id.clone());
if !env.storage().persistent().has(&key) {
return Err(Error::NotFound);
}

NotificationAccessed {
notification_id,
accessor,
category: NotificationCategory::Notification,
accessed_at: env.ledger().timestamp(),
}
.publish(&env);

Ok(())
}
46 changes: 46 additions & 0 deletions contract/contracts/hello-world/src/base/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,3 +377,49 @@ pub struct ReputationTierChanged {
pub min_expiration_seconds: u64,
pub max_batch_size: u32,
}

// ============================================================================
// Schema Version Tracking (Issue #309)
// ============================================================================

/// Emitted when the on-chain notification schema version is set or upgraded.
///
/// Off-chain consumers should read `schema_version` from every event to gate
/// their parsing logic. Unsupported versions must be rejected at the listener
/// layer so incompatible payloads never reach downstream consumers.
#[contractevent]
#[derive(Clone)]
pub struct SchemaVersionSet {
#[topic]
pub admin: Address,
#[topic]
pub category: NotificationCategory,
#[topic]
pub priority: NotificationPriority,
/// New schema version number.
pub schema_version: u32,
/// Previous schema version (0 when first set).
pub previous_version: u32,
}

// ============================================================================
// Access Logging (Issue #312)
// ============================================================================

/// Emitted whenever a protected notification record is accessed.
///
/// Off-chain indexers should key off `(notification_id, accessor)` to build an
/// immutable access trail. The `accessed_at` timestamp is provided for ordering
/// and compliance reporting.
#[contractevent]
#[derive(Clone)]
pub struct NotificationAccessed {
#[topic]
pub notification_id: BytesN<32>,
#[topic]
pub accessor: Address,
#[topic]
pub category: NotificationCategory,
/// Ledger timestamp (seconds) when the access occurred.
pub accessed_at: u64,
}
36 changes: 36 additions & 0 deletions contract/contracts/hello-world/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,36 @@ impl AutoShareContract {
pub fn get_sender_reputation_tier(env: Env, sender: Address) -> u32 {
reputation_logic::get_reputation_tier(&env, &sender).unwrap_or(0)
}

// ============================================================================
// Schema Version Tracking (Issue #309)
// ============================================================================

/// Sets the on-chain notification schema version. Only the admin can call.
/// Emits a SchemaVersionSet event. Rejects versions outside the supported range.
pub fn set_schema_version(env: Env, admin: Address, schema_version: u32) {
autoshare_logic::set_schema_version(env, admin, schema_version).unwrap();
}

/// Returns the current on-chain schema version (0 if never set).
pub fn get_schema_version(env: Env) -> u32 {
autoshare_logic::get_schema_version(env)
}

/// Returns true if the given schema version is within the supported range.
pub fn is_version_supported(env: Env, version: u32) -> bool {
autoshare_logic::is_version_supported(env, version)
}

// ============================================================================
// Access Logging (Issue #312)
// ============================================================================

/// Emits a NotificationAccessed event for the specified notification.
/// Call whenever a protected notification record is read to build an immutable access trail.
pub fn record_notification_access(env: Env, notification_id: BytesN<32>, accessor: Address) {
autoshare_logic::record_notification_access(env, notification_id, accessor).unwrap();
}
}

#[cfg(test)]
Expand Down Expand Up @@ -602,4 +632,10 @@ mod tests {

#[path = "../tests/fuzz_test.rs"]
mod fuzz_test;

#[path = "../tests/schema_version_test.rs"]
mod schema_version_test;

#[path = "../tests/access_log_test.rs"]
mod access_log_test;
}
66 changes: 66 additions & 0 deletions contract/contracts/hello-world/src/tests/access_log_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
use crate::{AutoShareContract, AutoShareContractClient};
use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, String};

fn setup(env: &Env) -> (Address, AutoShareContractClient) {
let id = env.register(AutoShareContract, ());
let client = AutoShareContractClient::new(env, &id);
let admin = Address::generate(env);
env.mock_all_auths();
client.initialize_admin(&admin);
(admin, client)
}

fn schedule_test_notification(
client: &AutoShareContractClient,
env: &Env,
creator: &Address,
) -> BytesN<32> {
let mut id_bytes = [0u8; 32];
id_bytes[0] = 42;
let notification_id = BytesN::from_array(env, &id_bytes);
client.schedule_notification(&notification_id, creator, &3600u64, &String::from_str(env, "Test"));
notification_id
}

#[test]
fn test_access_event_emitted_for_existing_notification() {
let env = Env::default();
env.mock_all_auths();
let (admin, client) = setup(&env);
let notification_id = schedule_test_notification(&client, &env, &admin);
let accessor = Address::generate(&env);

// Should not panic — notification exists.
client.record_notification_access(&notification_id, &accessor);
}

#[test]
#[should_panic]
fn test_access_event_fails_for_nonexistent_notification() {
let env = Env::default();
env.mock_all_auths();
let (_admin, client) = setup(&env);

let mut id_bytes = [0u8; 32];
id_bytes[0] = 99;
let notification_id = BytesN::from_array(&env, &id_bytes);
let accessor = Address::generate(&env);

// Should panic — notification does not exist.
client.record_notification_access(&notification_id, &accessor);
}

#[test]
fn test_multiple_access_events_can_be_emitted() {
let env = Env::default();
env.mock_all_auths();
let (admin, client) = setup(&env);
let notification_id = schedule_test_notification(&client, &env, &admin);

let accessor1 = Address::generate(&env);
let accessor2 = Address::generate(&env);

client.record_notification_access(&notification_id, &accessor1);
client.record_notification_access(&notification_id, &accessor2);
// Both succeed — audit trail is append-only.
}
90 changes: 90 additions & 0 deletions contract/contracts/hello-world/src/tests/schema_version_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
use crate::{AutoShareContract, AutoShareContractClient};
use soroban_sdk::{testutils::Address as _, Address, Env};

fn setup(env: &Env) -> (Address, AutoShareContractClient) {
let id = env.register(AutoShareContract, ());
let client = AutoShareContractClient::new(env, &id);
let admin = Address::generate(env);
env.mock_all_auths();
client.initialize_admin(&admin);
(admin, client)
}

#[test]
fn test_schema_version_default_is_zero() {
let env = Env::default();
let (_admin, client) = setup(&env);
assert_eq!(client.get_schema_version(), 0);
}

#[test]
fn test_set_schema_version_stores_and_emits() {
let env = Env::default();
env.mock_all_auths();
let (admin, client) = setup(&env);

client.set_schema_version(&admin, &1);
assert_eq!(client.get_schema_version(), 1);
}

#[test]
fn test_set_schema_version_previous_version_tracked() {
let env = Env::default();
env.mock_all_auths();
let (admin, client) = setup(&env);

// Set once; previous is 0.
client.set_schema_version(&admin, &1);
// Set again to the same supported value.
client.set_schema_version(&admin, &1);
assert_eq!(client.get_schema_version(), 1);
}

#[test]
fn test_is_version_supported_returns_true_for_valid() {
let env = Env::default();
let (_admin, client) = setup(&env);
assert!(client.is_version_supported(&1));
}

#[test]
fn test_is_version_supported_returns_false_for_zero() {
let env = Env::default();
let (_admin, client) = setup(&env);
assert!(!client.is_version_supported(&0));
}

#[test]
fn test_is_version_supported_returns_false_for_future() {
let env = Env::default();
let (_admin, client) = setup(&env);
assert!(!client.is_version_supported(&999));
}

#[test]
#[should_panic]
fn test_set_schema_version_rejects_unsupported_version() {
let env = Env::default();
env.mock_all_auths();
let (admin, client) = setup(&env);
client.set_schema_version(&admin, &999);
}

#[test]
#[should_panic]
fn test_set_schema_version_rejects_zero() {
let env = Env::default();
env.mock_all_auths();
let (admin, client) = setup(&env);
client.set_schema_version(&admin, &0);
}

#[test]
#[should_panic]
fn test_set_schema_version_requires_admin() {
let env = Env::default();
env.mock_all_auths();
let (_admin, client) = setup(&env);
let non_admin = Address::generate(&env);
client.set_schema_version(&non_admin, &1);
}
Loading
Loading