Skip to content
This repository was archived by the owner on Jun 12, 2026. It is now read-only.

Commit 2f7dcda

Browse files
authored
Merge pull request #11 from boundlessfi/feat/overdue-milestone-escalation
feat: Add overdue milestone escalation with 14-day grace period
2 parents 82c57f5 + 0f42100 commit 2f7dcda

43 files changed

Lines changed: 8520 additions & 4 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

contracts/crowdfund_registry/src/contract.rs

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ use crate::error::CrowdfundError;
22
use crate::events::{
33
CampaignApproved, CampaignCancelled, CampaignCreated, CampaignFailed, CampaignFunded,
44
CampaignRejected, CampaignSubmittedForReview, CampaignTerminated, CampaignValidated,
5-
CampaignVoteRejected, DisputeResolved, MilestoneApproved, MilestoneDisputed, MilestoneOverdue,
6-
MilestoneRejected, MilestoneRevisionRequested, MilestoneSubmitted, PledgeRecorded,
7-
RefundBatchProcessed,
5+
CampaignVoteRejected, DisputeResolved, MilestoneApproved, MilestoneDisputed,
6+
MilestoneEscalated, MilestoneOverdue, MilestoneRejected, MilestoneRevisionRequested,
7+
MilestoneSubmitted, PledgeRecorded, RefundBatchProcessed,
88
};
99
use crate::storage::{
1010
Campaign, CampaignStatus, CrowdfundDataKey, CrowdfundMilestoneStatus, DisputeResolution,
@@ -1133,7 +1133,7 @@ impl CrowdfundRegistry {
11331133
}
11341134

11351135
let ms_key = CrowdfundDataKey::CampaignMilestone(campaign_id, milestone_index);
1136-
let ms: Milestone = env
1136+
let mut ms: Milestone = env
11371137
.storage()
11381138
.persistent()
11391139
.get(&ms_key)
@@ -1149,6 +1149,12 @@ impl CrowdfundRegistry {
11491149
return Err(CrowdfundError::MilestoneNotOverdue);
11501150
}
11511151

1152+
// Record when flagged (only if not already flagged)
1153+
if ms.flagged_at == 0 {
1154+
ms.flagged_at = env.ledger().timestamp();
1155+
env.storage().persistent().set(&ms_key, &ms);
1156+
}
1157+
11521158
MilestoneOverdue {
11531159
campaign_id,
11541160
milestone_id: milestone_index,
@@ -1158,6 +1164,64 @@ impl CrowdfundRegistry {
11581164
Ok(())
11591165
}
11601166

1167+
/// Permissionless: escalate an overdue milestone after the 14-day grace period.
1168+
/// Rejects the milestone and cancels the campaign, enabling refunds.
1169+
pub fn escalate_overdue_milestone(
1170+
env: Env,
1171+
campaign_id: u64,
1172+
milestone_index: u32,
1173+
) -> Result<(), CrowdfundError> {
1174+
let key = CrowdfundDataKey::Campaign(campaign_id);
1175+
let mut campaign: Campaign = env
1176+
.storage()
1177+
.persistent()
1178+
.get(&key)
1179+
.ok_or(CrowdfundError::CampaignNotFound)?;
1180+
1181+
if campaign.status != CampaignStatus::Funded && campaign.status != CampaignStatus::Executing
1182+
{
1183+
return Err(CrowdfundError::InvalidState);
1184+
}
1185+
1186+
let ms_key = CrowdfundDataKey::CampaignMilestone(campaign_id, milestone_index);
1187+
let mut ms: Milestone = env
1188+
.storage()
1189+
.persistent()
1190+
.get(&ms_key)
1191+
.ok_or(CrowdfundError::MilestoneNotFound)?;
1192+
1193+
if ms.flagged_at == 0 {
1194+
return Err(CrowdfundError::MilestoneNotFlagged);
1195+
}
1196+
1197+
// 14-day grace period after flagging
1198+
let grace_deadline = ms.flagged_at + 14 * 86_400;
1199+
if env.ledger().timestamp() <= grace_deadline {
1200+
return Err(CrowdfundError::GracePeriodNotExpired);
1201+
}
1202+
1203+
// Only escalate if still Pending (creator didn't submit during grace period)
1204+
if ms.status != CrowdfundMilestoneStatus::Pending {
1205+
return Err(CrowdfundError::MilestoneNotPending);
1206+
}
1207+
1208+
// Reject milestone and cancel campaign for refunds
1209+
ms.status = CrowdfundMilestoneStatus::Rejected;
1210+
env.storage().persistent().set(&ms_key, &ms);
1211+
1212+
campaign.status = CampaignStatus::Cancelled;
1213+
campaign.refund_progress = 0;
1214+
env.storage().persistent().set(&key, &campaign);
1215+
1216+
MilestoneEscalated {
1217+
campaign_id,
1218+
milestone_id: milestone_index,
1219+
}
1220+
.publish(&env);
1221+
1222+
Ok(())
1223+
}
1224+
11611225
// ========================================================================
11621226
// ADMIN
11631227
// ========================================================================
@@ -1238,6 +1302,7 @@ impl CrowdfundRegistry {
12381302
description: desc,
12391303
pct,
12401304
status: CrowdfundMilestoneStatus::Pending,
1305+
flagged_at: 0,
12411306
};
12421307
env.storage().persistent().set(
12431308
&CrowdfundDataKey::CampaignMilestone(campaign_id, i),

contracts/crowdfund_registry/src/error.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,6 @@ pub enum CrowdfundError {
3232
VoteThresholdNotMet = 825,
3333
NoVoteSession = 826,
3434
MilestoneNotDisputed = 827,
35+
MilestoneNotFlagged = 828,
36+
GracePeriodNotExpired = 829,
3537
}

contracts/crowdfund_registry/src/events/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,14 @@ pub struct MilestoneOverdue {
9898
pub milestone_id: u32,
9999
}
100100

101+
#[contractevent]
102+
#[derive(Clone, Debug, Eq, PartialEq)]
103+
pub struct MilestoneEscalated {
104+
#[topic]
105+
pub campaign_id: u64,
106+
pub milestone_id: u32,
107+
}
108+
101109
#[contractevent]
102110
#[derive(Clone, Debug, Eq, PartialEq)]
103111
pub struct CampaignSubmittedForReview {

contracts/crowdfund_registry/src/storage/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ pub struct Milestone {
8484
pub description: String,
8585
pub pct: u32, // percentage of total (basis points: 10000 = 100%)
8686
pub status: CrowdfundMilestoneStatus,
87+
pub flagged_at: u64, // 0 = not flagged; otherwise timestamp when overdue was flagged
8788
}
8889

8990
#[contracttype]

contracts/crowdfund_registry/src/tests/mod.rs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,3 +693,150 @@ fn test_vote_threshold_not_met_while_active() {
693693
CampaignStatus::Submitted
694694
);
695695
}
696+
697+
#[test]
698+
fn test_overdue_flag_and_escalate() {
699+
let t = setup();
700+
let sac = StellarAssetClient::new(&t.env, &t.token_addr);
701+
702+
let owner = Address::generate(&t.env);
703+
let donor = Address::generate(&t.env);
704+
sac.mint(&donor, &10_000);
705+
706+
let deadline = t.env.ledger().timestamp() + 5000;
707+
708+
let cid = t.client.create_campaign(
709+
&owner,
710+
&String::from_str(&t.env, "Overdue escalation"),
711+
&1000i128,
712+
&t.token_addr,
713+
&deadline,
714+
&make_milestones(&t.env),
715+
&100i128,
716+
&false,
717+
);
718+
719+
advance_to_campaigning(&t, cid);
720+
t.client.pledge(&donor, &cid, &1100);
721+
722+
// Advance 30+ days past deadline → flag overdue
723+
t.env.ledger().with_mut(|l| {
724+
l.timestamp = deadline + 30 * 86_400 + 1;
725+
});
726+
727+
t.client.flag_overdue_milestone(&cid, &0);
728+
729+
// Milestone should have flagged_at set
730+
let ms = t.client.get_milestone(&cid, &0);
731+
assert_eq!(ms.status, CrowdfundMilestoneStatus::Pending);
732+
assert!(ms.flagged_at > 0);
733+
734+
// Escalate too early (before 14-day grace) → should fail
735+
let result = t.client.try_escalate_overdue_milestone(&cid, &0);
736+
assert!(result.is_err());
737+
738+
// Advance 14+ days past flagging
739+
t.env.ledger().with_mut(|l| {
740+
l.timestamp += 14 * 86_400 + 1;
741+
});
742+
743+
// Escalate now → should succeed
744+
t.client.escalate_overdue_milestone(&cid, &0);
745+
746+
let ms = t.client.get_milestone(&cid, &0);
747+
assert_eq!(ms.status, CrowdfundMilestoneStatus::Rejected);
748+
749+
let campaign = t.client.get_campaign(&cid);
750+
assert_eq!(campaign.status, CampaignStatus::Cancelled);
751+
752+
// Backers can get refunds
753+
let balance_before = t.token.balance(&donor);
754+
t.client.process_refund_batch(&cid);
755+
assert!(t.token.balance(&donor) > balance_before);
756+
}
757+
758+
#[test]
759+
fn test_overdue_escalate_not_flagged_fails() {
760+
let t = setup();
761+
let sac = StellarAssetClient::new(&t.env, &t.token_addr);
762+
763+
let owner = Address::generate(&t.env);
764+
let donor = Address::generate(&t.env);
765+
sac.mint(&donor, &10_000);
766+
767+
let deadline = t.env.ledger().timestamp() + 5000;
768+
769+
let cid = t.client.create_campaign(
770+
&owner,
771+
&String::from_str(&t.env, "Not flagged"),
772+
&1000i128,
773+
&t.token_addr,
774+
&deadline,
775+
&make_milestones(&t.env),
776+
&100i128,
777+
&false,
778+
);
779+
780+
advance_to_campaigning(&t, cid);
781+
t.client.pledge(&donor, &cid, &1100);
782+
783+
// Try to escalate without flagging first → should fail
784+
t.env.ledger().with_mut(|l| {
785+
l.timestamp = deadline + 60 * 86_400;
786+
});
787+
788+
let result = t.client.try_escalate_overdue_milestone(&cid, &0);
789+
assert!(result.is_err());
790+
}
791+
792+
#[test]
793+
fn test_overdue_creator_submits_during_grace_period() {
794+
let t = setup();
795+
let sac = StellarAssetClient::new(&t.env, &t.token_addr);
796+
797+
let owner = Address::generate(&t.env);
798+
let donor = Address::generate(&t.env);
799+
sac.mint(&donor, &10_000);
800+
801+
let deadline = t.env.ledger().timestamp() + 5000;
802+
803+
let cid = t.client.create_campaign(
804+
&owner,
805+
&String::from_str(&t.env, "Grace period save"),
806+
&1000i128,
807+
&t.token_addr,
808+
&deadline,
809+
&make_milestones(&t.env),
810+
&100i128,
811+
&false,
812+
);
813+
814+
advance_to_campaigning(&t, cid);
815+
t.client.pledge(&donor, &cid, &1100);
816+
817+
// Flag overdue
818+
t.env.ledger().with_mut(|l| {
819+
l.timestamp = deadline + 30 * 86_400 + 1;
820+
});
821+
t.client.flag_overdue_milestone(&cid, &0);
822+
823+
// Creator submits during grace period
824+
t.client.submit_milestone(&cid, &0);
825+
let ms = t.client.get_milestone(&cid, &0);
826+
assert_eq!(ms.status, CrowdfundMilestoneStatus::Submitted);
827+
828+
// Advance past grace period
829+
t.env.ledger().with_mut(|l| {
830+
l.timestamp += 14 * 86_400 + 1;
831+
});
832+
833+
// Escalate should fail — milestone is no longer Pending
834+
let result = t.client.try_escalate_overdue_milestone(&cid, &0);
835+
assert!(result.is_err());
836+
837+
// Campaign still active
838+
assert_eq!(
839+
t.client.get_campaign(&cid).status,
840+
CampaignStatus::Executing
841+
);
842+
}

contracts/crowdfund_registry/test_snapshots/tests/test_cancel_campaign.1.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1777,6 +1777,14 @@
17771777
"string": "MVP"
17781778
}
17791779
},
1780+
{
1781+
"key": {
1782+
"symbol": "flagged_at"
1783+
},
1784+
"val": {
1785+
"u64": "0"
1786+
}
1787+
},
17801788
{
17811789
"key": {
17821790
"symbol": "id"
@@ -1844,6 +1852,14 @@
18441852
"string": "Beta"
18451853
}
18461854
},
1855+
{
1856+
"key": {
1857+
"symbol": "flagged_at"
1858+
},
1859+
"val": {
1860+
"u64": "0"
1861+
}
1862+
},
18471863
{
18481864
"key": {
18491865
"symbol": "id"

contracts/crowdfund_registry/test_snapshots/tests/test_create_and_submit_campaign.1.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,6 +1073,14 @@
10731073
"string": "MVP"
10741074
}
10751075
},
1076+
{
1077+
"key": {
1078+
"symbol": "flagged_at"
1079+
},
1080+
"val": {
1081+
"u64": "0"
1082+
}
1083+
},
10761084
{
10771085
"key": {
10781086
"symbol": "id"
@@ -1140,6 +1148,14 @@
11401148
"string": "Beta"
11411149
}
11421150
},
1151+
{
1152+
"key": {
1153+
"symbol": "flagged_at"
1154+
},
1155+
"val": {
1156+
"u64": "0"
1157+
}
1158+
},
11431159
{
11441160
"key": {
11451161
"symbol": "id"

contracts/crowdfund_registry/test_snapshots/tests/test_create_campaign.1.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,6 +1073,14 @@
10731073
"string": "MVP"
10741074
}
10751075
},
1076+
{
1077+
"key": {
1078+
"symbol": "flagged_at"
1079+
},
1080+
"val": {
1081+
"u64": "0"
1082+
}
1083+
},
10761084
{
10771085
"key": {
10781086
"symbol": "id"
@@ -1140,6 +1148,14 @@
11401148
"string": "Beta"
11411149
}
11421150
},
1151+
{
1152+
"key": {
1153+
"symbol": "flagged_at"
1154+
},
1155+
"val": {
1156+
"u64": "0"
1157+
}
1158+
},
11431159
{
11441160
"key": {
11451161
"symbol": "id"

0 commit comments

Comments
 (0)