Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/onchain/contracts/crowdfund_vault/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,7 @@ pub enum CrowdfundError {
RefundWindowClosed = 29,
RefundWindowNotOpen = 30,
Reentrancy = 31,
BatchTooLarge = 32,
EmptyBatch = 33,
AlreadyExecuted = 32,
}
18 changes: 18 additions & 0 deletions apps/onchain/contracts/crowdfund_vault/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,21 @@ pub struct StorageMigratedEvent {
pub admin: Address,
pub storage_version: u32,
}

/// Input type for a single milestone approval decision within a batch.
#[soroban_sdk::contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BatchMilestoneDecision {
pub project_id: u64,
pub milestone_id: u32,
}

/// Emitted once per batch after all decisions have been applied.
#[contractevent]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BatchMilestoneProcessedEvent {
#[topic]
pub admin: Address,
/// Number of milestones successfully approved in this batch.
pub approved_count: u32,
}
93 changes: 93 additions & 0 deletions apps/onchain/contracts/crowdfund_vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,99 @@ impl CrowdfundVaultContract {
Ok(())
}

/// Approve up to MAX_BATCH_SIZE milestones in one transaction (admin only).
///
/// Each item is processed independently: already-approved or expired-project
/// entries are skipped (not counted). Only the successful approvals are
/// counted in the summary event.
pub fn batch_approve_milestones(
env: Env,
admin: Address,
decisions: Vec<events::BatchMilestoneDecision>,
) -> Result<u32, CrowdfundError> {
const MAX_BATCH_SIZE: u32 = 10;

// Validate + auth before any storage mutations
Self::verify_admin(&env, &admin)?;

let is_paused: bool = env
.storage()
.instance()
.get(&DataKey::Paused)
.unwrap_or(false);
if is_paused {
return Err(CrowdfundError::ContractPaused);
}

if decisions.len() == 0 {
return Err(CrowdfundError::EmptyBatch);
}
if decisions.len() > MAX_BATCH_SIZE {
return Err(CrowdfundError::BatchTooLarge);
}

let mut approved_count: u32 = 0;

for decision in decisions.iter() {
let project_id = decision.project_id;
let milestone_id = decision.milestone_id;

// Load project; skip if not found
let mut project: ProjectData = match env
.storage()
.persistent()
.get(&DataKey::Project(project_id))
{
Some(p) => p,
None => continue,
};

// Skip expired projects (and trigger expiry side-effects)
if Self::fail_if_project_expired(&env, project_id, &mut project).is_err() {
continue;
}

// Skip already-approved milestones
let already: bool = env
.storage()
.persistent()
.get(&DataKey::MilestoneApproved(project_id, milestone_id))
.unwrap_or(false);
if already {
continue;
}

// Apply approval
env.storage().persistent().set(
&DataKey::MilestoneApproved(project_id, milestone_id),
&true,
);
env.storage().persistent().set(
&DataKey::MilestoneDisputed(project_id, milestone_id),
&false,
);

// Per-item event (reuses existing MilestoneApprovedEvent)
events::MilestoneApprovedEvent {
admin: admin.clone(),
project_id,
milestone_id,
}
.publish(&env);

approved_count += 1;
}

// Summary event
events::BatchMilestoneProcessedEvent {
admin,
approved_count,
}
.publish(&env);

Ok(approved_count)
}

