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
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
resolver = "2"
members = [
"campaign",
"campaigns-registry",
"common",
"crates/contracts/core",
"crates/tools",
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Decision: **Option B (conservative)** — keep `campaign/` (`milestonex-campaign

`crates/contracts/core/` (`milestonex-core`) is retained only as a legacy compatibility/reference contract. Do not add new campaign features there; use `campaign/` for any new logic, analytics endpoints, or deployment work. Any remaining behavior worth preserving from `core` should be migrated into `campaign/` before `core` is removed in a future breaking release.

`campaigns-registry/` (`milestonex-campaigns-registry`) is a separate, additive contract for the many-campaigns-per-instance use case that `campaign/` intentionally does not support (`campaign/` stays single-campaign-per-instance). It does not replace `campaign/` for single-campaign deployments. See [docs/multi-campaign.md](docs/multi-campaign.md) for its API, migration plan, and current scope limitations.

Canonical campaign analytics now live on `milestonex-campaign`: use `get_campaign_report`, `get_platform_summary`, `get_dashboard_metrics`, `get_donation_count`, `get_donor_count`, `get_release_count`, and `get_total_tx_count` for dashboard and export workflows.

**MilestoneX** is an on-chain crowdfunding protocol built on the **Stellar Network** and **Soroban smart contracts**. It provides a transparent, trust-minimized platform where campaign creators can raise funds in native XLM or any Stellar-based asset (USDC, NGNT, custom tokens), and donors retain full visibility into how their contributions are deployed.
Expand All @@ -32,7 +34,13 @@ milestonex-contract/
| |-- Cargo.toml
| `-- src/
| `-- lib.rs
|-- campaigns-registry/ # Multi-campaign registry contract (additive)
| |-- Cargo.toml
| `-- src/
| `-- lib.rs
├── Cargo.toml # Workspace configuration
├── docs/
│ └── multi-campaign.md # campaigns-registry API and migration plan
├── crates/
│ ├── contracts/
│ │ └── core/ # Legacy compatibility/reference contract
Expand Down
18 changes: 18 additions & 0 deletions campaigns-registry/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "milestonex-campaigns-registry"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
description = "MilestoneX multi-campaign registry contract — many campaigns in one instance"

[lib]
crate-type = ["cdylib", "rlib"]
doctest = false

[dependencies]
soroban-sdk = { workspace = true }

[dev-dependencies]
soroban-sdk = { version = "26.0.1", features = ["testutils"] }
# Inherit the workspace-wide ed25519-dalek = 2.2.0 exact pin (see
# /Cargo.toml [workspace.dependencies] for the rationale).
235 changes: 235 additions & 0 deletions campaigns-registry/src/contract.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
//! Campaign registry business logic, wired into the contract impl in
//! `lib.rs` as thin entrypoint methods on `CampaignsRegistry`.
//!
//! Every function here takes a `campaign_id` and scopes its reads/writes to
//! that one campaign via `storage::get_campaign_or_panic` /
//! `storage::set_campaign`, so operations on one campaign never affect
//! another.

use crate::event;
use crate::storage::{get_campaign_or_panic, get_donor, next_campaign_id, set_campaign, set_donor, set_next_campaign_id};
use crate::types::{
validate_campaign_transition, AssetInfo, CampaignMetadata, CampaignRecord, CampaignStatus,
CampaignStatusResponse, DonorRecord, Error,
};
use soroban_sdk::{panic_with_error, Address, Env, String};

/// Maximum number of campaigns a single contract instance will create.
///
/// Bounds per-instance growth so instance-wide operations (e.g. a future
/// paginated listing endpoint) stay tractable. `create_campaign` panics with
/// `Error::MaxCampaignsExceeded` once reached; deploy a new contract
/// instance to create more.
pub const MAX_CAMPAIGNS_PER_INSTANCE: u64 = 10_000;

/// Create a new campaign, returning its assigned ID (starting at 1).
///
/// # Panics
/// - `Error::InvalidGoalAmount` if `metadata.goal_amount <= 0`
/// - `Error::InvalidEndTime` if `metadata.end_time` <= current ledger timestamp
/// - `Error::InvalidAssets` if `metadata.accepted_assets` is empty
/// - `Error::InvalidAssetCode` if any accepted asset has an invalid code
/// - `Error::MaxCampaignsExceeded` if `MAX_CAMPAIGNS_PER_INSTANCE` is reached
pub fn create_campaign(env: &Env, creator: Address, metadata: CampaignMetadata) -> u64 {
creator.require_auth();

if metadata.goal_amount <= 0 {
panic_with_error!(env, Error::InvalidGoalAmount);
}

let now = env.ledger().timestamp();
if metadata.end_time <= now {
panic_with_error!(env, Error::InvalidEndTime);
}

if metadata.accepted_assets.is_empty() {
panic_with_error!(env, Error::InvalidAssets);
}
for asset in metadata.accepted_assets.iter() {
if !asset.has_valid_code() {
panic_with_error!(env, Error::InvalidAssetCode);
}
}

let next_id = next_campaign_id(env);
if next_id >= MAX_CAMPAIGNS_PER_INSTANCE {
panic_with_error!(env, Error::MaxCampaignsExceeded);
}
let id = next_id + 1;

let campaign = CampaignRecord {
id,
creator: creator.clone(),
goal_amount: metadata.goal_amount,
raised_amount: 0,
end_time: metadata.end_time,
status: CampaignStatus::Active,
accepted_assets: metadata.accepted_assets,
min_donation_amount: metadata.min_donation_amount,
created_at_ledger: env.ledger().sequence(),
created_at_time: now,
concluded_at_ledger: None,
donation_count: 0,
};

set_campaign(env, &campaign);
set_next_campaign_id(env, id);

event::campaign_created(env, id, &creator);

id
}

/// Returns `true` when `asset` is in `campaign.accepted_assets`.
fn is_asset_accepted(env: &Env, asset: &AssetInfo, campaign: &CampaignRecord) -> bool {
match asset {
AssetInfo::Stellar(addr) => campaign
.accepted_assets
.iter()
.any(|a| a.issuer == Some(addr.clone())),
AssetInfo::Native => {
let xlm_code = String::from_str(env, "XLM");
campaign
.accepted_assets
.iter()
.any(|a| a.asset_code == xlm_code)
}
}
}

/// Donate to a specific campaign, identified by `campaign_id`.
///
/// Internal accounting only: like `milestonex-campaign::donate`, this
/// updates the campaign's recorded balance and does not itself perform a
/// SEP-41 token transfer into contract custody.
///
/// # Panics
/// - `Error::CampaignNotFound` if no campaign exists with this ID
/// - `Error::CampaignNotActive` unless status is `Active` or `GoalReached`
/// - `Error::CampaignEnded` if the campaign's deadline has passed
/// - `Error::DonationTooSmall` if `amount <= 0` or below `min_donation_amount`
/// - `Error::AssetNotAccepted` if `asset` is not in the campaign's accepted list
/// - `Error::Overflow` if `raised_amount` or a counter would overflow
pub fn donate(env: &Env, donor: Address, campaign_id: u64, amount: i128, asset: AssetInfo) {
donor.require_auth();

let mut campaign = get_campaign_or_panic(env, campaign_id);

if !campaign.status.accepts_donations() {
panic_with_error!(env, Error::CampaignNotActive);
}
if env.ledger().timestamp() >= campaign.end_time {
panic_with_error!(env, Error::CampaignEnded);
}
if amount <= 0 || (campaign.min_donation_amount > 0 && amount < campaign.min_donation_amount) {
panic_with_error!(env, Error::DonationTooSmall);
}
if !is_asset_accepted(env, &asset, &campaign) {
panic_with_error!(env, Error::AssetNotAccepted);
}

campaign.raised_amount = campaign
.raised_amount
.checked_add(amount)
.unwrap_or_else(|| panic_with_error!(env, Error::Overflow));
campaign.donation_count = campaign
.donation_count
.checked_add(1)
.unwrap_or_else(|| panic_with_error!(env, Error::Overflow));

if campaign.status == CampaignStatus::Active && campaign.raised_amount >= campaign.goal_amount
{
campaign.status = CampaignStatus::GoalReached;
event::campaign_status_changed(
env,
campaign_id,
CampaignStatus::Active,
CampaignStatus::GoalReached,
);
}

set_campaign(env, &campaign);

let mut donor_record = get_donor(env, campaign_id, &donor).unwrap_or_else(|| DonorRecord {
donor: donor.clone(),
total_donated: 0,
donation_count: 0,
last_donation_time: 0,
last_donation_ledger: 0,
});
donor_record.total_donated = donor_record
.total_donated
.checked_add(amount)
.unwrap_or_else(|| panic_with_error!(env, Error::Overflow));
donor_record.donation_count = donor_record
.donation_count
.checked_add(1)
.unwrap_or_else(|| panic_with_error!(env, Error::Overflow));
donor_record.last_donation_time = env.ledger().timestamp();
donor_record.last_donation_ledger = env.ledger().sequence();
set_donor(env, campaign_id, &donor, &donor_record);

event::donation_received(env, campaign_id, &donor, amount, campaign.raised_amount);
}

/// End a campaign early (before its deadline). Requires creator authorization.
///
/// # Panics
/// - `Error::CampaignNotFound` if no campaign exists with this ID
/// - `Error::InvalidCampaignTransition` if the campaign is already `Cancelled`
pub fn end_campaign(env: &Env, campaign_id: u64) {
let mut campaign = get_campaign_or_panic(env, campaign_id);
campaign.creator.require_auth();

let from = campaign.status;
validate_campaign_transition(env, &from, &CampaignStatus::Ended)
.unwrap_or_else(|e| panic_with_error!(env, e));

campaign.status = CampaignStatus::Ended;
campaign.concluded_at_ledger = Some(env.ledger().sequence());
set_campaign(env, &campaign);

event::campaign_status_changed(env, campaign_id, from, CampaignStatus::Ended);
}

/// Cancel a campaign. Requires creator authorization.
///
/// # Panics
/// - `Error::CampaignNotFound` if no campaign exists with this ID
/// - `Error::InvalidCampaignTransition` if the campaign is already `Cancelled`
pub fn cancel_campaign(env: &Env, campaign_id: u64) {
let mut campaign = get_campaign_or_panic(env, campaign_id);
campaign.creator.require_auth();

let from = campaign.status;
validate_campaign_transition(env, &from, &CampaignStatus::Cancelled)
.unwrap_or_else(|e| panic_with_error!(env, e));

campaign.status = CampaignStatus::Cancelled;
campaign.concluded_at_ledger = Some(env.ledger().sequence());
set_campaign(env, &campaign);

event::campaign_status_changed(env, campaign_id, from, CampaignStatus::Cancelled);
}

/// Get a campaign's current status with computed `days_remaining`.
/// No auth required (read-only view).
///
/// # Panics
/// - `Error::CampaignNotFound` if no campaign exists with this ID
#[must_use]
pub fn get_campaign_status(env: &Env, campaign_id: u64) -> CampaignStatusResponse {
let campaign = get_campaign_or_panic(env, campaign_id);

let now = env.ledger().timestamp();
let days_remaining = if now < campaign.end_time {
((campaign.end_time - now) / 86_400) as i64
} else {
-(((now - campaign.end_time) / 86_400) as i64)
};

CampaignStatusResponse {
status: campaign.status,
days_remaining,
}
}
46 changes: 46 additions & 0 deletions campaigns-registry/src/event.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// `Events::publish` is deprecated in soroban-sdk 26.x in favour of the
// `#[contractevent]` macro. Migrating is tracked as a follow-up, matching
// the same suppression already used in `campaign/src/event.rs`.
#![allow(deprecated)]

use crate::types::CampaignStatus;
use soroban_sdk::{Address, Env, Symbol};

/// Emitted by `create_campaign`.
pub fn campaign_created(env: &Env, campaign_id: u64, creator: &Address) {
let topics = (
Symbol::new(env, "campaign_created"),
env.current_contract_address(),
);
env.events().publish(topics, (campaign_id, creator));
}

/// Emitted by `donate`.
pub fn donation_received(
env: &Env,
campaign_id: u64,
donor: &Address,
amount: i128,
new_raised_total: i128,
) {
let topics = (
Symbol::new(env, "donation_received"),
env.current_contract_address(),
);
env.events()
.publish(topics, (campaign_id, donor, amount, new_raised_total));
}

/// Emitted by donation-driven and explicit campaign status transitions.
pub fn campaign_status_changed(
env: &Env,
campaign_id: u64,
from: CampaignStatus,
to: CampaignStatus,
) {
let topics = (
Symbol::new(env, "campaign_status_changed"),
env.current_contract_address(),
);
env.events().publish(topics, (campaign_id, from, to));
}
Loading
Loading