From 6896b7299849195957a6646ffef8e99524d26af3 Mon Sep 17 00:00:00 2001 From: OrbitChain Developer Date: Sun, 21 Jun 2026 03:10:43 +0100 Subject: [PATCH] fix: replace non-standard ms_skip event with documented milestone_release_skipped topic - Add missing timestamp variable in multi_asset_release.rs - Replace raw symbol_short event publish with event::milestone_release_skipped using documented (campaign, milestone_release_skipped) topic - Add milestone_release_completed summary event after per-asset loop - Add event assertions to native asset skip test - Document both events in docs/events.md --- campaign/src/event.rs | 27 +++++++ campaign/src/multi_asset_release.rs | 20 ++++-- campaign/src/test/release_milestone_tests.rs | 76 ++++++++++++++++++++ docs/events.md | 35 +++++++++ 4 files changed, 154 insertions(+), 4 deletions(-) diff --git a/campaign/src/event.rs b/campaign/src/event.rs index 9d14e68..07dc173 100644 --- a/campaign/src/event.rs +++ b/campaign/src/event.rs @@ -83,3 +83,30 @@ pub fn contract_unfrozen(env: &Env, admin: &Address, timestamp: u64) { (admin, timestamp), ); } + +/// Emitted when a milestone release skips an asset (no issuer, dust, etc.). +pub fn milestone_release_skipped( + env: &Env, + milestone_index: u32, + asset_code: String, + reason: Symbol, +) { + env.events().publish( + ("campaign", "milestone_release_skipped"), + (milestone_index, asset_code, reason), + ); +} + +/// Emitted after all per-asset releases complete for a milestone. +pub fn milestone_release_completed( + env: &Env, + milestone_index: u32, + total_released: i128, + asset_count: u32, + timestamp: u64, +) { + env.events().publish( + ("campaign", "milestone_release_completed"), + (milestone_index, total_released, asset_count, timestamp), + ); +} diff --git a/campaign/src/multi_asset_release.rs b/campaign/src/multi_asset_release.rs index e7db271..57ba7a1 100644 --- a/campaign/src/multi_asset_release.rs +++ b/campaign/src/multi_asset_release.rs @@ -115,8 +115,9 @@ pub fn release_milestone_multi_asset( milestone.status = MilestoneStatus::Released; set_milestone(env, milestone_index, &milestone); - // ── 7. Execute proportional transfers ─────────────────────────────────── let timestamp = env.ledger().timestamp(); + + // ── 7. Execute proportional transfers ─────────────────────────────────── let mut total_released: i128 = 0; for asset in campaign.accepted_assets.iter() { @@ -124,9 +125,11 @@ pub fn release_milestone_multi_asset( Some(addr) => addr.clone(), None => { // Native asset or asset without issuer — skip gracefully - env.events().publish( - (symbol_short!("ms_skip"), symbol_short!("no_issuer")), - (milestone_index, asset.asset_code.clone()), + event::milestone_release_skipped( + env, + milestone_index, + asset.asset_code.clone(), + symbol_short!("no_issuer"), ); continue; } @@ -193,6 +196,15 @@ pub fn release_milestone_multi_asset( .unwrap_or_else(|| panic_with_error!(env, Error::Overflow)); } + // Emit summary event after all per-asset releases + event::milestone_release_completed( + env, + milestone_index, + total_released, + campaign.accepted_assets.len() as u32, + timestamp, + ); + // ── 8. Update global total-raised bookkeeping ──────────────────────────── let new_total_raised = total_raised .checked_sub(total_released) diff --git a/campaign/src/test/release_milestone_tests.rs b/campaign/src/test/release_milestone_tests.rs index 24468c6..93b6537 100644 --- a/campaign/src/test/release_milestone_tests.rs +++ b/campaign/src/test/release_milestone_tests.rs @@ -470,3 +470,79 @@ fn test_frozen_contract_release_panics() { crate::release_milestone::release_milestone(&env, 0, recipient); }); } + +// ─── Skip events: assets without issuers are skipped gracefully ─────────────── + +/// Test: when an asset has no issuer (native XLM), a milestone_release_skipped +/// event is emitted and the release continues with other assets. +#[test] +fn test_native_asset_skip_emits_event() { + let env = Env::default(); + env.ledger().set_timestamp(BASE); + env.mock_all_auths(); + with_contract(&env, || { + let creator = Address::generate(&env); + + // Create a campaign with one asset that has an issuer + let token_admin = Address::generate(&env); + let token_issuer = env.register_stellar_asset_contract(token_admin.clone()); + + let mut assets: Vec = Vec::new(&env); + // Add native asset (no issuer) + assets.push_back(StellarAsset { + asset_code: String::from_str(&env, "XLM"), + issuer: None, + }); + // Add an asset with issuer + assets.push_back(StellarAsset { + asset_code: String::from_str(&env, "USDC"), + issuer: Some(token_issuer.clone()), + }); + + let campaign = CampaignData { + creator: creator.clone(), + goal_amount: 3000, + raised_amount: 3000, + end_time: env.ledger().timestamp() + 86_400, + status: CampaignStatus::Active, + accepted_assets: assets, + milestone_count: 1, + min_donation_amount: 0, + created_at_ledger: env.ledger().sequence(), + created_at_time: env.ledger().timestamp(), + concluded_at_ledger: None, + }; + set_campaign(&env, &campaign); + + // Mint tokens for the USDC asset + let token_admin_client = StellarAssetClient::new(&env, &token_issuer); + token_admin_client.mint(&env.current_contract_address(), &10_000_000i128); + + create_test_milestone(&env, 0, 3000, MilestoneStatus::Unlocked); + let recipient = Address::generate(&env); + + // Release should succeed and emit skip event for XLM + crate::release_milestone::release_milestone(&env, 0, recipient.clone()); + + // Verify USDC was released + let token_client = soroban_sdk::token::Client::new(&env, &token_issuer); + assert_eq!(token_client.balance(&recipient), 3000); + + // Verify milestone status is Released + let milestone = get_milestone(&env, 0).expect("Milestone should exist"); + assert_eq!(milestone.status, MilestoneStatus::Released); + + // Verify skip event was emitted with correct topic + let events = env.events().all(); + let skip_event = events.iter().find(|e| { + e.topics == (soroban_sdk::Symbol::new(&env, "campaign"), soroban_sdk::Symbol::new(&env, "milestone_release_skipped")) + }); + assert!(skip_event.is_some(), "milestone_release_skipped event should be emitted"); + + // Verify completion event was emitted + let completed_event = events.iter().find(|e| { + e.topics == (soroban_sdk::Symbol::new(&env, "campaign"), soroban_sdk::Symbol::new(&env, "milestone_release_completed")) + }); + assert!(completed_event.is_some(), "milestone_release_completed event should be emitted"); + }); +} diff --git a/docs/events.md b/docs/events.md index 7afe3e2..259da20 100644 --- a/docs/events.md +++ b/docs/events.md @@ -76,6 +76,41 @@ is emitted per asset. --- +## `milestone_release_skipped` + +Emitted when an asset is skipped during milestone release due to missing issuer, +dust amount, or other release condition. + +**Topics:** `["campaign", "milestone_release_skipped"]` + +**Data:** + +| Field | Type | Description | +|---|---|---| +| `milestone_index` | `u32` | Zero-based milestone index | +| `asset_code` | `String` | Asset code that was skipped (e.g. `"XLM"`) | +| `reason` | `Symbol` | Skip reason: `"no_issuer"`, `"dust_below_minimum"`, `"zero_release_amount"`, or `"already_released"` | + +--- + +## `milestone_release_completed` + +Emitted after all per-asset releases complete for a milestone. +Allows indexers to detect the end of a release without counting individual events. + +**Topics:** `["campaign", "milestone_release_completed"]` + +**Data:** + +| Field | Type | Description | +|---|---|---| +| `milestone_index` | `u32` | Zero-based milestone index | +| `total_released` | `i128` | Total amount released across all assets in stroops | +| `asset_count` | `u32` | Number of accepted assets in the campaign | +| `timestamp` | `u64` | Ledger timestamp of the release | + +--- + ## `campaign_ended` Emitted when the campaign transitions to the `Ended` state (deadline passed or concluded normally).