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
113 changes: 110 additions & 3 deletions contracts/src/events.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,116 @@
//! Standardized webhook event schema — Issue #243.
//! Contract Event Replay Index (ring buffer) — Issue #274.
//!
//! Every contract event is published as a four-field envelope:
//! `{ event_type, session_id, timestamp, payload }` under the `webhook`
//! topic so off-chain relay daemons can parse a single shape.
//!
//! Additionally, every state-change event writes a compact summary entry
//! into a ring buffer of the last 1000 events stored in Temporary storage
//! (DataKey::EventLog(index)). Off-chain indexers that missed events can
//! call `get_event_log(from_index, limit)` to re-fetch them without
//! re-scanning the entire chain.

use soroban_sdk::{contracttype, symbol_short, Env, IntoVal, Symbol, Val, Vec};

/// Maximum number of entries kept in the on-chain ring buffer.
pub const EVENT_LOG_CAPACITY: u32 = 1000;

/// Compact summary stored per ring-buffer slot.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EventLogEntry {
/// Monotonically increasing global index (wraps at EVENT_LOG_CAPACITY).
pub index: u32,
/// The event type symbol (mirrors the `event_type` module).
pub event_type: Symbol,
/// Session id (0 for non-session events).
pub session_id: u64,
/// Ledger timestamp at the time of the event.
pub timestamp: u64,
}

// ---------------------------------------------------------------------------
// Ring-buffer helpers
// ---------------------------------------------------------------------------

/// Storage key for the ring-buffer head pointer (next write position).
fn head_key() -> Symbol {
symbol_short!("evtHead")
}

/// Write one entry into the ring buffer and advance the head pointer.
fn append_to_ring(env: &Env, event_type: Symbol, session_id: u64) {
let head: u32 = env
.storage()
.temporary()
.get(&head_key())
.unwrap_or(0u32);

let slot = head % EVENT_LOG_CAPACITY;
let entry = EventLogEntry {
index: head,
event_type,
session_id,
timestamp: env.ledger().timestamp(),
};

let key = crate::DataKey::EventLog(slot);
env.storage().temporary().set(&key, &entry);

use soroban_sdk::{symbol_short, Env, IntoVal, Symbol, Val};
env.storage()
.temporary()
.set(&head_key(), &head.saturating_add(1));
}
Comment on lines +43 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

For soroban-sdk 21.0.0, when using env.storage().temporary().set(...), do those entries require explicit TTL extension to remain queryable later, and what is the supported API for extending TTL on temporary-storage keys?

💡 Result:

In soroban-sdk 21.0.0, entries created via env.storage.temporary.set(...) do have a limited lifetime (Time-To-Live, or TTL) and will be permanently deleted once that TTL expires [1]. To ensure such entries remain queryable beyond their initial expiration, you must proactively extend their TTL [2][3]. The supported API for extending the TTL of a temporary storage key is the extend_ttl method, accessed through the temporary storage interface [4][5]: env.storage.temporary.extend_ttl(&key, threshold, extend_to); This method works as follows: 1. &key: The identifier for the data entry you wish to extend [4]. 2. threshold: A safeguard parameter; the extension only triggers if the current TTL is lower than this value (measured in ledgers) [4][3]. 3. extend_ttl: The new desired TTL value (measured in ledgers) if the extension is triggered [4][2]. This design allows developers to distribute the cost of TTL management by checking and extending entries during normal contract interactions, rather than performing unnecessary extensions on every call [3]. While temporary entries are ephemeral and generally intended for short-lived data, explicit management is required if you need to maintain access to them over a longer duration [3][1].

Citations:


Extend TTL for replay buffer keys (temporary storage).

append_to_ring writes both the ring-slot entry (DataKey::EventLog(slot)) and the replay head (head_key()) to env.storage().temporary() but never refreshes their TTL. Temporary entries in Soroban are deleted when their TTL expires, so get_event_log() / event_log_head() can silently reset and break the replay contract.

