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
4 changes: 3 additions & 1 deletion contracts/contracts/stellar-grants/src/data_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ pub fn export_milestones(env: &Env, grant_id: u64) -> Vec<ExportMilestone> {
} else {
None
};
let approved_at = if milestone.state == crate::types::MilestoneState::Approved {
let approved_at = if milestone.state == crate::types::MilestoneState::Approved
|| milestone.state == crate::types::MilestoneState::Paid
{
Some(milestone.status_updated_at)
} else {
None
Expand Down
18 changes: 18 additions & 0 deletions contracts/contracts/stellar-grants/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ pub struct GrantCancelled {
pub timestamp: u64,
}

/// Issue #698: emitted instead of silently dropping the entry when a
/// `grant_index` list (per-owner, per-status, per-token, or the global
/// recency order) has already reached `MAX_INDEX_ENTRIES`.
#[contractevent]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IndexCapReached {
pub grant_id: u64,
pub timestamp: u64,
}

#[contractevent]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RefundExecuted {
Expand Down Expand Up @@ -454,6 +464,14 @@ impl Events {
event.publish(env);
}

pub fn emit_index_cap_reached(env: &Env, grant_id: u64) {
let event = IndexCapReached {
grant_id,
timestamp: env.ledger().timestamp(),
};
event.publish(env);
}

pub fn emit_refund_executed(env: &Env, grant_id: u64, funder: Address, amount: i128) {
let event = RefundExecuted {
grant_id,
Expand Down
102 changes: 98 additions & 4 deletions contracts/contracts/stellar-grants/src/funder_report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,25 @@ use crate::escrow;
use crate::storage::Storage;
use crate::types::{FunderGrantSummary, FunderReport, FunderTokenSummary, GrantStatus};

/// Fetch every grant summary for a funder, regardless of how many grants
/// they've contributed to. `grant_summaries` itself supports offset/limit
/// pagination, but the report/summary/dashboard functions below used to
/// hard-code `(0, 50)` — silently truncating any funder past their 50th
/// grant (issue #697). `grant_summaries`'s own loop is bounded by the real
/// grant counter regardless of the limit passed in, so passing the full
/// counter as the limit fetches everything in one pass.
fn all_grant_summaries(
env: &Env,
funder: &Address,
) -> Result<Vec<FunderGrantSummary>, ContractError> {
let total_grants = Storage::get_grant_counter(env);
let limit = u32::try_from(total_grants).unwrap_or(u32::MAX);
grant_summaries(env, funder, 0, limit)
}

/// Build a comprehensive financial report for a funder. Read-only.
pub fn get_report(env: &Env, funder: &Address) -> Result<FunderReport, ContractError> {
let grants = grant_summaries(env, funder, 0, 50)?;
let grants = all_grant_summaries(env, funder)?;
let token_sums = build_token_summaries(env, funder, &grants);

let mut active: u32 = 0;
Expand Down Expand Up @@ -52,7 +68,7 @@ pub fn get_report(env: &Env, funder: &Address) -> Result<FunderReport, ContractE

/// Return per-token financial summary for a funder.
pub fn token_summary(env: &Env, funder: &Address, token: &Address) -> FunderTokenSummary {
let grants = grant_summaries(env, funder, 0, 50).unwrap_or_else(|_| Vec::new(env));
let grants = all_grant_summaries(env, funder).unwrap_or_else(|_| Vec::new(env));
let mut summary = FunderTokenSummary {
token: token.clone(),
total_committed: 0,
Expand Down Expand Up @@ -222,7 +238,7 @@ pub fn total_in_escrow(env: &Env, funder: &Address, token: &Address) -> i128 {
/// Return a lightweight report suitable for a dashboard widget.
/// Returns: (grants_count, total_committed, total_in_escrow, total_paid_out)
pub fn dashboard_summary(env: &Env, funder: &Address) -> (u32, i128, i128, i128) {
let grants = grant_summaries(env, funder, 0, 50).unwrap_or_else(|_| Vec::new(env));
let grants = all_grant_summaries(env, funder).unwrap_or_else(|_| Vec::new(env));
let count = grants.len() as u32;
let mut committed: i128 = 0;
let mut escrowed: i128 = 0;
Expand Down Expand Up @@ -289,7 +305,85 @@ mod tests {
use super::*;
use crate::types::{EscrowAccount, FunderLedger, Grant, GrantFund, GrantStatus};
use soroban_sdk::testutils::{Address as _, Ledger};
use soroban_sdk::Vec;
use soroban_sdk::{String, Vec};

/// Issue #697: `get_report`, `token_summary`, and `dashboard_summary` used
/// to hard-code `grant_summaries(env, funder, 0, 50)`, silently dropping
/// everything past a funder's 50th grant. Seed more than 50 grants for one
/// funder and assert every one of them is reflected in the aggregates.
#[test]
fn test_funder_with_more_than_50_grants_gets_complete_report() {
let env = Env::default();
let funder = Address::generate(&env);
let owner = Address::generate(&env);
let token = Address::generate(&env);

let total_grants: u64 = 55;
for _ in 0..total_grants {
let grant_id = Storage::increment_grant_counter(&env);
let grant = Grant {
id: grant_id,
owner: owner.clone(),
title: String::from_str(&env, "Grant"),
description: String::from_str(&env, "Test"),
token: token.clone(),
status: GrantStatus::Active,
total_amount: 100,
milestone_amount: 100,
reviewers: Vec::new(&env),
total_milestones: 1,
milestones_paid_out: 0,
escrow_balance: 100,
funders: Vec::new(&env),
reason: None,
timestamp: env.ledger().timestamp(),
require_compliance: None,
};
Storage::set_grant(&env, grant_id, &grant);

Storage::set_escrow_account(
&env,
grant_id,
&EscrowAccount {
owner: owner.clone(),
token: token.clone(),
balance: 100,
total_deposited: 100,
total_released: 0,
locked: false,
},
);

Storage::set_funder_ledger(
&env,
grant_id,
&funder,
&FunderLedger {
funder: funder.clone(),
contributed: 100,
refunded: 0,
last_contribution_at: env.ledger().timestamp(),
},
);
}

let report = get_report(&env, &funder).unwrap();
assert_eq!(report.total_grants_funded, total_grants as u32);
assert_eq!(report.active_grants, total_grants as u32);
assert_eq!(report.grant_summaries.len(), total_grants as u32);

let (count, committed, escrowed, _paid) = dashboard_summary(&env, &funder);
assert_eq!(count, total_grants as u32);
assert_eq!(committed, 100 * total_grants as i128);
assert_eq!(escrowed, 100 * total_grants as i128);

let ts = token_summary(&env, &funder, &token);
assert_eq!(ts.total_committed, 100 * total_grants as i128);
assert_eq!(
total_in_escrow(&env, &funder, &token),
100 * total_grants as i128
);
}

#[test]
fn test_unknown_funder_returns_empty_report() {
Expand Down
21 changes: 21 additions & 0 deletions contracts/contracts/stellar-grants/src/governance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ use crate::reviewer_sla;
use crate::storage::Storage;
use crate::types::{ContractError, Grant, Milestone, MilestoneState, VotingMechanism};

/// Transition every `Approved` milestone on a grant to `Paid`. Call this only
/// once the grant's payout path has actually confirmed the fund transfer for
/// those milestones (see `StellarGrantsContract::finalize_grant_release` and
/// `execute_escrow_release` in lib.rs) — `Approved` alone does not mean the
/// funds moved, since a multisig-gated release can leave a milestone
/// `Approved` for a time after quorum but before the transfer executes.
/// Read paths (`portfolio::earnings_by_token`, `data_export`) only count
/// `Paid` milestones as earned/paid-out, so skipping this step after a real
/// payout silently zeroes a contributor's reported earnings (issue #696).
pub fn mark_milestones_paid(env: &Env, grant_id: u64, total_milestones: u32) {
for idx in 0..total_milestones {
if let Some(mut milestone) = Storage::get_milestone(env, grant_id, idx) {
if milestone.state == MilestoneState::Approved {
milestone.state = MilestoneState::Paid;
Storage::set_milestone(env, grant_id, idx, &milestone);
Events::emit_milestone_paid(env, grant_id, idx, milestone.amount);
}
}
}
}

pub struct VoteResult {
pub approved: bool,
pub quorum_reached: bool,
Expand Down
106 changes: 92 additions & 14 deletions contracts/contracts/stellar-grants/src/grant_index.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,36 @@
use soroban_sdk::{Address, Env, Vec};

use crate::constants;
use crate::events::Events;
use crate::storage::{DataKey, GrantKey};
use crate::types::GrantStatus;

fn push_to_index(env: &Env, key: &DataKey, grant_id: u64) {
/// Push `grant_id` onto the index list at `key`, capped at `cap` entries.
/// Returns `true` if the id is present in the list afterwards (either just
/// added or already there), `false` if the list was already at capacity and
/// the id had to be dropped.
///
/// Before issue #698, a cap hit was a silent no-op: `grant_create` still
/// succeeded, but the grant became permanently invisible to `by_owner`,
/// `by_status`, `by_token`, `recent`, and `data_export` for that index, with
/// no error or event to reveal it. Surfacing an `IndexCapReached` event here
/// at least makes the condition observable instead of a silent data loss.
fn push_to_index(env: &Env, key: &DataKey, grant_id: u64, cap: u32) -> bool {
let mut list: Vec<u64> = env
.storage()
.persistent()
.get(key)
.unwrap_or_else(|| Vec::new(env));
if list.len() < constants::MAX_INDEX_ENTRIES && !list.contains(grant_id) {
list.push_back(grant_id);
env.storage().persistent().set(key, &list);
if list.contains(grant_id) {
return true;
}
if list.len() >= cap {
Events::emit_index_cap_reached(env, grant_id);
return false;
}
list.push_back(grant_id);
env.storage().persistent().set(key, &list);
true
}

fn remove_from_index(env: &Env, key: &DataKey, grant_id: u64) {
Expand All @@ -35,31 +52,26 @@ pub fn on_grant_created(
token: &Address,
status: GrantStatus,
) {
let cap = constants::MAX_INDEX_ENTRIES;
push_to_index(
env,
&DataKey::Grant(GrantKey::OwnerIndex(owner.clone())),
grant_id,
cap,
);
push_to_index(
env,
&DataKey::Grant(GrantKey::StatusIndex(status as u32)),
grant_id,
cap,
);
push_to_index(
env,
&DataKey::Grant(GrantKey::TokenIndex(token.clone())),
grant_id,
cap,
);
let order_key = DataKey::Grant(GrantKey::GlobalOrder);
let mut order: Vec<u64> = env
.storage()
.persistent()
.get(&order_key)
.unwrap_or_else(|| Vec::new(env));
if order.len() < constants::MAX_INDEX_ENTRIES {
order.push_back(grant_id);
env.storage().persistent().set(&order_key, &order);
}
push_to_index(env, &DataKey::Grant(GrantKey::GlobalOrder), grant_id, cap);
}

pub fn on_status_changed(
Expand All @@ -78,6 +90,7 @@ pub fn on_status_changed(
env,
&DataKey::Grant(GrantKey::StatusIndex(new_status as u32)),
grant_id,
constants::MAX_INDEX_ENTRIES,
);
}
}
Expand All @@ -87,6 +100,7 @@ pub fn on_contributor_assigned(env: &Env, grant_id: u64, contributor: &Address)
env,
&DataKey::Grant(GrantKey::ContribIndex(contributor.clone())),
grant_id,
constants::MAX_INDEX_ENTRIES,
);
}

Expand Down Expand Up @@ -165,3 +179,67 @@ pub fn index_counts(env: &Env, owner: Option<&Address>) -> (u32, u32, u32) {
};
(owned, active.len(), contributed)
}

#[cfg(test)]
mod tests {
use super::*;
use soroban_sdk::testutils::Events as _;
use soroban_sdk::Env;

/// Issue #698: hitting the cap used to be a silent no-op — the id was
/// dropped with no error and no event. A reduced cap (3, instead of the
/// real 10,000) keeps this test fast while still exercising the same
/// code path.
#[test]
fn test_push_to_index_respects_cap_and_surfaces_the_drop() {
let env = Env::default();
let key = DataKey::Grant(GrantKey::GlobalOrder);
let cap = 3u32;

assert!(push_to_index(&env, &key, 1, cap));
assert!(push_to_index(&env, &key, 2, cap));
assert!(push_to_index(&env, &key, 3, cap));

let list: Vec<u64> = env.storage().persistent().get(&key).unwrap();
assert_eq!(list.len(), 3);

let accepted = push_to_index(&env, &key, 4, cap);
assert!(!accepted, "push beyond the cap must report failure");

let list: Vec<u64> = env.storage().persistent().get(&key).unwrap();
assert_eq!(list.len(), 3, "list must not silently grow past the cap");
assert!(
!list.contains(4),
"dropped id must not silently appear in the index"
);

let events = env.events().all();
let mut found_cap_event = false;
for e in events.events() {
if format!("{:?}", e).contains("index_cap_reached") {
found_cap_event = true;
}
}
assert!(
found_cap_event,
"IndexCapReached event must be emitted instead of silently dropping the entry"
);
}

#[test]
fn test_push_to_index_dedupes_without_double_counting_against_cap() {
let env = Env::default();
let key = DataKey::Grant(GrantKey::GlobalOrder);
let cap = 2u32;

assert!(push_to_index(&env, &key, 1, cap));
assert!(push_to_index(&env, &key, 2, cap));

// Re-pushing an id already in a full list is a no-op success, not a
// cap breach — it must not emit a spurious IndexCapReached event.
assert!(push_to_index(&env, &key, 1, cap));

let list: Vec<u64> = env.storage().persistent().get(&key).unwrap();
assert_eq!(list.len(), 2);
}
}
Loading
Loading