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
124 changes: 121 additions & 3 deletions contracts/crowdfund_registry/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ use crate::error::CrowdfundError;
use crate::events::{
CampaignApproved, CampaignCancelled, CampaignCreated, CampaignFailed, CampaignFunded,
CampaignRejected, CampaignSubmittedForReview, CampaignTerminated, CampaignValidated,
MilestoneApproved, MilestoneDisputed, MilestoneOverdue, MilestoneRevisionRequested,
MilestoneSubmitted, PledgeRecorded, RefundBatchProcessed,
DisputeResolved, MilestoneApproved, MilestoneDisputed, MilestoneOverdue,
MilestoneRevisionRequested, MilestoneSubmitted, PledgeRecorded, RefundBatchProcessed,
};
use crate::storage::{
Campaign, CampaignStatus, CrowdfundDataKey, CrowdfundMilestoneStatus, Milestone, VoteContext,
Campaign, CampaignStatus, CrowdfundDataKey, CrowdfundMilestoneStatus, DisputeResolution,
Milestone, VoteContext,
};
use boundless_types::ttl::{
INSTANCE_TTL_EXTEND, INSTANCE_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND, PERSISTENT_TTL_THRESHOLD,
Expand Down Expand Up @@ -91,6 +92,20 @@ impl CrowdfundRegistry {
.ok_or(CrowdfundError::MilestoneNotFound)
}

pub fn get_dispute_status(
env: Env,
campaign_id: u64,
milestone_index: u32,
) -> Result<CrowdfundMilestoneStatus, CrowdfundError> {
let ms_key = CrowdfundDataKey::CampaignMilestone(campaign_id, milestone_index);
let ms: Milestone = env
.storage()
.persistent()
.get(&ms_key)
.ok_or(CrowdfundError::MilestoneNotFound)?;
Ok(ms.status)
}

pub fn get_pledge(env: Env, campaign_id: u64, backer: Address) -> i128 {
env.storage()
.persistent()
Expand Down Expand Up @@ -905,6 +920,109 @@ impl CrowdfundRegistry {
Ok(())
}

pub fn resolve_dispute(
env: Env,
campaign_id: u64,
milestone_index: u32,
resolution: DisputeResolution,
) -> Result<(), CrowdfundError> {
let admin = Self::require_admin(&env)?;
admin.require_auth();

let key = CrowdfundDataKey::Campaign(campaign_id);
let mut campaign: Campaign = env
.storage()
.persistent()
.get(&key)
.ok_or(CrowdfundError::CampaignNotFound)?;

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.status != CrowdfundMilestoneStatus::Disputed {
return Err(CrowdfundError::MilestoneNotDisputed);
}

match resolution {
DisputeResolution::ApproveCreator => {
// Resolve in favor of creator: release milestone funds
ms.status = CrowdfundMilestoneStatus::Released;
env.storage().persistent().set(&ms_key, &ms);

let escrow_addr = Self::get_escrow_addr(&env);
let release_args: Vec<Val> = Vec::from_array(
&env,
[
campaign.pool_id.clone().into_val(&env),
milestone_index.into_val(&env),
],
);
env.invoke_contract::<()>(
&escrow_addr,
&sym(&env, "release_slot"),
release_args,
);

// Check if all milestones are released
let mut all_done = true;
for i in 0..campaign.milestone_count {
let m: Milestone = env
.storage()
.persistent()
.get(&CrowdfundDataKey::CampaignMilestone(campaign_id, i))
.unwrap();
if m.status != CrowdfundMilestoneStatus::Released {
all_done = false;
break;
}
}

if all_done {
campaign.status = CampaignStatus::Completed;

let rep_addr = Self::get_rep_addr(&env);
let rep_args: Vec<Val> = Vec::from_array(
&env,
[
env.current_contract_address().into_val(&env),
campaign.owner.clone().into_val(&env),
],
);
env.invoke_contract::<()>(
&rep_addr,
&sym(&env, "record_campaign_backed"),
rep_args,
);
}

env.storage().persistent().set(&key, &campaign);
}
DisputeResolution::ApproveBacker => {
// Resolve in favor of backer: reject milestone, 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);
}
}

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

Self::extend_instance_ttl(&env);
Ok(())
}

pub fn terminate_campaign(env: Env, campaign_id: u64) -> Result<(), CrowdfundError> {
let admin = Self::require_admin(&env)?;
admin.require_auth();
Expand Down
1 change: 1 addition & 0 deletions contracts/crowdfund_registry/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ pub enum CrowdfundError {
NotSubmitted = 824,
VoteThresholdNotMet = 825,
NoVoteSession = 826,
MilestoneNotDisputed = 827,
}
10 changes: 10 additions & 0 deletions contracts/crowdfund_registry/src/events/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::storage::DisputeResolution;
use soroban_sdk::{contractevent, Address, BytesN, String};

#[contractevent]
Expand Down Expand Up @@ -134,3 +135,12 @@ pub struct MilestoneRevisionRequested {
pub campaign_id: u64,
pub milestone_id: u32,
}

#[contractevent]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DisputeResolved {
#[topic]
pub campaign_id: u64,
pub milestone_id: u32,
pub resolution: DisputeResolution,
}
7 changes: 7 additions & 0 deletions contracts/crowdfund_registry/src/storage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ pub enum CrowdfundMilestoneStatus {
Disputed,
}

#[contracttype]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum DisputeResolution {
ApproveCreator,
ApproveBacker,
}

#[contracttype]
#[derive(Clone, Debug)]
pub struct Milestone {
Expand Down
139 changes: 138 additions & 1 deletion contracts/crowdfund_registry/src/tests/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::contract::{CrowdfundRegistry, CrowdfundRegistryClient};
use crate::storage::CampaignStatus;
use crate::storage::{CampaignStatus, CrowdfundMilestoneStatus, DisputeResolution};
use core_escrow::{CoreEscrow, CoreEscrowClient};
use governance_voting::{GovernanceVoting, GovernanceVotingClient};
use reputation_registry::{ReputationRegistry, ReputationRegistryClient};
Expand Down Expand Up @@ -443,3 +443,140 @@ fn test_invalid_milestones_rejected() {
);
assert!(result.is_err());
}

#[test]
fn test_resolve_dispute_approve_creator() {
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 cid = t.client.create_campaign(
&owner,
&String::from_str(&t.env, "Dispute Creator Win"),
&1000i128,
&t.token_addr,
&(t.env.ledger().timestamp() + 86400),
&make_milestones(&t.env),
&100i128,
&false,
);

advance_to_campaigning(&t, cid);

// Fund the campaign
t.client.pledge(&donor, &cid, &1100);
assert_eq!(t.client.get_campaign(&cid).status, CampaignStatus::Funded);

// Submit milestone 0
t.client.submit_milestone(&cid, &0);
assert_eq!(
t.client.get_dispute_status(&cid, &0),
CrowdfundMilestoneStatus::Submitted
);

// Backer disputes milestone 0
t.client.dispute_milestone(&donor, &cid, &0);
assert_eq!(
t.client.get_dispute_status(&cid, &0),
CrowdfundMilestoneStatus::Disputed
);

// Admin resolves in favor of creator → funds released
let balance_before = t.token.balance(&owner);
t.client
.resolve_dispute(&cid, &0, &DisputeResolution::ApproveCreator);

let ms = t.client.get_milestone(&cid, &0);
assert_eq!(ms.status, CrowdfundMilestoneStatus::Released);
assert!(t.token.balance(&owner) > balance_before);

// Campaign is still Executing (milestone 1 not done yet)
let campaign = t.client.get_campaign(&cid);
assert_eq!(campaign.status, CampaignStatus::Executing);

// Complete milestone 1 normally
t.client.submit_milestone(&cid, &1);
t.client.approve_milestone(&cid, &1);

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

#[test]
fn test_resolve_dispute_approve_backer() {
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 cid = t.client.create_campaign(
&owner,
&String::from_str(&t.env, "Dispute Backer Win"),
&1000i128,
&t.token_addr,
&(t.env.ledger().timestamp() + 86400),
&make_milestones(&t.env),
&100i128,
&false,
);

advance_to_campaigning(&t, cid);

// Fund the campaign
t.client.pledge(&donor, &cid, &1100);

// Submit and dispute milestone 0
t.client.submit_milestone(&cid, &0);
t.client.dispute_milestone(&donor, &cid, &0);

// Admin resolves in favor of backer → milestone rejected, campaign cancelled
let balance_before_refund = t.token.balance(&donor);
t.client
.resolve_dispute(&cid, &0, &DisputeResolution::ApproveBacker);

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 now get refunds
t.client.process_refund_batch(&cid);
assert!(t.token.balance(&donor) > balance_before_refund);
}

#[test]
fn test_resolve_dispute_not_disputed_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 cid = t.client.create_campaign(
&owner,
&String::from_str(&t.env, "Not disputed"),
&1000i128,
&t.token_addr,
&(t.env.ledger().timestamp() + 86400),
&make_milestones(&t.env),
&100i128,
&false,
);

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

// Try to resolve a non-disputed milestone → should fail
let result = t
.client
.try_resolve_dispute(&cid, &0, &DisputeResolution::ApproveCreator);
assert!(result.is_err());
}
Loading