diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..123a7099 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(grep -E \"\\\\.\\(cairo|rs\\)$\")", + "Bash(cargo test *)", + "Read(//home/feyishola/.cargo/bin/**)", + "Read(//home/feyishola/**)", + "Read(//usr/local/bin/**)", + "Read(//opt/**)", + "Read(//home/feyishola/.rustup/**)" + ] + } +} diff --git a/apps/onchain/contracts/matching_pool/src/errors.rs b/apps/onchain/contracts/matching_pool/src/errors.rs index 019db386..0f0ff233 100644 --- a/apps/onchain/contracts/matching_pool/src/errors.rs +++ b/apps/onchain/contracts/matching_pool/src/errors.rs @@ -21,4 +21,6 @@ pub enum MatchingPoolError { InvalidRoundDates = 15, ContractPaused = 16, Reentrancy = 17, + ContributorCapExceeded = 18, + RoundCapExceeded = 19, } diff --git a/apps/onchain/contracts/matching_pool/src/events.rs b/apps/onchain/contracts/matching_pool/src/events.rs index 10d79482..e6f5fff7 100644 --- a/apps/onchain/contracts/matching_pool/src/events.rs +++ b/apps/onchain/contracts/matching_pool/src/events.rs @@ -69,3 +69,11 @@ pub struct AllMatchesDistributedEvent { pub round_id: u64, pub total_distributed: i128, } + +#[contractevent] +pub struct RoundCapsSetEvent { + #[topic] + pub round_id: u64, + pub per_contributor_cap: i128, + pub round_contribution_cap: i128, +} diff --git a/apps/onchain/contracts/matching_pool/src/lib.rs b/apps/onchain/contracts/matching_pool/src/lib.rs index 2c458f99..1bde0403 100644 --- a/apps/onchain/contracts/matching_pool/src/lib.rs +++ b/apps/onchain/contracts/matching_pool/src/lib.rs @@ -10,7 +10,7 @@ use math::{sqrt_scaled, unscale}; use reentrancy_guard::{acquire as acquire_reentrancy, release as release_reentrancy}; use soroban_sdk::token::TokenClient; use soroban_sdk::{contract, contractimpl, vec, Address, BytesN, Env, Symbol, Vec}; -use storage::{DataKey, RoundData}; +use storage::{CapData, DataKey, RoundData}; #[contract] pub struct MatchingPoolContract; @@ -112,6 +112,15 @@ impl MatchingPoolContract { env.storage() .instance() .set(&DataKey::NextRoundId, &(round_id + 1)); + env.storage() + .persistent() + .set(&DataKey::RoundContributorCap(round_id), &0i128); + env.storage() + .persistent() + .set(&DataKey::RoundContributionCap(round_id), &0i128); + env.storage() + .persistent() + .set(&DataKey::RoundTotalContributions(round_id), &0i128); events::RoundCreatedEvent { admin, round_id, @@ -278,6 +287,30 @@ impl MatchingPoolContract { } let contrib_key = DataKey::ContributorAmount(round_id, project_id, contributor.clone()); let prev: i128 = env.storage().persistent().get(&contrib_key).unwrap_or(0); + + let contributor_cap: i128 = env + .storage() + .persistent() + .get(&DataKey::RoundContributorCap(round_id)) + .unwrap_or(0); + if contributor_cap > 0 && prev.saturating_add(amount) > contributor_cap { + return Err(MatchingPoolError::ContributorCapExceeded); + } + + let round_cap: i128 = env + .storage() + .persistent() + .get(&DataKey::RoundContributionCap(round_id)) + .unwrap_or(0); + let current_round_total: i128 = env + .storage() + .persistent() + .get(&DataKey::RoundTotalContributions(round_id)) + .unwrap_or(0); + if round_cap > 0 && current_round_total.saturating_add(amount) > round_cap { + return Err(MatchingPoolError::RoundCapExceeded); + } + if prev == 0 { let cnt_key = DataKey::ProjectContributorCount(round_id, project_id); let cnt: u32 = env.storage().persistent().get(&cnt_key).unwrap_or(0); @@ -295,6 +328,10 @@ impl MatchingPoolContract { env.storage() .persistent() .set(&total_key, &(total + amount)); + env.storage().persistent().set( + &DataKey::RoundTotalContributions(round_id), + &(current_round_total + amount), + ); events::ContributionRecordedEvent { round_id, project_id, @@ -503,6 +540,67 @@ impl MatchingPoolContract { unscale(unscale(squared)) } + pub fn set_round_caps( + env: Env, + admin: Address, + round_id: u64, + per_contributor_cap: i128, + round_contribution_cap: i128, + ) -> Result<(), MatchingPoolError> { + Self::require_admin(&env, &admin)?; + if per_contributor_cap < 0 || round_contribution_cap < 0 { + return Err(MatchingPoolError::InvalidAmount); + } + let round: RoundData = env + .storage() + .persistent() + .get(&DataKey::Round(round_id)) + .ok_or(MatchingPoolError::RoundNotFound)?; + if round.is_finalized { + return Err(MatchingPoolError::RoundAlreadyFinalized); + } + env.storage() + .persistent() + .set(&DataKey::RoundContributorCap(round_id), &per_contributor_cap); + env.storage() + .persistent() + .set(&DataKey::RoundContributionCap(round_id), &round_contribution_cap); + events::RoundCapsSetEvent { + round_id, + per_contributor_cap, + round_contribution_cap, + } + .publish(&env); + Ok(()) + } + + pub fn get_round_caps(env: Env, round_id: u64) -> Result { + env.storage() + .persistent() + .get::<_, RoundData>(&DataKey::Round(round_id)) + .ok_or(MatchingPoolError::RoundNotFound)?; + let per_contributor_cap: i128 = env + .storage() + .persistent() + .get(&DataKey::RoundContributorCap(round_id)) + .unwrap_or(0); + let round_contribution_cap: i128 = env + .storage() + .persistent() + .get(&DataKey::RoundContributionCap(round_id)) + .unwrap_or(0); + let total_contributions: i128 = env + .storage() + .persistent() + .get(&DataKey::RoundTotalContributions(round_id)) + .unwrap_or(0); + Ok(CapData { + per_contributor_cap, + round_contribution_cap, + total_contributions, + }) + } + pub fn get_round(env: Env, round_id: u64) -> Result { env.storage() .persistent() diff --git a/apps/onchain/contracts/matching_pool/src/storage.rs b/apps/onchain/contracts/matching_pool/src/storage.rs index e3508f93..dfd374bd 100644 --- a/apps/onchain/contracts/matching_pool/src/storage.rs +++ b/apps/onchain/contracts/matching_pool/src/storage.rs @@ -18,6 +18,9 @@ pub enum DataKey { ContributorAmount(u64, u64, Address), // (round_id, project_id, contributor) -> i128 MatchDistributed(u64), // round_id -> bool RoundStatus(u64), // round_id -> Symbol ("ACTIVE"|"FINALIZED"|"DISTRIBUTED") + RoundContributorCap(u64), // round_id -> i128 (0=no cap; per-contributor per-project) + RoundContributionCap(u64), // round_id -> i128 (0=no cap; total across all projects) + RoundTotalContributions(u64), // round_id -> i128 (running sum of all contributions) } /// Core data for a funding round @@ -33,3 +36,12 @@ pub struct RoundData { pub is_finalized: bool, pub is_distributed: bool, } + +/// Cap configuration and live state for a round (returned by get_round_caps) +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CapData { + pub per_contributor_cap: i128, // 0 = uncapped + pub round_contribution_cap: i128, // 0 = uncapped + pub total_contributions: i128, // running sum of all recorded contributions +} diff --git a/apps/onchain/contracts/matching_pool/src/test.rs b/apps/onchain/contracts/matching_pool/src/test.rs index 7b24659a..832d40f8 100644 --- a/apps/onchain/contracts/matching_pool/src/test.rs +++ b/apps/onchain/contracts/matching_pool/src/test.rs @@ -1,4 +1,5 @@ use crate::errors::MatchingPoolError; +use crate::storage::CapData; use crate::{MatchingPoolContract, MatchingPoolContractClient}; use soroban_sdk::{ symbol_short, @@ -392,6 +393,258 @@ fn test_preview_distribution() { assert_eq!(alloc0 + alloc1, 1_000_000); } +// ── Contribution caps ──────────────────────────────────────────────────────── + +/// Sets up a round (start=1000, end=3000) with project 1 approved and timestamps +/// left at 500 (before the round window) so callers can advance time themselves. +fn setup_capped_round<'a>( + env: &Env, + client: &MatchingPoolContractClient<'a>, + admin: &Address, + token_addr: &Address, +) -> u64 { + let round_id = client.create_round( + admin, + &symbol_short!("CAP"), + token_addr, + &1000u64, + &3000u64, + ); + client.approve_project(admin, &round_id, &1u64); + round_id +} + +#[test] +fn test_per_contributor_cap_exact_allowed() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, token, _) = setup(&env); + client.initialize(&admin); + env.ledger().set_timestamp(500); + let round_id = setup_capped_round(&env, &client, &admin, &token.address); + + client.set_round_caps(&admin, &round_id, &1_000i128, &0i128); + + let contributor = Address::generate(&env); + env.ledger().set_timestamp(1500); + // Exactly at cap — must succeed. + client.record_contribution(&round_id, &1u64, &contributor, &1_000); + assert_eq!(client.get_project_contributions(&round_id, &1u64), 1_000); +} + +#[test] +fn test_per_contributor_cap_over_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, token, _) = setup(&env); + client.initialize(&admin); + env.ledger().set_timestamp(500); + let round_id = setup_capped_round(&env, &client, &admin, &token.address); + + client.set_round_caps(&admin, &round_id, &1_000i128, &0i128); + + let contributor = Address::generate(&env); + env.ledger().set_timestamp(1500); + // One unit over the cap — must be rejected. + assert_eq!( + client.try_record_contribution(&round_id, &1u64, &contributor, &1_001), + Err(Ok(MatchingPoolError::ContributorCapExceeded)) + ); +} + +#[test] +fn test_per_contributor_cap_cumulative() { + // First contribution succeeds; second would push the total over the cap. + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, token, _) = setup(&env); + client.initialize(&admin); + env.ledger().set_timestamp(500); + let round_id = setup_capped_round(&env, &client, &admin, &token.address); + + client.set_round_caps(&admin, &round_id, &1_000i128, &0i128); + + let contributor = Address::generate(&env); + env.ledger().set_timestamp(1500); + client.record_contribution(&round_id, &1u64, &contributor, &600); + // 600 + 401 = 1001 > cap of 1000 → must be rejected. + assert_eq!( + client.try_record_contribution(&round_id, &1u64, &contributor, &401), + Err(Ok(MatchingPoolError::ContributorCapExceeded)) + ); + // 600 + 400 = 1000 = cap → must be allowed. + client.record_contribution(&round_id, &1u64, &contributor, &400); + assert_eq!(client.get_project_contributions(&round_id, &1u64), 1_000); +} + +#[test] +fn test_round_total_cap_exact_allowed() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, token, _) = setup(&env); + client.initialize(&admin); + env.ledger().set_timestamp(500); + let round_id = setup_capped_round(&env, &client, &admin, &token.address); + + // Round cap = 500; per-contributor cap = 0 (uncapped). + client.set_round_caps(&admin, &round_id, &0i128, &500i128); + + env.ledger().set_timestamp(1500); + let c1 = Address::generate(&env); + let c2 = Address::generate(&env); + client.record_contribution(&round_id, &1u64, &c1, &250); + // 250 + 250 = 500 = cap → must succeed. + client.record_contribution(&round_id, &1u64, &c2, &250); + assert_eq!(client.get_project_contributions(&round_id, &1u64), 500); +} + +#[test] +fn test_round_total_cap_over_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, token, _) = setup(&env); + client.initialize(&admin); + env.ledger().set_timestamp(500); + let round_id = setup_capped_round(&env, &client, &admin, &token.address); + + client.set_round_caps(&admin, &round_id, &0i128, &500i128); + + let contributor = Address::generate(&env); + env.ledger().set_timestamp(1500); + // Single contribution of 501 exceeds round cap of 500. + assert_eq!( + client.try_record_contribution(&round_id, &1u64, &contributor, &501), + Err(Ok(MatchingPoolError::RoundCapExceeded)) + ); +} + +#[test] +fn test_round_total_cap_across_contributors() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, token, _) = setup(&env); + client.initialize(&admin); + env.ledger().set_timestamp(500); + let round_id = setup_capped_round(&env, &client, &admin, &token.address); + + client.set_round_caps(&admin, &round_id, &0i128, &500i128); + + env.ledger().set_timestamp(1500); + let c1 = Address::generate(&env); + let c2 = Address::generate(&env); + client.record_contribution(&round_id, &1u64, &c1, &300); + // 300 + 201 = 501 > cap → rejected. + assert_eq!( + client.try_record_contribution(&round_id, &1u64, &c2, &201), + Err(Ok(MatchingPoolError::RoundCapExceeded)) + ); + // 300 + 200 = 500 = cap → allowed. + client.record_contribution(&round_id, &1u64, &c2, &200); + assert_eq!(client.get_project_contributions(&round_id, &1u64), 500); +} + +#[test] +fn test_caps_queryable() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, token, _) = setup(&env); + client.initialize(&admin); + env.ledger().set_timestamp(500); + let round_id = setup_capped_round(&env, &client, &admin, &token.address); + + client.set_round_caps(&admin, &round_id, &1_000i128, &5_000i128); + + let caps = client.get_round_caps(&round_id); + assert_eq!( + caps, + CapData { + per_contributor_cap: 1_000, + round_contribution_cap: 5_000, + total_contributions: 0, + } + ); + + // Make a contribution and verify total_contributions updates. + let contributor = Address::generate(&env); + env.ledger().set_timestamp(1500); + client.record_contribution(&round_id, &1u64, &contributor, &300); + let caps_after = client.get_round_caps(&round_id); + assert_eq!(caps_after.total_contributions, 300); +} + +#[test] +fn test_set_caps_non_admin_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, token, _) = setup(&env); + client.initialize(&admin); + env.ledger().set_timestamp(500); + let round_id = setup_capped_round(&env, &client, &admin, &token.address); + + let non_admin = Address::generate(&env); + assert_eq!( + client.try_set_round_caps(&non_admin, &round_id, &1_000i128, &5_000i128), + Err(Ok(MatchingPoolError::Unauthorized)) + ); +} + +#[test] +fn test_set_caps_after_finalization_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, token, _) = setup(&env); + client.initialize(&admin); + env.ledger().set_timestamp(500); + let round_id = setup_capped_round(&env, &client, &admin, &token.address); + + env.ledger().set_timestamp(4000); // past end_time + client.finalize_round(&admin, &round_id); + + assert_eq!( + client.try_set_round_caps(&admin, &round_id, &1_000i128, &5_000i128), + Err(Ok(MatchingPoolError::RoundAlreadyFinalized)) + ); +} + +#[test] +fn test_set_caps_negative_amount_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, token, _) = setup(&env); + client.initialize(&admin); + env.ledger().set_timestamp(500); + let round_id = setup_capped_round(&env, &client, &admin, &token.address); + + assert_eq!( + client.try_set_round_caps(&admin, &round_id, &-1i128, &0i128), + Err(Ok(MatchingPoolError::InvalidAmount)) + ); + assert_eq!( + client.try_set_round_caps(&admin, &round_id, &0i128, &-1i128), + Err(Ok(MatchingPoolError::InvalidAmount)) + ); +} + +#[test] +fn test_zero_caps_are_uncapped() { + // Default caps (0, 0) impose no limits — any contribution amount is accepted. + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, token, _) = setup(&env); + client.initialize(&admin); + env.ledger().set_timestamp(500); + let round_id = setup_capped_round(&env, &client, &admin, &token.address); + + // Caps default to 0 after create_round; no set_round_caps call needed. + let contributor = Address::generate(&env); + env.ledger().set_timestamp(1500); + client.record_contribution(&round_id, &1u64, &contributor, &i128::MAX / 2); + assert_eq!( + client.get_project_contributions(&round_id, &1u64), + i128::MAX / 2 + ); +} + #[test] fn test_reentrancy_guard_fund_pool_rejects_when_locked() { let env = Env::default(); diff --git a/apps/onchain/contracts/pricing_adapter/src/errors.rs b/apps/onchain/contracts/pricing_adapter/src/errors.rs index 5c28c1e5..a8449aee 100644 --- a/apps/onchain/contracts/pricing_adapter/src/errors.rs +++ b/apps/onchain/contracts/pricing_adapter/src/errors.rs @@ -9,4 +9,6 @@ pub enum PricingAdapterError { Unauthorized = 3, PriceNotFound = 4, InvalidPrice = 5, + PriceStale = 6, + PriceInvalidated = 7, } diff --git a/apps/onchain/contracts/pricing_adapter/src/events.rs b/apps/onchain/contracts/pricing_adapter/src/events.rs index 17c5c463..e116449f 100644 --- a/apps/onchain/contracts/pricing_adapter/src/events.rs +++ b/apps/onchain/contracts/pricing_adapter/src/events.rs @@ -21,3 +21,18 @@ pub struct OracleUpdatedEvent { pub admin: Address, pub oracle: Address, } + +#[contractevent] +pub struct StalenessWindowSetEvent { + #[topic] + pub asset: Address, + pub admin: Address, + pub window: u64, +} + +#[contractevent] +pub struct PriceInvalidatedEvent { + #[topic] + pub asset: Address, + pub admin: Address, +} diff --git a/apps/onchain/contracts/pricing_adapter/src/lib.rs b/apps/onchain/contracts/pricing_adapter/src/lib.rs index 90564473..dda008ad 100644 --- a/apps/onchain/contracts/pricing_adapter/src/lib.rs +++ b/apps/onchain/contracts/pricing_adapter/src/lib.rs @@ -6,7 +6,7 @@ mod storage; use errors::PricingAdapterError; use soroban_sdk::{contract, contractimpl, Address, Env}; -use storage::DataKey; +use storage::{DataKey, PriceData}; pub const BASE_DECIMALS: u32 = 7; @@ -29,7 +29,7 @@ impl PricingAdapterContract { } /// Set the price for a specific asset. Price should be scaled by 10^7 (BASE_DECIMALS). - /// `asset_decimals` specifies the decimal places of the original asset token. + /// Records the current ledger timestamp and clears any prior invalidation flag. pub fn set_price( env: Env, admin: Address, @@ -48,6 +48,14 @@ impl PricingAdapterContract { env.storage() .persistent() .set(&DataKey::AssetDecimals(asset.clone()), &asset_decimals); + env.storage().persistent().set( + &DataKey::PriceTimestamp(asset.clone()), + &env.ledger().timestamp(), + ); + // A fresh price clears any prior explicit invalidation. + env.storage() + .persistent() + .set(&DataKey::PriceInvalidated(asset.clone()), &false); let event = events::PriceUpdatedEvent { admin, @@ -58,7 +66,49 @@ impl PricingAdapterContract { Ok(()) } - /// Get the current configured price of an asset + /// Configure the maximum age (in seconds) a price may have before it is + /// considered stale. Set to 0 to disable the staleness check for this asset. + pub fn set_staleness_window( + env: Env, + admin: Address, + asset: Address, + window: u64, + ) -> Result<(), PricingAdapterError> { + Self::require_admin(&env, &admin)?; + env.storage() + .persistent() + .set(&DataKey::StalenessWindow(asset.clone()), &window); + events::StalenessWindowSetEvent { + asset, + admin, + window, + } + .publish(&env); + Ok(()) + } + + /// Explicitly mark the current price for an asset as invalid. Downstream + /// calls to get_safe_price will return PriceInvalidated until a new price + /// is set with set_price. + pub fn invalidate_price( + env: Env, + admin: Address, + asset: Address, + ) -> Result<(), PricingAdapterError> { + Self::require_admin(&env, &admin)?; + // Require the price to exist before allowing invalidation. + env.storage() + .persistent() + .get::<_, i128>(&DataKey::AssetPrice(asset.clone())) + .ok_or(PricingAdapterError::PriceNotFound)?; + env.storage() + .persistent() + .set(&DataKey::PriceInvalidated(asset.clone()), &true); + events::PriceInvalidatedEvent { asset, admin }.publish(&env); + Ok(()) + } + + /// Get the raw stored price without any validity checks (backward-compatible). pub fn get_price(env: Env, asset: Address) -> Result { env.storage() .persistent() @@ -66,6 +116,48 @@ impl PricingAdapterContract { .ok_or(PricingAdapterError::PriceNotFound) } + /// Get the price only if it is fresh and not invalidated. + /// Returns PriceStale or PriceInvalidated if either condition is true. + pub fn get_safe_price(env: Env, asset: Address) -> Result { + let price = Self::get_price(env.clone(), asset.clone())?; + Self::check_price_validity(&env, &asset, price) + } + + /// Return the full price state including freshness metadata and derived + /// validity flags. Useful for dashboards and debugging; does not error on + /// stale or invalidated prices — callers inspect the fields themselves. + pub fn get_price_data(env: Env, asset: Address) -> Result { + let price = Self::get_price(env.clone(), asset.clone())?; + let timestamp: u64 = env + .storage() + .persistent() + .get(&DataKey::PriceTimestamp(asset.clone())) + .unwrap_or(0); + let staleness_window: u64 = env + .storage() + .persistent() + .get(&DataKey::StalenessWindow(asset.clone())) + .unwrap_or(0); + let is_invalidated: bool = env + .storage() + .persistent() + .get(&DataKey::PriceInvalidated(asset.clone())) + .unwrap_or(false); + let is_stale = staleness_window > 0 + && env + .ledger() + .timestamp() + .saturating_sub(timestamp) + > staleness_window; + Ok(PriceData { + price, + timestamp, + staleness_window, + is_invalidated, + is_stale, + }) + } + /// Get the decimals configured for an asset (defaults to 7) pub fn get_asset_decimals(env: Env, asset: Address) -> u32 { env.storage() @@ -99,6 +191,46 @@ impl PricingAdapterContract { Ok(normalized) } + // ── Private helpers ────────────────────────────────────────────────────── + + /// Returns `price` unchanged if it passes both the invalidation flag check + /// and the staleness window check; otherwise returns the appropriate error. + /// `age > window` is the comparison so that a price exactly `window` + /// seconds old is still considered fresh. + fn check_price_validity( + env: &Env, + asset: &Address, + price: i128, + ) -> Result { + if env + .storage() + .persistent() + .get::<_, bool>(&DataKey::PriceInvalidated(asset.clone())) + .unwrap_or(false) + { + return Err(PricingAdapterError::PriceInvalidated); + } + + let window: u64 = env + .storage() + .persistent() + .get(&DataKey::StalenessWindow(asset.clone())) + .unwrap_or(0); + if window > 0 { + let timestamp: u64 = env + .storage() + .persistent() + .get(&DataKey::PriceTimestamp(asset.clone())) + .unwrap_or(0); + let age = env.ledger().timestamp().saturating_sub(timestamp); + if age > window { + return Err(PricingAdapterError::PriceStale); + } + } + + Ok(price) + } + fn require_admin(env: &Env, caller: &Address) -> Result<(), PricingAdapterError> { let admin: Address = env .storage() diff --git a/apps/onchain/contracts/pricing_adapter/src/storage.rs b/apps/onchain/contracts/pricing_adapter/src/storage.rs index f0c034ab..539fa5ff 100644 --- a/apps/onchain/contracts/pricing_adapter/src/storage.rs +++ b/apps/onchain/contracts/pricing_adapter/src/storage.rs @@ -6,5 +6,21 @@ pub enum DataKey { Admin, AssetPrice(Address), AssetOracle(Address), - AssetDecimals(Address), // Stores decimals if needed for normalization + AssetDecimals(Address), + PriceTimestamp(Address), // asset -> u64 (ledger timestamp of last set_price call) + StalenessWindow(Address), // asset -> u64 (max age in seconds; 0 = no staleness check) + PriceInvalidated(Address), // asset -> bool (explicit admin invalidation flag) +} + +/// Full price state returned by get_price_data. +/// `is_stale` and `is_invalidated` are derived at query time so callers always +/// see the current validity assessment without a separate call. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PriceData { + pub price: i128, + pub timestamp: u64, + pub staleness_window: u64, + pub is_invalidated: bool, + pub is_stale: bool, } diff --git a/apps/onchain/contracts/pricing_adapter/src/test.rs b/apps/onchain/contracts/pricing_adapter/src/test.rs index 16d54e50..bfcc7c9b 100644 --- a/apps/onchain/contracts/pricing_adapter/src/test.rs +++ b/apps/onchain/contracts/pricing_adapter/src/test.rs @@ -1,5 +1,25 @@ use super::*; -use soroban_sdk::{testutils::Address as _, Env}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Env, +}; +use storage::PriceData; + +const PRICE: i128 = 10_000_000; // $1.00 scaled by 10^7 +const DECIMALS: u32 = 7; + +/// Creates a contract client, admin, and asset address; initializes the contract. +/// Caller must create `Env::default()` and call `env.mock_all_auths()` first. +fn setup<'a>(env: &'a Env) -> (PricingAdapterContractClient<'a>, Address, Address) { + let admin = Address::generate(env); + let asset = Address::generate(env); + let contract_id = env.register(PricingAdapterContract, ()); + let client = PricingAdapterContractClient::new(env, &contract_id); + client.initialize(&admin); + (client, admin, asset) +} + +// ── Existing tests (unchanged) ─────────────────────────────────────────────── #[test] fn test_initialization() { @@ -62,19 +82,6 @@ fn test_normalize_amount_same_decimals() { let amount: i128 = 5_000_000; // 5 tokens let normalized = client.normalize_amount(&asset, &amount); - // Normalized amount should be 5 * 10^7 = 50_000_000 - // Wait, (5_000_000 * 10_000_000) / 10^7 = 5_000_000 - // Wait! 5 tokens * $1 = $5. $5 scaled by 10^7 is 50_000_000! - // But my formula gave 5_000_000. Let's re-check! - // Amount is 5_000_000. - // Price is 10_000_000. - // Normalized = 5_000_000 * 10_000_000 / 10^7 = 5_000_000. - // This is NOT 50_000_000! - // So 5_000_000 in base representation represents 0.5 USD! - // Wait, 5 tokens is 5 * 10^7 = 50_000_000. - // Oh, my amount was 5_000_000, which is 0.5 tokens! - // 0.5 tokens * $1.00 = $0.5. $0.5 scaled by 10^7 is 5_000_000. - // Okay, so the formula is correct! assert_eq!(normalized, 5_000_000); } @@ -98,8 +105,304 @@ fn test_normalize_amount_different_decimals() { let amount: i128 = 2 * 1_000_000_000_000_000_000; // 2 ETH let normalized = client.normalize_amount(ð_asset, &amount); - // Normalized should be 2 * $3000 = $6000 - // $6000 scaled by 10^7 = 60_000 * 10^7 = 60_000_000_000 let expected: i128 = 6000 * 10_000_000; assert_eq!(normalized, expected); } + +// ── Freshness metadata ─────────────────────────────────────────────────────── + +#[test] +fn test_price_timestamp_stored_on_set_price() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, asset) = setup(&env); + env.ledger().set_timestamp(5_000); + client.set_price(&admin, &asset, &PRICE, &DECIMALS); + + let data = client.get_price_data(&asset); + assert_eq!(data.timestamp, 5_000); +} + +#[test] +fn test_price_timestamp_updated_on_subsequent_set_price() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, asset) = setup(&env); + env.ledger().set_timestamp(1_000); + client.set_price(&admin, &asset, &PRICE, &DECIMALS); + env.ledger().set_timestamp(2_500); + client.set_price(&admin, &asset, &PRICE, &DECIMALS); + + let data = client.get_price_data(&asset); + assert_eq!(data.timestamp, 2_500); +} + +// ── Staleness window — boundary values ────────────────────────────────────── + +#[test] +fn test_get_safe_price_fresh() { + // Price age = 50s, window = 60s — fresh. + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, asset) = setup(&env); + env.ledger().set_timestamp(1_000); + client.set_price(&admin, &asset, &PRICE, &DECIMALS); + client.set_staleness_window(&admin, &asset, &60u64); + + env.ledger().set_timestamp(1_050); + assert_eq!(client.get_safe_price(&asset), PRICE); +} + +#[test] +fn test_get_safe_price_at_exact_boundary_is_fresh() { + // age == window → still fresh (> not >=). + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, asset) = setup(&env); + env.ledger().set_timestamp(1_000); + client.set_price(&admin, &asset, &PRICE, &DECIMALS); + client.set_staleness_window(&admin, &asset, &60u64); + + env.ledger().set_timestamp(1_060); // age = 60 == window + assert_eq!(client.get_safe_price(&asset), PRICE); +} + +#[test] +fn test_get_safe_price_one_second_past_boundary_is_stale() { + // age = window + 1 → stale. + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, asset) = setup(&env); + env.ledger().set_timestamp(1_000); + client.set_price(&admin, &asset, &PRICE, &DECIMALS); + client.set_staleness_window(&admin, &asset, &60u64); + + env.ledger().set_timestamp(1_061); // age = 61 > 60 + assert_eq!( + client.try_get_safe_price(&asset), + Err(Ok(PricingAdapterError::PriceStale)) + ); +} + +#[test] +fn test_get_safe_price_stale_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, asset) = setup(&env); + env.ledger().set_timestamp(0); + client.set_price(&admin, &asset, &PRICE, &DECIMALS); + client.set_staleness_window(&admin, &asset, &60u64); + + env.ledger().set_timestamp(200); // age = 200 >> 60 + assert_eq!( + client.try_get_safe_price(&asset), + Err(Ok(PricingAdapterError::PriceStale)) + ); +} + +#[test] +fn test_zero_staleness_window_never_stale() { + // window = 0 disables the check — any age is accepted. + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, asset) = setup(&env); + env.ledger().set_timestamp(0); + client.set_price(&admin, &asset, &PRICE, &DECIMALS); + // Default window is 0; explicit set to confirm. + client.set_staleness_window(&admin, &asset, &0u64); + + env.ledger().set_timestamp(u64::MAX / 2); + assert_eq!(client.get_safe_price(&asset), PRICE); +} + +#[test] +fn test_get_price_ignores_staleness() { + // get_price is backward-compatible — never errors on stale prices. + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, asset) = setup(&env); + env.ledger().set_timestamp(0); + client.set_price(&admin, &asset, &PRICE, &DECIMALS); + client.set_staleness_window(&admin, &asset, &60u64); + + env.ledger().set_timestamp(9_999); + assert_eq!(client.get_price(&asset), PRICE); +} + +// ── Invalidation flag ──────────────────────────────────────────────────────── + +#[test] +fn test_invalidate_price_blocks_safe_price() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, asset) = setup(&env); + env.ledger().set_timestamp(1_000); + client.set_price(&admin, &asset, &PRICE, &DECIMALS); + client.invalidate_price(&admin, &asset); + + assert_eq!( + client.try_get_safe_price(&asset), + Err(Ok(PricingAdapterError::PriceInvalidated)) + ); +} + +#[test] +fn test_invalidation_cleared_by_new_set_price() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, asset) = setup(&env); + env.ledger().set_timestamp(1_000); + client.set_price(&admin, &asset, &PRICE, &DECIMALS); + client.invalidate_price(&admin, &asset); + + // Re-publishing the price clears the invalidation flag. + env.ledger().set_timestamp(1_100); + client.set_price(&admin, &asset, &PRICE, &DECIMALS); + assert_eq!(client.get_safe_price(&asset), PRICE); +} + +#[test] +fn test_invalidate_non_admin_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, asset) = setup(&env); + env.ledger().set_timestamp(1_000); + client.set_price(&admin, &asset, &PRICE, &DECIMALS); + + let non_admin = Address::generate(&env); + assert_eq!( + client.try_invalidate_price(&non_admin, &asset), + Err(Ok(PricingAdapterError::Unauthorized)) + ); +} + +#[test] +fn test_invalidate_nonexistent_price_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, _) = setup(&env); + let unknown_asset = Address::generate(&env); + assert_eq!( + client.try_invalidate_price(&admin, &unknown_asset), + Err(Ok(PricingAdapterError::PriceNotFound)) + ); +} + +#[test] +fn test_invalidation_does_not_affect_get_price() { + // get_price is backward-compatible — never errors on invalidated prices. + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, asset) = setup(&env); + env.ledger().set_timestamp(1_000); + client.set_price(&admin, &asset, &PRICE, &DECIMALS); + client.invalidate_price(&admin, &asset); + + assert_eq!(client.get_price(&asset), PRICE); +} + +// ── get_price_data ─────────────────────────────────────────────────────────── + +#[test] +fn test_get_price_data_fresh_and_valid() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, asset) = setup(&env); + env.ledger().set_timestamp(1_000); + client.set_price(&admin, &asset, &PRICE, &DECIMALS); + client.set_staleness_window(&admin, &asset, &120u64); + + env.ledger().set_timestamp(1_050); + let data = client.get_price_data(&asset); + + assert_eq!( + data, + PriceData { + price: PRICE, + timestamp: 1_000, + staleness_window: 120, + is_invalidated: false, + is_stale: false, + } + ); +} + +#[test] +fn test_get_price_data_shows_stale_flag() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, asset) = setup(&env); + env.ledger().set_timestamp(1_000); + client.set_price(&admin, &asset, &PRICE, &DECIMALS); + client.set_staleness_window(&admin, &asset, &60u64); + + env.ledger().set_timestamp(1_100); // age = 100 > window = 60 + let data = client.get_price_data(&asset); + + assert!(data.is_stale); + assert!(!data.is_invalidated); +} + +#[test] +fn test_get_price_data_shows_invalidated_flag() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, asset) = setup(&env); + env.ledger().set_timestamp(1_000); + client.set_price(&admin, &asset, &PRICE, &DECIMALS); + client.invalidate_price(&admin, &asset); + + let data = client.get_price_data(&asset); + assert!(data.is_invalidated); + assert!(!data.is_stale); +} + +#[test] +fn test_get_price_data_no_staleness_window_is_never_stale() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, asset) = setup(&env); + env.ledger().set_timestamp(0); + client.set_price(&admin, &asset, &PRICE, &DECIMALS); + + env.ledger().set_timestamp(u64::MAX / 2); + let data = client.get_price_data(&asset); + + assert_eq!(data.staleness_window, 0); + assert!(!data.is_stale); +} + +// ── set_staleness_window guards ────────────────────────────────────────────── + +#[test] +fn test_set_staleness_window_non_admin_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _, asset) = setup(&env); + let non_admin = Address::generate(&env); + assert_eq!( + client.try_set_staleness_window(&non_admin, &asset, &60u64), + Err(Ok(PricingAdapterError::Unauthorized)) + ); +} + +#[test] +fn test_staleness_window_can_be_updated() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, asset) = setup(&env); + env.ledger().set_timestamp(1_000); + client.set_price(&admin, &asset, &PRICE, &DECIMALS); + client.set_staleness_window(&admin, &asset, &30u64); + + // Age = 40s → stale under window=30. + env.ledger().set_timestamp(1_040); + assert_eq!( + client.try_get_safe_price(&asset), + Err(Ok(PricingAdapterError::PriceStale)) + ); + + // Widen window to 60 — same age now fresh. + client.set_staleness_window(&admin, &asset, &60u64); + assert_eq!(client.get_safe_price(&asset), PRICE); +}