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
120 changes: 110 additions & 10 deletions contracts/claims-processor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ enum StorageKey {
/// still be submitted for the triggering event (u64). `0` means a claim
/// can only be filed while the policy is Active (behaves as before).
ClaimDeadline,
/// Configurable delay in seconds between claim approval and payout (u64).
/// 0 = immediate payout (default behavior).
PayoutDelay,
}

// ─── Errors ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -155,6 +158,8 @@ pub enum Error {
/// A `submit_claim` arrived after `end_time + claim_deadline` had elapsed,
/// so the window to file a claim for the triggering event has closed.
ClaimDeadlinePassed = 19,
/// Payout delay has not yet elapsed — the claim cannot be settled now.
PayoutDelayNotElapsed = 20,
}

/// Approximate Stellar ledger close time in seconds, used to convert
Expand Down Expand Up @@ -330,6 +335,8 @@ impl ClaimsProcessor {
dispute_reason: None,
paid_amount: None,
partial_payout_bps: None,
installments: None,
payout_ready_at: None,
};
env.storage().persistent().set(&StorageKey::Claim(claim_id), &claim);
env.storage().persistent().extend_ttl(&StorageKey::Claim(claim_id), TTL_THRESHOLD, TTL_EXTEND_TO);
Expand Down Expand Up @@ -461,6 +468,8 @@ impl ClaimsProcessor {
dispute_reason: None,
paid_amount: None,
partial_payout_bps: None,
installments: None,
payout_ready_at: None,
};
env.storage().persistent().set(&StorageKey::Claim(cid), &claim);
env.storage().persistent().extend_ttl(&StorageKey::Claim(cid), TTL_THRESHOLD, TTL_EXTEND_TO);
Expand Down Expand Up @@ -1232,6 +1241,84 @@ impl ClaimsProcessor {
Self::claim_deadline(&env)
}

// ── Payout Delay (issue #432) ─────────────────────────────────────────────

/// Set the delay in seconds between claim approval and actual payout.
///
/// When non-zero, a claim that passes evaluation enters `PaidPendingDelay`
/// status and the payout is held for `delay_seconds` before becoming
/// claimable. This gives the protocol a window to catch fraud or errors
/// before funds leave the pool. `0` restores immediate payout behavior.
pub fn set_payout_delay(env: Env, admin: Address, delay_seconds: u64) {
Self::require_admin(&env, &admin);
env.storage().instance().set(&StorageKey::PayoutDelay, &delay_seconds);
env.events().publish(
(Symbol::new(&env, "payout_delay_set"),),
PayoutDelayUpdated { delay_seconds },
);
}

/// The configured payout delay in seconds (default: 0 — immediate).
pub fn get_payout_delay(env: Env) -> u64 {
Self::payout_delay(&env)
}

/// The configured payout delay, or the default.
fn payout_delay(env: &Env) -> u64 {
env.storage()
.instance()
.get(&StorageKey::PayoutDelay)
.unwrap_or(0)
}

/// Claim the payout for an approved claim after the payout delay has elapsed.
///
/// When a payout delay is configured, approved claims enter Paid/PartiallyPaid
/// status but funds are not transferred until the delay passes. This function
/// completes the transfer. Callable by anyone — the claim is already approved.
pub fn claim_payout(env: Env, claim_id: u128) -> i128 {
Self::require_not_paused(&env);

let mut claim: Claim = env.storage().persistent()
.get(&StorageKey::Claim(claim_id))
.unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound));

let payout_ready_at = claim.payout_ready_at
.unwrap_or_else(|| panic_with_error!(&env, Error::PayoutDelayNotElapsed));

let now = env.ledger().timestamp();
if now < payout_ready_at {
panic_with_error!(&env, Error::PayoutDelayNotElapsed);
}

let paid_amount = claim.paid_amount
.unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound));

// Clear payout_ready_at so this cannot be called again
claim.payout_ready_at = None;
env.storage().persistent().set(&StorageKey::Claim(claim_id), &claim);

// Execute the actual payout
let policy_engine: Address = env.storage().instance()
.get(&StorageKey::PolicyEngine)
.unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized));
let risk_pool: Address = env.storage().instance()
.get(&StorageKey::RiskPool)
.unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized));

PolicyEngineClient::new(&env, &policy_engine)
.pay_claim(&env.current_contract_address(), &claim.policy_id);
RiskPoolClient::new(&env, &risk_pool)
.release_for_claim(&env.current_contract_address(), &claim.policy_id);

