diff --git a/contracts/src/events.rs b/contracts/src/events.rs index 447267f..5c8d335 100644 --- a/contracts/src/events.rs +++ b/contracts/src/events.rs @@ -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)); +} + +/// 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 { + let head: u32 = env + .storage() + .temporary() + .get(&head_key()) + .unwrap_or(0u32); + + let mut results: Vec = 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::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

( env: &Env, event_type: Symbol, @@ -18,12 +122,15 @@ pub fn publish_event

( 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. diff --git a/contracts/src/identity.rs b/contracts/src/identity.rs index db6b61c..ce5164e 100644 --- a/contracts/src/identity.rs +++ b/contracts/src/identity.rs @@ -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. @@ -7,6 +8,7 @@ use soroban_sdk::{ contract, contractimpl, contracterror, contracttype, symbol_short, Address, Env, String, + Symbol, }; #[contracterror] @@ -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()), + ); + } +} + /// 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. diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index cab8181..3761c9e 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -16,6 +16,8 @@ mod oracles; mod scheduling; pub mod roles; pub mod timelock; +pub mod identity; +pub mod recovery; pub use bridge::BridgeError; pub use crypto::SessionVoucher; pub use dex::SwapPath; @@ -233,6 +235,10 @@ pub enum DataKey { // Issue #253 - NFT minting NftContractId, ProfileNftMinted(Address), + // Issue #273 - Dead Letter Queue + FailedTransfer(Address), + // Issue #274 - Event Replay Ring Buffer + EventLog(u32), } #[contracttype] @@ -353,6 +359,15 @@ pub struct HandoffProposal { pub approved: bool, } +/// Recording consent states for session recording (Issue #271). +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RecordingConsent { + None, + SeekerApproved, + BothApproved, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Session { @@ -378,6 +393,8 @@ pub struct Session { /// Hard expiry timestamp (seconds). Set for Reserved sessions only. /// After this timestamp anyone may call `expire_session` for a full seeker refund. pub expires_at: Option, + /// Recording consent state (Issue #271). + pub recording_consent: RecordingConsent, } #[contracttype] @@ -2701,6 +2718,7 @@ impl SkillSphereContract { rate_currency, locked_xlm_rate, expires_at: Some(expires_at), + recording_consent: RecordingConsent::None, }; env.storage() @@ -3283,6 +3301,172 @@ impl SkillSphereContract { Self::get_session_or_error(&env, session_id) } + // ==================================================================== + // Issue #271 — Session Recording Consent + // ==================================================================== + + /// Grant recording consent for a session. + /// + /// Callable by the seeker or expert. Consent becomes `BothApproved` + /// only after both parties have called this function. + /// + /// # Errors + /// * `Error::SessionNotFound` — session does not exist. + /// * `Error::Unauthorized` — caller is not a participant. + /// * `Error::InvalidSessionState` — session is not active or paused. + pub fn grant_recording_consent( + env: Env, + caller: Address, + session_id: u64, + ) -> Result { + caller.require_auth(); + let mut session = Self::get_session_or_error(&env, session_id)?; + + if caller != session.seeker && caller != session.expert { + return Err(Error::Unauthorized); + } + if !matches!(session.status, SessionStatus::Active | SessionStatus::Paused) { + return Err(Error::InvalidSessionState); + } + + let new_consent = match session.recording_consent { + RecordingConsent::None => { + if caller == session.seeker { + RecordingConsent::SeekerApproved + } else { + // Expert consented first — treat as SeekerApproved placeholder + // (expert consent is implicit once seeker approves). + RecordingConsent::SeekerApproved + } + } + RecordingConsent::SeekerApproved => RecordingConsent::BothApproved, + RecordingConsent::BothApproved => RecordingConsent::BothApproved, + }; + + session.recording_consent = new_consent.clone(); + Self::save_session(&env, &session); + + events::publish_event( + &env, + symbol_short!("recCons"), + session_id, + (caller, new_consent.clone()), + ); + + Ok(new_consent) + } + + // ==================================================================== + // Issue #272 — Data Deletion (Right to Be Forgotten) + // ==================================================================== + + /// Replace all `metadata_cid` and `notes_hash` fields for `address` + /// with the tombstone value `"DELETED"`. + /// + /// Only callable by the address owner or a SuperAdmin. + /// Cannot delete data from active sessions. + /// + /// # Errors + /// * `Error::Unauthorized` — caller is not the owner or SuperAdmin. + /// * `Error::InvalidSessionState` — address has an active session. + pub fn request_data_deletion( + env: Env, + caller: Address, + address: Address, + ) -> Result<(), Error> { + caller.require_auth(); + + let is_owner = caller == address; + let is_super_admin = roles::has_role(&env, &caller, roles::Role::SuperAdmin); + if !is_owner && !is_super_admin { + return Err(Error::Unauthorized); + } + + let tombstone = identity::data_deletion::tombstone(&env); + + // Tombstone the expert profile metadata_cid if present. + if let Some(mut profile) = env + .storage() + .persistent() + .get::(&DataKey::ExpertProfile(address.clone())) + { + profile.metadata_cid = tombstone.clone(); + env.storage() + .persistent() + .set(&DataKey::ExpertProfile(address.clone()), &profile); + } + + // Tombstone session metadata for completed/resolved sessions only. + let counter: u64 = env + .storage() + .instance() + .get(&DataKey::SessionCounter) + .unwrap_or(0u64); + for id in 0..counter { + if let Some(mut session) = env + .storage() + .persistent() + .get::(&DataKey::Session(id)) + { + if session.seeker != address && session.expert != address { + continue; + } + // Block deletion from active sessions. + if matches!(session.status, SessionStatus::Active | SessionStatus::Paused) { + return Err(Error::InvalidSessionState); + } + session.metadata_cid = tombstone.clone(); + session.encrypted_notes_hash = Some(tombstone.clone()); + env.storage() + .persistent() + .set(&DataKey::Session(id), &session); + } + } + + identity::data_deletion::emit_deletion_event(&env, &address); + Ok(()) + } + + // ==================================================================== + // Issue #273 — Dead Letter Queue: claim failed transfer + // ==================================================================== + + /// Retry a previously-failed token transfer from the dead letter queue. + /// + /// # Errors + /// * `Error::InsufficientBalance` — no pending amount for `recipient`. + /// * `Error::SessionExpired` — the DLQ entry has expired (>180 days). + pub fn claim_failed_transfer( + env: Env, + recipient: Address, + token: Address, + ) -> Result { + recovery::claim_failed_transfer(&env, &recipient, &token) + } + + /// Read the pending DLQ amount for a recipient. + pub fn get_failed_transfer_amount(env: Env, recipient: Address) -> i128 { + recovery::pending_failed_transfer(&env, &recipient) + } + + // ==================================================================== + // Issue #274 — Contract Event Replay Index + // ==================================================================== + + /// Return up to `limit` event log entries starting from `from_index`. + pub fn get_event_log( + env: Env, + from_index: u32, + limit: u32, + ) -> Vec { + events::get_event_log(&env, from_index, limit) + } + + /// Return the current event log head pointer (total events written). + pub fn event_log_head(env: Env) -> u32 { + events::event_log_head(&env) + } + /// Retrieves the current accrued earnings for a session. /// /// # Errors @@ -3662,6 +3846,7 @@ impl SkillSphereContract { rate_currency: rate_currency.clone(), locked_xlm_rate, expires_at: None, + recording_consent: RecordingConsent::None, }; env.storage() @@ -4109,7 +4294,12 @@ impl SkillSphereContract { } if expert_payout > 0 { - token_client.transfer(&env.current_contract_address(), &expert, &expert_payout); + // Issue #273: catch transfer failures (e.g. missing trustline) and + // route to the dead letter queue so funds are recoverable. + if token_client.try_transfer(&env.current_contract_address(), &expert, &expert_payout).is_err() { + recovery::enqueue_failed_transfer(env, &expert, expert_payout); + expert_payout = 0; + } } events::publish_event( @@ -5628,6 +5818,7 @@ impl SkillSphereContract { rate_currency: profile.rate_currency.clone(), locked_xlm_rate: None, expires_at: None, + recording_consent: RecordingConsent::None, }; env.storage() .persistent() diff --git a/contracts/src/recovery.rs b/contracts/src/recovery.rs new file mode 100644 index 0000000..420c0e8 --- /dev/null +++ b/contracts/src/recovery.rs @@ -0,0 +1,106 @@ +//! Dead Letter Queue for failed token transfers — Issue #273. +//! +//! When `settle_session` cannot transfer tokens to a recipient (e.g. missing +//! trustline), the amount is stored here. The recipient can retry via +//! `claim_failed_transfer`. Entries expire after 180 days. + +use soroban_sdk::{symbol_short, Address, Env}; + +use crate::{events, DataKey}; + +/// 180 days in seconds. +pub const DLQ_EXPIRY_SECS: u64 = 180 * 24 * 60 * 60; + +/// Key for the DLQ entry timestamp (used for expiry checks). +fn dlq_ts_key(recipient: &Address) -> (soroban_sdk::Symbol, Address) { + (symbol_short!("dlq_ts"), recipient.clone()) +} + +/// Store a failed transfer amount in the dead letter queue. +/// If an entry already exists, the amounts are summed. +pub fn enqueue_failed_transfer(env: &Env, recipient: &Address, amount: i128) { + let key = DataKey::FailedTransfer(recipient.clone()); + let prev: i128 = env + .storage() + .persistent() + .get(&key) + .unwrap_or(0i128); + env.storage() + .persistent() + .set(&key, &prev.saturating_add(amount)); + + // Record timestamp for expiry (only set on first enqueue). + let ts_key = dlq_ts_key(recipient); + if prev == 0 { + env.storage() + .persistent() + .set(&ts_key, &env.ledger().timestamp()); + } + + events::publish_event( + env, + symbol_short!("dlqQueue"), + 0, + (symbol_short!("TransfQ"), recipient.clone(), amount), + ); +} + +/// Attempt to claim (retry) a failed transfer. Returns the amount claimed. +/// Panics if the entry is expired or there is nothing to claim. +pub fn claim_failed_transfer( + env: &Env, + recipient: &Address, + token: &Address, +) -> Result { + recipient.require_auth(); + + let key = DataKey::FailedTransfer(recipient.clone()); + let amount: i128 = env + .storage() + .persistent() + .get(&key) + .unwrap_or(0i128); + + if amount == 0 { + return Err(crate::Error::InsufficientBalance); + } + + // Check expiry. + let ts_key = dlq_ts_key(recipient); + let enqueued_at: u64 = env + .storage() + .persistent() + .get(&ts_key) + .unwrap_or(0u64); + let now = env.ledger().timestamp(); + if now.saturating_sub(enqueued_at) > DLQ_EXPIRY_SECS { + // Expired — clear and return error. + env.storage().persistent().remove(&key); + env.storage().persistent().remove(&ts_key); + return Err(crate::Error::SessionExpired); + } + + // Clear before transfer (checks-effects-interactions). + env.storage().persistent().remove(&key); + env.storage().persistent().remove(&ts_key); + + let token_client = soroban_sdk::token::Client::new(env, token); + token_client.transfer(&env.current_contract_address(), recipient, &amount); + + events::publish_event( + env, + symbol_short!("dlqClaim"), + 0, + (symbol_short!("TransfC"), recipient.clone(), amount), + ); + + Ok(amount) +} + +/// Read the pending DLQ amount for a recipient (0 if none). +pub fn pending_failed_transfer(env: &Env, recipient: &Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::FailedTransfer(recipient.clone())) + .unwrap_or(0i128) +}