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: 1 addition & 1 deletion EVENT_PAYLOADS.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ Every `publish(...)` call in the contract, with its topics, data shape, and the
| Field | Value |
|---------|------------------------------------------------------------|
| Topics | `("campaigns_bulk_verified",)` |
| Data | `(verified_count: u32, total: u32)` |
| Data | `(verified_count: u32, failed_count: u32, total: u32)` |
| Source | `lib.rs:1175` — `verify_campaigns()` |

---
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/components/MilestoneProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React from 'react';
import { CampaignMilestone } from '@/types/campaign';

interface MilestoneProgressBarProps {
milestones: CampaignMilestone[];
}

export const MilestoneProgressBar: React.FC<MilestoneProgressBarProps> = ({ milestones }) => {
if (!milestones || milestones.length === 0) {
return null;
}

return (
<div className="space-y-6 my-6 p-6 bg-white rounded-xl shadow-sm border border-gray-100">
<h3 className="text-lg font-semibold text-gray-900">Campaign Milestones</h3>
<div className="space-y-4">
{milestones.map((milestone, index) => {
const progressPercentage = Math.min(
Math.round((milestone.currentAmount / milestone.targetAmount) * 100),
100
);

return (
<div key={milestone.id || index} className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="font-medium text-gray-800">
{index + 1}. {milestone.title}
</span>
<span className="text-gray-600">
{milestone.currentAmount} / {milestone.targetAmount} XLM ({progressPercentage}%)
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5 overflow-hidden">
<div
className={`h-2.5 rounded-full transition-all duration-500 ${
milestone.isCompleted ? 'bg-green-600' : 'bg-indigo-600'
}`}
style={{ width: `${progressPercentage}%` }}
/>
</div>
<p className="text-xs text-gray-500">{milestone.description}</p>
</div>
);
})}
</div>
</div>
);
};
17 changes: 17 additions & 0 deletions frontend/src/types/campaign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export interface CampaignMilestone {
id: string;
title: string;
description: string;
targetAmount: number;
currentAmount: number;
isCompleted: boolean;
dueDate?: string;
}

export interface CampaignWithMilestones {
id: string;
title: string;
goalAmount: number;
totalRaised: number;
milestones: CampaignMilestone[];
}
17 changes: 17 additions & 0 deletions src/errors.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#![allow(unused_macros)]
use soroban_sdk::contracterror;

/// Represents a distinct error type that can occur within the contract.
Expand Down Expand Up @@ -115,6 +116,22 @@ macro_rules! error_names {
};
}

/// Builds an exhaustive `match self { Error::V => stringify!(V), ... }` from
/// a bare list of variant identifiers. Each name is derived from the
/// identifier via `stringify!` instead of being retyped as a separate string
/// literal, so `name()` cannot report a name that has drifted (e.g. via a
/// typo) from the actual variant it matches — the only thing left to keep in
/// sync by hand is the list of identifiers itself, and forgetting one there
/// is still caught by the compiler because the expanded `match` remains
/// exhaustive-checked against every `Error` variant (#651).
macro_rules! error_names {
($self:expr, [$($variant:ident),* $(,)?]) => {
match $self {
$(Error::$variant => stringify!($variant),)*
}
};
}

impl Error {
/// Returns the canonical string name of this error variant, so event
/// payloads and debug logs can show a human-readable name instead of the
Expand Down
25 changes: 11 additions & 14 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,16 +223,19 @@ impl ProofOfHeart {
voting::admin_verify(&env, campaign_id)
}

pub fn verify_campaigns(env: Env, campaign_ids: soroban_sdk::Vec<u32>) -> Result<u32, Error> {
pub fn verify_campaigns(
env: Env,
campaign_ids: soroban_sdk::Vec<u32>,
) -> Result<(soroban_sdk::Vec<u32>, soroban_sdk::Vec<u32>), Error> {
let admin = get_admin(&env);
assert_admin(&env, &admin)?;
lifecycle::require_not_paused(&env)?;

const MAX_BATCH_SIZE: u32 = 50;
let batch_size = campaign_ids.len().min(MAX_BATCH_SIZE);

let mut verified_count = 0u32;
let mut first_error: Option<Error> = None;
let mut verified_ids = soroban_sdk::Vec::new(&env);
let mut failed_ids = soroban_sdk::Vec::new(&env);

bump_instance_ttl(&env);

Expand All @@ -241,27 +244,21 @@ impl ProofOfHeart {
storage::extend_voting_state_ttl(&env, campaign_id);
match voting::admin_verify(&env, campaign_id) {
Ok(()) => {
verified_count += 1;
verified_ids.push_back(campaign_id);
}
Err(e) => {
if first_error.is_none() {
first_error = Some(e);
}
Err(_) => {
failed_ids.push_back(campaign_id);
}
}
}
}

env.events().publish(
("campaigns_bulk_verified",),
(verified_count, campaign_ids.len()),
(verified_ids.len(), failed_ids.len(), campaign_ids.len()),
);

if let Some(err) = first_error {
Err(err)
} else {
Ok(verified_count)
}
Ok((verified_ids, failed_ids))
}

pub fn verify_campaign_with_votes(env: Env, campaign_id: u32) -> Result<(), Error> {
Expand Down
8 changes: 4 additions & 4 deletions src/tests/test_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,13 +377,13 @@ fn test_verify_campaigns_extends_ttl_on_failure() {

// 3. Verify the campaign successfully first.
let ids = soroban_sdk::Vec::from_array(&env, [campaign_id]);
let first_res = client.verify_campaigns(&ids);
assert_eq!(first_res, 1);
let (verified, _) = client.verify_campaigns(&ids);
assert_eq!(verified.len(), 1);

// Now try to verify the campaign again.
// Since it's already verified, it will fail verification.
let second_res = client.try_verify_campaigns(&ids);
assert!(second_res.is_err()); // verification failed (AdminVerificationConflict error)
let (_, failed) = client.verify_campaigns(&ids);
assert_eq!(failed.len(), 1); // verification failed (AdminVerificationConflict error)

// 4. Despite the failure, the voting state TTL should have been extended.
let current_ledger = env.ledger().sequence();
Expand Down
15 changes: 10 additions & 5 deletions src/tests/test_voting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,10 @@ fn test_verify_campaigns_extends_voting_state_ttl() {
));

// Bulk verify the campaign
let count = client.verify_campaigns(&soroban_sdk::Vec::from_array(&env, [campaign_id]));
assert_eq!(count, 1);
let (verified, failed) =
client.verify_campaigns(&soroban_sdk::Vec::from_array(&env, [campaign_id]));
assert_eq!(verified.len(), 1);
assert_eq!(failed.len(), 0);

// Verify campaign is verified (confirming it worked)
let campaign = client.get_campaign(&campaign_id);
Expand Down Expand Up @@ -368,10 +370,13 @@ fn test_verify_campaigns_partial_failure_returns_err() {
0i128,
));

// 999 does not exist — will produce CampaignNotFound
// 999 does not exist — will be returned in failed_ids
let ids = soroban_sdk::Vec::from_array(&env, [campaign_id, 999u32]);
let res = client.try_verify_campaigns(&ids);
assert!(res.unwrap_err().is_ok()); // Err variant, inner Ok means contract error
let (verified, failed) = client.verify_campaigns(&ids);
assert_eq!(verified.len(), 1);
assert_eq!(verified.get(0).unwrap(), campaign_id);
assert_eq!(failed.len(), 1);
assert_eq!(failed.get(0).unwrap(), 999u32);
}

// ── verification via votes ──────────────────────────────────────────────────────
Expand Down
Loading