Skip to content

Commit 8e4abed

Browse files
authored
Merge pull request #9 from boundlessfi/fix/vote-rejection-handling
fix: Handle community vote rejection and expiry in check_vote_threshold
2 parents 0bf7e84 + c1b6e3b commit 8e4abed

8 files changed

Lines changed: 5911 additions & 26 deletions

File tree

contracts/crowdfund_registry/src/contract.rs

Lines changed: 79 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ use crate::error::CrowdfundError;
22
use crate::events::{
33
CampaignApproved, CampaignCancelled, CampaignCreated, CampaignFailed, CampaignFunded,
44
CampaignRejected, CampaignSubmittedForReview, CampaignTerminated, CampaignValidated,
5-
DisputeResolved, MilestoneApproved, MilestoneDisputed, MilestoneOverdue,
5+
CampaignVoteRejected, DisputeResolved, MilestoneApproved, MilestoneDisputed, MilestoneOverdue,
66
MilestoneRevisionRequested, MilestoneSubmitted, PledgeRecorded, RefundBatchProcessed,
77
};
88
use crate::storage::{
99
Campaign, CampaignStatus, CrowdfundDataKey, CrowdfundMilestoneStatus, DisputeResolution,
10-
Milestone, VoteContext,
10+
Milestone, VoteContext, VoteOption, VotingSession,
1111
};
1212
use boundless_types::ttl::{
1313
INSTANCE_TTL_EXTEND, INSTANCE_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND, PERSISTENT_TTL_THRESHOLD,
@@ -387,7 +387,11 @@ impl CrowdfundRegistry {
387387
campaign.vote_session_id = None;
388388
env.storage().persistent().set(&key, &campaign);
389389

390-
CampaignRejected { id: campaign_id, reason }.publish(&env);
390+
CampaignRejected {
391+
id: campaign_id,
392+
reason,
393+
}
394+
.publish(&env);
391395
Ok(())
392396
}
393397

@@ -441,17 +445,76 @@ impl CrowdfundRegistry {
441445
.ok_or(CrowdfundError::NoVoteSession)?;
442446

443447
let gov_addr = Self::get_gov_addr(&env);
444-
let args: Vec<Val> = Vec::from_array(&env, [session_id.into_val(&env)]);
445-
let reached: bool = env.invoke_contract(&gov_addr, &sym(&env, "threshold_reached"), args);
446448

447-
if !reached {
448-
return Err(CrowdfundError::VoteThresholdNotMet);
449-
}
449+
// Check if vote threshold has been reached
450+
let threshold_args: Vec<Val> = Vec::from_array(&env, [session_id.clone().into_val(&env)]);
451+
let reached: bool =
452+
env.invoke_contract(&gov_addr, &sym(&env, "threshold_reached"), threshold_args);
450453

451-
campaign.status = CampaignStatus::Campaigning;
452-
env.storage().persistent().set(&key, &campaign);
454+
if reached {
455+
// Threshold reached — check which option won
456+
let approve_args: Vec<Val> = Vec::from_array(
457+
&env,
458+
[
459+
session_id.clone().into_val(&env),
460+
0u32.into_val(&env), // option 0 = Approve
461+
],
462+
);
463+
let reject_args: Vec<Val> = Vec::from_array(
464+
&env,
465+
[
466+
session_id.into_val(&env),
467+
1u32.into_val(&env), // option 1 = Reject
468+
],
469+
);
470+
471+
let approve_option: VoteOption =
472+
env.invoke_contract(&gov_addr, &sym(&env, "get_option"), approve_args);
473+
let reject_option: VoteOption =
474+
env.invoke_contract(&gov_addr, &sym(&env, "get_option"), reject_args);
475+
476+
let approve_votes = approve_option.votes;
477+
let reject_votes = reject_option.votes;
478+
479+
if approve_votes > reject_votes {
480+
campaign.status = CampaignStatus::Campaigning;
481+
env.storage().persistent().set(&key, &campaign);
482+
CampaignValidated { id: campaign_id }.publish(&env);
483+
} else if reject_votes > approve_votes {
484+
campaign.status = CampaignStatus::Draft;
485+
campaign.vote_session_id = None;
486+
env.storage().persistent().set(&key, &campaign);
487+
CampaignVoteRejected {
488+
id: campaign_id,
489+
reason: String::from_str(&env, "reject_majority"),
490+
}
491+
.publish(&env);
492+
} else {
493+
// Tie — leave state unchanged, session stays open
494+
return Err(CrowdfundError::VoteThresholdNotMet);
495+
}
496+
} else {
497+
// Threshold not reached — check if voting period has expired
498+
let session_args: Vec<Val> = Vec::from_array(&env, [session_id.into_val(&env)]);
499+
let session: VotingSession =
500+
env.invoke_contract(&gov_addr, &sym(&env, "get_session"), session_args);
501+
502+
if env.ledger().timestamp() <= session.end_at {
503+
// Voting still open, threshold not met yet
504+
return Err(CrowdfundError::VoteThresholdNotMet);
505+
}
506+
507+
// Voting expired without reaching threshold — reject
508+
campaign.status = CampaignStatus::Draft;
509+
campaign.vote_session_id = None;
510+
env.storage().persistent().set(&key, &campaign);
511+
CampaignVoteRejected {
512+
id: campaign_id,
513+
reason: String::from_str(&env, "expired_without_approval"),
514+
}
515+
.publish(&env);
516+
}
453517

454-
CampaignValidated { id: campaign_id }.publish(&env);
455518
Ok(())
456519
}
457520

@@ -961,11 +1024,7 @@ impl CrowdfundRegistry {
9611024
milestone_index.into_val(&env),
9621025
],
9631026
);
964-
env.invoke_contract::<()>(
965-
&escrow_addr,
966-
&sym(&env, "release_slot"),
967-
release_args,
968-
);
1027+
env.invoke_contract::<()>(&escrow_addr, &sym(&env, "release_slot"), release_args);
9691028

