Skip to content
Open
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
27 changes: 27 additions & 0 deletions campaign/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
}
20 changes: 16 additions & 4 deletions campaign/src/multi_asset_release.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,18 +115,21 @@ 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() {
let token_address = match &asset.issuer {
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;
}
Expand Down Expand Up @@ -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)
Expand Down
76 changes: 76 additions & 0 deletions campaign/src/test/release_milestone_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<StellarAsset> = 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");
});
}
35 changes: 35 additions & 0 deletions docs/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading