diff --git a/campaign/Cargo.toml b/campaign/Cargo.toml index 55c52fc..2f76b72 100644 --- a/campaign/Cargo.toml +++ b/campaign/Cargo.toml @@ -11,6 +11,10 @@ crate-type = ["cdylib", "rlib"] soroban-sdk = { workspace = true } common = { workspace = true } +[features] +default = [] +diag = [] + [dev-dependencies] soroban-sdk = { version = "26.0.1", features = ["testutils"] } # Inherit the workspace-wide ed25519-dalek = 2.2.0 exact pin (see diff --git a/campaign/src/event.rs b/campaign/src/event.rs index b075e98..d8c952e 100644 --- a/campaign/src/event.rs +++ b/campaign/src/event.rs @@ -6,6 +6,9 @@ use soroban_sdk::{Address, Env, String, Symbol}; +#[cfg(feature = "diag")] +use crate::types::CampaignMetrics; + /// Emitted when a donation is received by the campaign. pub fn donation_received( env: &Env, @@ -100,3 +103,12 @@ pub fn contract_unfrozen(env: &Env, admin: &Address, timestamp: u64) { env.events() .publish(("campaign", "contract_unfrozen"), (admin, timestamp)); } + +/// Emit current diagnostic metrics as an event. +/// Only compiled when the `diag` feature is enabled. +#[cfg(feature = "diag")] +pub fn diagnostics_emit(env: &Env, metrics: &CampaignMetrics) { + let ledger = env.ledger().sequence(); + env.events() + .publish(("campaign", "diagnostics"), (metrics, ledger)); +} diff --git a/campaign/src/lib.rs b/campaign/src/lib.rs index 098abda..afc92fd 100644 --- a/campaign/src/lib.rs +++ b/campaign/src/lib.rs @@ -25,6 +25,8 @@ pub mod types; pub mod views; use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, String, Vec}; +#[cfg(feature = "diag")] +use storage::storage_increment_diagnostic_counter; use storage::{ acquire_lock, get_campaign, get_donor, get_donor_asset_donation, get_milestone, increment_donor_asset_donation, is_frozen, release_lock, set_campaign, set_donor, set_frozen, @@ -35,9 +37,9 @@ use storage::{ }; use types::{ - AssetInfo, CampaignData, CampaignInitializedEvent, CampaignReport, CampaignStatus, - CampaignStatusResponse, DashboardMetrics, DonorRecord, Error, MilestoneData, MilestoneStatus, - PlatformSummary, StellarAsset, + AssetInfo, CampaignData, CampaignInitializedEvent, CampaignMetrics, CampaignReport, + CampaignStatus, CampaignStatusResponse, DashboardMetrics, DonorRecord, Error, MilestoneData, + MilestoneStatus, PlatformSummary, StellarAsset, }; pub const VERSION: u32 = 1; @@ -271,6 +273,12 @@ impl CampaignContract { env.ledger().timestamp(), ); + // Track diagnostic counter (no-op when `diag` feature is disabled) + #[cfg(feature = "diag")] + storage_increment_diagnostic_counter(&env, |m: &mut CampaignMetrics| { + m.donations_total += 1; + }); + // Issue #242 – Release reentrancy lock release_lock(&env); } @@ -327,6 +335,38 @@ impl CampaignContract { } } + /// Emit current diagnostics as a `diagnostics` event. + /// + /// When the `diag` feature is disabled the event is not published + /// (the function body is empty). No auth required (view-like call). + pub fn emit_diagnostics(env: Env) { + #[cfg(feature = "diag")] + { + let metrics = crate::storage::storage_get_diagnostic_metrics(&env); + event::diagnostics_emit(&env, &metrics); + let mut metrics = metrics; + metrics.last_diagnostics_ledger = env.ledger().sequence(); + crate::storage::storage_set_diagnostic_metrics(&env, &metrics); + } + #[cfg(not(feature = "diag"))] + let _ = env; + } + + /// Returns diagnostic counters for the campaign contract. + /// + /// When the `diag` feature is disabled (default), returns all zeros. + /// When `diag` is enabled, returns live counters tracked in storage. + /// No auth required (read-only view). + pub fn metrics_view(env: Env) -> CampaignMetrics { + #[cfg(feature = "diag")] + { + return crate::storage::storage_get_diagnostic_metrics(&env); + } + #[cfg(not(feature = "diag"))] + let _ = env; + CampaignMetrics::default() + } + /// Returns compact metrics for campaign dashboards. pub fn get_dashboard_metrics(env: Env) -> DashboardMetrics { let summary = Self::get_platform_summary(env); @@ -483,6 +523,12 @@ impl CampaignContract { (&donor, donor_record.total_donated), ); + // Track diagnostic counter (no-op when `diag` feature is disabled) + #[cfg(feature = "diag")] + storage_increment_diagnostic_counter(&env, |m: &mut CampaignMetrics| { + m.refunds_total += 1; + }); + // Issue #242 – Release reentrancy lock release_lock(&env); } @@ -841,6 +887,7 @@ pub fn validate_milestone_transition( mod test { pub mod budget_invariant_tests; pub mod claim_refund_tests; + pub mod diagnostics_tests; pub mod get_campaign_status_tests; pub mod integration_tests; pub mod invariant_tests; diff --git a/campaign/src/multi_asset_release.rs b/campaign/src/multi_asset_release.rs index ada1805..d53f3a8 100644 --- a/campaign/src/multi_asset_release.rs +++ b/campaign/src/multi_asset_release.rs @@ -1,9 +1,13 @@ use crate::event; +#[cfg(feature = "diag")] +use crate::storage::storage_increment_diagnostic_counter; use crate::storage::{ acquire_lock, get_campaign, get_milestone, is_frozen, release_lock, set_milestone, storage_get_asset_raised, storage_get_total_raised, storage_increment_release_count, storage_set_asset_raised, storage_set_total_raised, }; +#[cfg(feature = "diag")] +use crate::types::CampaignMetrics; use crate::types::{Error, MilestoneStatus}; use soroban_sdk::{panic_with_error, symbol_short, token, Address, Env}; @@ -197,6 +201,10 @@ pub fn release_milestone_multi_asset(env: &Env, milestone_index: u32, recipient: let new_total_raised = total_raised.checked_sub(total_released).unwrap_or(0).max(0); storage_set_total_raised(env, new_total_raised); storage_increment_release_count(env); + #[cfg(feature = "diag")] + storage_increment_diagnostic_counter(env, |m: &mut CampaignMetrics| { + m.milestones_released_total += 1; + }); // Issue #242 – Release reentrancy lock release_lock(env); diff --git a/campaign/src/release_milestone.rs b/campaign/src/release_milestone.rs index 47ca2b9..5b7ecac 100644 --- a/campaign/src/release_milestone.rs +++ b/campaign/src/release_milestone.rs @@ -1,8 +1,12 @@ use crate::event; +#[cfg(feature = "diag")] +use crate::storage::storage_increment_diagnostic_counter; use crate::storage::{ acquire_lock, get_campaign, get_milestone, is_frozen, release_lock, set_milestone, storage_increment_release_count, }; +#[cfg(feature = "diag")] +use crate::types::CampaignMetrics; use crate::types::{Error, MilestoneStatus}; use soroban_sdk::{panic_with_error, token, Address, Env}; @@ -125,6 +129,10 @@ pub fn release_milestone(env: &Env, milestone_index: u32, recipient: Address) { milestone.released_to = Some(recipient); set_milestone(env, milestone_index, &milestone); storage_increment_release_count(env); + #[cfg(feature = "diag")] + storage_increment_diagnostic_counter(env, |m: &mut CampaignMetrics| { + m.milestones_released_total += 1; + }); // Issue #242 – Release reentrancy lock release_lock(env); diff --git a/campaign/src/storage.rs b/campaign/src/storage.rs index 5553f4f..da34029 100644 --- a/campaign/src/storage.rs +++ b/campaign/src/storage.rs @@ -1,5 +1,7 @@ // src/storage.rs +#[cfg(feature = "diag")] +use crate::types::CampaignMetrics; use crate::types::{CampaignData, DataKey, DonorRecord, Error, MilestoneData}; use soroban_sdk::{panic_with_error, Address, Env}; @@ -371,6 +373,39 @@ pub fn set_frozen(env: &Env, frozen: bool) { bump_persistent(env, &key); } +// ─── Diagnostic metrics (feature-gated) ────────────────────────────────────── + +/// Load the diagnostic metrics counter record. +/// Returns default (all zeros) if never written. +#[cfg(feature = "diag")] +pub fn storage_get_diagnostic_metrics(env: &Env) -> CampaignMetrics { + let value: CampaignMetrics = env + .storage() + .persistent() + .get(&DataKey::DiagnosticMetrics) + .unwrap_or_default(); + bump_persistent(env, &DataKey::DiagnosticMetrics); + value +} + +/// Persist the diagnostic metrics. +#[cfg(feature = "diag")] +pub fn storage_set_diagnostic_metrics(env: &Env, metrics: &CampaignMetrics) { + env.storage() + .persistent() + .set(&DataKey::DiagnosticMetrics, metrics); + bump_persistent(env, &DataKey::DiagnosticMetrics); +} + +/// Increment a diagnostic counter in storage. +/// No-op when the `diag` feature is disabled (the function body is not compiled). +#[cfg(feature = "diag")] +pub fn storage_increment_diagnostic_counter(env: &Env, field: fn(&mut CampaignMetrics)) { + let mut metrics = storage_get_diagnostic_metrics(env); + field(&mut metrics); + storage_set_diagnostic_metrics(env, &metrics); +} + // ─── Bulk TTL refresh ───────────────────────────────────────────────────────── /// Refresh TTL for all core persistent keys in a single call. diff --git a/campaign/src/test/diagnostics_tests.rs b/campaign/src/test/diagnostics_tests.rs new file mode 100644 index 0000000..b61fa83 --- /dev/null +++ b/campaign/src/test/diagnostics_tests.rs @@ -0,0 +1,75 @@ +#![cfg(test)] + +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{Env, String}; + +use super::with_contract; +use crate::types::{CampaignMetrics, StellarAsset}; +use crate::CampaignContract; + +fn setup_basic_env(env: &Env) { + env.mock_all_auths(); + with_contract(env, || { + let creator = soroban_sdk::Address::generate(env); + let mut assets: soroban_sdk::Vec = soroban_sdk::Vec::new(env); + assets.push_back(StellarAsset { + asset_code: String::from_str(env, "XLM"), + issuer: Some(soroban_sdk::Address::generate(env)), + }); + let mut milestones: soroban_sdk::Vec = + soroban_sdk::Vec::new(env); + milestones.push_back(crate::types::MilestoneData { + index: 0, + target_amount: 1000, + released_amount: 0, + description_hash: soroban_sdk::BytesN::from_array(env, &[1u8; 32]), + status: crate::types::MilestoneStatus::Locked, + released_at: None, + released_at_ledger: None, + release_tx: None, + released_to: None, + }); + + CampaignContract::initialize( + env.clone(), + creator, + 1000, + env.ledger().timestamp() + 86400, + assets, + milestones, + 0, + ) + .unwrap(); + }); +} + +#[test] +fn test_metrics_view_returns_defaults_before_any_ops() { + let env = Env::default(); + setup_basic_env(&env); + with_contract(&env, || { + let metrics = CampaignContract::metrics_view(env.clone()); + assert_eq!(metrics.donations_total, 0); + assert_eq!(metrics.milestones_released_total, 0); + assert_eq!(metrics.refunds_total, 0); + }); +} + +#[test] +fn test_emit_diagnostics_does_not_panic() { + let env = Env::default(); + setup_basic_env(&env); + with_contract(&env, || { + CampaignContract::emit_diagnostics(env.clone()); + }); +} + +#[test] +fn test_metrics_view_returns_struct() { + let env = Env::default(); + let metrics = CampaignMetrics::default(); + assert_eq!(metrics.donations_total, 0); + assert_eq!(metrics.milestones_released_total, 0); + assert_eq!(metrics.refunds_total, 0); + assert_eq!(metrics.last_diagnostics_ledger, 0); +} diff --git a/campaign/src/types.rs b/campaign/src/types.rs index fd3c2bc..3d592a9 100644 --- a/campaign/src/types.rs +++ b/campaign/src/types.rs @@ -182,6 +182,22 @@ pub const WIRE_CODE_TABLE: &[(Error, u32)] = &[ (Error::InvalidPage, 84), ]; +/// Diagnostic counters for the campaign contract. +/// +/// Only populated when the `diag` feature is enabled. The `metrics_view` +/// entrypoint always exists but returns all zeros when the feature is off. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub struct CampaignMetrics { + /// Total number of successful donation calls. + pub donations_total: u64, + /// Total number of completed milestone releases. + pub milestones_released_total: u64, + /// Total number of successfully processed refunds. + pub refunds_total: u64, + /// Ledger sequence when diagnostics were last emitted. + pub last_diagnostics_ledger: u32, +} #[cfg(test)] mod error_code_tests { #[test] @@ -375,6 +391,8 @@ pub enum DataKey { ReentrancyLock, /// Freeze flag; present and true = contract is frozen, mutating ops blocked. Frozen, + /// Diagnostic counters (only written when feature `diag` is enabled). + DiagnosticMetrics, } // ─── Asset types ────────────────────────────────────────────────────────────── diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 0000000..47ab36a --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,79 @@ +# Observability — Diagnostic Metrics & Events + +The campaign contract supports an optional `diag` feature that enables structured +tracing and runtime counters for observability. When the feature is disabled +(default), all diagnostic code is compiled away — zero storage overhead, zero +event emission. + +## Feature Flag + +| Flag | Default | Description | +|--------|---------|------------------------------------------------------| +| `diag` | off | Enables diagnostic counters and `diagnostics` events | + +Enable at build time: + +```bash +cargo build -p milestonex-campaign --features diag --target wasm32v1-none --release +``` + +## Metrics View + +`metrics_view` — always available; returns all-zero counters when `diag` is off. + +### `CampaignMetrics` + +| Counter | Type | Description | +|---------------------------|---------|-------------------------------------------| +| `donations_total` | `u64` | Successful donation calls | +| `milestones_released_total` | `u64` | Completed milestone releases | +| `refunds_total` | `u64` | Successfully processed refunds | +| `last_diagnostics_ledger` | `u32` | Ledger sequence of last `emit_diagnostics` | + +## Diagnostics Event + +`emit_diagnostics` publishes a `("campaign", "diagnostics")` event containing +the current `CampaignMetrics` struct and the ledger sequence. The event is only +emitted when the `diag` feature is enabled; when disabled the entrypoint is a +no-op. + +### Event payload (feature `diag` on) + +```json +{ + "topic": ["campaign", "diagnostics"], + "data": { + "metrics": { + "donations_total": 42, + "milestones_released_total": 3, + "refunds_total": 1, + "last_diagnostics_ledger": 20100 + }, + "ledger": 20100 + } +} +``` + +## Usage + +```rust +// Read counters (always available) +let metrics = contract_client.metrics_view(); + +// Emit a diagnostics event (only emits when built with --features diag) +contract_client.emit_diagnostics(); +``` + +## Testing + +Run diagnostics tests with the default (diag off) configuration: + +```bash +cargo test -p milestonex-campaign -- diagnostics +``` + +Run diagnostics tests with the feature enabled: + +```bash +cargo test -p milestonex-campaign --features diag -- diagnostics +```