env.events().publish(
(Symbol::new(&env, "payout_released"),),
(claim_id, claim.policy_id, paid_amount),
);

paid_amount
}

/// Escalate a claim that has been Pending past the threshold.
///
/// A claim that nothing processes is worse than a rejected one: a
Expand Down Expand Up @@ -1388,33 +1475,46 @@ impl ClaimsProcessor {
claim.trigger_met = trigger_met;
claim.processed_at = Some(env.ledger().timestamp());

let payout_delay = Self::payout_delay(env);

let result = if trigger_met {
// Determine payout: full or partial based on partial_payout_bps.
let bps = partial_payout_bps.unwrap_or(10_000);
let effective_bps = if bps > 10_000 { 10_000 } else { bps };
let now = env.ledger().timestamp();

if effective_bps >= 10_000 {
// Full payment
claim.status = ClaimStatus::Paid;
claim.paid_amount = Some(claim.coverage_amount);
claim.partial_payout_bps = Some(10_000);
PolicyEngineClient::new(env, &policy_engine)
.pay_claim(&env.current_contract_address(), &claim.policy_id);
// Atomic lock release
RiskPoolClient::new(env, &risk_pool)
.release_for_claim(&env.current_contract_address(), &claim.policy_id);

if payout_delay > 0 {
// Delay payout: record when payout becomes available
claim.payout_ready_at = Some(now.saturating_add(payout_delay));
} else {
// Immediate payout
PolicyEngineClient::new(env, &policy_engine)
.pay_claim(&env.current_contract_address(), &claim.policy_id);
RiskPoolClient::new(env, &risk_pool)
.release_for_claim(&env.current_contract_address(), &claim.policy_id);
}
ClaimResult::Paid
} else {
// Partial payment: calculate proportional payout
let paid = claim.coverage_amount * (effective_bps as i128) / 10_000;
claim.status = ClaimStatus::PartiallyPaid;
claim.paid_amount = Some(paid);
claim.partial_payout_bps = Some(effective_bps);
PolicyEngineClient::new(env, &policy_engine)
.pay_claim(&env.current_contract_address(), &claim.policy_id);
// Atomic lock release
RiskPoolClient::new(env, &risk_pool)
.release_for_claim(&env.current_contract_address(), &claim.policy_id);

if payout_delay > 0 {
claim.payout_ready_at = Some(now.saturating_add(payout_delay));
} else {
PolicyEngineClient::new(env, &policy_engine)
.pay_claim(&env.current_contract_address(), &claim.policy_id);
RiskPoolClient::new(env, &risk_pool)
.release_for_claim(&env.current_contract_address(), &claim.policy_id);
}
ClaimResult::PartiallyPaid
}
} else {
Expand Down
10 changes: 10 additions & 0 deletions contracts/claims-processor/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ pub struct Claim {
pub partial_payout_bps: Option<u32>,
/// Installment payout configuration for large claims.
pub installments: Option<InstallmentSchedule>,
/// Timestamp at which payout becomes available (issue #432).
/// `None` means payout is immediate or not applicable.
pub payout_ready_at: Option<u64>,
}

/// Configuration for installment-based claim payouts.
Expand Down Expand Up @@ -276,3 +279,10 @@ pub struct InstallmentPaid {
pub total_installments: u32,
}

/// Emitted when the payout delay configuration is updated.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PayoutDelayUpdated {
pub delay_seconds: u64,
}

29 changes: 22 additions & 7 deletions contracts/governance-dao/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Parashield Governance DAO
//! Parashield Governance DAO
//!
//! Token-weighted governance over protocol parameters:
//! - Add/remove insurance products
Expand Down Expand Up @@ -153,6 +153,7 @@ pub enum Error {
DiscussionPeriodNotRequired = 39,
/// `vote_batch` was called with an empty proposal list.
NoProposals = 40,
InvalidInput = 41,
}

#[contract]
Expand Down Expand Up @@ -649,24 +650,31 @@ impl GovernanceDao {
panic_with_error!(&env, Error::InsufficientWeight);
}

// Apply vote weight cap to prevent whale dominance (issue #431).
let capped_own_weight = if config.vote_weight_cap > 0 {
core::cmp::min(own_weight, config.vote_weight_cap)
} else {
own_weight
};

// 2. Lock tokens in the DAO contract to prevent token cycling / double-voting
//
// Only the voter's own tokens are locked. Delegated weight is counted,
// never custodied — the DAO has no authority to move a delegator's
// balance, and taking it would turn delegation into a custody decision.
gov_token.transfer(&voter, &env.current_contract_address(), &own_weight);
gov_token.transfer(&voter, &env.current_contract_address(), &capped_own_weight);

// 3. Add any weight delegated to this voter, recording each delegator
// so they cannot also vote this proposal themselves.
let delegated_weight = Self::collect_delegated_weight(&env, &voter, proposal_id, &gov_token);
let weight = own_weight.saturating_add(delegated_weight);
let weight = capped_own_weight.saturating_add(delegated_weight);

// Save the tracked locked balance for later retrieval
// Only the voter's own tokens were transferred in, so only that amount
// is refundable — refunding `weight` would pay out delegated balances
// the contract never held.
let lock_key = StorageKey::LockedBalance(proposal_id, voter.clone());
env.storage().persistent().set(&lock_key, &own_weight);
env.storage().persistent().set(&lock_key, &capped_own_weight);

match choice {
VoteChoice::For => proposal.votes_for += weight,
Expand Down Expand Up @@ -763,15 +771,22 @@ impl GovernanceDao {
if own_weight <= 0 {
panic_with_error!(&env, Error::InsufficientWeight);
}
gov_token.transfer(&voter, &env.current_contract_address(), &own_weight);

// Apply vote weight cap to prevent whale dominance (issue #431).
let capped_own_weight = if config.vote_weight_cap > 0 {
core::cmp::min(own_weight, config.vote_weight_cap)
} else {
own_weight
};
gov_token.transfer(&voter, &env.current_contract_address(), &capped_own_weight);

for i in 0..proposal_ids.len() {
let proposal_id = proposal_ids.get_unchecked(i);
let mut proposal = proposals.get_unchecked(i);

let delegated_weight =
Self::collect_delegated_weight(&env, &voter, proposal_id, &gov_token);
let weight = own_weight.saturating_add(delegated_weight);
let weight = capped_own_weight.saturating_add(delegated_weight);

match choice {
VoteChoice::For => proposal.votes_for += weight,
Expand All @@ -792,7 +807,7 @@ impl GovernanceDao {
// The tokens were locked once for the whole batch. Attribute the
// real lock to the first proposal only; `withdraw_tokens` treats the
// rest as sharing that lock and no-ops for them.
let lock_amount = if i == 0 { own_weight } else { 0 };
let lock_amount = if i == 0 { capped_own_weight } else { 0 };
env.storage()
.persistent()
.set(&StorageKey::LockedBalance(proposal_id, voter.clone()), &lock_amount);
Expand Down
6 changes: 6 additions & 0 deletions contracts/governance-dao/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ pub fn setup() -> (
majority_bps: 5_100u32, // 51%
voting_period: VOTING_PERIOD,
proposal_timelock: 0,
discussion_period: 0,
vote_weight_cap: 0,
},
);

Expand Down Expand Up @@ -97,6 +99,8 @@ fn cannot_initialize_twice() {
majority_bps: 0,
voting_period: 0,
proposal_timelock: 0,
discussion_period: 0,
vote_weight_cap: 0,
},
);
}
Expand Down Expand Up @@ -359,6 +363,8 @@ fn test_proposal_timelock_execution() {
majority_bps: 5_100u32,
voting_period: 604800,
proposal_timelock: 604800,
discussion_period: 0,
vote_weight_cap: 0,
},
);

Expand Down
2 changes: 2 additions & 0 deletions contracts/governance-dao/src/test_advanced.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ fn base_config(gov_token: Address) -> DaoConfig {
majority_bps: 5_100u32,
voting_period: VOTING_PERIOD,
proposal_timelock: 0,
discussion_period: 0,
vote_weight_cap: 0,
}
}

Expand Down
11 changes: 11 additions & 0 deletions contracts/governance-dao/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ pub struct DaoConfig {
/// Mandatory discussion period in seconds before voting opens.
/// Set to 0 to disable (proposals go straight to Active).
pub discussion_period: u64,
/// Maximum voting weight any single address may cast per proposal (7-decimal).
/// 0 = no cap (unlimited whale voting). Capping prevents a single large
/// holder from dominating governance outcomes.
pub vote_weight_cap: i128,
}

/// Settings controlling adaptive (decaying) quorum.
Expand Down Expand Up @@ -379,3 +383,10 @@ pub struct ProposalCreatedFromTemplate {
pub proposal_id: u64,
pub template_name: Symbol,
}

/// Emitted when the vote weight cap is updated via DAO config.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VoteWeightCapUpdated {
pub vote_weight_cap: i128,
}
Loading