diff --git a/contracts/claims-processor/src/lib.rs b/contracts/claims-processor/src/lib.rs index d8370af..1cd14f8 100644 --- a/contracts/claims-processor/src/lib.rs +++ b/contracts/claims-processor/src/lib.rs @@ -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 ─────────────────────────────────────────────────────────────────── @@ -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 @@ -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); @@ -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); @@ -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 @@ -1388,21 +1475,30 @@ 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 @@ -1410,11 +1506,15 @@ impl ClaimsProcessor { 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 { diff --git a/contracts/claims-processor/src/types.rs b/contracts/claims-processor/src/types.rs index a3452c5..86ef6ee 100644 --- a/contracts/claims-processor/src/types.rs +++ b/contracts/claims-processor/src/types.rs @@ -58,6 +58,9 @@ pub struct Claim { pub partial_payout_bps: Option, /// Installment payout configuration for large claims. pub installments: Option, + /// Timestamp at which payout becomes available (issue #432). + /// `None` means payout is immediate or not applicable. + pub payout_ready_at: Option, } /// Configuration for installment-based claim payouts. @@ -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, +} + diff --git a/contracts/governance-dao/src/lib.rs b/contracts/governance-dao/src/lib.rs index 142d472..b5ed1d7 100644 --- a/contracts/governance-dao/src/lib.rs +++ b/contracts/governance-dao/src/lib.rs @@ -1,4 +1,4 @@ -//! Parashield Governance DAO +//! Parashield Governance DAO //! //! Token-weighted governance over protocol parameters: //! - Add/remove insurance products @@ -153,6 +153,7 @@ pub enum Error { DiscussionPeriodNotRequired = 39, /// `vote_batch` was called with an empty proposal list. NoProposals = 40, + InvalidInput = 41, } #[contract] @@ -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, @@ -763,7 +771,14 @@ 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); @@ -771,7 +786,7 @@ impl GovernanceDao { 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, @@ -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); diff --git a/contracts/governance-dao/src/test.rs b/contracts/governance-dao/src/test.rs index d92fa35..4ba314b 100644 --- a/contracts/governance-dao/src/test.rs +++ b/contracts/governance-dao/src/test.rs @@ -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, }, ); @@ -97,6 +99,8 @@ fn cannot_initialize_twice() { majority_bps: 0, voting_period: 0, proposal_timelock: 0, + discussion_period: 0, + vote_weight_cap: 0, }, ); } @@ -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, }, ); diff --git a/contracts/governance-dao/src/test_advanced.rs b/contracts/governance-dao/src/test_advanced.rs index c4fae31..1434b7a 100644 --- a/contracts/governance-dao/src/test_advanced.rs +++ b/contracts/governance-dao/src/test_advanced.rs @@ -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, } } diff --git a/contracts/governance-dao/src/types.rs b/contracts/governance-dao/src/types.rs index a7b0fd0..47f32cc 100644 --- a/contracts/governance-dao/src/types.rs +++ b/contracts/governance-dao/src/types.rs @@ -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. @@ -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, +} diff --git a/contracts/oracle-verifier/src/lib.rs b/contracts/oracle-verifier/src/lib.rs index e8333d2..ee79b71 100644 --- a/contracts/oracle-verifier/src/lib.rs +++ b/contracts/oracle-verifier/src/lib.rs @@ -138,6 +138,10 @@ enum StorageKey { /// Per-product consensus threshold configuration (ConsensusThreshold). /// Specifies different oracle agreement levels for different data types/products. ConsensusThreshold(Symbol), + /// Cross-validation rule between two data types — (source, target) → CrossValidationRule. + CrossValidationRule(Symbol, Symbol), + /// List of target data types that a source data type has cross-validation rules for. + CrossValidationTargets(Symbol), } // ─── Errors ─────────────────────────────────────────────────────────────────── @@ -172,6 +176,8 @@ pub enum Error { InvalidMaxAge = 24, EncryptionRequiredForType = 25, TimestampOutOfRange = 26, + CrossValidationFailed = 27, + InvalidInput = 28, } // ─── Contract ───────────────────────────────────────────────────────────────── @@ -784,6 +790,180 @@ impl OracleVerifier { } } + // ── Cross-Validation (issue #430) ──────────────────────────────────────── + + /// Add or update a cross-validation rule between two data types. + /// + /// When data is submitted for `source_type`, the aggregated values for + /// `source_type` and `target_type` on the same key must not differ by + /// more than `max_variance`. This catches inconsistent oracle data + /// across correlated feeds (e.g. rainfall vs. temperature). + pub fn set_cross_validation_rule( + env: Env, + admin: Address, + source_type: Symbol, + target_type: Symbol, + max_variance: i128, + description: Bytes, + ) { + Self::require_admin(&env, &admin); + if max_variance < 0 { + panic_with_error!(&env, Error::InvalidInput); + } + if source_type == target_type { + panic_with_error!(&env, Error::InvalidInput); + } + + let rule = CrossValidationRule { + source_type: source_type.clone(), + target_type: target_type.clone(), + max_variance, + description, + }; + env.storage().instance().set( + &StorageKey::CrossValidationRule(source_type.clone(), target_type.clone()), + &rule, + ); + + // Track which targets this source has rules for + let mut targets: Vec = env + .storage() + .instance() + .get(&StorageKey::CrossValidationTargets(source_type.clone())) + .unwrap_or_else(|| Vec::new(&env)); + let mut found = false; + for i in 0..targets.len() { + if targets.get_unchecked(i) == target_type { + found = true; + break; + } + } + if !found { + targets.push_back(target_type.clone()); + env.storage().instance().set( + &StorageKey::CrossValidationTargets(source_type.clone()), + &targets, + ); + } + + env.events().publish( + (Symbol::new(&env, "cross_validation_rule_added"),), + CrossValidationRuleAdded { + source_type, + target_type, + max_variance, + }, + ); + } + + /// Remove a cross-validation rule between two data types. + pub fn remove_cross_validation_rule( + env: Env, + admin: Address, + source_type: Symbol, + target_type: Symbol, + ) { + Self::require_admin(&env, &admin); + let key = StorageKey::CrossValidationRule(source_type.clone(), target_type.clone()); + if !env.storage().instance().has(&key) { + panic_with_error!(&env, Error::InvalidInput); + } + env.storage().instance().remove(&key); + + // Remove from targets list + let mut targets: Vec = env + .storage() + .instance() + .get(&StorageKey::CrossValidationTargets(source_type.clone())) + .unwrap_or_else(|| Vec::new(&env)); + let mut pruned: Vec = Vec::new(&env); + for i in 0..targets.len() { + if targets.get_unchecked(i) != target_type { + pruned.push_back(targets.get_unchecked(i)); + } + } + env.storage().instance().set( + &StorageKey::CrossValidationTargets(source_type.clone()), + &pruned, + ); + + env.events().publish( + (Symbol::new(&env, "cross_validation_rule_removed"),), + CrossValidationRuleRemoved { + source_type, + target_type, + }, + ); + } + + /// Get the cross-validation rule between two data types, if one exists. + pub fn get_cross_validation_rule( + env: Env, + source_type: Symbol, + target_type: Symbol, + ) -> Option { + env.storage().instance().get( + &StorageKey::CrossValidationRule(source_type, target_type), + ) + } + + /// Get all cross-validation targets for a given source data type. + pub fn get_cross_validation_targets(env: Env, source_type: Symbol) -> Vec { + env.storage().instance().get( + &StorageKey::CrossValidationTargets(source_type), + ).unwrap_or_else(|| Vec::new(&env)) + } + + /// Check cross-validation rules between a source data type and all its + /// configured targets for a given key. Returns Ok(()) if all rules pass, + /// or the first failing rule's details. + /// + /// Called internally after aggregation to detect inconsistent oracle data + /// across correlated feeds. Panics with `CrossValidationFailed` if any + /// rule is violated. + fn check_cross_validation(env: &Env, source_type: &Symbol, key: &Symbol) { + let targets: Vec = env + .storage() + .instance() + .get(&StorageKey::CrossValidationTargets(source_type.clone())) + .unwrap_or_else(|| Vec::new(env)); + + if targets.is_empty() { + return; + } + + // Get source aggregated value + let source_value = Self::get_median_value(env, source_type, key); + + for i in 0..targets.len() { + let target_type = targets.get_unchecked(i); + let rule: CrossValidationRule = match env.storage().instance().get( + &StorageKey::CrossValidationRule(source_type.clone(), target_type.clone()), + ) { + Some(r) => r, + None => continue, + }; + + // Try to get target aggregated value — skip if no data available + let target_points: Vec = match env.storage().persistent().get( + &StorageKey::DataPoints(target_type.clone(), key.clone()), + ) { + Some(pts) => pts, + None => continue, + }; + if target_points.is_empty() { + continue; + } + + let target_value = Self::get_median_value(env, &target_type, key); + let variance = source_value.saturating_sub(target_value).abs(); + + if variance > rule.max_variance { + panic_with_error!(env, Error::CrossValidationFailed); + } + } + } + /// Set the minimum number of seconds a single oracle must wait between /// submissions for the same data_type. Guards against a malicious or /// malfunctioning oracle flooding the contract with submissions to @@ -1265,6 +1445,9 @@ impl OracleVerifier { env.storage().persistent().set(&dp_key, &pruned_points); env.storage().persistent().extend_ttl(&dp_key, TTL_THRESHOLD, TTL_EXTEND_TO); + // Check cross-validation rules after storing new data + Self::check_cross_validation(&env, &data_type, &key); + env.events().publish( (Symbol::new(&env, "oracle_data_submitted"),), OracleDataSubmitted { diff --git a/contracts/oracle-verifier/src/types.rs b/contracts/oracle-verifier/src/types.rs index e0becaa..13ddd9e 100644 --- a/contracts/oracle-verifier/src/types.rs +++ b/contracts/oracle-verifier/src/types.rs @@ -420,4 +420,46 @@ pub struct OracleEncryptedDataSubmitted { #[derive(Clone, Debug, Eq, PartialEq)] pub struct TimestampFutureBufferUpdated { pub seconds: u64, +} + +/// A cross-validation rule between two oracle data types. +/// +/// Ensures that submitted data for `source_type` is consistent with +/// `target_type` within `max_variance`. For example, rainfall and +/// temperature data from the same region should not diverge wildly. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CrossValidationRule { + pub source_type: Symbol, + pub target_type: Symbol, + /// Maximum allowed absolute variance between aggregated values (fixed-point). + pub max_variance: i128, + /// Description of the rule for auditability. + pub description: Bytes, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CrossValidationRuleAdded { + pub source_type: Symbol, + pub target_type: Symbol, + pub max_variance: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CrossValidationRuleRemoved { + pub source_type: Symbol, + pub target_type: Symbol, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CrossValidationFailed { + pub source_type: Symbol, + pub source_value: i128, + pub target_type: Symbol, + pub target_value: i128, + pub variance: i128, + pub max_variance: i128, } \ No newline at end of file diff --git a/contracts/risk-pool/src/lib.rs b/contracts/risk-pool/src/lib.rs index f602b4c..71e9b72 100644 --- a/contracts/risk-pool/src/lib.rs +++ b/contracts/risk-pool/src/lib.rs @@ -1,4 +1,4 @@ -//! Parashield Risk Pool +//! Parashield Risk Pool //! //! Liquidity providers deposit USDC into category-specific risk pools. //! Pool-share tokens represent proportional ownership. @@ -137,6 +137,10 @@ enum StorageKey { /// Dynamic fee adjustment configuration (DynamicFeeConfig). /// Allows pool fees to automatically adjust based on market conditions and utilization. DynamicFeeConfig, + /// Fee tier configuration — Symbol (tier name) → FeeTier. + FeeTier(Symbol), + /// Names of all registered fee tiers (Vec). + FeeTierList, } #[contracterror] @@ -178,6 +182,8 @@ pub enum Error { ExitAlreadyQueued = 33, NoExitRequest = 34, ExitDelayNotElapsed = 35, + InvalidParameter = 36, + InvalidFeeTier = 37, } #[contract] @@ -1176,6 +1182,136 @@ impl RiskPool { adjusted_fee.min(config.max_fee_bps).max(config.min_fee_bps) } + // ── Fee Tiers (issue #429) ──────────────────────────────────────────────── + + /// Add or update an LP fee tier. LPs meeting the tier's requirements + /// (minimum deposit and/or minimum lock duration) receive the discount + /// on protocol fees. + /// + /// `discount_bps` is in basis points: 500 = 5% discount, 1000 = 10%, etc. + /// Maximum 10000 (100% discount, i.e. zero fees). + pub fn set_fee_tier( + env: Env, + admin: Address, + name: Symbol, + min_deposit: i128, + min_lock_duration: u64, + discount_bps: u32, + ) { + Self::require_admin(&env, &admin); + if discount_bps > 10_000 { + panic_with_error!(&env, Error::InvalidFeeTier); + } + if min_deposit < 0 { + panic_with_error!(&env, Error::InvalidFeeTier); + } + + let tier = FeeTier { + min_deposit, + min_lock_duration, + discount_bps, + name: name.clone(), + }; + env.storage().instance().set(&StorageKey::FeeTier(name.clone()), &tier); + + // Track tier names + let mut names: Vec = env + .storage() + .instance() + .get(&StorageKey::FeeTierList) + .unwrap_or_else(|| Vec::new(&env)); + let mut found = false; + for i in 0..names.len() { + if names.get_unchecked(i) == name { + found = true; + break; + } + } + if !found { + names.push_back(name.clone()); + env.storage().instance().set(&StorageKey::FeeTierList, &names); + } + + env.events().publish( + (Symbol::new(&env, "fee_tier_updated"),), + FeeTierUpdated { + tier_name: name, + min_deposit, + min_lock_duration, + discount_bps, + }, + ); + } + + /// Remove an LP fee tier by name. + pub fn remove_fee_tier(env: Env, admin: Address, name: Symbol) { + Self::require_admin(&env, &admin); + let key = StorageKey::FeeTier(name.clone()); + if !env.storage().instance().has(&key) { + panic_with_error!(&env, Error::InvalidFeeTier); + } + env.storage().instance().remove(&key); + + let mut names: Vec = env + .storage() + .instance() + .get(&StorageKey::FeeTierList) + .unwrap_or_else(|| Vec::new(&env)); + let mut pruned: Vec = Vec::new(&env); + for i in 0..names.len() { + if names.get_unchecked(i) != name { + pruned.push_back(names.get_unchecked(i)); + } + } + env.storage().instance().set(&StorageKey::FeeTierList, &pruned); + } + + /// Get a specific fee tier by name. + pub fn get_fee_tier(env: Env, name: Symbol) -> Option { + env.storage().instance().get(&StorageKey::FeeTier(name)) + } + + /// List all registered fee tier names. + pub fn get_fee_tier_names(env: Env) -> Vec { + env.storage().instance() + .get(&StorageKey::FeeTierList) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Get the effective fee discount for a provider based on their deposit + /// amount and lock duration. Returns the highest applicable discount. + pub fn get_lp_fee_discount(env: Env, provider: Address) -> u32 { + let position: LpPosition = match env.storage().persistent() + .get(&StorageKey::LpPosition(provider.clone())) + { + Some(p) => p, + None => return 0, + }; + + let names: Vec = env + .storage() + .instance() + .get(&StorageKey::FeeTierList) + .unwrap_or_else(|| Vec::new(&env)); + + let now = env.ledger().timestamp(); + let mut best_discount: u32 = 0; + + for i in 0..names.len() { + let tier_name = names.get_unchecked(i); + if let Some(tier) = env.storage().instance().get::<_, FeeTier>(&StorageKey::FeeTier(tier_name.clone())) { + let deposit_met = position.deposited >= tier.min_deposit; + let lock_duration = now.saturating_sub(position.deposited_at); + let lock_met = tier.min_lock_duration == 0 || lock_duration >= tier.min_lock_duration; + if deposit_met && lock_met && tier.discount_bps > best_discount { + best_discount = tier.discount_bps; + } + } + } + + best_discount + } + /// Return the current admin address. Panics with `NotInitialized` if not set up. pub fn get_admin(env: Env) -> Address { env.storage().instance().get(&StorageKey::Admin) diff --git a/contracts/risk-pool/src/types.rs b/contracts/risk-pool/src/types.rs index f61dc20..f49e75d 100644 --- a/contracts/risk-pool/src/types.rs +++ b/contracts/risk-pool/src/types.rs @@ -514,3 +514,27 @@ pub struct VotesDelegated { pub provider: Address, pub delegate: Address, } + +/// An LP fee tier based on deposit amount and/or lock duration. +/// LPs with larger deposits or longer commitment get lower fees. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FeeTier { + /// Minimum deposit amount for this tier (7-decimal stroops). 0 = no minimum. + pub min_deposit: i128, + /// Minimum lock duration in seconds for this tier. 0 = no lock required. + pub min_lock_duration: u64, + /// Fee discount in basis points (0-10000). 0 = no discount, 10000 = 100% off. + pub discount_bps: u32, + /// Human-readable tier name. + pub name: Symbol, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FeeTierUpdated { + pub tier_name: Symbol, + pub min_deposit: i128, + pub min_lock_duration: u64, + pub discount_bps: u32, +}