diff --git a/contracts/contracts/stellar-grants/src/delegate.rs b/contracts/contracts/stellar-grants/src/delegate.rs index 0195a6a7..8a5f54a1 100644 --- a/contracts/contracts/stellar-grants/src/delegate.rs +++ b/contracts/contracts/stellar-grants/src/delegate.rs @@ -23,18 +23,17 @@ pub struct DelegationRevoked { pub revoked_at: u64, } -fn is_registered_reviewer(env: &Env, reviewer: &Address) -> bool { - let count = Storage::get_grant_count(env); - let mut id = 1u64; - while id <= count { - if let Some(grant) = Storage::get_grant(env, id) { - if grant.reviewers.contains(reviewer.clone()) { - return true; - } - } - id += 1; +fn is_registered_reviewer(env: &Env, reviewer: &Address, scope: &DelegationScope) -> bool { + match scope { + DelegationScope::PerGrant(grant_id) => Storage::get_grant(env, *grant_id) + .map(|g| g.reviewers.contains(reviewer.clone())) + .unwrap_or(false), + // Global scope has no single grant to check at creation time; the real + // authorization boundary is enforced per-grant at vote time in + // resolve_delegator/is_authorized_proxy, which only ever iterate one + // grant's reviewer list. + DelegationScope::Global => true, } - false } fn get_raw(env: &Env, delegator: &Address, scope: &DelegationScope) -> Option { @@ -71,7 +70,12 @@ fn active_matching(env: &Env, delegator: &Address, grant_id: u64) -> Option bool { +fn would_create_cycle( + env: &Env, + delegator: &Address, + delegate: &Address, + scope: &DelegationScope, +) -> bool { if delegator == delegate { return true; } @@ -101,7 +105,7 @@ pub fn delegate_vote( max_uses: Option, ) -> Result<(), ContractError> { delegator.require_auth(); - if !is_registered_reviewer(env, delegator) { + if !is_registered_reviewer(env, delegator, &scope) { return Err(ContractError::Unauthorized); } if max_uses == Some(0) || would_create_cycle(env, delegator, delegate, &scope) { @@ -194,6 +198,10 @@ pub fn consume_delegation_for_vote( Err(ContractError::Unauthorized) } -pub fn get_delegation(env: &Env, delegator: &Address, scope: &DelegationScope) -> Option { +pub fn get_delegation( + env: &Env, + delegator: &Address, + scope: &DelegationScope, +) -> Option { active_for_scope(env, delegator, scope) } diff --git a/contracts/contracts/stellar-grants/src/lib.rs b/contracts/contracts/stellar-grants/src/lib.rs index 3e912846..70669e34 100644 --- a/contracts/contracts/stellar-grants/src/lib.rs +++ b/contracts/contracts/stellar-grants/src/lib.rs @@ -40,6 +40,7 @@ mod cross_contract; mod crowdfund; mod dao; mod data_export; +mod delegate; mod dispute; mod emergency; mod errors; @@ -88,6 +89,7 @@ mod quadratic; mod rate_limit; mod reentrancy; mod referral; +mod refund; mod registry; mod relay; mod reputation; @@ -97,6 +99,7 @@ mod reviewer_pool; mod reviewer_reward; pub mod reviewer_sla; mod scoring; +mod snapshot; mod split_payment; mod storage; mod streaming; @@ -123,30 +126,31 @@ pub use types::{ ComplianceStatus, ConditionResult, ContractVersion, ContributionType, ContributorPortfolio, ContributorRegisterPayload, CriterionStatus, CrossChainProof, CrowdfundCampaign, CrowdfundPledge, CrowdfundStatus, DaoProposal, DaoProposalStatus, DaoProposalType, - DashboardView, DecayConfig, DecayType, DexConfig, Dispute, DisputeStatus, EscrowAccount, - EscrowLifecycleState, EscrowMode, EscrowReleaseApproval, EscrowReleaseRequest, EscrowState, - EvidenceField, EvidenceFieldType, EvidenceSchema, ExportGrant, ExportGrantPage, - ExportMilestone, ExportMilestonePage, ExtensionRequest, ExtensionStatus, FeeRecord, ForkRecord, - FunderGrantSummary, FunderLedger, FunderReport, FunderTokenSummary, Grant, GrantArchetype, - GrantCard, GrantCategory, GrantDetailView, GrantFund, GrantPortfolio, GrantStatus, - GrantSummary, GrantTag, GrantTemplate, GrantVersion, HookCallResult, HookEvent, - HookRegistration, InsuranceClaim, InsurancePolicy, Invoice, InvoiceStatus, IpRights, - LicenseRecord, LicenseType, LineItem, LockupRecord, LockupStatus, MatchingAllocation, - MatchingContribution, MatchingRound, MerkleCommitment, MerkleProof, MigrationRecord, Milestone, - MilestoneDag, MilestoneDependency, MilestoneNft, MilestoneState, MilestoneSubmission, - MilestoneSubmitPayload, MilestoneTemplate, MultiGrantBatchResult, MultisigProposal, - MultisigSigner, NftMetadata, NotificationEvent, OracleConfig, ParamRecord, ParamType, - ParamValue, PauseRecord, PaymentSplit, PaymentStream, PerformanceBond, PortfolioFilter, - PortfolioStats, PriceQuote, ProtocolConfig, ProtocolMetrics, ProtocolModule, ProvenanceRecord, - PublicReview, PublicReviewSignal, QuadraticVoteRecord, RateLimitAction, ReferralCode, - ReferralRecord, ReferralReward, RegistryEntry, RegistryEntryType, RelayAllowance, RelayConfig, - RelayDispatch, RelayRecord, RelayableAction, ReleaseCondition, RenewalProposal, RenewalStatus, - ReputationTier, RevenueEpoch, ReviewParticipation, ReviewerAvailability, ReviewerProfile, - ReviewerRequest, ReviewerRequestStatus, ReviewerRewardPool, ReviewerRewardRecord, ReviewerView, - Role, RoleAssignment, RollingWindow, ScoreResult, ScoringDimension, ScoringRubric, - ScoringWeight, SignatureStatus, SplitRecipient, StakerEpochRecord, StructuredEvidence, - Subscription, SubscriptionScope, SwapResult, SwapRoute, SyndicateGrant, SyndicateMember, - SyndicateStatus, TemplateCategory, TimerRecord, TimerTriggerType, TokenMetric, + DashboardView, DecayConfig, DecayType, Delegation, DelegationScope, DexConfig, Dispute, + DisputeStatus, EscrowAccount, EscrowLifecycleState, EscrowMode, EscrowReleaseApproval, + EscrowReleaseRequest, EscrowState, EvidenceField, EvidenceFieldType, EvidenceSchema, + ExportGrant, ExportGrantPage, ExportMilestone, ExportMilestonePage, ExtensionRequest, + ExtensionStatus, FeeRecord, ForkRecord, FunderGrantSummary, FunderLedger, FunderReport, + FunderTokenSummary, Grant, GrantArchetype, GrantCard, GrantCategory, GrantDetailView, + GrantFund, GrantPortfolio, GrantStatus, GrantSummary, GrantTag, GrantTemplate, GrantVersion, + HookCallResult, HookEvent, HookRegistration, InsuranceClaim, InsurancePolicy, Invoice, + InvoiceStatus, IpRights, LicenseRecord, LicenseType, LineItem, LockupRecord, LockupStatus, + MatchingAllocation, MatchingContribution, MatchingRound, MerkleCommitment, MerkleProof, + MigrationRecord, Milestone, MilestoneDag, MilestoneDependency, MilestoneNft, MilestoneState, + MilestoneSubmission, MilestoneSubmitPayload, MilestoneTemplate, MultiGrantBatchResult, + MultisigProposal, MultisigSigner, NftMetadata, NotificationEvent, OracleConfig, ParamRecord, + ParamType, ParamValue, PauseRecord, PaymentSplit, PaymentStream, PerformanceBond, + PortfolioFilter, PortfolioStats, PriceQuote, ProtocolConfig, ProtocolMetrics, ProtocolModule, + ProvenanceRecord, PublicReview, PublicReviewSignal, QuadraticVoteRecord, RateLimitAction, + ReferralCode, ReferralRecord, ReferralReward, RefundCalculation, RefundPolicy, + RefundPolicyType, RegistryEntry, RegistryEntryType, RelayAllowance, RelayConfig, RelayDispatch, + RelayRecord, RelayableAction, ReleaseCondition, RenewalProposal, RenewalStatus, ReputationTier, + RevenueEpoch, ReviewParticipation, ReviewerAvailability, ReviewerProfile, ReviewerRequest, + ReviewerRequestStatus, ReviewerRewardPool, ReviewerRewardRecord, ReviewerView, Role, + RoleAssignment, RollingWindow, ScoreResult, ScoringDimension, ScoringRubric, ScoringWeight, + SignatureStatus, SnapshotTrigger, SplitRecipient, StakerEpochRecord, StateSnapshot, + StructuredEvidence, Subscription, SubscriptionScope, SwapResult, SwapRoute, SyndicateGrant, + SyndicateMember, SyndicateStatus, TemplateCategory, TimerRecord, TimerTriggerType, TokenMetric, TransferProposal, TransferableRole, TreasurySnapshot, VerificationAttestation, VerificationLevel, VerificationStatus, VoiceCredits, VotingMechanism, WaitlistConfig, WaitlistEntry, WhitelistEntry, WhitelistMode, WhitelistScope, WithdrawStreamPayload, @@ -365,7 +369,15 @@ impl StellarGrantsContract { let total_refundable = grant.escrow_balance; if total_refundable > 0 { - escrow::refund_all(&env, grant_id)?; + // Issue #727: use the configured refund policy when the owner + // has explicitly set one, otherwise fall back to the existing + // flat refund-all behavior. Exactly one of these runs, so + // there's no double-payout. + if refund::has_policy(&env, grant_id) { + refund::execute_refund(&env, grant_id, &caller)?; + } else { + escrow::refund_all(&env, grant_id)?; + } } let mut grant = @@ -657,6 +669,20 @@ impl StellarGrantsContract { let mut grant = Storage::get_grant_v(&env, grant_id); let mut milestone = Storage::get_milestone_v(&env, grant_id, milestone_idx); + // Issue #724: `reviewer` may be a delegate voting on behalf of the real + // reviewer. Resolve back to the delegator and burn one use of the + // delegation; if `reviewer` is already a registered reviewer, this is a + // no-op and behavior is unchanged. + let effective_reviewer = if grant.reviewers.contains(reviewer.clone()) { + reviewer.clone() + } else { + let delegator = delegate::resolve_delegator(&env, &reviewer, grant_id); + if delegator != reviewer { + delegate::consume_delegation_for_vote(&env, &delegator, &reviewer, grant_id)?; + } + delegator + }; + if approve && !checklist::all_required_approved(&env, grant_id, milestone_idx) { return Err(ContractError::RequiredCriteriaNotMet); } @@ -665,7 +691,7 @@ impl StellarGrantsContract { &env, &mut grant, &mut milestone, - &reviewer, + &effective_reviewer, approve, feedback, )?; @@ -677,7 +703,7 @@ impl StellarGrantsContract { provenance::record( &env, ContributionType::MilestoneReviewed, - &reviewer, + &effective_reviewer, grant_id, Some(milestone_idx), None, @@ -685,7 +711,13 @@ impl StellarGrantsContract { soroban_sdk::Vec::new(&env), ); - reviewer_reward::record_participation(&env, &reviewer, grant_id, milestone_idx, false); + reviewer_reward::record_participation( + &env, + &effective_reviewer, + grant_id, + milestone_idx, + false, + ); if result.quorum_reached { if result.approved { @@ -700,7 +732,7 @@ impl StellarGrantsContract { &env, grant_id, AuditAction::MilestoneApproved, - &reviewer, + &effective_reviewer, Some(milestone_idx), Some(milestone.amount), ); @@ -746,7 +778,7 @@ impl StellarGrantsContract { &env, grant_id, AuditAction::MilestoneRejected, - &reviewer, + &effective_reviewer, Some(milestone_idx), None, ); @@ -1776,6 +1808,8 @@ impl StellarGrantsContract { emergency::require_not_paused(&env)?; let grant = Storage::get_grant(&env, grant_id).ok_or(ContractError::GrantNotFound)?; dispute::raise_dispute(&env, &grant, milestone_idx, &caller, reason)?; + // Issue #726: capture a tamper-evident state snapshot when a dispute is raised. + snapshot::capture(&env, grant_id, SnapshotTrigger::DisputeRaised, &caller)?; metrics::increment(&env, MetricField::DisputesRaised, 1); Ok(()) } @@ -1858,6 +1892,125 @@ impl StellarGrantsContract { Storage::get_dispute(&env, grant_id, milestone_idx) } + // ── Reviewer Vote Delegation (#724) ─────────────────────────────────────── + + /// Delegate a reviewer's vote (globally or for a single grant) to another address. + pub fn delegate_vote( + env: Env, + delegator: Address, + delegate: Address, + scope: DelegationScope, + expires_at: Option, + max_uses: Option, + ) -> Result<(), ContractError> { + delegate::delegate_vote(&env, &delegator, &delegate, scope, expires_at, max_uses) + } + + /// Revoke a previously created vote delegation. + pub fn revoke_delegation( + env: Env, + delegator: Address, + scope: DelegationScope, + ) -> Result<(), ContractError> { + delegate::revoke_delegation(&env, &delegator, &scope) + } + + /// Fetch an active delegation for a delegator/scope pair, if one exists. + pub fn get_delegation( + env: Env, + delegator: Address, + scope: DelegationScope, + ) -> Option { + delegate::get_delegation(&env, &delegator, &scope) + } + + // ── State Snapshots for Audit/Dispute Support (#726) ────────────────────── + + /// Manually capture a point-in-time state snapshot of a grant. + pub fn snapshot_capture( + env: Env, + caller: Address, + grant_id: u64, + trigger: SnapshotTrigger, + ) -> Result { + caller.require_auth(); + snapshot::capture(&env, grant_id, trigger, &caller) + } + + /// Fetch a specific state snapshot by id. + pub fn get_snapshot( + env: Env, + grant_id: u64, + snapshot_id: u32, + ) -> Result { + snapshot::get_snapshot(&env, grant_id, snapshot_id) + } + + /// List all state snapshots captured for a grant. + pub fn list_snapshots(env: Env, grant_id: u64) -> Vec { + snapshot::list_snapshots(&env, grant_id) + } + + /// Fetch the most recent state snapshot for a grant, if any. + pub fn latest_snapshot(env: Env, grant_id: u64) -> Option { + snapshot::latest_snapshot(&env, grant_id) + } + + /// Diff two state snapshots and return the symbols of changed fields. + pub fn diff_snapshots( + env: Env, + grant_id: u64, + a_id: u32, + b_id: u32, + ) -> Vec { + snapshot::diff_snapshots(&env, grant_id, a_id, b_id) + } + + // ── Configurable Refund Policies (#727) ─────────────────────────────────── + + /// Attach a refund policy to a grant. Owner-only; must be set before any + /// funds are escrowed (see `refund::set_policy`). + pub fn refund_set_policy( + env: Env, + owner: Address, + grant_id: u64, + policy: RefundPolicy, + ) -> Result<(), ContractError> { + refund::set_policy(&env, &owner, grant_id, policy) + } + + /// Fetch the refund policy configured for a grant (a default FullRefund + /// policy if none has been explicitly set). + pub fn refund_get_policy(env: Env, grant_id: u64) -> RefundPolicy { + refund::get_policy(&env, grant_id) + } + + /// Preview the refund/compensation split for a grant under its configured policy. + pub fn refund_calculate( + env: Env, + grant_id: u64, + canceller: Address, + ) -> Result { + refund::calculate_refund(&env, grant_id, &canceller) + } + + /// Execute the configured refund policy directly. Callable by the grant + /// owner or global admin (mirrors `cancel_grant`'s own authorization). + pub fn refund_execute( + env: Env, + grant_id: u64, + canceller: Address, + ) -> Result { + canceller.require_auth(); + let grant = Storage::get_grant(&env, grant_id).ok_or(ContractError::GrantNotFound)?; + let is_owner = grant.owner == canceller; + let is_admin = Storage::get_global_admin(&env) == Some(canceller.clone()); + if !is_owner && !is_admin { + return Err(ContractError::Unauthorized); + } + refund::execute_refund(&env, grant_id, &canceller) + } + // ── Clawback Mechanism Entry Points ─────────────────────────────────────── pub fn clawback_initiate( @@ -4634,6 +4787,9 @@ fn apply_milestone_submission( data_export::set_last_updated(env, grant_id, env.ledger().timestamp()); Events::emit_milestone_submitted(env, grant_id, milestone_idx, description); + // Issue #726: capture a tamper-evident state snapshot on every submission. + snapshot::capture(env, grant_id, SnapshotTrigger::MilestoneSubmission, actor)?; + audit::log( env, grant_id, diff --git a/contracts/contracts/stellar-grants/src/refund.rs b/contracts/contracts/stellar-grants/src/refund.rs index bbd7399f..4a609456 100644 --- a/contracts/contracts/stellar-grants/src/refund.rs +++ b/contracts/contracts/stellar-grants/src/refund.rs @@ -10,48 +10,96 @@ pub enum RefundKey { Policy(u64), } -pub fn set_policy(env: &Env, owner: &Address, grant_id: u64, policy: RefundPolicy) -> Result<(), ContractError> { +pub fn set_policy( + env: &Env, + owner: &Address, + grant_id: u64, + policy: RefundPolicy, +) -> Result<(), ContractError> { owner.require_auth(); let grant = Storage::get_grant(env, grant_id).ok_or(ContractError::GrantNotFound)?; if grant.owner != *owner || policy.grant_id != grant_id || grant.escrow_balance > 0 { return Err(ContractError::InvalidInput); } - env.storage().persistent().set(&RefundKey::Policy(grant_id), &policy); + env.storage() + .persistent() + .set(&RefundKey::Policy(grant_id), &policy); Ok(()) } +/// Whether an owner has explicitly configured a refund policy for this grant, +/// as opposed to `get_policy`'s fabricated default (FullRefund) when nothing +/// has been stored. +pub fn has_policy(env: &Env, grant_id: u64) -> bool { + env.storage().persistent().has(&RefundKey::Policy(grant_id)) +} + pub fn get_policy(env: &Env, grant_id: u64) -> RefundPolicy { - env.storage().persistent().get(&RefundKey::Policy(grant_id)).unwrap_or(RefundPolicy { - grant_id, - policy_type: RefundPolicyType::FullRefund, - penalty_bps: 0, - grace_period_ledgers: 0, - min_refund_pct_bps: 0, - }) + env.storage() + .persistent() + .get(&RefundKey::Policy(grant_id)) + .unwrap_or(RefundPolicy { + grant_id, + policy_type: RefundPolicyType::FullRefund, + penalty_bps: 0, + grace_period_ledgers: 0, + min_refund_pct_bps: 0, + }) } -pub fn calculate_refund(env: &Env, grant_id: u64, _canceller: &Address) -> Result { +pub fn calculate_refund( + env: &Env, + grant_id: u64, + _canceller: &Address, +) -> Result { let grant = Storage::get_grant(env, grant_id).ok_or(ContractError::GrantNotFound)?; let policy = get_policy(env, grant_id); let gross = grant.escrow_balance; let now = env.ledger().sequence(); let start = grant.timestamp as u32; let mut applied = policy.policy_type.clone(); - let raw = if policy.grace_period_ledgers > 0 && now < start.saturating_add(policy.grace_period_ledgers) { + let raw = if policy.grace_period_ledgers > 0 + && now < start.saturating_add(policy.grace_period_ledgers) + { applied = RefundPolicyType::FullRefund; gross } else { match policy.policy_type { RefundPolicyType::FullRefund => gross, RefundPolicyType::ProportionalToRemaining => { - if grant.total_milestones == 0 { 0 } else { gross.saturating_mul(grant.total_milestones.saturating_sub(grant.milestones_paid_out) as i128).checked_div(grant.total_milestones as i128).unwrap_or(0) } + if grant.total_milestones == 0 { + 0 + } else { + gross + .saturating_mul( + grant + .total_milestones + .saturating_sub(grant.milestones_paid_out) + as i128, + ) + .checked_div(grant.total_milestones as i128) + .unwrap_or(0) + } } - RefundPolicyType::TimeWeighted => time_weighted_refund(gross, start, start.saturating_add(grant.total_milestones.saturating_mul(10_000)), now), - RefundPolicyType::PenaltyOnCancel => gross.saturating_sub(gross.saturating_mul(policy.penalty_bps as i128).checked_div(BPS).unwrap_or(0)), + RefundPolicyType::TimeWeighted => time_weighted_refund( + gross, + start, + start.saturating_add(grant.total_milestones.saturating_mul(10_000)), + now, + ), + RefundPolicyType::PenaltyOnCancel => gross.saturating_sub( + gross + .saturating_mul(policy.penalty_bps as i128) + .checked_div(BPS) + .unwrap_or(0), + ), _ => 0, } }; - let floor = gross.saturating_mul(policy.min_refund_pct_bps as i128).checked_div(BPS).unwrap_or(0); + let floor = gross + .saturating_mul(policy.min_refund_pct_bps as i128) + .checked_div(BPS) + .unwrap_or(0); let funder_refund = raw.max(floor).min(gross); let contributor_compensation = gross.saturating_sub(funder_refund); Ok(RefundCalculation { @@ -63,23 +111,43 @@ pub fn calculate_refund(env: &Env, grant_id: u64, _canceller: &Address) -> Resul }) } -pub fn execute_refund(env: &Env, grant_id: u64, canceller: &Address) -> Result { +pub fn execute_refund( + env: &Env, + grant_id: u64, + canceller: &Address, +) -> Result { let calc = calculate_refund(env, grant_id, canceller)?; let mut grant = Storage::get_grant(env, grant_id).ok_or(ContractError::GrantNotFound)?; let client = token::Client::new(env, &grant.token); if calc.contributor_compensation > 0 { - client.transfer(&env.current_contract_address(), &grant.owner, &calc.contributor_compensation); + client.transfer( + &env.current_contract_address(), + &grant.owner, + &calc.contributor_compensation, + ); } if calc.funder_refund > 0 { let mut total: i128 = 0; - for fund in grant.funders.iter() { total = total.saturating_add(fund.amount); } + for fund in grant.funders.iter() { + total = total.saturating_add(fund.amount); + } if total > 0 { let len = grant.funders.len(); let mut paid: i128 = 0; for i in 0..len { let fund = grant.funders.get(i).ok_or(ContractError::InvalidInput)?; - let amount = if i + 1 == len { calc.funder_refund.saturating_sub(paid) } else { fund.amount.saturating_mul(calc.funder_refund).checked_div(total).unwrap_or(0) }; - if amount > 0 { client.transfer(&env.current_contract_address(), &fund.funder, &amount); paid = paid.saturating_add(amount); } + let amount = if i + 1 == len { + calc.funder_refund.saturating_sub(paid) + } else { + fund.amount + .saturating_mul(calc.funder_refund) + .checked_div(total) + .unwrap_or(0) + }; + if amount > 0 { + client.transfer(&env.current_contract_address(), &fund.funder, &amount); + paid = paid.saturating_add(amount); + } } } } @@ -88,8 +156,20 @@ pub fn execute_refund(env: &Env, grant_id: u64, canceller: &Address) -> Result i128 { - if gross <= 0 || end_ledger <= start_ledger || current_ledger >= end_ledger { return 0; } - if current_ledger <= start_ledger { return gross; } - gross.saturating_mul(end_ledger.saturating_sub(current_ledger) as i128).checked_div(end_ledger.saturating_sub(start_ledger) as i128).unwrap_or(0) +pub fn time_weighted_refund( + gross: i128, + start_ledger: u32, + end_ledger: u32, + current_ledger: u32, +) -> i128 { + if gross <= 0 || end_ledger <= start_ledger || current_ledger >= end_ledger { + return 0; + } + if current_ledger <= start_ledger { + return gross; + } + gross + .saturating_mul(end_ledger.saturating_sub(current_ledger) as i128) + .checked_div(end_ledger.saturating_sub(start_ledger) as i128) + .unwrap_or(0) } diff --git a/contracts/contracts/stellar-grants/src/snapshot.rs b/contracts/contracts/stellar-grants/src/snapshot.rs index 6f2aa2d2..0521dc71 100644 --- a/contracts/contracts/stellar-grants/src/snapshot.rs +++ b/contracts/contracts/stellar-grants/src/snapshot.rs @@ -18,12 +18,19 @@ fn next_id(env: &Env, grant_id: u64) -> u32 { id } -pub fn capture(env: &Env, grant_id: u64, trigger: SnapshotTrigger, captured_by: &Address) -> Result { +pub fn capture( + env: &Env, + grant_id: u64, + trigger: SnapshotTrigger, + captured_by: &Address, +) -> Result { let grant = Storage::get_grant(env, grant_id).ok_or(ContractError::GrantNotFound)?; let id = next_id(env, grant_id); let mut states: Vec = Vec::new(env); for idx in 0..grant.total_milestones { - let state = Storage::get_milestone(env, grant_id, idx).map(|m| m.state).unwrap_or(MilestoneState::Pending); + let state = Storage::get_milestone(env, grant_id, idx) + .map(|m| m.state) + .unwrap_or(MilestoneState::Pending); states.push_back(state); } let snapshot = StateSnapshot { @@ -39,39 +46,80 @@ pub fn capture(env: &Env, grant_id: u64, trigger: SnapshotTrigger, captured_by: captured_at_ledger: env.ledger().sequence(), captured_by: captured_by.clone(), }; - env.storage().persistent().set(&SnapshotKey::One(grant_id, id), &snapshot); - let mut ids: Vec = env.storage().persistent().get(&SnapshotKey::List(grant_id)).unwrap_or_else(|| Vec::new(env)); + env.storage() + .persistent() + .set(&SnapshotKey::One(grant_id, id), &snapshot); + let mut ids: Vec = env + .storage() + .persistent() + .get(&SnapshotKey::List(grant_id)) + .unwrap_or_else(|| Vec::new(env)); ids.push_back(id); - env.storage().persistent().set(&SnapshotKey::List(grant_id), &ids); + env.storage() + .persistent() + .set(&SnapshotKey::List(grant_id), &ids); Ok(id) } -pub fn get_snapshot(env: &Env, grant_id: u64, snapshot_id: u32) -> Result { - env.storage().persistent().get(&SnapshotKey::One(grant_id, snapshot_id)).ok_or(ContractError::InvalidState) +pub fn get_snapshot( + env: &Env, + grant_id: u64, + snapshot_id: u32, +) -> Result { + env.storage() + .persistent() + .get(&SnapshotKey::One(grant_id, snapshot_id)) + .ok_or(ContractError::InvalidState) } pub fn list_snapshots(env: &Env, grant_id: u64) -> Vec { - let ids: Vec = env.storage().persistent().get(&SnapshotKey::List(grant_id)).unwrap_or_else(|| Vec::new(env)); + let ids: Vec = env + .storage() + .persistent() + .get(&SnapshotKey::List(grant_id)) + .unwrap_or_else(|| Vec::new(env)); let mut out = Vec::new(env); for id in ids.iter() { - if let Ok(s) = get_snapshot(env, grant_id, id) { out.push_back(s); } + if let Ok(s) = get_snapshot(env, grant_id, id) { + out.push_back(s); + } } out } pub fn latest_snapshot(env: &Env, grant_id: u64) -> Option { let list = list_snapshots(env, grant_id); - if list.is_empty() { None } else { list.get(list.len() - 1) } + if list.is_empty() { + None + } else { + list.get(list.len() - 1) + } } pub fn diff_snapshots(env: &Env, grant_id: u64, a_id: u32, b_id: u32) -> Vec { let mut changes = Vec::new(env); - let a = match get_snapshot(env, grant_id, a_id) { Ok(s) => s, Err(_) => return changes }; - let b = match get_snapshot(env, grant_id, b_id) { Ok(s) => s, Err(_) => return changes }; - if a.grant_status != b.grant_status { changes.push_back(Symbol::new(env, "grant_status")); } - if a.escrow_balance != b.escrow_balance { changes.push_back(Symbol::new(env, "escrow_balance")); } - if a.milestones_paid_out != b.milestones_paid_out { changes.push_back(Symbol::new(env, "milestones_paid_out")); } - if a.total_milestones != b.total_milestones { changes.push_back(Symbol::new(env, "total_milestones")); } - if a.milestone_states != b.milestone_states { changes.push_back(Symbol::new(env, "milestone_states")); } + let a = match get_snapshot(env, grant_id, a_id) { + Ok(s) => s, + Err(_) => return changes, + }; + let b = match get_snapshot(env, grant_id, b_id) { + Ok(s) => s, + Err(_) => return changes, + }; + if a.grant_status != b.grant_status { + changes.push_back(Symbol::new(env, "grant_status")); + } + if a.escrow_balance != b.escrow_balance { + changes.push_back(Symbol::new(env, "escrow_balance")); + } + if a.milestones_paid_out != b.milestones_paid_out { + changes.push_back(Symbol::new(env, "milestones_paid_out")); + } + if a.total_milestones != b.total_milestones { + changes.push_back(Symbol::new(env, "total_milestones")); + } + if a.milestone_states != b.milestone_states { + changes.push_back(Symbol::new(env, "milestone_states")); + } changes } diff --git a/contracts/contracts/stellar-grants/tests/test_delegate_voting.rs b/contracts/contracts/stellar-grants/tests/test_delegate_voting.rs new file mode 100644 index 00000000..e76e191d --- /dev/null +++ b/contracts/contracts/stellar-grants/tests/test_delegate_voting.rs @@ -0,0 +1,289 @@ +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, Env, String, Vec, +}; +use stellar_grants::{AcceptanceCriteria, DelegationScope, StellarGrantsContractClient}; + +/// Satisfy the required-criteria checklist gate for a milestone so an +/// `approve = true` vote actually counts (see integration_lifecycle.rs's +/// `setup_checklist` for the same pattern). +fn satisfy_checklist( + env: &Env, + client: &StellarGrantsContractClient, + owner: &Address, + reviewer: &Address, + grant_id: u64, + milestone_idx: u32, +) { + let criteria = Vec::from_array( + env, + [AcceptanceCriteria { + idx: 0, + description: String::from_str(env, "Criteria 1"), + is_required: true, + }], + ); + client.checklist_define_criteria(owner, &grant_id, &milestone_idx, &criteria); + + let evidence = Vec::from_array(env, [Some(String::from_str(env, "https://evidence.com"))]); + client.checklist_submit(owner, &grant_id, &milestone_idx, &evidence); + + client.checklist_review_criterion(reviewer, &grant_id, &milestone_idx, &0u32, &true); +} + +/// Bootstrap a contract + funded grant with the given reviewers/milestones, +/// mirroring the setup conventions used in tests/test_milestone_dispute.rs. +fn setup_grant<'a>( + env: &Env, + admin: &Address, + owner: &Address, + reviewers: &Vec
, + num_milestones: u32, +) -> (StellarGrantsContractClient<'a>, u64) { + let token_admin_addr = Address::generate(env); + let token = env + .register_stellar_asset_contract_v2(token_admin_addr.clone()) + .address(); + let token_admin = token::StellarAssetClient::new(env, &token); + let contract_id = env.register_contract(None, stellar_grants::StellarGrantsContract); + let client = StellarGrantsContractClient::new(env, &contract_id); + client.initialize(admin); + + let milestone_amount: i128 = 1000; + let total_amount = milestone_amount * num_milestones as i128; + + let grant_id = client.grant_create( + owner, + &String::from_str(env, "Test Grant"), + &String::from_str(env, "Desc"), + &token, + &total_amount, + &milestone_amount, + &num_milestones, + reviewers, + ); + + let funder = Address::generate(env); + token_admin.mint(&funder, &total_amount); + client.grant_fund(&grant_id, &funder, &total_amount); + + (client, grant_id) +} + +#[test] +fn test_global_delegation_proxy_vote_resolves_to_real_reviewer() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let owner = Address::generate(&env); + let reviewer = Address::generate(&env); + let delegate = Address::generate(&env); + + let mut reviewers: Vec
= Vec::new(&env); + reviewers.push_back(reviewer.clone()); + + let (client, grant_id) = setup_grant(&env, &admin, &owner, &reviewers, 1); + + client.delegate_vote(&reviewer, &delegate, &DelegationScope::Global, &None, &None); + + client.milestone_submit( + &grant_id, + &0, + &owner, + &String::from_str(&env, "Milestone 1"), + &String::from_str(&env, "proof"), + ); + satisfy_checklist(&env, &client, &owner, &reviewer, grant_id, 0); + + // The delegate casts the vote, authenticating as itself. + client.milestone_vote(&grant_id, &0, &delegate, &true, &None); + + // Quorum is reached (1/1 reviewers) and the vote is recorded under the + // real reviewer's address, not the proxy's. + let milestone = client.get_milestone(&grant_id, &0); + assert_eq!(milestone.state, stellar_grants::MilestoneState::Approved); + assert_eq!(milestone.votes.get(reviewer.clone()), Some(true)); + assert!(milestone.votes.get(delegate.clone()).is_none()); + + // Unlimited-use global delegation stays active after being used. + let delegation = client.get_delegation(&reviewer, &DelegationScope::Global); + assert!(delegation.is_some()); +} + +#[test] +fn test_per_grant_delegation_max_uses_exhausted_after_one_vote() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let owner = Address::generate(&env); + let reviewer = Address::generate(&env); + let delegate = Address::generate(&env); + + let mut reviewers: Vec
= Vec::new(&env); + reviewers.push_back(reviewer.clone()); + + let (client, grant_id) = setup_grant(&env, &admin, &owner, &reviewers, 2); + + let scope = DelegationScope::PerGrant(grant_id); + client.delegate_vote(&reviewer, &delegate, &scope, &None, &Some(1u32)); + + client.milestone_submit( + &grant_id, + &0, + &owner, + &String::from_str(&env, "Milestone 1"), + &String::from_str(&env, "proof"), + ); + satisfy_checklist(&env, &client, &owner, &reviewer, grant_id, 0); + client.milestone_vote(&grant_id, &0, &delegate, &true, &None); + + // The single use has been consumed, so the delegation is now inactive. + let delegation = client.get_delegation(&reviewer, &scope); + assert!(delegation.is_none()); +} + +#[test] +#[should_panic] +fn test_exhausted_delegation_blocks_further_proxy_vote() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let owner = Address::generate(&env); + let reviewer = Address::generate(&env); + let delegate = Address::generate(&env); + + let mut reviewers: Vec
= Vec::new(&env); + reviewers.push_back(reviewer.clone()); + + let (client, grant_id) = setup_grant(&env, &admin, &owner, &reviewers, 2); + + let scope = DelegationScope::PerGrant(grant_id); + client.delegate_vote(&reviewer, &delegate, &scope, &None, &Some(1u32)); + + client.milestone_submit( + &grant_id, + &0, + &owner, + &String::from_str(&env, "Milestone 1"), + &String::from_str(&env, "proof"), + ); + satisfy_checklist(&env, &client, &owner, &reviewer, grant_id, 0); + client.milestone_vote(&grant_id, &0, &delegate, &true, &None); + + // Milestone 0 is approved (1/1 quorum), so milestone 1 can now be submitted. + client.milestone_submit( + &grant_id, + &1, + &owner, + &String::from_str(&env, "Milestone 2"), + &String::from_str(&env, "proof"), + ); + satisfy_checklist(&env, &client, &owner, &reviewer, grant_id, 1); + + // The delegation's single use was already consumed — the delegate is no + // longer an authorized proxy and isn't a registered reviewer either. + client.milestone_vote(&grant_id, &1, &delegate, &true, &None); +} + +#[test] +#[should_panic] +fn test_expired_delegation_rejects_proxy_vote() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let owner = Address::generate(&env); + let reviewer = Address::generate(&env); + let delegate = Address::generate(&env); + + let mut reviewers: Vec
= Vec::new(&env); + reviewers.push_back(reviewer.clone()); + + let (client, grant_id) = setup_grant(&env, &admin, &owner, &reviewers, 1); + + let now = env.ledger().timestamp(); + client.delegate_vote( + &reviewer, + &delegate, + &DelegationScope::Global, + &Some(now + 100), + &None, + ); + + env.ledger().set_timestamp(now + 200); + + // The delegation has expired. + assert!(client + .get_delegation(&reviewer, &DelegationScope::Global) + .is_none()); + + client.milestone_submit( + &grant_id, + &0, + &owner, + &String::from_str(&env, "Milestone 1"), + &String::from_str(&env, "proof"), + ); + satisfy_checklist(&env, &client, &owner, &reviewer, grant_id, 0); + + // The delegate is no longer an authorized proxy and isn't a reviewer. + client.milestone_vote(&grant_id, &0, &delegate, &true, &None); +} + +#[test] +fn test_revoke_delegation() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let owner = Address::generate(&env); + let reviewer = Address::generate(&env); + let delegate = Address::generate(&env); + + let mut reviewers: Vec
= Vec::new(&env); + reviewers.push_back(reviewer.clone()); + + let (client, _grant_id) = setup_grant(&env, &admin, &owner, &reviewers, 1); + + client.delegate_vote(&reviewer, &delegate, &DelegationScope::Global, &None, &None); + assert!(client + .get_delegation(&reviewer, &DelegationScope::Global) + .is_some()); + + client.revoke_delegation(&reviewer, &DelegationScope::Global); + assert!(client + .get_delegation(&reviewer, &DelegationScope::Global) + .is_none()); +} + +#[test] +fn test_delegation_cycle_is_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let owner = Address::generate(&env); + let reviewer_a = Address::generate(&env); + let reviewer_b = Address::generate(&env); + + let mut reviewers: Vec
= Vec::new(&env); + reviewers.push_back(reviewer_a.clone()); + reviewers.push_back(reviewer_b.clone()); + + let (client, _grant_id) = setup_grant(&env, &admin, &owner, &reviewers, 1); + + client.delegate_vote( + &reviewer_a, + &reviewer_b, + &DelegationScope::Global, + &None, + &None, + ); + + // B delegating back to A would create a cycle — must be rejected. + let result = client.try_delegate_vote( + &reviewer_b, + &reviewer_a, + &DelegationScope::Global, + &None, + &None, + ); + assert!(result.is_err()); +} diff --git a/contracts/contracts/stellar-grants/tests/test_refund_policy.rs b/contracts/contracts/stellar-grants/tests/test_refund_policy.rs new file mode 100644 index 00000000..7fc61a9d --- /dev/null +++ b/contracts/contracts/stellar-grants/tests/test_refund_policy.rs @@ -0,0 +1,130 @@ +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, Env, String, Vec, +}; +use stellar_grants::{RefundPolicy, RefundPolicyType, StellarGrantsContractClient}; + +#[test] +fn test_time_weighted_refund_policy_on_partial_cancel() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| li.timestamp = 1_000); + + let admin = Address::generate(&env); + let owner = Address::generate(&env); + let reviewer = Address::generate(&env); + let token_admin_addr = Address::generate(&env); + let token = env + .register_stellar_asset_contract_v2(token_admin_addr.clone()) + .address(); + let token_admin = token::StellarAssetClient::new(&env, &token); + let token_client = token::Client::new(&env, &token); + let contract_id = env.register_contract(None, stellar_grants::StellarGrantsContract); + let client = StellarGrantsContractClient::new(&env, &contract_id); + client.initialize(&admin); + + let mut reviewers: Vec
= Vec::new(&env); + reviewers.push_back(reviewer.clone()); + + let grant_id = client.grant_create( + &owner, + &String::from_str(&env, "Test Grant"), + &String::from_str(&env, "Desc"), + &token, + &1000, + &1000, + &1, + &reviewers, + ); + + // `refund::set_policy` requires escrow_balance == 0, so this must happen + // before the grant is funded. + let policy = RefundPolicy { + grant_id, + policy_type: RefundPolicyType::TimeWeighted, + penalty_bps: 0, + grace_period_ledgers: 0, + min_refund_pct_bps: 0, + }; + client.refund_set_policy(&owner, &grant_id, &policy); + + let funder = Address::generate(&env); + token_admin.mint(&funder, &1000); + client.grant_fund(&grant_id, &funder, &1000); + + // Advance to the halfway point of the (total_milestones * 10_000)-ledger + // time-weighted window so the refund split is a clean 50/50. + let grant = client.get_grant(&grant_id); + let start = grant.timestamp as u32; + env.ledger() + .with_mut(|li| li.sequence_number = start + 5_000); + + let funder_before = token_client.balance(&funder); + let owner_before = token_client.balance(&owner); + + client.grant_cancel( + &grant_id, + &owner, + &String::from_str(&env, "no longer needed"), + ); + + let funder_refund = token_client.balance(&funder) - funder_before; + let owner_compensation = token_client.balance(&owner) - owner_before; + + assert_eq!(funder_refund, 500); + assert_eq!(owner_compensation, 500); + // No double-payout: the two payouts must exactly cover the gross escrow. + assert_eq!(funder_refund + owner_compensation, 1000); + + let cancelled_grant = client.get_grant(&grant_id); + assert_eq!(cancelled_grant.escrow_balance, 0); +} + +#[test] +fn test_cancel_without_policy_falls_back_to_full_refund() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let owner = Address::generate(&env); + let reviewer = Address::generate(&env); + let token_admin_addr = Address::generate(&env); + let token = env + .register_stellar_asset_contract_v2(token_admin_addr.clone()) + .address(); + let token_admin = token::StellarAssetClient::new(&env, &token); + let token_client = token::Client::new(&env, &token); + let contract_id = env.register_contract(None, stellar_grants::StellarGrantsContract); + let client = StellarGrantsContractClient::new(&env, &contract_id); + client.initialize(&admin); + + let mut reviewers: Vec
= Vec::new(&env); + reviewers.push_back(reviewer.clone()); + + let grant_id = client.grant_create( + &owner, + &String::from_str(&env, "Test Grant"), + &String::from_str(&env, "Desc"), + &token, + &1000, + &1000, + &1, + &reviewers, + ); + + let funder = Address::generate(&env); + token_admin.mint(&funder, &1000); + client.grant_fund(&grant_id, &funder, &1000); + + let funder_before = token_client.balance(&funder); + + // No policy was ever set — cancellation must use the original flat + // escrow::refund_all behavior, refunding the funder in full. + client.grant_cancel( + &grant_id, + &owner, + &String::from_str(&env, "no longer needed"), + ); + + assert_eq!(token_client.balance(&funder) - funder_before, 1000); +} diff --git a/contracts/contracts/stellar-grants/tests/test_state_snapshot.rs b/contracts/contracts/stellar-grants/tests/test_state_snapshot.rs new file mode 100644 index 00000000..4f459eef --- /dev/null +++ b/contracts/contracts/stellar-grants/tests/test_state_snapshot.rs @@ -0,0 +1,89 @@ +use soroban_sdk::{testutils::Address as _, token, Address, Env, String, Symbol, Vec}; +use stellar_grants::{AcceptanceCriteria, StellarGrantsContractClient}; + +#[test] +fn test_milestone_submission_and_dispute_auto_capture_snapshots() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let owner = Address::generate(&env); + let reviewer = Address::generate(&env); + let token_admin_addr = Address::generate(&env); + let token = env + .register_stellar_asset_contract_v2(token_admin_addr.clone()) + .address(); + let token_admin = token::StellarAssetClient::new(&env, &token); + let contract_id = env.register_contract(None, stellar_grants::StellarGrantsContract); + let client = StellarGrantsContractClient::new(&env, &contract_id); + client.initialize(&admin); + + let mut reviewers: Vec
= Vec::new(&env); + reviewers.push_back(reviewer.clone()); + + let grant_id = client.grant_create( + &owner, + &String::from_str(&env, "Test Grant"), + &String::from_str(&env, "Desc"), + &token, + &1000, + &1000, + &1, + &reviewers, + ); + + let funder = Address::generate(&env); + token_admin.mint(&funder, &1000); + client.grant_fund(&grant_id, &funder, &1000); + + // No snapshots exist yet. + assert!(client.list_snapshots(&grant_id).is_empty()); + + // Submitting a milestone auto-captures a `MilestoneSubmission` snapshot. + client.milestone_submit( + &grant_id, + &0, + &owner, + &String::from_str(&env, "Milestone 1"), + &String::from_str(&env, "proof"), + ); + + let after_submit = client.list_snapshots(&grant_id); + assert_eq!(after_submit.len(), 1); + + // Move the milestone from Submitted -> Approved so the two snapshots + // actually differ. + let criteria = Vec::from_array( + &env, + [AcceptanceCriteria { + idx: 0, + description: String::from_str(&env, "Criteria 1"), + is_required: true, + }], + ); + client.checklist_define_criteria(&owner, &grant_id, &0, &criteria); + let evidence = Vec::from_array(&env, [Some(String::from_str(&env, "https://evidence.com"))]); + client.checklist_submit(&owner, &grant_id, &0, &evidence); + client.checklist_review_criterion(&reviewer, &grant_id, &0, &0u32, &true); + client.milestone_vote(&grant_id, &0, &reviewer, &true, &None); + + // Raising a dispute auto-captures a `DisputeRaised` snapshot. + client.dispute_raise( + &grant_id, + &0, + &owner, + &String::from_str(&env, "Quality concerns"), + ); + + let snapshots = client.list_snapshots(&grant_id); + assert_eq!(snapshots.len(), 2); + + let first_id = snapshots.get(0).unwrap().id; + let second_id = snapshots.get(1).unwrap().id; + + let latest = client.latest_snapshot(&grant_id).unwrap(); + assert_eq!(latest.id, second_id); + + let changes = client.diff_snapshots(&grant_id, &first_id, &second_id); + let milestone_states_symbol = Symbol::new(&env, "milestone_states"); + assert!(changes.iter().any(|s| s == milestone_states_symbol)); +}