From 44b1bb4cde795f326d6331d3281b893cfd7936b6 Mon Sep 17 00:00:00 2001 From: augustinemartins Date: Thu, 22 Oct 2026 13:08:33 +0000 Subject: [PATCH 1/3] feat(split): contract usage analytics (#1034) Track how the contract is used so dashboards can read current activity without replaying the event history. - Event tracking: analytics::record is called from the lifecycle event helpers (created, paid, released, refunded), so every code path that emits those events is counted. - Aggregation: lifetime UsageTotals in instance storage and per-UTC-day DailyUsage buckets in persistent storage, plus unique creator/payer counts. - Reporting: get_usage_report(from_day, to_day), up to 90 days, empty days included. - Dashboard and insights: get_usage_dashboard and get_usage_insights (averages, release/refund rates, week-over-week payment trend, busiest day). - Admin can switch recording off with set_analytics_enabled. Recording never emits events and counters saturate, so it cannot change the event stream or revert the operation being recorded. Closes #1034 Co-Authored-By: Claude Opus 5.5 --- STORAGE_KEY_REGISTRY.md | 12 + contracts/split/src/analytics.rs | 376 +++++++++++++++++++++++++++++ contracts/split/src/events.rs | 6 + contracts/split/src/lib.rs | 45 ++++ contracts/split/src/test.rs | 141 +++++++++++ docs/ISSUE_1034_USAGE_ANALYTICS.md | 45 ++++ 6 files changed, 625 insertions(+) create mode 100644 contracts/split/src/analytics.rs create mode 100644 docs/ISSUE_1034_USAGE_ANALYTICS.md diff --git a/STORAGE_KEY_REGISTRY.md b/STORAGE_KEY_REGISTRY.md index 58925d5..fcaaeac 100644 --- a/STORAGE_KEY_REGISTRY.md +++ b/STORAGE_KEY_REGISTRY.md @@ -313,3 +313,15 @@ Run tests with: ```sh cargo test --lib storage_keys ``` + +## Usage Analytics Storage (Issue #1034) + +Defined privately in `contracts/split/src/analytics.rs` as `symbol_short!` keys. The `an_` prefix is used by no other module. + +| Key | Tier | Value | Purpose | +|-----|------|-------|---------| +| `an_on` | instance | `bool` | Recording on/off (absent = on) | +| `an_tot` | instance | `UsageTotals` | Lifetime usage counters | +| `(an_day, u64)` | persistent | `DailyUsage` | Usage for one UTC day (`timestamp / 86_400`) | +| `(an_crt, Address)` | persistent | `bool` | Address has created an invoice (unique-creator count) | +| `(an_pyr, Address)` | persistent | `bool` | Address has paid an invoice (unique-payer count) | diff --git a/contracts/split/src/analytics.rs b/contracts/split/src/analytics.rs new file mode 100644 index 0000000..96e0883 --- /dev/null +++ b/contracts/split/src/analytics.rs @@ -0,0 +1,376 @@ +//! Contract usage analytics (issue #1034). +//! +//! Tracks how the contract is used so operators and dashboards can see +//! activity without replaying the full event history. +//! +//! - **Event tracking**: [`record`] is called from the lifecycle event helpers +//! in `events.rs` (`invoice_created`, `payment_received`, `invoice_released`, +//! `invoice_refunded`). Every code path that emits one of those events is +//! counted, with no changes needed at each call site. +//! - **Aggregation**: activity is rolled up into lifetime [`UsageTotals`] +//! (instance storage) and per-UTC-day [`DailyUsage`] buckets (persistent +//! storage, keyed by `timestamp / 86_400`). +//! - **Reporting**: [`report`] returns the daily buckets for a day range, +//! including empty days, so charts have no gaps. +//! - **Dashboard**: [`dashboard`] bundles lifetime totals, today's bucket and +//! derived insights in one read. +//! - **Insights**: [`insights`] derives averages, conversion rates and the +//! week-over-week payment trend from the stored aggregates. +//! +//! Recording writes storage but never publishes events, so it does not change +//! the event stream that indexers and tests rely on. The admin can switch +//! recording off with [`set_enabled`]; reads keep working on the data already +//! collected. + +use soroban_sdk::{contracttype, symbol_short, Address, Env, Symbol, Vec}; + +use crate::error::ContractError; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Seconds per analytics bucket (one UTC day). +pub const SECONDS_PER_DAY: u64 = 86_400; +/// Maximum number of days a single report may span (bounds the read loop). +pub const MAX_REPORT_DAYS: u64 = 90; +/// Length of the comparison windows used for the trend insight. +pub const TREND_WINDOW_DAYS: u64 = 7; +/// Basis-point scale used for rates. +const BPS: i128 = 10_000; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/// The kinds of contract usage that are tracked. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UsageKind { + InvoiceCreated, + PaymentReceived, + InvoiceReleased, + InvoiceRefunded, +} + +/// Lifetime usage counters. +#[contracttype] +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct UsageTotals { + pub invoices_created: u64, + pub payments: u64, + pub releases: u64, + pub refunds: u64, + /// Sum of invoice totals at creation. + pub created_volume: i128, + /// Sum of payment amounts received. + pub payment_volume: i128, + /// Sum of amounts refunded. + pub refund_volume: i128, + /// Distinct addresses that have created an invoice. + pub unique_creators: u64, + /// Distinct addresses that have paid into an invoice. + pub unique_payers: u64, + /// Timestamp of the first recorded activity (0 if none). + pub first_activity_at: u64, + /// Timestamp of the most recent recorded activity (0 if none). + pub last_activity_at: u64, +} + +/// Usage aggregated over one UTC day. +#[contracttype] +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct DailyUsage { + /// Day index: `timestamp / 86_400`. + pub day: u64, + pub invoices_created: u32, + pub payments: u32, + pub releases: u32, + pub refunds: u32, + pub created_volume: i128, + pub payment_volume: i128, + pub refund_volume: i128, +} + +/// Values derived from the aggregates. +#[contracttype] +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct UsageInsights { + /// Average payment amount (0 when there are no payments). + pub avg_payment: i128, + /// Average invoice total at creation (0 when there are no invoices). + pub avg_invoice_total: i128, + /// Average payments per created invoice, scaled by 100 (e.g. 250 = 2.5). + pub payments_per_invoice_x100: u64, + /// Released invoices as a share of created invoices, in basis points. + pub release_rate_bps: u32, + /// Refunded invoices as a share of created invoices, in basis points. + pub refund_rate_bps: u32, + /// Payments in the last 7 days (today included). + pub payments_this_week: u32, + /// Payments in the 7 days before that. + pub payments_last_week: u32, + /// Week-over-week change in payment count, in basis points. `None` when + /// the previous week had no payments, so there is nothing to compare to. + pub payment_growth_bps: Option, + /// Day index with the most payments in the last 14 days (0 if none). + pub busiest_day: u64, + /// Payment count on `busiest_day`. + pub busiest_day_payments: u32, +} + +/// Everything a usage dashboard needs in one call. +#[contracttype] +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct UsageDashboard { + pub enabled: bool, + pub totals: UsageTotals, + pub today: DailyUsage, + pub insights: UsageInsights, +} + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- + +fn enabled_key() -> Symbol { + symbol_short!("an_on") +} + +fn totals_key() -> Symbol { + symbol_short!("an_tot") +} + +fn day_key(day: u64) -> (Symbol, u64) { + (symbol_short!("an_day"), day) +} + +fn creator_seen_key(addr: &Address) -> (Symbol, Address) { + (symbol_short!("an_crt"), addr.clone()) +} + +fn payer_seen_key(addr: &Address) -> (Symbol, Address) { + (symbol_short!("an_pyr"), addr.clone()) +} + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +/// Whether usage recording is on. Defaults to `true`. +pub fn is_enabled(env: &Env) -> bool { + env.storage().instance().get(&enabled_key()).unwrap_or(true) +} + +/// Turn usage recording on or off. The caller must check admin auth. +pub fn set_enabled(env: &Env, enabled: bool) { + env.storage().instance().set(&enabled_key(), &enabled); + env.events() + .publish((symbol_short!("split"), symbol_short!("an_on")), enabled); +} + +// --------------------------------------------------------------------------- +// Recording +// --------------------------------------------------------------------------- + +/// Day index for a ledger timestamp. +pub fn day_of(timestamp: u64) -> u64 { + timestamp / SECONDS_PER_DAY +} + +/// Record one usage event. +/// +/// * `actor` is the creator for `InvoiceCreated` and the payer for +/// `PaymentReceived`; it is ignored for the other kinds. +/// * `amount` is the invoice total, payment amount or refund amount; it is +/// ignored for `InvoiceReleased`. Negative amounts are treated as zero. +/// +/// Counters saturate instead of failing, so analytics can never revert the +/// operation being recorded. +pub fn record(env: &Env, kind: UsageKind, actor: Option<&Address>, amount: i128) { + if !is_enabled(env) { + return; + } + let now = env.ledger().timestamp(); + let amount = if amount > 0 { amount } else { 0 }; + + let mut totals = get_totals(env); + let day = day_of(now); + let mut bucket = get_day(env, day); + + match kind { + UsageKind::InvoiceCreated => { + totals.invoices_created = totals.invoices_created.saturating_add(1); + totals.created_volume = totals.created_volume.saturating_add(amount); + bucket.invoices_created = bucket.invoices_created.saturating_add(1); + bucket.created_volume = bucket.created_volume.saturating_add(amount); + if let Some(addr) = actor { + if mark_seen(env, creator_seen_key(addr)) { + totals.unique_creators = totals.unique_creators.saturating_add(1); + } + } + } + UsageKind::PaymentReceived => { + totals.payments = totals.payments.saturating_add(1); + totals.payment_volume = totals.payment_volume.saturating_add(amount); + bucket.payments = bucket.payments.saturating_add(1); + bucket.payment_volume = bucket.payment_volume.saturating_add(amount); + if let Some(addr) = actor { + if mark_seen(env, payer_seen_key(addr)) { + totals.unique_payers = totals.unique_payers.saturating_add(1); + } + } + } + UsageKind::InvoiceReleased => { + totals.releases = totals.releases.saturating_add(1); + bucket.releases = bucket.releases.saturating_add(1); + } + UsageKind::InvoiceRefunded => { + totals.refunds = totals.refunds.saturating_add(1); + totals.refund_volume = totals.refund_volume.saturating_add(amount); + bucket.refunds = bucket.refunds.saturating_add(1); + bucket.refund_volume = bucket.refund_volume.saturating_add(amount); + } + } + + if totals.first_activity_at == 0 { + totals.first_activity_at = now; + } + totals.last_activity_at = now; + + env.storage().instance().set(&totals_key(), &totals); + env.storage().persistent().set(&day_key(day), &bucket); +} + +/// Mark an address as seen. Returns `true` the first time. +fn mark_seen(env: &Env, key: (Symbol, Address)) -> bool { + let storage = env.storage().persistent(); + if storage.has(&key) { + false + } else { + storage.set(&key, &true); + true + } +} + +// --------------------------------------------------------------------------- +// Reads +// --------------------------------------------------------------------------- + +/// Lifetime totals (all zero before any activity). +pub fn get_totals(env: &Env) -> UsageTotals { + env.storage().instance().get(&totals_key()).unwrap_or_default() +} + +/// The bucket for `day`, or an empty bucket if nothing happened that day. +pub fn get_day(env: &Env, day: u64) -> DailyUsage { + env.storage() + .persistent() + .get(&day_key(day)) + .unwrap_or(DailyUsage { day, ..Default::default() }) +} + +/// Daily buckets for `from_day..=to_day`, one entry per day (empty days +/// included). Fails with `InvalidArgument` when the range is reversed or +/// longer than [`MAX_REPORT_DAYS`]. +pub fn report(env: &Env, from_day: u64, to_day: u64) -> Result, ContractError> { + if from_day > to_day || to_day - from_day >= MAX_REPORT_DAYS { + return Err(ContractError::InvalidArgument); + } + let mut out = Vec::new(env); + for offset in 0..=(to_day - from_day) { + out.push_back(get_day(env, from_day + offset)); + } + Ok(out) +} + +/// Sum of payments over `days` days ending at `last_day` (inclusive). +fn payments_in_window(env: &Env, last_day: u64, days: u64) -> u32 { + let mut sum: u32 = 0; + for offset in 0..days { + if offset > last_day { + break; + } + sum = sum.saturating_add(get_day(env, last_day - offset).payments); + } + sum +} + +fn rate_bps(part: u64, whole: u64) -> u32 { + if whole == 0 { + return 0; + } + let bps = (part as i128).saturating_mul(BPS) / whole as i128; + bps.min(u32::MAX as i128) as u32 +} + +/// Derived insights from the stored aggregates. +pub fn insights(env: &Env) -> UsageInsights { + let totals = get_totals(env); + let today = day_of(env.ledger().timestamp()); + + let avg_payment = if totals.payments > 0 { + totals.payment_volume / totals.payments as i128 + } else { + 0 + }; + let avg_invoice_total = if totals.invoices_created > 0 { + totals.created_volume / totals.invoices_created as i128 + } else { + 0 + }; + let payments_per_invoice_x100 = if totals.invoices_created > 0 { + totals.payments.saturating_mul(100) / totals.invoices_created + } else { + 0 + }; + + let this_week = payments_in_window(env, today, TREND_WINDOW_DAYS); + let last_week = if today >= TREND_WINDOW_DAYS { + payments_in_window(env, today - TREND_WINDOW_DAYS, TREND_WINDOW_DAYS) + } else { + 0 + }; + let payment_growth_bps = if last_week > 0 { + let diff = this_week as i64 - last_week as i64; + Some(diff * BPS as i64 / last_week as i64) + } else { + None + }; + + let mut busiest_day = 0u64; + let mut busiest_day_payments = 0u32; + for offset in 0..(TREND_WINDOW_DAYS * 2) { + if offset > today { + break; + } + let bucket = get_day(env, today - offset); + if bucket.payments > busiest_day_payments { + busiest_day = bucket.day; + busiest_day_payments = bucket.payments; + } + } + + UsageInsights { + avg_payment, + avg_invoice_total, + payments_per_invoice_x100, + release_rate_bps: rate_bps(totals.releases, totals.invoices_created), + refund_rate_bps: rate_bps(totals.refunds, totals.invoices_created), + payments_this_week: this_week, + payments_last_week: last_week, + payment_growth_bps, + busiest_day, + busiest_day_payments, + } +} + +/// Totals, today's bucket and insights in one read. +pub fn dashboard(env: &Env) -> UsageDashboard { + UsageDashboard { + enabled: is_enabled(env), + totals: get_totals(env), + today: get_day(env, day_of(env.ledger().timestamp())), + insights: insights(env), + } +} diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index ffffebd..971c0ca 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -25,6 +25,7 @@ //! symbol exceeds the short-macro length limit or must be constructed //! dynamically. +use crate::analytics::{self, UsageKind}; use crate::storage_keys::ev_seq_key; use crate::types::{DisputeOutcome, FeeSplit, InvoicePhase, InvoiceStatus, OverfundingPolicy, RepScore, TimelockAction}; use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, String, Symbol, Vec}; @@ -115,6 +116,7 @@ pub fn invoice_created( (symbol_short!("split"), symbol_short!("created"), invoice_id), (creator.clone(), total, cross_chain_ref.clone(), event_seq), ); + analytics::record(env, UsageKind::InvoiceCreated, Some(creator), total); } /// Emitted at invoice creation when `forward_to` is configured, making @@ -137,6 +139,7 @@ pub fn payment_received(env: &Env, invoice_id: u64, payer: &Address, amount: i12 (symbol_short!("split"), symbol_short!("paid"), invoice_id), (payer.clone(), amount, token.clone(), event_seq), ); + analytics::record(env, UsageKind::PaymentReceived, Some(payer), amount); } /// Issue #954: Emitted when a payment is received, carrying the full @@ -219,6 +222,7 @@ pub fn invoice_released(env: &Env, invoice_id: u64, recipients: &Vec
) { ), (recipients.clone(), event_seq), ); + analytics::record(env, UsageKind::InvoiceReleased, None, 0); } /// Emitted when an invoice is refunded. @@ -234,6 +238,7 @@ pub fn invoice_refunded(env: &Env, invoice_id: u64, amount: i128) { ), (invoice_id, amount, event_seq), ); + analytics::record(env, UsageKind::InvoiceRefunded, None, amount); } /// Issue #956: Emitted when an invoice is refunded, carrying the reason. @@ -254,6 +259,7 @@ pub fn invoice_refunded_with_reason( ), (invoice_id, amount, *reason, event_seq), ); + analytics::record(env, UsageKind::InvoiceRefunded, None, amount); } /// Emitted when a release-condition preimage is verified. diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 37eec34..165cfd6 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -83,6 +83,7 @@ pub mod security_audit; pub mod payment_tracking; pub mod velocity; pub mod split_rules; +pub mod analytics; #[cfg(test)] mod test; @@ -18950,3 +18951,47 @@ impl SplitContract { .unwrap_or_else(|| Vec::new(&env)) } } + +// --------------------------------------------------------------------------- +// Issue #1034: Contract usage analytics +// --------------------------------------------------------------------------- +#[contractimpl] +impl SplitContract { + /// Turn usage recording on or off. Admin only. Reads keep working on the + /// data already collected while recording is off. + pub fn set_analytics_enabled(env: Env, enabled: bool) { + require_admin(&env); + analytics::set_enabled(&env, enabled); + } + + /// Whether usage recording is on (default `true`). + pub fn is_analytics_enabled(env: Env) -> bool { + analytics::is_enabled(&env) + } + + /// Lifetime usage counters. + pub fn get_usage_totals(env: Env) -> analytics::UsageTotals { + analytics::get_totals(&env) + } + + /// Usage for one UTC day (`day = timestamp / 86_400`). + pub fn get_usage_day(env: Env, day: u64) -> analytics::DailyUsage { + analytics::get_day(&env, day) + } + + /// Daily usage for `from_day..=to_day` (at most 90 days), empty days included. + pub fn get_usage_report(env: Env, from_day: u64, to_day: u64) -> Vec { + analytics::report(&env, from_day, to_day).unwrap_or_else(|e| panic_with_error!(&env, e)) + } + + /// Averages, conversion rates and the week-over-week payment trend. + pub fn get_usage_insights(env: Env) -> analytics::UsageInsights { + analytics::insights(&env) + } + + /// Totals, today's bucket and insights in one read. + pub fn get_usage_dashboard(env: Env) -> analytics::UsageDashboard { + analytics::dashboard(&env) + } +} + diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 7356ce6..a72e77a 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -9701,3 +9701,144 @@ fn test_913_non_creator_cannot_set_rules() { rules.push_back(rule_913(0, 0, RuleCriteria::Always, RuleAmount::Fixed(1))); c.set_invoice_split_rules(&Address::generate(&env), &id, &rules); } + +// --------------------------------------------------------------------------- +// Issue #1034: Contract usage analytics +// --------------------------------------------------------------------------- + +#[test] +fn test_1034_tracks_create_pay_and_release() { + let (env, contract_id, token_id) = setup_initialized(); + let c = client(&env, &contract_id); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + StellarAssetClient::new(&env, &token_id).mint(&payer, &500); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); + c.pay(&payer, &id, &100_i128, &0_u64, &false, &false, &None); + c.pay(&payer, &id, &100_i128, &0_u64, &false, &false, &None); + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); + + let totals = c.get_usage_totals(); + assert_eq!(totals.invoices_created, 1); + assert_eq!(totals.created_volume, 200); + assert_eq!(totals.payments, 2); + assert_eq!(totals.payment_volume, 200); + assert_eq!(totals.releases, 1); + assert_eq!(totals.unique_creators, 1); + assert_eq!(totals.unique_payers, 1); + assert_eq!(totals.first_activity_at, 1_000); + assert_eq!(totals.last_activity_at, 1_000); + + let today = c.get_usage_day(&0); + assert_eq!(today.invoices_created, 1); + assert_eq!(today.payments, 2); + assert_eq!(today.releases, 1); + + let insights = c.get_usage_insights(); + assert_eq!(insights.avg_payment, 100); + assert_eq!(insights.avg_invoice_total, 200); + assert_eq!(insights.payments_per_invoice_x100, 200); + assert_eq!(insights.release_rate_bps, 10_000); + assert_eq!(insights.refund_rate_bps, 0); + + let dash = c.get_usage_dashboard(); + assert!(dash.enabled); + assert_eq!(dash.totals, totals); + assert_eq!(dash.today, today); +} + +#[test] +fn test_1034_tracks_refunds() { + let (env, contract_id, token_id) = setup_initialized(); + let c = client(&env, &contract_id); + let payer = Address::generate(&env); + StellarAssetClient::new(&env, &token_id).mint(&payer, &100); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &Address::generate(&env), &Address::generate(&env), 500, &token_id, 2_000); + c.pay(&payer, &id, &100_i128, &0_u64, &false, &false, &None); + env.ledger().set_timestamp(3_000); + c.refund(&id); + + let totals = c.get_usage_totals(); + assert_eq!(totals.refunds, 1); + assert_eq!(totals.refund_volume, 100); + assert_eq!(c.get_usage_insights().refund_rate_bps, 10_000); +} + +#[test] +fn test_1034_disabling_stops_recording() { + let (env, contract_id, token_id) = setup_initialized(); + let c = client(&env, &contract_id); + env.ledger().set_timestamp(1_000); + make_invoice(&env, &c, &Address::generate(&env), &Address::generate(&env), 100, &token_id, 9_999); + assert_eq!(c.get_usage_totals().invoices_created, 1); + + c.set_analytics_enabled(&false); + assert!(!c.is_analytics_enabled()); + make_invoice(&env, &c, &Address::generate(&env), &Address::generate(&env), 100, &token_id, 9_999); + assert_eq!(c.get_usage_totals().invoices_created, 1); + + c.set_analytics_enabled(&true); + make_invoice(&env, &c, &Address::generate(&env), &Address::generate(&env), 100, &token_id, 9_999); + assert_eq!(c.get_usage_totals().invoices_created, 2); +} + +#[test] +fn test_1034_report_and_weekly_trend() { + use analytics::{UsageKind, SECONDS_PER_DAY}; + let (env, contract_id, _token_id) = setup_initialized(); + let c = client(&env, &contract_id); + let payer = Address::generate(&env); + + env.as_contract(&contract_id, || { + env.ledger().set_timestamp(10 * SECONDS_PER_DAY); + analytics::record(&env, UsageKind::PaymentReceived, Some(&payer), 10); + analytics::record(&env, UsageKind::PaymentReceived, Some(&payer), 30); + env.ledger().set_timestamp(17 * SECONDS_PER_DAY + 5); + for _ in 0..3 { + analytics::record(&env, UsageKind::PaymentReceived, Some(&payer), 20); + } + }); + + let report = c.get_usage_report(&10, &17); + assert_eq!(report.len(), 8); + assert_eq!(report.get(0).unwrap().day, 10); + assert_eq!(report.get(0).unwrap().payments, 2); + assert_eq!(report.get(0).unwrap().payment_volume, 40); + assert_eq!(report.get(1).unwrap().payments, 0); + assert_eq!(report.get(7).unwrap().payments, 3); + + let insights = c.get_usage_insights(); + assert_eq!(insights.payments_this_week, 3); + assert_eq!(insights.payments_last_week, 2); + assert_eq!(insights.payment_growth_bps, Some(5_000)); + assert_eq!(insights.busiest_day, 17); + assert_eq!(insights.busiest_day_payments, 3); + assert_eq!(c.get_usage_totals().unique_payers, 1); +} + +#[test] +fn test_1034_growth_is_none_without_previous_week() { + let (env, contract_id, token_id) = setup_initialized(); + let c = client(&env, &contract_id); + let payer = Address::generate(&env); + StellarAssetClient::new(&env, &token_id).mint(&payer, &100); + env.ledger().set_timestamp(1_000); + let id = make_invoice(&env, &c, &Address::generate(&env), &Address::generate(&env), 500, &token_id, 9_999); + c.pay(&payer, &id, &100_i128, &0_u64, &false, &false, &None); + assert_eq!(c.get_usage_insights().payment_growth_bps, None); +} + +#[test] +fn test_1034_report_rejects_bad_ranges() { + let (env, contract_id, _token_id) = setup_initialized(); + let c = client(&env, &contract_id); + assert!(c.try_get_usage_report(&5, &4).is_err()); + assert!(c.try_get_usage_report(&0, &90).is_err()); + assert_eq!(c.get_usage_report(&0, &89).len(), 90); +} + diff --git a/docs/ISSUE_1034_USAGE_ANALYTICS.md b/docs/ISSUE_1034_USAGE_ANALYTICS.md new file mode 100644 index 0000000..08b5cd7 --- /dev/null +++ b/docs/ISSUE_1034_USAGE_ANALYTICS.md @@ -0,0 +1,45 @@ +# Issue #1034: Contract usage analytics + +**Status:** implemented in `contracts/split/src/analytics.rs`. + +## Design + +Analytics are kept on-chain as aggregates, so a dashboard can read current usage with one call instead +of replaying every event. + +| Requirement | How it is met | +|---|---| +| Event tracking | `analytics::record` is called from the lifecycle event helpers in `events.rs` (`invoice_created`, `payment_received`, `invoice_released`, `invoice_refunded`, `invoice_refunded_with_reason`). Every code path that emits one of these events is counted, with no per-call-site changes. | +| Aggregation | Lifetime `UsageTotals` in instance storage, plus one `DailyUsage` bucket per UTC day in persistent storage. Unique creators and payers are counted with one marker entry per address. | +| Reporting | `get_usage_report(from_day, to_day)` returns one bucket per day, empty days included, for up to 90 days. | +| Dashboards | `get_usage_dashboard()` returns totals, today's bucket and insights together. | +| Insights | `get_usage_insights()` derives average payment and invoice size, payments per invoice, release and refund rates, the week-over-week payment trend and the busiest recent day. | + +## Rules + +- Recording never publishes events, so the existing event stream is unchanged. +- Counters saturate instead of failing, so analytics can never revert the operation being recorded. +- The admin can switch recording off with `set_analytics_enabled(false)`. Reads keep working on the data + already collected. +- Day index = `ledger timestamp / 86_400` (UTC days). + +## Entry points + +| Function | Access | +|---|---| +| `set_analytics_enabled(enabled)` | admin | +| `is_analytics_enabled()` | anyone | +| `get_usage_totals()` | anyone | +| `get_usage_day(day)` | anyone | +| `get_usage_report(from_day, to_day)` | anyone | +| `get_usage_insights()` | anyone | +| `get_usage_dashboard()` | anyone | + +## Cost + +Each tracked event adds one instance write, one persistent write for the day bucket, and on the first +activity from an address one more persistent write for its marker. + +## Tests + +`test_1034_*` in `contracts/split/src/test.rs`. From 70aa529bf5eb7d22a09863a54223cd9038c638e2 Mon Sep 17 00:00:00 2001 From: augustinemartins Date: Thu, 22 Oct 2026 13:08:40 +0000 Subject: [PATCH 2/3] feat(split): contract monitoring and alerting (#1033) Watch the contract for unusual activity using the usage analytics aggregates and raise alerts. - Metric collection: MetricKind covers today's invoices, payments, payment volume, refunds and refund volume, the lifetime refund rate, and the size of the payment being processed. get_metrics returns a snapshot. - Alerting rules: up to 20 admin-managed AlertRules (metric, Above or Below, threshold, severity, cooldown, enabled). - Evaluation: rules for the affected metrics run after each recorded usage event; check_alerts (open to anyone) evaluates all rules so a keeper can catch conditions no event triggers. - Alert delivery: alerts are stored (newest 50 kept), emitted as (split, alert, rule_id) events, and pushed to an optional receiver contract's on_alert via try_invoke_contract. A failing receiver never reverts the triggering operation. - Monitoring analytics and dashboard: MonitoringStats (evaluations, alerts by severity, unacknowledged, failed deliveries) and get_monitoring_dashboard. Admin can acknowledge alerts. With no rules registered the payment path pays one instance read. Closes #1033 Co-Authored-By: Claude Opus 5.5 --- STORAGE_KEY_REGISTRY.md | 13 + contracts/split/src/analytics.rs | 3 + contracts/split/src/lib.rs | 74 ++++ contracts/split/src/monitoring.rs | 550 +++++++++++++++++++++++++ contracts/split/src/test.rs | 103 +++++ docs/ISSUE_1033_MONITORING_ALERTING.md | 55 +++ 6 files changed, 798 insertions(+) create mode 100644 contracts/split/src/monitoring.rs create mode 100644 docs/ISSUE_1033_MONITORING_ALERTING.md diff --git a/STORAGE_KEY_REGISTRY.md b/STORAGE_KEY_REGISTRY.md index fcaaeac..51679f2 100644 --- a/STORAGE_KEY_REGISTRY.md +++ b/STORAGE_KEY_REGISTRY.md @@ -325,3 +325,16 @@ Defined privately in `contracts/split/src/analytics.rs` as `symbol_short!` keys. | `(an_day, u64)` | persistent | `DailyUsage` | Usage for one UTC day (`timestamp / 86_400`) | | `(an_crt, Address)` | persistent | `bool` | Address has created an invoice (unique-creator count) | | `(an_pyr, Address)` | persistent | `bool` | Address has paid an invoice (unique-payer count) | + +## Monitoring Storage (Issue #1033) + +Defined privately in `contracts/split/src/monitoring.rs` as `symbol_short!` keys. The `mon_` prefix is used by no other module. + +| Key | Tier | Value | Purpose | +|-----|------|-------|---------| +| `mon_rul` | instance | `Vec` | Registered alert rules (≤ `MAX_ALERT_RULES` = 20) | +| `mon_rid` | instance | `u32` | Next rule id | +| `mon_aid` | instance | `u64` | Next alert id | +| `mon_st` | instance | `MonitoringStats` | Monitor counters | +| `mon_rcv` | instance | `Address` | Optional alert receiver contract | +| `(mon_al, u64)` | persistent | `Alert` | Alert record; only the newest `MAX_ALERT_HISTORY` (50) are kept | diff --git a/contracts/split/src/analytics.rs b/contracts/split/src/analytics.rs index 96e0883..65e00b6 100644 --- a/contracts/split/src/analytics.rs +++ b/contracts/split/src/analytics.rs @@ -240,6 +240,9 @@ pub fn record(env: &Env, kind: UsageKind, actor: Option<&Address>, amount: i128) env.storage().instance().set(&totals_key(), &totals); env.storage().persistent().set(&day_key(day), &bucket); + + // Issue #1033: evaluate alert rules against the updated aggregates. + crate::monitoring::on_usage(env, kind, amount); } /// Mark an address as seen. Returns `true` the first time. diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 165cfd6..7619275 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -84,6 +84,7 @@ pub mod payment_tracking; pub mod velocity; pub mod split_rules; pub mod analytics; +pub mod monitoring; #[cfg(test)] mod test; @@ -18995,3 +18996,76 @@ impl SplitContract { } } +// --------------------------------------------------------------------------- +// Issue #1033: Contract monitoring and alerting +// --------------------------------------------------------------------------- +#[contractimpl] +impl SplitContract { + /// Register an alert rule and return its id. Admin only. + pub fn add_alert_rule(env: Env, rule: monitoring::AlertRule) -> u32 { + require_admin(&env); + monitoring::add_rule(&env, rule).unwrap_or_else(|e| panic_with_error!(&env, e)) + } + + /// Remove an alert rule. Admin only. + pub fn remove_alert_rule(env: Env, rule_id: u32) { + require_admin(&env); + monitoring::remove_rule(&env, rule_id).unwrap_or_else(|e| panic_with_error!(&env, e)) + } + + /// Enable or disable an alert rule. Admin only. + pub fn set_alert_rule_enabled(env: Env, rule_id: u32, enabled: bool) { + require_admin(&env); + monitoring::set_rule_enabled(&env, rule_id, enabled) + .unwrap_or_else(|e| panic_with_error!(&env, e)) + } + + /// All registered alert rules. + pub fn get_alert_rules(env: Env) -> Vec { + monitoring::get_rules(&env) + } + + /// Set (`Some`) or clear (`None`) the contract that receives + /// `on_alert(alert)` calls. Admin only. + pub fn set_alert_receiver(env: Env, receiver: Option
) { + require_admin(&env); + monitoring::set_receiver(&env, &receiver); + } + + /// Evaluate every alert rule now and return the number of alerts raised. + /// Open to anyone so a keeper can catch conditions no event triggers. + pub fn check_alerts(env: Env) -> u32 { + monitoring::check_all(&env) + } + + /// Mark an alert as handled. Admin only. + pub fn acknowledge_alert(env: Env, alert_id: u64) { + require_admin(&env); + monitoring::acknowledge(&env, alert_id).unwrap_or_else(|e| panic_with_error!(&env, e)) + } + + /// One alert by id (`None` if unknown or pruned from history). + pub fn get_alert(env: Env, alert_id: u64) -> Option { + monitoring::get_alert(&env, alert_id) + } + + /// Up to `limit` most recent alerts, newest first. + pub fn get_recent_alerts(env: Env, limit: u64) -> Vec { + monitoring::recent_alerts(&env, limit) + } + + /// Current value of every collectable metric. + pub fn get_metrics(env: Env) -> Vec { + monitoring::snapshot(&env) + } + + /// Counters about the monitor itself. + pub fn get_monitoring_stats(env: Env) -> monitoring::MonitoringStats { + monitoring::get_stats(&env) + } + + /// Stats, rules, metric values and recent alerts in one read. + pub fn get_monitoring_dashboard(env: Env) -> monitoring::MonitoringDashboard { + monitoring::dashboard(&env) + } +} diff --git a/contracts/split/src/monitoring.rs b/contracts/split/src/monitoring.rs new file mode 100644 index 0000000..7abcf2b --- /dev/null +++ b/contracts/split/src/monitoring.rs @@ -0,0 +1,550 @@ +//! Contract monitoring and alerting (issue #1033). +//! +//! Builds on the usage analytics from issue #1034 to watch the contract for +//! unusual activity and raise alerts. +//! +//! - **Metric collection**: [`collect_metric`] reads a [`MetricKind`] from the +//! analytics aggregates (today's bucket or lifetime totals). The size of the +//! payment being processed is also available as a metric. +//! - **Alerting rules**: the admin registers up to [`MAX_ALERT_RULES`] +//! [`AlertRule`]s, each comparing one metric against a threshold, with a +//! severity and a cooldown so a rule cannot fire on every call. +//! - **Evaluation**: rules are checked automatically after each recorded usage +//! event ([`on_usage`], called from `analytics::record`), but only rules for +//! metrics that event can change. [`check_all`] evaluates every rule and is +//! open to anyone, so a keeper can catch `Below` conditions such as "no +//! payments today" that no event would trigger. +//! - **Alert delivery**: each alert is stored in a bounded history, emitted as +//! a `(split, alert, rule_id)` event for off-chain relayers, and, when the +//! admin has set a receiver contract, pushed to its `on_alert(alert)` +//! function with `try_invoke_contract`. A failing receiver can never revert +//! the operation that raised the alert. +//! - **Monitoring analytics**: [`MonitoringStats`] counts evaluations, alerts +//! by severity, unacknowledged alerts and failed deliveries. +//! - **Dashboard**: [`dashboard`] returns stats, rules, current metric values +//! and the most recent alerts in one read. +//! +//! When no rules are registered, [`on_usage`] returns after one instance read, +//! so the payment path pays almost nothing for monitoring by default. + +use soroban_sdk::{contracttype, symbol_short, Address, Env, IntoVal, Symbol, Val, Vec}; + +use crate::analytics::{self, UsageKind}; +use crate::error::ContractError; + +// --------------------------------------------------------------------------- +// Limits +// --------------------------------------------------------------------------- + +/// Maximum number of alert rules (bounds the evaluation loop). +pub const MAX_ALERT_RULES: u32 = 20; +/// Number of most recent alerts kept in history. +pub const MAX_ALERT_HISTORY: u64 = 50; +/// Number of alerts returned by the dashboard. +pub const DASHBOARD_RECENT_ALERTS: u64 = 10; +/// Function called on the alert receiver contract. +pub const RECEIVER_FN: &str = "on_alert"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/// A value the monitor can watch. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MetricKind { + /// Invoices created today. + DailyInvoicesCreated, + /// Payments received today. + DailyPayments, + /// Payment volume received today. + DailyPaymentVolume, + /// Refunds issued today. + DailyRefunds, + /// Refund volume issued today. + DailyRefundVolume, + /// Lifetime refunded invoices / created invoices, in basis points. + RefundRateBps, + /// Amount of the single payment being processed. Only evaluated when a + /// payment is recorded, never by `check_all`. + PaymentAmount, +} + +/// How a metric is compared to a rule's threshold. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Comparator { + /// Fires when the value is strictly greater than the threshold. + Above, + /// Fires when the value is strictly less than the threshold. + Below, +} + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AlertSeverity { + Info, + Warning, + Critical, +} + +/// An alerting rule. `id`, `last_triggered_at` and `trigger_count` are set by +/// the contract; values passed in for them are ignored. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AlertRule { + pub id: u32, + pub metric: MetricKind, + pub comparator: Comparator, + pub threshold: i128, + pub severity: AlertSeverity, + /// Minimum seconds between two alerts from this rule. + pub cooldown_secs: u64, + pub enabled: bool, + pub last_triggered_at: u64, + pub trigger_count: u32, +} + +/// One raised alert. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Alert { + pub id: u64, + pub rule_id: u32, + pub metric: MetricKind, + pub severity: AlertSeverity, + pub value: i128, + pub threshold: i128, + pub triggered_at: u64, + pub acknowledged: bool, + /// `Some(true)` delivered to the receiver contract, `Some(false)` the + /// receiver call failed, `None` no receiver was set. + pub delivered: Option, +} + +/// Counters about the monitor itself. +#[contracttype] +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct MonitoringStats { + /// Rule evaluations performed. + pub evaluations: u64, + pub total_alerts: u64, + pub info_alerts: u64, + pub warning_alerts: u64, + pub critical_alerts: u64, + pub unacknowledged: u64, + pub failed_deliveries: u64, + pub last_alert_at: u64, +} + +/// A metric and its current value. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MetricValue { + pub metric: MetricKind, + pub value: i128, +} + +/// Everything a monitoring dashboard needs in one call. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MonitoringDashboard { + pub stats: MonitoringStats, + pub rules: Vec, + pub metrics: Vec, + /// Most recent alerts, newest first. + pub recent_alerts: Vec, + pub receiver: Option
, +} + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- + +fn rules_key() -> Symbol { + symbol_short!("mon_rul") +} + +fn next_rule_id_key() -> Symbol { + symbol_short!("mon_rid") +} + +fn next_alert_id_key() -> Symbol { + symbol_short!("mon_aid") +} + +fn stats_key() -> Symbol { + symbol_short!("mon_st") +} + +fn receiver_key() -> Symbol { + symbol_short!("mon_rcv") +} + +fn alert_key(id: u64) -> (Symbol, u64) { + (symbol_short!("mon_al"), id) +} + +// --------------------------------------------------------------------------- +// Rules +// --------------------------------------------------------------------------- + +pub fn get_rules(env: &Env) -> Vec { + env.storage() + .instance() + .get(&rules_key()) + .unwrap_or_else(|| Vec::new(env)) +} + +fn save_rules(env: &Env, rules: &Vec) { + env.storage().instance().set(&rules_key(), rules); +} + +/// Add a rule and return its id. The caller must check admin auth. +pub fn add_rule(env: &Env, rule: AlertRule) -> Result { + if rule.metric == MetricKind::RefundRateBps && rule.threshold > 10_000 { + return Err(ContractError::InvalidArgument); + } + if rule.threshold < 0 { + return Err(ContractError::InvalidArgument); + } + let mut rules = get_rules(env); + if rules.len() >= MAX_ALERT_RULES { + return Err(ContractError::BatchLimitExceeded); + } + let id: u32 = env.storage().instance().get(&next_rule_id_key()).unwrap_or(1); + env.storage() + .instance() + .set(&next_rule_id_key(), &id.saturating_add(1)); + + rules.push_back(AlertRule { + id, + last_triggered_at: 0, + trigger_count: 0, + ..rule + }); + save_rules(env, &rules); + env.events() + .publish((symbol_short!("split"), symbol_short!("mon_add")), id); + Ok(id) +} + +/// Remove a rule. The caller must check admin auth. +pub fn remove_rule(env: &Env, rule_id: u32) -> Result<(), ContractError> { + let rules = get_rules(env); + let index = find_rule(&rules, rule_id).ok_or(ContractError::MissingStorageEntry)?; + let mut rules = rules; + rules.remove(index); + save_rules(env, &rules); + env.events() + .publish((symbol_short!("split"), symbol_short!("mon_rm")), rule_id); + Ok(()) +} + +/// Enable or disable a rule. The caller must check admin auth. +pub fn set_rule_enabled(env: &Env, rule_id: u32, enabled: bool) -> Result<(), ContractError> { + let mut rules = get_rules(env); + let index = find_rule(&rules, rule_id).ok_or(ContractError::MissingStorageEntry)?; + let mut rule = rules.get_unchecked(index); + rule.enabled = enabled; + rules.set(index, rule); + save_rules(env, &rules); + Ok(()) +} + +fn find_rule(rules: &Vec, rule_id: u32) -> Option { + for (i, rule) in rules.iter().enumerate() { + if rule.id == rule_id { + return Some(i as u32); + } + } + None +} + +// --------------------------------------------------------------------------- +// Receiver +// --------------------------------------------------------------------------- + +pub fn get_receiver(env: &Env) -> Option
{ + env.storage().instance().get(&receiver_key()) +} + +/// Set (`Some`) or clear (`None`) the alert receiver contract. The caller +/// must check admin auth. +pub fn set_receiver(env: &Env, receiver: &Option
) { + match receiver { + Some(addr) => env.storage().instance().set(&receiver_key(), addr), + None => env.storage().instance().remove(&receiver_key()), + } +} + +// --------------------------------------------------------------------------- +// Metric collection +// --------------------------------------------------------------------------- + +/// Current value of `metric`. `PaymentAmount` has no stored value and +/// returns `None`; it is only known while a payment is being recorded. +pub fn collect_metric(env: &Env, metric: MetricKind) -> Option { + let today = analytics::get_day(env, analytics::day_of(env.ledger().timestamp())); + match metric { + MetricKind::DailyInvoicesCreated => Some(today.invoices_created as i128), + MetricKind::DailyPayments => Some(today.payments as i128), + MetricKind::DailyPaymentVolume => Some(today.payment_volume), + MetricKind::DailyRefunds => Some(today.refunds as i128), + MetricKind::DailyRefundVolume => Some(today.refund_volume), + MetricKind::RefundRateBps => { + let totals = analytics::get_totals(env); + if totals.invoices_created == 0 { + Some(0) + } else { + Some(totals.refunds as i128 * 10_000 / totals.invoices_created as i128) + } + } + MetricKind::PaymentAmount => None, + } +} + +/// Metrics that a usage event of `kind` can change. +fn affects(kind: UsageKind, metric: MetricKind) -> bool { + match kind { + UsageKind::InvoiceCreated => matches!( + metric, + MetricKind::DailyInvoicesCreated | MetricKind::RefundRateBps + ), + UsageKind::PaymentReceived => matches!( + metric, + MetricKind::DailyPayments | MetricKind::DailyPaymentVolume | MetricKind::PaymentAmount + ), + UsageKind::InvoiceRefunded => matches!( + metric, + MetricKind::DailyRefunds | MetricKind::DailyRefundVolume | MetricKind::RefundRateBps + ), + UsageKind::InvoiceReleased => false, + } +} + +/// Whether `value` breaches the rule's threshold. +pub fn breaches(comparator: Comparator, value: i128, threshold: i128) -> bool { + match comparator { + Comparator::Above => value > threshold, + Comparator::Below => value < threshold, + } +} + +// --------------------------------------------------------------------------- +// Evaluation +// --------------------------------------------------------------------------- + +/// Called after every recorded usage event. Evaluates only the enabled rules +/// whose metric this event can change. +pub fn on_usage(env: &Env, kind: UsageKind, amount: i128) { + if !env.storage().instance().has(&rules_key()) { + return; + } + evaluate(env, Some((kind, amount))); +} + +/// Evaluate every enabled rule (except `PaymentAmount`, which needs a +/// payment). Returns the number of alerts raised. +pub fn check_all(env: &Env) -> u32 { + evaluate(env, None) +} + +fn evaluate(env: &Env, trigger: Option<(UsageKind, i128)>) -> u32 { + let mut rules = get_rules(env); + if rules.is_empty() { + return 0; + } + let now = env.ledger().timestamp(); + let mut stats = get_stats(env); + let mut raised = 0u32; + let mut changed = false; + + for i in 0..rules.len() { + let mut rule = rules.get_unchecked(i); + if !rule.enabled { + continue; + } + let value = match trigger { + Some((kind, amount)) => { + if !affects(kind, rule.metric) { + continue; + } + if rule.metric == MetricKind::PaymentAmount { + Some(amount) + } else { + collect_metric(env, rule.metric) + } + } + None => collect_metric(env, rule.metric), + }; + let Some(value) = value else { continue }; + + stats.evaluations = stats.evaluations.saturating_add(1); + changed = true; + + if !breaches(rule.comparator, value, rule.threshold) { + continue; + } + let cooled_down = rule.trigger_count == 0 + || now >= rule.last_triggered_at.saturating_add(rule.cooldown_secs); + if !cooled_down { + continue; + } + + rule.last_triggered_at = now; + rule.trigger_count = rule.trigger_count.saturating_add(1); + rules.set(i, rule.clone()); + raise(env, &rule, value, now, &mut stats); + raised += 1; + } + + if raised > 0 { + save_rules(env, &rules); + } + if changed { + save_stats(env, &stats); + } + raised +} + +/// Store, publish and deliver one alert. +fn raise(env: &Env, rule: &AlertRule, value: i128, now: u64, stats: &mut MonitoringStats) { + let id: u64 = env.storage().instance().get(&next_alert_id_key()).unwrap_or(1); + env.storage() + .instance() + .set(&next_alert_id_key(), &id.saturating_add(1)); + + let mut alert = Alert { + id, + rule_id: rule.id, + metric: rule.metric, + severity: rule.severity, + value, + threshold: rule.threshold, + triggered_at: now, + acknowledged: false, + delivered: None, + }; + + env.events().publish( + (symbol_short!("split"), symbol_short!("alert"), rule.id), + (id, rule.metric, rule.severity, value, rule.threshold), + ); + + if let Some(receiver) = get_receiver(env) { + let mut args: Vec = Vec::new(env); + args.push_back(alert.clone().into_val(env)); + let res = env.try_invoke_contract::( + &receiver, + &Symbol::new(env, RECEIVER_FN), + args, + ); + let ok = matches!(res, Ok(Ok(_))); + alert.delivered = Some(ok); + if !ok { + stats.failed_deliveries = stats.failed_deliveries.saturating_add(1); + } + } + + env.storage().persistent().set(&alert_key(id), &alert); + // Keep only the most recent MAX_ALERT_HISTORY alerts. + if id > MAX_ALERT_HISTORY { + let old = alert_key(id - MAX_ALERT_HISTORY); + if let Some(old_alert) = env.storage().persistent().get::<_, Alert>(&old) { + if !old_alert.acknowledged { + stats.unacknowledged = stats.unacknowledged.saturating_sub(1); + } + env.storage().persistent().remove(&old); + } + } + + stats.total_alerts = stats.total_alerts.saturating_add(1); + stats.unacknowledged = stats.unacknowledged.saturating_add(1); + stats.last_alert_at = now; + match rule.severity { + AlertSeverity::Info => stats.info_alerts = stats.info_alerts.saturating_add(1), + AlertSeverity::Warning => stats.warning_alerts = stats.warning_alerts.saturating_add(1), + AlertSeverity::Critical => stats.critical_alerts = stats.critical_alerts.saturating_add(1), + } +} + +// --------------------------------------------------------------------------- +// Alerts +// --------------------------------------------------------------------------- + +pub fn get_alert(env: &Env, alert_id: u64) -> Option { + env.storage().persistent().get(&alert_key(alert_id)) +} + +/// Mark an alert as handled. The caller must check admin auth. +pub fn acknowledge(env: &Env, alert_id: u64) -> Result<(), ContractError> { + let mut alert = get_alert(env, alert_id).ok_or(ContractError::MissingStorageEntry)?; + if alert.acknowledged { + return Ok(()); + } + alert.acknowledged = true; + env.storage().persistent().set(&alert_key(alert_id), &alert); + let mut stats = get_stats(env); + stats.unacknowledged = stats.unacknowledged.saturating_sub(1); + save_stats(env, &stats); + env.events() + .publish((symbol_short!("split"), symbol_short!("alert_ack")), alert_id); + Ok(()) +} + +/// Up to `limit` most recent alerts, newest first (capped at the history size). +pub fn recent_alerts(env: &Env, limit: u64) -> Vec { + let mut out = Vec::new(env); + let next: u64 = env.storage().instance().get(&next_alert_id_key()).unwrap_or(1); + let limit = limit.min(MAX_ALERT_HISTORY); + let mut id = next; + while id > 1 && (out.len() as u64) < limit { + id -= 1; + match get_alert(env, id) { + Some(alert) => out.push_back(alert), + None => break, + } + } + out +} + +// --------------------------------------------------------------------------- +// Stats + dashboard +// --------------------------------------------------------------------------- + +pub fn get_stats(env: &Env) -> MonitoringStats { + env.storage().instance().get(&stats_key()).unwrap_or_default() +} + +fn save_stats(env: &Env, stats: &MonitoringStats) { + env.storage().instance().set(&stats_key(), stats); +} + +/// Current value of every collectable metric. +pub fn snapshot(env: &Env) -> Vec { + let mut out = Vec::new(env); + for metric in [ + MetricKind::DailyInvoicesCreated, + MetricKind::DailyPayments, + MetricKind::DailyPaymentVolume, + MetricKind::DailyRefunds, + MetricKind::DailyRefundVolume, + MetricKind::RefundRateBps, + ] { + if let Some(value) = collect_metric(env, metric) { + out.push_back(MetricValue { metric, value }); + } + } + out +} + +pub fn dashboard(env: &Env) -> MonitoringDashboard { + MonitoringDashboard { + stats: get_stats(env), + rules: get_rules(env), + metrics: snapshot(env), + recent_alerts: recent_alerts(env, DASHBOARD_RECENT_ALERTS), + receiver: get_receiver(env), + } +} diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index a72e77a..d02421a 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -9842,3 +9842,106 @@ fn test_1034_report_rejects_bad_ranges() { assert_eq!(c.get_usage_report(&0, &89).len(), 90); } +// --------------------------------------------------------------------------- +// Issue #1033: Contract monitoring and alerting +// --------------------------------------------------------------------------- + +fn alert_rule_1033( + metric: monitoring::MetricKind, + comparator: monitoring::Comparator, + threshold: i128, + severity: monitoring::AlertSeverity, +) -> monitoring::AlertRule { + monitoring::AlertRule { + id: 0, + metric, + comparator, + threshold, + severity, + cooldown_secs: 0, + enabled: true, + last_triggered_at: 0, + trigger_count: 0, + } +} + +#[test] +fn test_1033_large_payment_raises_alert() { + use monitoring::{AlertSeverity, Comparator, MetricKind}; + let (env, contract_id, token_id) = setup_initialized(); + let c = client(&env, &contract_id); + let payer = Address::generate(&env); + StellarAssetClient::new(&env, &token_id).mint(&payer, &1_000); + env.ledger().set_timestamp(1_000); + + let rule_id = c.add_alert_rule(&alert_rule_1033( + MetricKind::PaymentAmount, + Comparator::Above, + 300, + AlertSeverity::Critical, + )); + assert_eq!(rule_id, 1); + + let id = make_invoice(&env, &c, &Address::generate(&env), &Address::generate(&env), 1_000, &token_id, 9_999); + c.pay(&payer, &id, &100_i128, &0_u64, &false, &false, &None); + assert_eq!(c.get_monitoring_stats().total_alerts, 0); + c.pay(&payer, &id, &400_i128, &0_u64, &false, &false, &None); + + let stats = c.get_monitoring_stats(); + assert_eq!(stats.total_alerts, 1); + assert_eq!(stats.critical_alerts, 1); + assert_eq!(stats.unacknowledged, 1); + + let alert = c.get_alert(&1).unwrap(); + assert_eq!(alert.rule_id, rule_id); + assert_eq!(alert.value, 400); + assert_eq!(alert.delivered, None); + + c.acknowledge_alert(&1); + assert!(c.get_alert(&1).unwrap().acknowledged); + assert_eq!(c.get_monitoring_stats().unacknowledged, 0); +} + +#[test] +fn test_1033_check_alerts_and_dashboard() { + use monitoring::{AlertSeverity, Comparator, MetricKind}; + let (env, contract_id, _token_id) = setup_initialized(); + let c = client(&env, &contract_id); + env.ledger().set_timestamp(1_000); + + // "No payments today" can only be caught by an explicit check. + c.add_alert_rule(&alert_rule_1033( + MetricKind::DailyPayments, + Comparator::Below, + 1, + AlertSeverity::Warning, + )); + assert_eq!(c.check_alerts(), 1); + + let dash = c.get_monitoring_dashboard(); + assert_eq!(dash.rules.len(), 1); + assert_eq!(dash.recent_alerts.len(), 1); + assert_eq!(dash.metrics.len(), 6); + assert_eq!(dash.stats.warning_alerts, 1); + assert_eq!(c.get_recent_alerts(&5).len(), 1); +} + +#[test] +fn test_1033_rule_management() { + use monitoring::{AlertSeverity, Comparator, MetricKind}; + let (env, contract_id, _token_id) = setup_initialized(); + let c = client(&env, &contract_id); + let rule = alert_rule_1033(MetricKind::DailyPayments, Comparator::Below, 1, AlertSeverity::Info); + + let id = c.add_alert_rule(&rule); + c.set_alert_rule_enabled(&id, &false); + assert!(!c.get_alert_rules().get(0).unwrap().enabled); + assert_eq!(c.check_alerts(), 0); + + c.remove_alert_rule(&id); + assert_eq!(c.get_alert_rules().len(), 0); + assert!(c.try_remove_alert_rule(&id).is_err()); + + let bad = alert_rule_1033(MetricKind::RefundRateBps, Comparator::Above, 20_000, AlertSeverity::Info); + assert!(c.try_add_alert_rule(&bad).is_err()); +} diff --git a/docs/ISSUE_1033_MONITORING_ALERTING.md b/docs/ISSUE_1033_MONITORING_ALERTING.md new file mode 100644 index 0000000..2a6b84a --- /dev/null +++ b/docs/ISSUE_1033_MONITORING_ALERTING.md @@ -0,0 +1,55 @@ +# Issue #1033: Contract monitoring and alerting + +**Status:** implemented in `contracts/split/src/monitoring.rs`. Builds on the usage analytics from +issue #1034. + +## Design + +| Requirement | How it is met | +|---|---| +| Monitoring system | Alert rules are checked against metrics read from the analytics aggregates. | +| Metric collection | `MetricKind`: invoices, payments, payment volume, refunds and refund volume for today, the lifetime refund rate (bps), and the amount of the payment being processed. `get_metrics()` returns the current value of each. | +| Alerting rules | Up to 20 `AlertRule`s: metric, `Above`/`Below`, threshold, severity (`Info`/`Warning`/`Critical`), cooldown and an enabled flag. | +| Dashboards | `get_monitoring_dashboard()` returns stats, rules, metric values, the 10 most recent alerts and the receiver. | +| Alert delivery | Each alert is stored, emitted as a `(split, alert, rule_id)` event for off-chain relayers, and sent to the optional receiver contract's `on_alert(alert)` through `try_invoke_contract`. | +| Monitoring analytics | `MonitoringStats`: evaluations, alerts by severity, unacknowledged alerts, failed deliveries and the time of the last alert. | + +## Evaluation + +- **On usage**: after each recorded usage event, only the enabled rules whose metric that event can + change are evaluated. With no rules registered this costs one instance read. +- **On demand**: `check_alerts()` evaluates every rule and anyone can call it. A keeper uses it for + conditions that no event triggers, such as "fewer than 1 payment today". `PaymentAmount` rules are + skipped here because they only have a value while a payment is processed. +- A rule fires again only after `cooldown_secs` have passed since its last alert. + +## Delivery guarantees + +- A failing or missing receiver never reverts the payment, refund or creation that raised the alert. + The alert records `delivered = Some(false)` and `failed_deliveries` is incremented. +- History keeps the 50 most recent alerts. Older alerts are removed as new ones arrive. +- If analytics recording is switched off, metrics stop changing and on-usage evaluation stops with it. + +## Entry points + +| Function | Access | +|---|---| +| `add_alert_rule(rule)`, `remove_alert_rule(id)`, `set_alert_rule_enabled(id, enabled)` | admin | +| `set_alert_receiver(receiver)` | admin | +| `acknowledge_alert(id)` | admin | +| `check_alerts()` | anyone | +| `get_alert_rules()`, `get_alert(id)`, `get_recent_alerts(limit)` | anyone | +| `get_metrics()`, `get_monitoring_stats()`, `get_monitoring_dashboard()` | anyone | + +## Events + +| Topics | Data | +|---|---| +| `(split, alert, rule_id)` | `(alert_id, metric, severity, value, threshold)` | +| `(split, alert_ack)` | `alert_id` | +| `(split, mon_add)` | `rule_id` | +| `(split, mon_rm)` | `rule_id` | + +## Tests + +`test_1033_*` in `contracts/split/src/test.rs`. From 05a156f00098a70578ce9e02ed8800fcc749d704 Mon Sep 17 00:00:00 2001 From: augustinemartins Date: Thu, 22 Oct 2026 13:11:48 +0000 Subject: [PATCH 3/3] docs: contract architecture and design decisions (#1032) - Rewrite docs/ARCHITECTURE.md: workspace crate map, split contract layering and module map, storage model, state flows, cross-cutting concerns, design decisions and references. - Mermaid diagrams: system context, module layering, invoice record layout, invoice status state machine (matches valid_transition), payment/release and refund sequences, alert lifecycle. - Update stale content: directory layout, lib.rs size, the broken EVENTS.md link, refund flow (Pending -> Expired -> Refunded), and list module files in src/ that lib.rs does not declare. - Add ADRs 0002 (single contract with modules), 0003 (analytics from event helpers), 0004 (split invoice records), 0005 (alert delivery) and index them in docs/adr/README.md. Closes #1032 Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 514 ++++++++++++++---- docs/adr/0002-single-contract-with-modules.md | 31 ++ .../0003-observability-from-event-helpers.md | 32 ++ docs/adr/0004-split-invoice-records.md | 28 + docs/adr/0005-alert-delivery.md | 29 + docs/adr/README.md | 6 + 6 files changed, 529 insertions(+), 111 deletions(-) create mode 100644 docs/adr/0002-single-contract-with-modules.md create mode 100644 docs/adr/0003-observability-from-event-helpers.md create mode 100644 docs/adr/0004-split-invoice-records.md create mode 100644 docs/adr/0005-alert-delivery.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0d3a92c..936ba8b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,71 +1,236 @@ # Contract Architecture -Overview of the StellarSplit Soroban contract's internal design, storage model, and invoice lifecycle. +How the StellarSplit Soroban contracts are structured, why they are built this way, and how state +moves through them. -## Directory Layout +- [1. System map](#1-system-map) +- [2. The `split` contract](#2-the-split-contract) +- [3. Storage model](#3-storage-model) +- [4. State flows](#4-state-flows) +- [5. Cross-cutting concerns](#5-cross-cutting-concerns) +- [6. Design decisions](#6-design-decisions) +- [7. References](#7-references) +Diagrams use [Mermaid](https://mermaid.js.org/), which GitHub renders inline. + +--- + +## 1. System map + +The repository is one Cargo workspace (`Cargo.toml`) with nine contract crates. All of them are +`#![no_std]` Soroban contracts built against the workspace `soroban-sdk` version. + +| Crate | Path | Role | +|---|---|---| +| `split` | `contracts/split` | Core invoice and payment-splitting contract. Holds most of the protocol logic. | +| `dashboard` | `contracts/dashboard` | Per-creator dashboard counters. Only the authorised `split` contract may write to it. | +| `factory` | `contracts/factory` | Deploys `split` instances as clones of one uploaded WASM blob. | +| `registry` | `contracts/registry` | Index from creator address to the `split` instances they deployed. | +| `invoice-escrow` | `contracts/invoice-escrow` | Stand-alone invoice escrow with its own oracle support. | +| `charity-split` | `contracts/charity-split` | Invoices that donate a share of every payout to a vetted charity. | +| `tax-strategy` | `contracts/tax-strategy` | Creator-configured tax set-asides applied to incoming payments. | +| `mentorship` | `contracts/mentorship` | Creator mentorship program. | +| `skill-verification` | `contracts/skill-verification` | Attestations of recipient skills. | + +```mermaid +flowchart LR + subgraph Off-chain + UI[Frontends / SDK] + IDX[Indexers and relayers] + KEEP[Keepers] + end + + subgraph Core + SPLIT[split] + DASH[dashboard] + end + + subgraph Deployment + FACT[factory] + REG[registry] + end + + subgraph Standalone + ESC[invoice-escrow] + CHAR[charity-split] + TAX[tax-strategy] + MENT[mentorship] + SKILL[skill-verification] + end + + TOKEN[(Stellar asset contracts)] + ORACLE[(Price / condition oracles)] + RCV[(Alert receiver contract)] + + UI -->|create, pay, release, refund| SPLIT + UI --> ESC & CHAR & TAX & MENT & SKILL + FACT -->|deploys clones of| SPLIT + FACT --> REG + SPLIT -->|record_created / released / refunded| DASH + SPLIT <-->|transfer| TOKEN + SPLIT -->|price / condition| ORACLE + SPLIT -->|on_alert| RCV + SPLIT -. events .-> IDX + KEEP -->|check_alerts, retries| SPLIT ``` -contracts/split/src/ -├── lib.rs # All public entry points (~7 500 lines) -├── types.rs # #[contracttype] structs and enums -├── events.rs # Event emission helpers -├── storage_keys.rs # Storage key constructor functions -├── storage_snapshot.rs # XDR snapshot test -├── error.rs # Error types -├── factory.rs # Factory pattern helpers -├── test.rs # Integration tests -└── tests.rs # Additional test modules -tests/ -└── snapshots/ - └── storage_keys.json # Committed XDR baseline for storage key regression tests + +Tests that span crates live in the top-level `tests/` directory. Fuzz targets live in `fuzz/`. + +--- + +## 2. The `split` contract + +### 2.1 Layering + +`lib.rs` defines `SplitContract` and every public entry point (about 19,000 lines, grouped into +`#[contractimpl]` blocks by feature and issue number). Logic that can stand alone lives in +modules, and `lib.rs` calls into them after checking authorisation. + +```mermaid +flowchart TB + EP["lib.rs
#[contractimpl] entry points
auth checks (require_admin, require_role, ...)"] + + subgraph Domain modules + VAL[validation] + CALC[calc] + RULES[split_rules] + VEL[velocity] + PT[payment_tracking] + SEC[security_audit] + INS[insurance_pool] + SUS[sustainability] + COOP[cooperative] + EDU[education_lockup] + end + + subgraph Observability + EV[events] + AN[analytics] + MON[monitoring] + ST[stats] + end + + subgraph Storage + SK[storage_keys] + STO[storage] + MIG[migrations] + TY[types] + end + + EP --> VAL & CALC & RULES & VEL & PT & SEC & INS & SUS & COOP & EDU + EP --> EV + EV --> AN --> MON + EP --> ST + EP --> SK & STO & MIG + VAL & CALC & RULES --> TY ``` +### 2.2 Module map + +Modules declared in `lib.rs`: + +| Module | Purpose | +|---|---| +| `constants` | Shared constants. | +| `error` | `ContractError` codes and recovery suggestions. | +| `types` | `#[contracttype]` structs and enums (invoice records, options, statuses). | +| `events` | Event helpers. Lifecycle helpers also feed `analytics`. | +| `validation` | Invoice input validation (#556, #558). | +| `calc` | Fee and distribution arithmetic. | +| `stats` | Protocol-wide counters (`StatsUpdated`). | +| `security_audit` | Security audit framework (#892). | +| `payment_tracking` | Per-recipient partial payment tracking (#911). | +| `velocity` | Contract-wide payer velocity limits (#912). | +| `split_rules` | Rules engine that computes per-recipient amounts (#913). | +| `analytics` | Usage event tracking, daily aggregates, reports and insights (#1034). | +| `monitoring` | Metrics, alert rules, alert delivery and monitoring stats (#1033). | +| `storage` | Persistent storage helpers with TTL management. | +| `storage_keys` | Central key constructors (#312). | +| `migrations` | Versioned schema checks (`require_schema_current`). | +| `insurance_pool` | Recipient insurance pool governance (#888). | +| `sustainability` | Sustainability impact tracking (#889). | +| `cooperative` | Creator cooperative profit sharing (#890). | +| `education_lockup` | Recipient education funding lock-up (#891). | +| `test`, `fuzz_tests`, `storage_snapshot` | Test-only modules (`#[cfg(test)]`). | + +These files are in `src/` but are **not** declared as modules in `lib.rs`, so they are not compiled: +`confidential_tracking.rs`, `creator_fee_tiers.rs`, `factory.rs`, `invoice_metadata.rs`, `math.rs`, +`priority.rs`, `webhooks.rs`. `lib.rs` calls `webhooks::` functions, so wiring that module in is +needed before those paths build. + +### 2.3 Entry point groups + +The full reference is [`API.md`](./API.md). At a high level: + +| Group | Examples | +|---|---| +| Setup and admin | `initialize`, admin roles, timelocked actions, pause, circuit breaker | +| Invoice lifecycle | `create_invoice`, `cancel_invoice`, `delete_invoice`, `pause_invoice`, `resume_invoice` | +| Payment | `pay` and its variants (batched, on-behalf-of, converted, confidential) | +| Release and refund | `release`, partial and staged release, `refund` | +| Disputes | `raise_dispute`, `resolve_dispute` | +| Configuration | velocity limits, split rules, fee waivers and tiers | +| Observability | `get_stats`, `get_usage_*`, alert rules, `check_alerts`, `get_monitoring_dashboard` | + --- -## Storage Tiers +## 3. Storage model -Soroban has three storage tiers with different rent / eviction behaviour. +### 3.1 Tiers -| Tier | Used For | Key examples | -|------|----------|-------------| -| `instance` | Contract-level singletons that live as long as the contract | `admin_key`, `paused_key`, `usdc_token_key`, `treasury_key`, `platform_fee_bps_key`, `circuit_breaker_key` | -| `persistent` | Per-entity data that needs long retention (invoice bodies, payer records) | `invoice_key`, `invoice_ext_key`, `invoice_hot_key`, `confidential_pay_key`, `vel_key` | -| `temporary` | Short-lived ephemeral state (not currently used in production paths) | — | +Soroban has three storage tiers with different rent and eviction behaviour. Tier choice follows +[ADR 0001](./adr/0001-storage-tiers.md). -Instance keys are never bumped — they inherit the contract's own TTL. Persistent keys are bumped on every read/write in hot paths. +| Tier | Used for | Examples | +|------|----------|----------| +| `instance` | Contract-level singletons read on most calls | admin, paused flag, token, fee config, circuit breaker, `an_tot`, `mon_rul` | +| `persistent` | Per-entity data that needs long retention | invoice records, payer records, `(an_day, day)`, `(mon_al, id)` | +| `temporary` | Data that can be rebuilt from an authoritative record | derived caches | ---- +Instance keys share the contract's TTL. Persistent keys are bumped on hot-path reads and writes. +The full key list is in [`STORAGE_KEY_REGISTRY.md`](../STORAGE_KEY_REGISTRY.md). -## Invoice Data Model +### 3.2 Invoice data model -To stay within Soroban's per-entry size limits an invoice is split across several storage keys that are always read together by `get_invoice`: +To stay within Soroban's per-entry size limits, an invoice is split across several keys that +`get_invoice` reads together ([ADR 0004](./adr/0004-split-invoice-records.md)): ``` -invoice_key(id) → InvoiceCore — creator, recipients, amounts, funded, status, payments, tranches, … -invoice_ext_key(id) → InvoiceExt — co-signers, split rules, oracle, penalty, velocity, allowed_payers, … -invoice_ext2_key(id) → InvoiceExt2 — Wave 5/6 fields: priorities, refund_grace_secs, scheduled_release_at, … -invoice_hot_key(id) → InvoiceHot — { status, funded, total, recipients } — read on every pay() fast-path -invoice_compact_key(id)→ CompactInvoice — XDR-compressed overlay for read-heavy SDK queries +invoice_key(id) → InvoiceCore — creator, recipients, amounts, funded, status, payments, tranches, … +invoice_ext_key(id) → InvoiceExt — co-signers, split rules, oracle, penalty, velocity, allowed_payers, … +invoice_ext2_key(id) → InvoiceExt2 — priorities, refund_grace_secs, scheduled_release_at, … +invoice_hot_key(id) → InvoiceHot — { status, funded, total, recipients }, read on every pay() fast path +invoice_compact_key(id) → CompactInvoice — XDR-compressed overlay for read-heavy SDK queries ``` -Wave 6 also added separate optimisation keys for invoices with many recipients: +Invoices with many recipients also use per-field keys: ``` -recipients_list_key(id) → Vec
— recipient addresses -amounts_list_key(id) → Vec — owed amounts -paid_flags_key(id) → Vec — per-recipient paid flags -milestone_flags_key(id) → Vec — per-milestone release flags -compact_status_key(id) → InvoiceStatus — cached status for fast reads -compact_deadline_ledger_key(id)→ u32 — deadline in ledgers for fast expiry checks +recipients_list_key(id) → Vec
— recipient addresses +amounts_list_key(id) → Vec — owed amounts +paid_flags_key(id) → Vec — per-recipient paid flags +milestone_flags_key(id) → Vec — per-milestone release flags +compact_status_key(id) → InvoiceStatus — cached status for fast reads +compact_deadline_ledger_key(id)→ u32 — deadline in ledgers for fast expiry checks ``` -### Key Types Summary +```mermaid +erDiagram + InvoiceCore ||--|| InvoiceExt : "same id" + InvoiceCore ||--|| InvoiceExt2 : "same id" + InvoiceCore ||--|| InvoiceHot : "cache of" + InvoiceCore ||--o| CompactInvoice : "overlay" + InvoiceCore ||--o{ PaymentShard : "pay_shard_key(id, shard)" + InvoiceCore ||--o{ Recipient : "recipients" +``` + +### 3.3 Key helpers | Key function | Parameters | Stores | |---|---|---| | `invoice_key` | `id: u64` | `InvoiceCore` | | `invoice_ext_key` | `id: u64` | `InvoiceExt` | -| `invoice_ext2_key` | `id: u64` | wave-5 extended fields | +| `invoice_ext2_key` | `id: u64` | extended fields | | `invoice_hot_key` | `id: u64` | `InvoiceHot` (fast-path cache) | | `creator_stats_count_key` | `creator: &Address` | `u64` invoice count | | `creator_stats_volume_key` | `creator: &Address` | `u64` lifetime volume | @@ -74,73 +239,139 @@ compact_deadline_ledger_key(id)→ u32 — deadline in ledgers for fa | `confidential_pay_key` | `(id, payer)` | `ConfidentialPayment` | | `pay_shard_key` | `(id, shard_index)` | `Payment` shard | ---- +### 3.4 Storage key snapshot -## InvoiceStatus Lifecycle - -``` - create_invoice() - │ - ▼ - ┌─────────┐ - │ Pending │◄──────────────────┐ - └────┬────┘ │ - │ resume_invoice() - pay() fills funded │ - │ ┌───────────────┐ - ▼ │ Paused │ - ┌──────────────────┐ └───────────────┘ - │ Fully funded? │──No──► deadline passed? - └──────┬───────────┘ │ - │ Yes refund() - ▼ │ - guards cleared? ▼ - (co-signers, ┌───────────────┐ - prerequisite, │ Refunded │ - oracle, tranche, └───────────────┘ - scheduled_at…) - │ Yes - ▼ - release() - │ - ▼ - ┌───────────────┐ - │ Released │ - └───────────────┘ - │ - archive_invoice_storage() - (after archive_after_ledgers) -``` - -`Cancelled` is a terminal state reachable via admin action. +Every storage key is XDR-serialised and compared against a committed baseline in +`tests/snapshots/storage_keys.json` by the `storage_key_snapshot` test. This stops accidental key +renames or format changes from silently breaking on-chain data compatibility. + +Adding, renaming or removing a key **intentionally fails the test**. Update the baseline and add a +migration note to the PR. + +```bash +# Run the snapshot test +cargo test -p split storage_snapshot + +# Update the baseline after an intentional key change +cargo test -p split storage_snapshot 2>&1 \ + | grep -A 9999 "EXPECTED (generated)" \ + | tail -n +2 | head -n -1 \ + > tests/snapshots/storage_keys.json +``` --- -## Guards on Release +## 4. State flows + +### 4.1 Invoice status + +`InvoiceStatus` is defined in `types.rs`. Allowed moves are enforced by `valid_transition` in +`lib.rs`: + +```mermaid +stateDiagram-v2 + [*] --> Pending: create_invoice + Pending --> Released: fully funded and guards pass + Pending --> PartiallyReleased: staged / partial release + Pending --> Disputed: raise_dispute + Pending --> Expired: deadline passed + Pending --> Refunded + Expired --> Refunded: refund + Pending --> Cancelled: cancel_invoice + PartiallyReleased --> Released: remaining stages released + PartiallyReleased --> Pending: dispute resolution + Disputed --> Pending: resolve_dispute + Disputed --> Refunded: resolve_dispute + PayoutInProgress --> Released + PayoutInProgress --> Refunded + Released --> [*] + Refunded --> [*] + Cancelled --> [*] +``` -`_release` checks these conditions in order before transferring funds: +- `Released`, `Refunded`, `Cancelled`, `Finalised` and `Deleted` are terminal. `valid_transition` + also lists `Expired` as terminal, but `refund()` moves an expired invoice to `Refunded` directly. +- `PayoutInProgress` is an intermediate state while `release_funds` pays recipients (#564). +- `Finalised` is treated as `Released` when checking parent invoices (#522). +- `Deleted` is a soft-delete: a tombstone is kept for the audit trail. +- Same-state transitions are allowed (idempotent). +- Pausing an invoice (`pause_invoice` / `resume_invoice`) blocks payments without changing its status. +- Settled invoices are archived by the internal `archive_invoice_storage` helper once the configured + archive delay (default `ARCHIVE_AFTER_LEDGERS`) has passed. + +### 4.2 Payment and release + +```mermaid +sequenceDiagram + actor Payer + participant S as split (lib.rs) + participant V as velocity / validation + participant T as token contract + participant E as events + participant A as analytics + participant M as monitoring + participant D as dashboard + + Payer->>S: pay(payer, invoice_id, amount, ...) + S->>S: require_auth, pause and circuit-breaker checks + S->>V: enforce limits, validate amount + S->>T: transfer(payer → contract) + S->>S: deduct fees, update funded, write InvoiceHot + S->>E: payment_received + E->>A: record(PaymentReceived) + A->>M: on_usage (only if rules exist) + alt fully funded and guards pass + S->>T: transfer(contract → each recipient) + S->>E: invoice_released + E->>A: record(InvoiceReleased) + S->>D: record_released (if dashboard configured) + end +``` -1. **Circuit breaker** — if active, panics with `ContractPaused` -2. **Contract pause** — if paused, only exempt addresses proceed -3. **Status** — must be `Pending` -4. **Prerequisite** — referenced invoice must be `Released` -5. **Co-signers** — must have `required_signatures` approvals -6. **Oracle condition** — `condition_met` must be `true` if `oracle_address` is set -7. **Min funding** — `funded * 10_000 / total >= min_funding_bps` -8. **Tranches** — all tranches must be unlocked by timestamp -9. **Release stages** — if `release_stages` is set, only the currently active stage amount is released -10. **Scheduled release** — `scheduled_release_at` must be in the past -11. **Release delay** — `release_delay_ledgers` post-full-funding ledger lock must have elapsed +### 4.3 Guards on release ---- +`_release` checks these conditions in order before transferring funds: + +1. **Circuit breaker**: if active, panics with `ContractPaused`. +2. **Contract pause**: if paused, only exempt addresses proceed. +3. **Status**: must be `Pending`. +4. **Prerequisite**: the referenced invoice must be `Released`. +5. **Co-signers**: must have `required_signatures` approvals. +6. **Oracle condition**: `condition_met` must be `true` if `oracle_address` is set. +7. **Min funding**: `funded * 10_000 / total >= min_funding_bps`. +8. **Tranches**: all tranches must be unlocked by timestamp. +9. **Release stages**: if `release_stages` is set, only the active stage amount is released. +10. **Scheduled release**: `scheduled_release_at` must be in the past. +11. **Release delay**: the `release_delay_ledgers` lock after full funding must have elapsed. + +### 4.4 Refund + +```mermaid +sequenceDiagram + actor Caller + participant S as split + participant T as token contract + participant E as events + participant A as analytics + + Caller->>S: refund(invoice_id) + S->>S: Pending and deadline passed → Expired + S->>S: total each payer's payments across all SHARD_COUNT shards + loop each payer + S->>T: transfer(contract → payer) + end + S->>S: status = Refunded + S->>E: invoice_refunded(amount) + E->>A: record(InvoiceRefunded, amount) +``` -## Fee Flow +### 4.5 Fee flow ``` payer sends amount │ ├─► platform_fee = amount * platform_fee_bps / 10_000 - │ (waived if creator on fee_waiver list) + │ (waived if creator is on the fee waiver list) │ (reduced if creator qualifies for a fee tier) ├─► tax = amount * tax_bps / 10_000 → tax_authority ├─► insurance = amount * insurance_premium_bps / 10_000 → insurance_fund @@ -148,6 +379,7 @@ payer sends amount ``` At release: + ``` funded balance │ @@ -156,32 +388,92 @@ funded balance └─► transfer to each recipient ``` +See [`FEE_FLOW.md`](./FEE_FLOW.md) for details. + +### 4.6 Alert lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Raised: rule breached and cooldown elapsed + Raised --> Delivered: receiver on_alert succeeded + Raised --> DeliveryFailed: receiver call failed + Raised --> Logged: no receiver set + Delivered --> Acknowledged: acknowledge_alert + DeliveryFailed --> Acknowledged: acknowledge_alert + Logged --> Acknowledged: acknowledge_alert + Acknowledged --> [*] + Raised --> Pruned: 50 newer alerts exist +``` + --- -## Event Model +## 5. Cross-cutting concerns -All events use a three-part topic: `(namespace, event_name, entity_id)`. +### 5.1 Authorisation -- Namespace is always `symbol_short!("split")` -- Entity ID is the `invoice_id` for invoice events, or omitted for contract-level events +- `require_admin` loads the admin address, checks the schema version (`migrations`) and calls + `require_auth`. +- `require_admin_role` and `require_role` gate multi-admin and role-based actions. +- Invoice actions check the creator or co-creators. +- Read-only getters need no auth. `check_alerts` is open to anyone on purpose (keepers). -See [`docs/EVENTS.md`](./EVENTS.md) for the complete event reference. +### 5.2 Events + +Events use the topic layout `(symbol_short!("split"), action, invoice_id?)`. The invoice id is left +out for contract-level events. Per-invoice lifecycle events end with an `event_seq` for stable +ordering. The naming rules are at the top of `contracts/split/src/events.rs`. + +### 5.3 Observability + +| Layer | Source | Read with | +|---|---|---| +| Protocol counters | `stats.rs` | `get_stats` | +| Usage analytics | `analytics.rs`, fed by lifecycle events | `get_usage_dashboard`, `get_usage_report` | +| Monitoring | `monitoring.rs`, fed by analytics | `get_monitoring_dashboard`, `(split, alert, …)` events | +| Per-creator dashboard | `dashboard` crate | `get_creator_dashboard` | + +### 5.4 Safety limits + +- Loops over contract-wide collections are bounded (for example `MAX_ALERT_RULES`, + `MAX_REPORT_DAYS`, `MAX_PAYMENT_BATCH_LIMIT`, `MAX_CO_CREATORS`). +- Counters that must never revert a payment saturate instead of failing. +- External calls whose failure must not revert the caller use `try_invoke_contract`. +- Release profile: `overflow-checks = true`, `panic = "abort"`, `opt-level = "z"`. --- -## Storage Key Snapshot +## 6. Design decisions -Every storage key is XDR-serialised and compared against a committed baseline in `tests/snapshots/storage_keys.json` by the `storage_key_snapshot` test. This prevents accidental key renames or format changes from silently breaking on-chain data compatibility. +Decisions are recorded as ADRs in [`docs/adr`](./adr/README.md). -Adding, renaming, or removing any key **intentionally fails the test** — update the baseline and include a migration note in the PR. +| ADR | Decision | +|---|---| +| [0001](./adr/0001-storage-tiers.md) | Choose the storage tier by access pattern. | +| [0002](./adr/0002-single-contract-with-modules.md) | Keep one `split` contract, with entry points in `lib.rs` and logic in modules. | +| [0003](./adr/0003-observability-from-event-helpers.md) | Record usage analytics from the lifecycle event helpers. | +| [0004](./adr/0004-split-invoice-records.md) | Split invoice records across several keys, with a hot-path cache. | +| [0005](./adr/0005-alert-delivery.md) | Deliver alerts as events plus an optional receiver contract that cannot revert the caller. | -```bash -# Run the snapshot test -cargo test -p split storage_snapshot +Feature-level design notes are in the `docs/ISSUE_*.md` files. -# Update the baseline after an intentional key change -cargo test -p split storage_snapshot 2>&1 \ - | grep -A 9999 "EXPECTED (generated)" \ - | tail -n +2 | head -n -1 \ - > tests/snapshots/storage_keys.json -``` +--- + +## 7. References + +Repository: + +- [`API.md`](./API.md): entry point reference +- [`ERROR_CODES.md`](./ERROR_CODES.md): `ContractError` codes +- [`FEE_FLOW.md`](./FEE_FLOW.md): fee calculation +- [`COMPUTE_BUDGETS.md`](./COMPUTE_BUDGETS.md) and [`PERFORMANCE_TUNING.md`](./PERFORMANCE_TUNING.md): cost +- [`STORAGE_KEY_CONVENTIONS.md`](./STORAGE_KEY_CONVENTIONS.md) and [`STORAGE_KEY_REGISTRY.md`](../STORAGE_KEY_REGISTRY.md): storage keys +- [`ISSUE_1034_USAGE_ANALYTICS.md`](./ISSUE_1034_USAGE_ANALYTICS.md) and [`ISSUE_1033_MONITORING_ALERTING.md`](./ISSUE_1033_MONITORING_ALERTING.md): observability +- [`CONTRIBUTING.md`](../CONTRIBUTING.md) + +External: + +- Soroban documentation: +- Soroban storage and state archival: +- `soroban-sdk` crate docs: +- Architecture decision records: +- Mermaid syntax: diff --git a/docs/adr/0002-single-contract-with-modules.md b/docs/adr/0002-single-contract-with-modules.md new file mode 100644 index 0000000..276207d --- /dev/null +++ b/docs/adr/0002-single-contract-with-modules.md @@ -0,0 +1,31 @@ +# ADR 0002: One `split` contract, entry points in `lib.rs`, logic in modules + +- **Status:** accepted (describes the current structure) +- **Context:** The protocol has grown to hundreds of entry points: invoices, payments, disputes, + fees, governance and observability. They share invoice state, the admin, pause and circuit-breaker + flags, and the token configuration. + +## Decision + +- Keep the core protocol in a single `split` contract instead of one contract per feature. +- Declare every public entry point in `lib.rs`, grouped into `#[contractimpl]` blocks by feature and + issue number. Each entry point does its authorisation check there. +- Put logic that can stand alone (validation, rules, velocity, analytics, monitoring, …) in a module + that takes `&Env` and owns its storage keys. The module does not check auth; the entry point does. +- Put independent products (`charity-split`, `tax-strategy`, `mentorship`, …) in separate crates. + +## Consequences + +- Features share state without cross-contract calls, which keeps instruction and fee costs down. +- Auth is checked in one visible place per entry point. +- Modules are easy to unit test with `env.as_contract`. +- `lib.rs` is very large (about 19,000 lines). [`ISSUE_966_LIB_MODULE_PLAN.md`](../ISSUE_966_LIB_MODULE_PLAN.md) + tracks moving more code into modules. +- The WASM size is bounded by one contract, so new features must watch binary size. + +## Alternatives considered + +- **One contract per feature.** Smaller binaries, but every shared read becomes a cross-contract + call, and invoice state would need a separate owner contract. +- **Entry points inside modules.** Soroban allows several `#[contractimpl]` blocks, but scattering + them makes the public surface and its auth checks harder to review. diff --git a/docs/adr/0003-observability-from-event-helpers.md b/docs/adr/0003-observability-from-event-helpers.md new file mode 100644 index 0000000..5ca25b0 --- /dev/null +++ b/docs/adr/0003-observability-from-event-helpers.md @@ -0,0 +1,32 @@ +# ADR 0003: Record usage analytics from the lifecycle event helpers + +- **Status:** accepted (issue #1034, extended by issue #1033) +- **Context:** Usage analytics need to see every invoice creation, payment, release and refund. These + happen on many code paths (plain, batched, on-behalf-of, converted and streamed payments; several + release and refund variants), and new paths keep being added. + +## Decision + +- Call `analytics::record` from the event helpers in `events.rs` (`invoice_created`, + `payment_received`, `invoice_released`, `invoice_refunded`, `invoice_refunded_with_reason`), not from + each entry point. +- Store aggregates on-chain: lifetime totals in instance storage and one bucket per UTC day in + persistent storage. +- Recording must never publish events and must never fail: counters saturate. +- Monitoring (`monitoring::on_usage`) runs at the end of `analytics::record`, so alert rules see the + updated aggregates. + +## Consequences + +- Any path that emits a lifecycle event is counted, including future ones, with no extra code. +- The event stream that indexers and tests depend on is unchanged. +- Each tracked event costs one instance write and one persistent write, plus one persistent write the + first time an address is seen. The admin can switch recording off. +- Analytics depend on the event helpers being called. A path that changes state without emitting the + event is not counted, which would also be an event bug. + +## Alternatives considered + +- **Off-chain only (indexer).** No on-chain cost, but contracts and keepers cannot read the numbers, + and alert rules could not run on-chain. +- **Hooks at every entry point.** Explicit, but easy to miss on new paths and repeated in many places. diff --git a/docs/adr/0004-split-invoice-records.md b/docs/adr/0004-split-invoice-records.md new file mode 100644 index 0000000..fc4328d --- /dev/null +++ b/docs/adr/0004-split-invoice-records.md @@ -0,0 +1,28 @@ +# ADR 0004: Split invoice records across several keys, with a hot-path cache + +- **Status:** accepted (describes the current data model) +- **Context:** An invoice carries many optional features: co-signers, oracles, tranches, split rules, + priorities and more. Storing it as one entry would exceed Soroban's per-entry size limit and make + every `pay()` read and rewrite the whole record. + +## Decision + +- Store an invoice as `InvoiceCore` plus extension records (`InvoiceExt`, `InvoiceExt2`, …) under the + same id. `get_invoice` reads them together. +- Keep a small `InvoiceHot` record (`status`, `funded`, `total`, `recipients`) that the payment fast + path reads and writes. +- Store payments in `SHARD_COUNT` shards per invoice so no single entry grows without bound. +- For invoices with many recipients, keep per-field lists (recipients, amounts, paid flags). + +## Consequences + +- Payments touch small entries, which keeps fees and instruction counts down. +- Adding an optional feature means adding fields to an extension record, not to the core. +- Readers must go through the getters so the parts stay consistent. The storage key snapshot test + catches accidental key changes. +- Refunds aggregate across shards, so their cost grows with the number of shards. + +## Alternatives considered + +- **One record per invoice.** Simple, but hits the size limit and rewrites everything on every payment. +- **One key per field everywhere.** Smallest writes, but many reads to rebuild an invoice. diff --git a/docs/adr/0005-alert-delivery.md b/docs/adr/0005-alert-delivery.md new file mode 100644 index 0000000..b783315 --- /dev/null +++ b/docs/adr/0005-alert-delivery.md @@ -0,0 +1,29 @@ +# ADR 0005: Deliver alerts as events plus an optional receiver contract + +- **Status:** accepted (issue #1033) +- **Context:** Soroban contracts cannot make HTTP calls, so alerts cannot be sent to a pager or chat + directly. Alerts are often raised during a payment, which must not fail because of monitoring. + +## Decision + +- Every alert is stored in a bounded history (newest 50) and emitted as a `(split, alert, rule_id)` + event that off-chain relayers can forward anywhere. +- When the admin sets a receiver contract, the alert is also passed to its `on_alert(alert)` function + with `try_invoke_contract`. The result is recorded on the alert (`delivered`), and failures are + counted in `MonitoringStats.failed_deliveries`. +- Rules have a cooldown so a sustained breach does not raise an alert on every call. +- `check_alerts` is open to anyone, so a keeper can evaluate rules that no event triggers. + +## Consequences + +- A broken or malicious receiver cannot revert the payment, refund or creation that raised the alert. +- Off-chain delivery is at-least-once via the event stream. Relayers deduplicate by alert id. +- Alerts older than the newest 50 are removed from storage but remain in the event history. +- The receiver call adds cost only when an alert fires. + +## Alternatives considered + +- **Events only.** Cheapest, but on-chain automation (for example auto-pausing) could not react. +- **Reuse the webhook subsystem.** Its retry ledger is more than alerts need, and the `webhooks` + module is not currently compiled into the contract. +- **Required receiver call.** Simpler, but lets a failing receiver block payments. diff --git a/docs/adr/README.md b/docs/adr/README.md index 533f031..796ef39 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -5,6 +5,10 @@ Each record explains one design decision: the context, the decision, and its con | # | Title | Status | |---|---|---| | [0001](./0001-storage-tiers.md) | Storage tiers by access pattern | Accepted | +| [0002](./0002-single-contract-with-modules.md) | One `split` contract, entry points in `lib.rs`, logic in modules | Accepted | +| [0003](./0003-observability-from-event-helpers.md) | Record usage analytics from the lifecycle event helpers | Accepted | +| [0004](./0004-split-invoice-records.md) | Split invoice records across several keys, with a hot-path cache | Accepted | +| [0005](./0005-alert-delivery.md) | Deliver alerts as events plus an optional receiver contract | Accepted | ## Adding a record @@ -12,3 +16,5 @@ Each record explains one design decision: the context, the decision, and its con 2. Number it with the next free four-digit number. 3. Add a row to the table above. 4. Mark a decision that replaces another one as superseded, and link the new record. + +The architecture overview that links these records is [`../ARCHITECTURE.md`](../ARCHITECTURE.md).