9701029
// Check if all milestones are released
9711030
let mut all_done = true;
@@ -1173,12 +1232,10 @@ impl CrowdfundRegistry {
11731232
pct,
11741233
status: CrowdfundMilestoneStatus::Pending,
11751234
};
1176-
env.storage()
1177-
.persistent()
1178-
.set(
1179-
&CrowdfundDataKey::CampaignMilestone(campaign_id, i),
1180-
&milestone,
1181-
);
1235+
env.storage().persistent().set(
1236+
&CrowdfundDataKey::CampaignMilestone(campaign_id, i),
1237+
&milestone,
1238+
);
11821239
}
11831240
}
11841241
}

contracts/crowdfund_registry/src/events/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,14 @@ pub struct MilestoneRevisionRequested {
136136
pub milestone_id: u32,
137137
}
138138

139+
#[contractevent]
140+
#[derive(Clone, Debug, Eq, PartialEq)]
141+
pub struct CampaignVoteRejected {
142+
#[topic]
143+
pub id: u64,
144+
pub reason: String,
145+
}
146+
139147
#[contractevent]
140148
#[derive(Clone, Debug, Eq, PartialEq)]
141149
pub struct DisputeResolved {

contracts/crowdfund_registry/src/storage/mod.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use soroban_sdk::{contracttype, Address, BytesN, String};
22

3-
// Local copy of governance_voting VoteContext for cross-contract serialization.
3+
// Local copies of governance_voting types for cross-contract serialization.
44
#[contracttype]
55
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
66
pub enum VoteContext {
@@ -10,6 +10,41 @@ pub enum VoteContext {
1010
HackathonJudging,
1111
}
1212

13+
#[contracttype]
14+
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
15+
pub enum VoteStatus {
16+
Pending,
17+
Active,
18+
Concluded,
19+
Cancelled,
20+
}
21+
22+
#[contracttype]
23+
#[derive(Clone, Debug)]
24+
pub struct VoteOption {
25+
pub id: u32,
26+
pub label: String,
27+
pub votes: u32,
28+
pub weighted_votes: u64,
29+
}
30+
31+
#[contracttype]
32+
#[derive(Clone, Debug)]
33+
pub struct VotingSession {
34+
pub session_id: BytesN<32>,
35+
pub context: VoteContext,
36+
pub module_id: u64,
37+
pub created_at: u64,
38+
pub start_at: u64,
39+
pub end_at: u64,
40+
pub status: VoteStatus,
41+
pub threshold: Option<u32>,
42+
pub threshold_reached: bool,
43+
pub total_votes: u32,
44+
pub quorum: Option<u32>,
45+
pub weight_by_reputation: bool,
46+
}
47+
1348
#[contracttype]
1449
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1550
pub enum CampaignStatus {

contracts/crowdfund_registry/src/tests/mod.rs

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,8 @@ fn test_reject_campaign() {
182182
);
183183

184184
t.client.submit_for_review(&cid);
185-
t.client.reject_campaign(&cid, &String::from_str(&t.env, "Need more detail"));
185+
t.client
186+
.reject_campaign(&cid, &String::from_str(&t.env, "Need more detail"));
186187
assert_eq!(t.client.get_campaign(&cid).status, CampaignStatus::Draft);
187188
}
188189

@@ -202,7 +203,10 @@ fn test_create_and_submit_campaign() {
202203
&true,
203204
);
204205

205-
assert_eq!(t.client.get_campaign(&cid).status, CampaignStatus::Submitted);
206+
assert_eq!(
207+
t.client.get_campaign(&cid).status,
208+
CampaignStatus::Submitted
209+
);
206210
}
207211

208212
#[test]
@@ -580,3 +584,112 @@ fn test_resolve_dispute_not_disputed_fails() {
580584
.try_resolve_dispute(&cid, &0, &DisputeResolution::ApproveCreator);
581585
assert!(result.is_err());
582586
}
587+
588+
#[test]
589+
fn test_vote_reject_returns_to_draft() {
590+
let t = setup();
591+
let owner = t.admin.clone();
592+
593+
let cid = t.client.create_campaign(
594+
&owner,
595+
&String::from_str(&t.env, "Vote reject"),
596+
&10000i128,
597+
&t.token_addr,
598+
&(t.env.ledger().timestamp() + 86400),
599+
&make_milestones(&t.env),
600+
&100i128,
601+
&false,
602+
);
603+
604+
t.client.submit_for_review(&cid);
605+
606+
// Admin approves → creates vote session (threshold=1)
607+
t.client.approve_campaign(&cid, &1000, &1);
608+
assert_eq!(
609+
t.client.get_campaign(&cid).status,
610+
CampaignStatus::Submitted
611+
);
612+
613+
// Voter votes "Reject" (option 1)
614+
let voter = Address::generate(&t.env);
615+
t.client.vote_campaign(&voter, &cid, &1);
616+
617+
// Check threshold → should reject back to Draft
618+
t.client.check_vote_threshold(&cid);
619+
620+
let campaign = t.client.get_campaign(&cid);
621+
assert_eq!(campaign.status, CampaignStatus::Draft);
622+
assert!(campaign.vote_session_id.is_none());
623+
}
624+
625+
#[test]
626+
fn test_vote_expired_without_quorum_returns_to_draft() {
627+
let t = setup();
628+
let owner = t.admin.clone();
629+
630+
let cid = t.client.create_campaign(
631+
&owner,
632+
&String::from_str(&t.env, "Vote expire"),
633+
&10000i128,
634+
&t.token_addr,
635+
&(t.env.ledger().timestamp() + 86400),
636+
&make_milestones(&t.env),
637+
&100i128,
638+
&false,
639+
);
640+
641+
t.client.submit_for_review(&cid);
642+
643+
// Admin approves → creates vote session (threshold=5, duration=1000)
644+
t.client.approve_campaign(&cid, &1000, &5);
645+
assert_eq!(
646+
t.client.get_campaign(&cid).status,
647+
CampaignStatus::Submitted
648+
);
649+
650+
// Only 1 vote cast (threshold is 5), so threshold not reached
651+
let voter = Address::generate(&t.env);
652+
t.client.vote_campaign(&voter, &cid, &0);
653+
654+
// Advance past voting deadline
655+
t.env.ledger().with_mut(|l| {
656+
l.timestamp += 1001;
657+
});
658+
659+
// Check threshold → voting expired, should reject back to Draft
660+
t.client.check_vote_threshold(&cid);
661+
662+
let campaign = t.client.get_campaign(&cid);
663+
assert_eq!(campaign.status, CampaignStatus::Draft);
664+
assert!(campaign.vote_session_id.is_none());
665+
}
666+
667+
#[test]
668+
fn test_vote_threshold_not_met_while_active() {
669+
let t = setup();
670+
let owner = t.admin.clone();
671+
672+
let cid = t.client.create_campaign(
673+
&owner,
674+
&String::from_str(&t.env, "Still voting"),
675+
&10000i128,
676+
&t.token_addr,
677+
&(t.env.ledger().timestamp() + 86400),
678+
&make_milestones(&t.env),
679+
&100i128,
680+
&false,
681+
);
682+
683+
t.client.submit_for_review(&cid);
684+
t.client.approve_campaign(&cid, &1000, &5);
685+
686+
// No votes yet, voting still active → should error
687+
let result = t.client.try_check_vote_threshold(&cid);
688+
assert!(result.is_err());
689+
690+
// Campaign stays in Submitted
691+
assert_eq!(
692+
t.client.get_campaign(&cid).status,
693+
CampaignStatus::Submitted
694+
);
695+
}

0 commit comments

Comments
 (0)