Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions campaign/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions campaign/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
}
53 changes: 50 additions & 3 deletions campaign/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions campaign/src/multi_asset_release.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions campaign/src/release_milestone.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -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);
Expand Down
35 changes: 35 additions & 0 deletions campaign/src/storage.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -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.
Expand Down
75 changes: 75 additions & 0 deletions campaign/src/test/diagnostics_tests.rs
Original file line number Diff line number Diff line change
@@ -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<StellarAsset> = 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<crate::types::MilestoneData> =
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);
}
18 changes: 18 additions & 0 deletions campaign/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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 ──────────────────────────────────────────────────────────────
Expand Down
Loading
Loading