Add TTL extension for both keys (slot entry and head) using the temporary-storage API, e.g. env.storage().temporary().extend_ttl(&key, threshold, extend_to—only extend when TTL is low, and size extend_to to cover the expected replay window).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/events.rs` around lines 43 - 64, In append_to_ring, after
writing the slot entry (DataKey::EventLog(slot)) and the replay head
(head_key()) to env.storage().temporary(), check each key's remaining TTL and
call the temporary storage extend_ttl API to refresh them when below a
threshold; specifically, for both the slot key and head_key() in append_to_ring,
call env.storage().temporary().extend_ttl(&key, threshold, extend_to) (or
equivalent) only when TTL is low, using extend_to sized to cover the expected
replay window so the event log and head do not expire prematurely.


/// Return up to `limit` entries starting from `from_index` (inclusive).
/// Results are ordered oldest-first. Returns an empty vec if the requested
/// range has been overwritten or does not yet exist.
pub fn get_event_log(env: &Env, from_index: u32, limit: u32) -> Vec<EventLogEntry> {
let head: u32 = env
.storage()
.temporary()
.get(&head_key())
.unwrap_or(0u32);

let mut results: Vec<EventLogEntry> = Vec::new(env);
let count = limit.min(EVENT_LOG_CAPACITY);
let mut idx = from_index;
let mut fetched = 0u32;

/// Publish a webhook envelope consumed by off-chain relay services.
while fetched < count && idx < head {
let slot = idx % EVENT_LOG_CAPACITY;
if let Some(entry) = env
.storage()
.temporary()
.get::<crate::DataKey, EventLogEntry>(&crate::DataKey::EventLog(slot))
{
// Confirm the slot hasn't been overwritten by a newer entry.
if entry.index == idx {
results.push_back(entry);
fetched += 1;
}
}
idx += 1;
}

results
}

/// Return the current head pointer (total events ever written, mod wraps internally).
pub fn event_log_head(env: &Env) -> u32 {
env.storage()
.temporary()
.get(&head_key())
.unwrap_or(0u32)
}

// ---------------------------------------------------------------------------
// Core publish helper
// ---------------------------------------------------------------------------

/// Publish a webhook envelope consumed by off-chain relay services,
/// and append a compact summary to the on-chain ring buffer.
pub fn publish_event<P>(
env: &Env,
event_type: Symbol,
Expand All @@ -18,12 +122,15 @@ pub fn publish_event<P>(
env.events().publish(
(symbol_short!("webhook"),),
(
event_type,
event_type.clone(),
session_id,
env.ledger().timestamp(),
payload.into_val(env),
),
);

// Issue #274: write summary to ring buffer.
append_to_ring(env, event_type, session_id);
}

/// Session lifecycle events.
Expand Down
34 changes: 34 additions & 0 deletions contracts/src/identity.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//! # KYC/KYB Integration Hooks (Issue #215)
//! # Data Deletion / Right to Be Forgotten (Issue #272)
//!
//! Optional KYC verification hooks for the identity contract.
//! Allows accounts to require KYC verification before participating in sessions.
Expand All @@ -7,6 +8,7 @@

use soroban_sdk::{
contract, contractimpl, contracterror, contracttype, symbol_short, Address, Env, String,
Symbol,
};

#[contracterror]
Expand Down Expand Up @@ -112,6 +114,38 @@ impl IdentityContract {
}
}

/// Standalone data-deletion helper (Issue #272).
///
/// Replaces all `metadata_cid` and `notes_hash` fields associated with
/// `address` in the main SkillSphere contract storage with the tombstone
/// value `"DELETED"`. Only callable by the address owner or a SuperAdmin.
/// Cannot delete data from active sessions.
///
/// # Storage keys touched (in the main contract's persistent storage)
/// * `DataKey::ExpertProfile(address)` — `metadata_cid` field
/// * `DataKey::Session(id)` — `metadata_cid` and `encrypted_notes_hash` for
/// every completed/resolved session where `seeker == address || expert == address`
///
/// Because this module does not have direct access to the main contract's
/// storage, the function is designed to be called from within the main
/// `SkillSphereContract` impl (see `lib.rs`).
pub mod data_deletion {
use soroban_sdk::{symbol_short, Address, Env, String};

/// Tombstone value used to overwrite deleted metadata fields.
pub fn tombstone(env: &Env) -> String {
String::from_str(env, "DELETED")
}

/// Emit the `DataDeletionRequested` event.
pub fn emit_deletion_event(env: &Env, address: &Address) {
env.events().publish(
(symbol_short!("dataDel"),),
(address.clone(), env.ledger().timestamp()),
);
Comment on lines +140 to +145
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Route deletion events through the shared event publisher.

Publishing directly here bypasses contracts/src/events.rs::publish_event, so DataDeletionRequested never reaches the replay ring and also skips the standardized webhook envelope. That leaves this new state-change event invisible to get_event_log().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/identity.rs` around lines 140 - 145, The emit_deletion_event
function is publishing directly to env.events(), bypassing the shared publisher
and preventing the DataDeletionRequested event from reaching the replay ring and
webhook envelope; update emit_deletion_event to call the centralized
publish_event helper (from contracts::events, e.g. publish_event or the shared
event publisher function) and pass the DataDeletionRequested event payload
(address and timestamp as the standardized event struct/enum) so the event goes
through the replay ring and standardized webhook envelope rather than calling
env.events().publish directly.

}
}

/// Helper macro to check KYC requirement before action execution.
/// If an account has KycStatus::Required and is not yet Verified, returns error.
/// No-ops if status is NotRequired or Verified.
Expand Down
Loading