/// Start a vote for a milestone approval
pub fn start_milestone_vote(
env: Env,
Expand Down
141 changes: 141 additions & 0 deletions apps/onchain/contracts/crowdfund_vault/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2763,3 +2763,144 @@ fn test_withdraw_cei_state_written_before_balance_assertion() {
assert_eq!(client.get_balance(&project_id), 300_000);
assert_eq!(token_client.balance(&owner), 200_000);
}

// ──────────────────────────────────────────────
// batch_approve_milestones tests
// ──────────────────────────────────────────────

#[test]
fn test_batch_approve_milestones_approves_all() {
let env = Env::default();
env.mock_all_auths();

let (client, admin, owner, _, token_client) = setup_test(&env);
client.initialize(&admin);

let pid0 = client.create_project(&owner, &symbol_short!("P0"), &1_000_000, &token_client.address);
let pid1 = client.create_project(&owner, &symbol_short!("P1"), &1_000_000, &token_client.address);

use crate::events::BatchMilestoneDecision;
let decisions = vec![
&env,
BatchMilestoneDecision { project_id: pid0, milestone_id: 0 },
BatchMilestoneDecision { project_id: pid1, milestone_id: 0 },
];

let count = client.batch_approve_milestones(&admin, &decisions);
assert_eq!(count, 2);
assert!(client.is_milestone_approved(&pid0, &0));
assert!(client.is_milestone_approved(&pid1, &0));
}

#[test]
fn test_batch_approve_milestones_skips_missing_project() {
let env = Env::default();
env.mock_all_auths();

let (client, admin, owner, _, token_client) = setup_test(&env);
client.initialize(&admin);

let pid = client.create_project(&owner, &symbol_short!("P0"), &1_000_000, &token_client.address);

use crate::events::BatchMilestoneDecision;
let decisions = vec![
&env,
BatchMilestoneDecision { project_id: pid, milestone_id: 0 },
BatchMilestoneDecision { project_id: 9999, milestone_id: 0 }, // non-existent
];

let count = client.batch_approve_milestones(&admin, &decisions);
assert_eq!(count, 1);
assert!(client.is_milestone_approved(&pid, &0));
}

#[test]
fn test_batch_approve_milestones_skips_already_approved() {
let env = Env::default();
env.mock_all_auths();

let (client, admin, owner, _, token_client) = setup_test(&env);
client.initialize(&admin);

let pid = client.create_project(&owner, &symbol_short!("P0"), &1_000_000, &token_client.address);
client.approve_milestone(&admin, &pid, &0);

use crate::events::BatchMilestoneDecision;
let decisions = vec![&env, BatchMilestoneDecision { project_id: pid, milestone_id: 0 }];

// already approved → skipped, count = 0
let count = client.batch_approve_milestones(&admin, &decisions);
assert_eq!(count, 0);
}

#[test]
fn test_batch_approve_milestones_empty_batch_fails() {
let env = Env::default();
env.mock_all_auths();

let (client, admin, _, _, _) = setup_test(&env);
client.initialize(&admin);

use crate::events::BatchMilestoneDecision;
let empty: soroban_sdk::Vec<BatchMilestoneDecision> = vec![&env];
let result = client.try_batch_approve_milestones(&admin, &empty);
assert_eq!(result, Err(Ok(CrowdfundError::EmptyBatch)));
}

#[test]
fn test_batch_approve_milestones_oversized_batch_fails() {
let env = Env::default();
env.mock_all_auths();

let (client, admin, owner, _, token_client) = setup_test(&env);
client.initialize(&admin);

use crate::events::BatchMilestoneDecision;
let mut decisions = soroban_sdk::Vec::new(&env);
for i in 0..11u64 {
// create enough projects so IDs are valid (though they'll be skipped if missing)
decisions.push_back(BatchMilestoneDecision { project_id: i, milestone_id: 0 });
}
// suppress unused warning
let _ = owner;
let _ = token_client;

let result = client.try_batch_approve_milestones(&admin, &decisions);
assert_eq!(result, Err(Ok(CrowdfundError::BatchTooLarge)));
}

#[test]
fn test_batch_approve_milestones_non_admin_fails() {
let env = Env::default();
env.mock_all_auths();

let (client, admin, owner, _, token_client) = setup_test(&env);
client.initialize(&admin);

let pid = client.create_project(&owner, &symbol_short!("P0"), &1_000_000, &token_client.address);

use crate::events::BatchMilestoneDecision;
let decisions = vec![&env, BatchMilestoneDecision { project_id: pid, milestone_id: 0 }];
let non_admin = Address::generate(&env);

let result = client.try_batch_approve_milestones(&non_admin, &decisions);
assert_eq!(result, Err(Ok(CrowdfundError::Unauthorized)));
}

#[test]
fn test_batch_approve_milestones_paused_fails() {
let env = Env::default();
env.mock_all_auths();

let (client, admin, owner, _, token_client) = setup_test(&env);
client.initialize(&admin);

let pid = client.create_project(&owner, &symbol_short!("P0"), &1_000_000, &token_client.address);
client.pause(&admin);

use crate::events::BatchMilestoneDecision;
let decisions = vec![&env, BatchMilestoneDecision { project_id: pid, milestone_id: 0 }];

let result = client.try_batch_approve_milestones(&admin, &decisions);
assert_eq!(result, Err(Ok(CrowdfundError::ContractPaused)));
}
Loading