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
73 changes: 69 additions & 4 deletions contracts/crowdfund_registry/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ use crate::error::CrowdfundError;
use crate::events::{
CampaignApproved, CampaignCancelled, CampaignCreated, CampaignFailed, CampaignFunded,
CampaignRejected, CampaignSubmittedForReview, CampaignTerminated, CampaignValidated,
CampaignVoteRejected, DisputeResolved, MilestoneApproved, MilestoneDisputed, MilestoneOverdue,
MilestoneRejected, MilestoneRevisionRequested, MilestoneSubmitted, PledgeRecorded,
RefundBatchProcessed,
CampaignVoteRejected, DisputeResolved, MilestoneApproved, MilestoneDisputed,
MilestoneEscalated, MilestoneOverdue, MilestoneRejected, MilestoneRevisionRequested,
MilestoneSubmitted, PledgeRecorded, RefundBatchProcessed,
};
use crate::storage::{
Campaign, CampaignStatus, CrowdfundDataKey, CrowdfundMilestoneStatus, DisputeResolution,
Expand Down Expand Up @@ -1133,7 +1133,7 @@ impl CrowdfundRegistry {
}

let ms_key = CrowdfundDataKey::CampaignMilestone(campaign_id, milestone_index);
let ms: Milestone = env
let mut ms: Milestone = env
.storage()
.persistent()
.get(&ms_key)
Expand All @@ -1149,6 +1149,12 @@ impl CrowdfundRegistry {
return Err(CrowdfundError::MilestoneNotOverdue);
}

// Record when flagged (only if not already flagged)
if ms.flagged_at == 0 {
ms.flagged_at = env.ledger().timestamp();
env.storage().persistent().set(&ms_key, &ms);
}

MilestoneOverdue {
campaign_id,
milestone_id: milestone_index,
Expand All @@ -1158,6 +1164,64 @@ impl CrowdfundRegistry {
Ok(())
}

/// Permissionless: escalate an overdue milestone after the 14-day grace period.
/// Rejects the milestone and cancels the campaign, enabling refunds.
pub fn escalate_overdue_milestone(
env: Env,
campaign_id: u64,
milestone_index: u32,
) -> Result<(), CrowdfundError> {
let key = CrowdfundDataKey::Campaign(campaign_id);
let mut campaign: Campaign = env
.storage()
.persistent()
.get(&key)
.ok_or(CrowdfundError::CampaignNotFound)?;

if campaign.status != CampaignStatus::Funded && campaign.status != CampaignStatus::Executing
{
return Err(CrowdfundError::InvalidState);
}

let ms_key = CrowdfundDataKey::CampaignMilestone(campaign_id, milestone_index);
let mut ms: Milestone = env
.storage()
.persistent()
.get(&ms_key)
.ok_or(CrowdfundError::MilestoneNotFound)?;

if ms.flagged_at == 0 {
return Err(CrowdfundError::MilestoneNotFlagged);
}

// 14-day grace period after flagging
let grace_deadline = ms.flagged_at + 14 * 86_400;
if env.ledger().timestamp() <= grace_deadline {
return Err(CrowdfundError::GracePeriodNotExpired);
}

// Only escalate if still Pending (creator didn't submit during grace period)
if ms.status != CrowdfundMilestoneStatus::Pending {
return Err(CrowdfundError::MilestoneNotPending);
}

// Reject milestone and cancel campaign for refunds
ms.status = CrowdfundMilestoneStatus::Rejected;
env.storage().persistent().set(&ms_key, &ms);

campaign.status = CampaignStatus::Cancelled;
campaign.refund_progress = 0;
env.storage().persistent().set(&key, &campaign);

MilestoneEscalated {
campaign_id,
milestone_id: milestone_index,
}
.publish(&env);

Ok(())
}

// ========================================================================
// ADMIN
// ========================================================================
Expand Down Expand Up @@ -1238,6 +1302,7 @@ impl CrowdfundRegistry {
description: desc,
pct,
status: CrowdfundMilestoneStatus::Pending,
flagged_at: 0,
};
env.storage().persistent().set(
&CrowdfundDataKey::CampaignMilestone(campaign_id, i),
Expand Down
2 changes: 2 additions & 0 deletions contracts/crowdfund_registry/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,6 @@ pub enum CrowdfundError {
VoteThresholdNotMet = 825,
NoVoteSession = 826,
MilestoneNotDisputed = 827,
MilestoneNotFlagged = 828,
GracePeriodNotExpired = 829,
}
8 changes: 8 additions & 0 deletions contracts/crowdfund_registry/src/events/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ pub struct MilestoneOverdue {
pub milestone_id: u32,
}

#[contractevent]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MilestoneEscalated {
#[topic]
pub campaign_id: u64,
pub milestone_id: u32,
}

#[contractevent]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CampaignSubmittedForReview {
Expand Down
1 change: 1 addition & 0 deletions contracts/crowdfund_registry/src/storage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ pub struct Milestone {
pub description: String,
pub pct: u32, // percentage of total (basis points: 10000 = 100%)
pub status: CrowdfundMilestoneStatus,
pub flagged_at: u64, // 0 = not flagged; otherwise timestamp when overdue was flagged
}

#[contracttype]
Expand Down
147 changes: 147 additions & 0 deletions contracts/crowdfund_registry/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -693,3 +693,150 @@ fn test_vote_threshold_not_met_while_active() {
CampaignStatus::Submitted
);
}

#[test]
fn test_overdue_flag_and_escalate() {
let t = setup();
let sac = StellarAssetClient::new(&t.env, &t.token_addr);

let owner = Address::generate(&t.env);
let donor = Address::generate(&t.env);
sac.mint(&donor, &10_000);

let deadline = t.env.ledger().timestamp() + 5000;

let cid = t.client.create_campaign(
&owner,
&String::from_str(&t.env, "Overdue escalation"),
&1000i128,
&t.token_addr,
&deadline,
&make_milestones(&t.env),
&100i128,
&false,
);

advance_to_campaigning(&t, cid);
t.client.pledge(&donor, &cid, &1100);

// Advance 30+ days past deadline → flag overdue
t.env.ledger().with_mut(|l| {
l.timestamp = deadline + 30 * 86_400 + 1;
});

t.client.flag_overdue_milestone(&cid, &0);

// Milestone should have flagged_at set
let ms = t.client.get_milestone(&cid, &0);
assert_eq!(ms.status, CrowdfundMilestoneStatus::Pending);
assert!(ms.flagged_at > 0);

// Escalate too early (before 14-day grace) → should fail
let result = t.client.try_escalate_overdue_milestone(&cid, &0);
assert!(result.is_err());

// Advance 14+ days past flagging
t.env.ledger().with_mut(|l| {
l.timestamp += 14 * 86_400 + 1;
});

// Escalate now → should succeed
t.client.escalate_overdue_milestone(&cid, &0);

let ms = t.client.get_milestone(&cid, &0);
assert_eq!(ms.status, CrowdfundMilestoneStatus::Rejected);

let campaign = t.client.get_campaign(&cid);
assert_eq!(campaign.status, CampaignStatus::Cancelled);

// Backers can get refunds
let balance_before = t.token.balance(&donor);
t.client.process_refund_batch(&cid);
assert!(t.token.balance(&donor) > balance_before);
}

#[test]
fn test_overdue_escalate_not_flagged_fails() {
let t = setup();
let sac = StellarAssetClient::new(&t.env, &t.token_addr);

let owner = Address::generate(&t.env);
let donor = Address::generate(&t.env);
sac.mint(&donor, &10_000);

let deadline = t.env.ledger().timestamp() + 5000;

let cid = t.client.create_campaign(
&owner,
&String::from_str(&t.env, "Not flagged"),
&1000i128,
&t.token_addr,
&deadline,
&make_milestones(&t.env),
&100i128,
&false,
);

advance_to_campaigning(&t, cid);
t.client.pledge(&donor, &cid, &1100);

// Try to escalate without flagging first → should fail
t.env.ledger().with_mut(|l| {
l.timestamp = deadline + 60 * 86_400;
});

let result = t.client.try_escalate_overdue_milestone(&cid, &0);
assert!(result.is_err());
}

#[test]
fn test_overdue_creator_submits_during_grace_period() {
let t = setup();
let sac = StellarAssetClient::new(&t.env, &t.token_addr);

let owner = Address::generate(&t.env);
let donor = Address::generate(&t.env);
sac.mint(&donor, &10_000);

let deadline = t.env.ledger().timestamp() + 5000;

let cid = t.client.create_campaign(
&owner,
&String::from_str(&t.env, "Grace period save"),
&1000i128,
&t.token_addr,
&deadline,
&make_milestones(&t.env),
&100i128,
&false,
);

advance_to_campaigning(&t, cid);
t.client.pledge(&donor, &cid, &1100);

// Flag overdue
t.env.ledger().with_mut(|l| {
l.timestamp = deadline + 30 * 86_400 + 1;
});
t.client.flag_overdue_milestone(&cid, &0);

// Creator submits during grace period
t.client.submit_milestone(&cid, &0);
let ms = t.client.get_milestone(&cid, &0);
assert_eq!(ms.status, CrowdfundMilestoneStatus::Submitted);

// Advance past grace period
t.env.ledger().with_mut(|l| {
l.timestamp += 14 * 86_400 + 1;
});

// Escalate should fail — milestone is no longer Pending
let result = t.client.try_escalate_overdue_milestone(&cid, &0);
assert!(result.is_err());

// Campaign still active
assert_eq!(
t.client.get_campaign(&cid).status,
CampaignStatus::Executing
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -1777,6 +1777,14 @@
"string": "MVP"
}
},
{
"key": {
"symbol": "flagged_at"
},
"val": {
"u64": "0"
}
},
{
"key": {
"symbol": "id"
Expand Down Expand Up @@ -1844,6 +1852,14 @@
"string": "Beta"
}
},
{
"key": {
"symbol": "flagged_at"
},
"val": {
"u64": "0"
}
},
{
"key": {
"symbol": "id"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,14 @@
"string": "MVP"
}
},
{
"key": {
"symbol": "flagged_at"
},
"val": {
"u64": "0"
}
},
{
"key": {
"symbol": "id"
Expand Down Expand Up @@ -1140,6 +1148,14 @@
"string": "Beta"
}
},
{
"key": {
"symbol": "flagged_at"
},
"val": {
"u64": "0"
}
},
{
"key": {
"symbol": "id"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,14 @@
"string": "MVP"
}
},
{
"key": {
"symbol": "flagged_at"
},
"val": {
"u64": "0"
}
},
{
"key": {
"symbol": "id"
Expand Down Expand Up @@ -1140,6 +1148,14 @@
"string": "Beta"
}
},
{
"key": {
"symbol": "flagged_at"
},
"val": {
"u64": "0"
}
},
{
"key": {
"symbol": "id"
Expand Down
Loading