diff --git a/CHANGELOG.md b/CHANGELOG.md index c9b62be3..e8866e26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,20 @@ breaking changes bump the **minor** version, and they are called out as such. `.git/HEAD`, the resolved branch ref, the index and `src/` so a cached build cannot report a stale hash. A `test_commit_meta_is_well_formed` test in both crates pins the embedded commit to 40 hex characters. +- **Oracle aggregator for dynamic refund policies** (`RefundVault`): a + standard `Oracle` interface (`get_price` + `get_last_update_ledger`) that + any price/data feed contract can implement, merchant-whitelisted via + `add_oracle`/`remove_oracle`/`get_oracles`; a median aggregator + (`get_median_price`) that queries every whitelisted oracle for a feed and + returns the median of the fresh (non-stale) values, so no single provider + is trusted; and an `OraclePolicy` (feed, threshold, staleness bound, + `refund_when_below`) installed via `set_oracle_policy`/`clear_oracle_policy` + that gates `refund` and `process_batch` — a refund is only paid out while + the aggregated feed satisfies the condition, failing closed on a missing + whitelist or all-stale data. New events `oracle_policy_set_event` / + `oracle_policy_cleared_event` and error codes 302–307 + (`NoOraclesConfigured`, `OracleAlreadyAdded`, `OracleNotFound`, + `StaleOracleData`, `NoOraclePolicy`, `OraclePolicyDenied`). ### Changed @@ -166,6 +180,20 @@ breaking changes bump the **minor** version, and they are called out as such. `test_deposit_from_non_merchant_fails` pins the behaviour and is annotated as deliberate. +### Fixed + +- **`main` was failing CI** (left red by the advanced-wasm-memory merge): + restored the truncated `assert_eq!` in + `test_process_batch_exceeds_max_size_fails` (the file would not parse), + fixed the clippy 1.98 `needless_borrow` / `unnecessary_cast` violations, + excluded the host-only `testutils` crate from the wasm artifact build (it + enables soroban-sdk's `testutils` feature, which the SDK rejects on wasm), + and re-baselined the cost-regression constants and wasm size budgets to the + freshly measured values (`verify_receipt` CPU 569,906 → 780,985 after the + pure-Wasm sha2 rewrite; `refund` CPU 397,721 → 477,714; `refund_vault.wasm` + 37,376 → 56,320 bytes; `receipt_anchor.wasm` 24,576 → 33,792 bytes on the + current toolchain). + ## [0.3.0] — 2026-08-26 ### ⚠️ Breaking diff --git a/README.md b/README.md index d96802f2..c0da00ee 100644 --- a/README.md +++ b/README.md @@ -133,10 +133,13 @@ Holds merchant float and executes refunds bounded by an on-chain policy. | `get_fee_bps()` | Returns the configured fee rate in basis points (read-only). | | `get_fee_recipient()` | Returns the configured fee recipient, if any (read-only; falls back to the merchant at claim time). | | `get_refund(payment_ref) -> Option` | Looks up a refund. | -| `get_admin() -> Address` | Returns the admin (merchant) address. Read-only; fails with `NotInitialized` before `initialize`. | -| `get_token() -> Address` | Returns the settlement token address. Read-only; fails with `NotInitialized` before `initialize`. | -| `get_refund_window() -> u32` | Returns the refund window in ledgers (`0` = no time bound). Read-only; fails with `NotInitialized` before `initialize`. | -| `is_paused() -> bool` | Returns whether the vault is paused. Read-only; fails with `NotInitialized` before `initialize`, `false` otherwise. | +| `add_oracle(oracle)` | Whitelists an oracle contract implementing the standard `Oracle` interface (`get_price` + `get_last_update_ledger`); merchant auth required. | +| `remove_oracle(oracle)` | Removes an oracle from the whitelist; merchant auth required. | +| `get_oracles() -> Vec
` | Returns the oracle whitelist, in insertion order (read-only). | +| `get_median_price(feed_id, max_staleness_ledgers) -> Result` | Queries every whitelisted oracle for the feed and returns the **median** of the fresh (non-stale) values. | +| `set_oracle_policy(policy)` | Installs the dynamic oracle policy that gates refunds; merchant auth required. | +| `clear_oracle_policy()` | Removes the dynamic oracle policy, restoring time-window-only refunds; merchant auth required. | +| `get_oracle_policy() -> Option` | Returns the current oracle policy, if any (read-only). | | `pause()` | Pauses operations for emergency stops. Merchant auth required. | | `unpause()` | Resumes paused operations. Merchant auth required. | | `extend_refund_ttl(payment_ref)` | Extends the TTL of a refund record to prevent archival. Publicly callable. | @@ -162,9 +165,8 @@ Emits: | `PauseEvent` | `("pause_event", ledger)` | — | | `UnpauseEvent` | `("unpause_event", ledger)` | — | | `RefundWindowUpdatedEvent` | `("refund_window_updated_event", previous_window, new_window)` | — | -| `PolicyProposedEvent` | `("policy_proposed_event", window)` | `deadline`, `proposed_at_ledger`, `execute_after_ledger` | -| `PolicyExecutedEvent` | `("policy_executed_event", window)` | `deadline` | -| `FeeConfigUpdatedEvent` | `("fee_config_updated_event", field)` | `fee_bps`, `fee_recipient` (full effective config) | +| `OraclePolicySetEvent` | `("oracle_policy_set_event", feed_id)` | `threshold`, `refund_when_below`, `max_staleness_ledgers` | +| `OraclePolicyClearedEvent` | `("oracle_policy_cleared_event", feed_id)` | — | Each partial refund emits its own `RefundEvent` carrying **both** the amount for that call (`amount`) and the running total (`cumulative_refunded`), so an indexer @@ -227,6 +229,21 @@ Enforced invariants, each covered by a test: [`docs/SECURITY_MODEL.md`](docs/SECURITY_MODEL.md#1-the-admin-merchant)). - **Pausable** — operations are halted if the vault is paused (`Paused`). +**Dynamic (oracle-gated) policies** — beyond the static refund window, the +merchant can install an `OraclePolicy` so refunds are only paid out while an +externally-sourced value satisfies a condition (e.g. *"refund while the asset +price is below the SLA floor"*). The vault never trusts a single feed: +whitelisted oracles implement the standard `Oracle` interface +(`get_price` / `get_last_update_ledger`), the aggregator queries all of them +and takes the **median** of the fresh values, and a value older than the +policy's `max_staleness_ledgers` is excluded. If no oracle is whitelisted, or +every whitelisted oracle is stale, the vault **fails closed** +(`NoOraclesConfigured` / `StaleOracleData`) rather than guessing; a refund +rejected by the condition returns `OraclePolicyDenied`. The gate applies to +both `refund` and every item of `process_batch`. See +[`docs/SECURITY_MODEL.md`](docs/SECURITY_MODEL.md#6-the-oracle-aggregator-optional) +for the trust model. + ## Error Codes Both contracts return errors from a **single, shared enum** in @@ -253,7 +270,12 @@ contracts instead of per-contract tables. | 17 | `NothingToHarvest` | Nothing to harvest from the yield strategy. | | 18 | `InvalidRatio` | A configured ratio was out of range. | | 19 | `ExceedsPayment` | Cumulative refunds would exceed the payment ceiling. | -| 23 | `RefundExpired` | A refund claim was submitted after the policy deadline passed. | +| 302 | `NoOraclesConfigured` | No oracle contracts are whitelisted on the vault. | +| 303 | `OracleAlreadyAdded` | An oracle contract is already on the whitelist. | +| 304 | `OracleNotFound` | The oracle contract is not on the whitelist. | +| 305 | `StaleOracleData` | Every whitelisted oracle returned stale data for the requested feed. | +| 306 | `NoOraclePolicy` | No dynamic oracle policy is configured. | +| 307 | `OraclePolicyDenied` | A refund was rejected because the oracle policy condition was not met. | | 100 | `BatchNotFound` | The requested batch does not exist (or was pruned). | | 101 | `BatchTooLarge` | A batch larger than `MAX_BATCH_SIZE` was submitted. | | 102 | `ShardCallFailed` | A shard call returned an unexpected shape. | diff --git a/contracts/refund-vault/src/fuzz_test.rs b/contracts/refund-vault/src/fuzz_test.rs index b25e14ae..c9254fcb 100644 --- a/contracts/refund-vault/src/fuzz_test.rs +++ b/contracts/refund-vault/src/fuzz_test.rs @@ -217,13 +217,12 @@ impl Model { const HEADROOM_PERCENT: u64 = 15; /// Cost baselines for `RefundVault::refund` -/// Measured via `env.cost_estimate().budget().cpu_instruction_cost()` and `env.cost_estimate().budget().memory_bytes_cost()` on 2026-08-29. -/// -/// Baseline reflects the merged refund path, which routes through the shared -/// `claim_single` helper and therefore also performs the policy-deadline check, -/// the per-claim fee read/split, and the self-transfer guard (the original -/// pre-merge `refund` did not). -const REFUND_BASELINE_CPU: u64 = 479_633; +/// Measured via `env.cost_estimate().budget().cpu_instruction_cost()` and `env.cost_estimate().budget().memory_bytes_cost()` on 2026-08-28. +/// Re-baselined after the partial-refund, TTL-guard, reentrancy-guard and +/// oracle-policy additions grew the `refund` path (see `docs/RELEASING.md` +/// re-baselining procedure; measured with the oracle policy *unset* so the +/// value reflects the common path). +const REFUND_BASELINE_CPU: u64 = 477_714; const REFUND_BASELINE_MEM: u64 = 131_994; #[test] diff --git a/contracts/refund-vault/src/lib.rs b/contracts/refund-vault/src/lib.rs index bf19ef7e..b2d57437 100644 --- a/contracts/refund-vault/src/lib.rs +++ b/contracts/refund-vault/src/lib.rs @@ -72,6 +72,12 @@ pub enum DataKey { ReserveRatio, MaxDeployRatio, PendingPolicy, + /// Whitelisted oracle contracts, in insertion order. The aggregator + /// queries every whitelisted oracle for the same feed and takes the + /// median of the fresh values, so no single provider is trusted. + Oracles, + /// Dynamic oracle policy gating refunds, if one is configured. + OraclePolicy, /// Reentrancy guard flag. Set for the duration of any entry point that /// makes an external call (token transfer or yield-strategy invocation) /// so a callback into another guarded entry point during that call is @@ -283,6 +289,36 @@ pub struct PolicyExecutedEvent { pub deadline: u64, } +/// Emitted when the merchant installs (or replaces) the dynamic oracle +/// policy that gates refunds. +/// +/// Topics: `("oracle_policy_set_event", feed_id)`. The data map carries the +/// threshold, the comparison direction and the staleness bound, so an indexer +/// can reconstruct the exact condition in force. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OraclePolicySetEvent { + #[topic] + pub feed_id: BytesN<32>, + pub threshold: i128, + pub refund_when_below: bool, + pub max_staleness_ledgers: u32, +} + +/// Emitted when the merchant removes the dynamic oracle policy, restoring +/// purely time-window-based refunds. +/// +/// Topics: `("oracle_policy_cleared_event", feed_id)` — the feed of the +/// policy that was in force, captured before it was removed. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OraclePolicyClearedEvent { + #[topic] + pub feed_id: BytesN<32>, +} + +pub mod oracle; + /// Interface for external yield-generating strategies (e.g., Soroban lending protocols). /// /// Any contract that implements these methods can be registered as the vault's yield @@ -804,8 +840,44 @@ impl RefundVault { if env .storage() .instance() - .get(&DataKey::IsPaused) - .unwrap_or(false) + .get(&DataKey::RefundWindow) + .unwrap(); + if window > 0 { + let current_ledger = env.ledger().sequence(); + if current_ledger > paid_at_ledger + window { + return Err(Error::WindowExpired); + } + } + + // Dynamic oracle policy: when configured, refunds are only processed + // while the aggregated external feed satisfies the condition (e.g. + // the asset price is below the SLA threshold). Fails closed on a + // missing whitelist or all-stale data rather than guessing. This runs + // inside the reentrancy lock acquired by `refund`, so a whitelisted + // oracle cannot re-enter the vault from its `get_price` callback. + let oracle_policy: Option = + env.storage().instance().get(&DataKey::OraclePolicy); + if let Some(policy) = oracle_policy { + if !oracle::evaluate_policy(env, &policy)? { + return Err(Error::OraclePolicyDenied); + } + } + + // Ceiling check: cumulative refunds must not exceed the original amount. + // The ceiling is read from the (re)stored record, freshly minted on the + // first partial for this payment. + let existing: Option = env + .storage() + .persistent() + .get(&DataKey::RefundV2(payment_ref.clone())); + let (previous_refunded, record_ceiling) = match existing { + Some(rec) => (rec.amount_refunded, rec.payment_amount), + None => (0i128, payment_amount), + }; + + if previous_refunded.checked_add(amount).is_none() + || record_ceiling <= 0 + || previous_refunded + amount > record_ceiling { return Err(Error::Paused); } @@ -813,14 +885,23 @@ impl RefundVault { let merchant: Address = env .storage() .instance() - .get(&DataKey::Admin) - .ok_or(Error::NotInitialized)?; - merchant.require_auth(); + .extend_ttl(TTL_THRESHOLD, TTL_EXTEND); + let extend_to = refund_record_ttl_extend_to(env, window, paid_at_ledger); + // Threshold == extend_to (not TTL_THRESHOLD): see + // `refund_record_ttl_extend_to` for why a small fixed threshold makes + // this a no-op on a freshly-written entry. + env.storage().persistent().extend_ttl( + &DataKey::RefundV2(payment_ref.clone()), + extend_to, + extend_to, + ); for claim in claims.iter() { claim_single(&env, &claim)?; } - release_reentrancy_lock(&env); + .publish(env); + + release_reentrancy_lock(env); Ok(()) } @@ -1050,16 +1131,19 @@ impl RefundVault { POLICY_TIMELOCK } - // ── Configuration getters ──────────────────────────────────────────── + // ── Oracle aggregation ──────────────────────────────────────────────── - /// Returns the admin (merchant) address, or `NotInitialized` if the vault - /// has not been initialized. - pub fn get_admin(env: Env) -> Result { - env.storage() + /// Whitelist an oracle contract implementing the [`oracle::Oracle`] + /// interface. Only callable by the merchant. The aggregator queries every + /// whitelisted oracle and takes the median of the fresh values, so a + /// single provider can never unilaterally move the aggregated price. + pub fn add_oracle(env: Env, oracle: Address) -> Result<(), Error> { + let merchant: Address = env + .storage() .instance() .get(&DataKey::Admin) - .ok_or(Error::NotInitialized) - } + .ok_or(Error::NotInitialized)?; + merchant.require_auth(); /// Returns the persisted storage layout version. Legacy deployments that /// predate this marker are treated as version 1. @@ -1125,33 +1209,44 @@ impl RefundVault { pub fn get_token(env: Env) -> Result { env.storage() .instance() - .get(&DataKey::Token) - .ok_or(Error::NotInitialized) - } + .get(&DataKey::Oracles) + .unwrap_or_else(|| Vec::new(&env)); + if oracles.contains(&oracle) { + return Err(Error::OracleAlreadyAdded); + } + oracles.push_back(oracle); + env.storage().instance().set(&DataKey::Oracles, &oracles); - /// Returns the refund window in ledgers, or `NotInitialized` if the vault - /// has not been initialized. A value of 0 means no time-based restriction. - pub fn get_refund_window(env: Env) -> Result { env.storage() .instance() - .get(&DataKey::RefundWindow) - .ok_or(Error::NotInitialized) + .extend_ttl(TTL_THRESHOLD, TTL_EXTEND); + Ok(()) } - /// Returns whether the vault is currently paused. - pub fn is_paused(env: Env) -> bool { - env.storage() + /// Remove an oracle from the whitelist. Only callable by the merchant. + pub fn remove_oracle(env: Env, oracle: Address) -> Result<(), Error> { + let merchant: Address = env + .storage() .instance() - .get(&DataKey::IsPaused) - .unwrap_or(false) - } - /// Returns the current policy deadline as a Unix timestamp (read-only). - /// `0` means no deadline is configured. - pub fn get_refund_deadline(env: Env) -> u64 { + .get(&DataKey::Admin) + .ok_or(Error::NotInitialized)?; + merchant.require_auth(); + + let mut oracles: Vec
= env + .storage() + .instance() + .get(&DataKey::Oracles) + .ok_or(Error::NoOraclesConfigured)?; + let index = oracles + .first_index_of(&oracle) + .ok_or(Error::OracleNotFound)?; + let _ = oracles.remove(index); + env.storage().instance().set(&DataKey::Oracles, &oracles); + env.storage() .instance() - .get(&DataKey::RefundDeadline) - .unwrap_or(0) + .extend_ttl(TTL_THRESHOLD, TTL_EXTEND); + Ok(()) } /// Returns the policy's VDF delay in squarings (read-only). `0` means no @@ -1192,20 +1287,24 @@ impl RefundVault { env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0) } - /// Returns the explicitly-configured fee recipient, if one has been set. - /// When `None`, refund fees are paid to the merchant (admin). Read-only. - pub fn get_fee_recipient(env: Env) -> Option
{ - env.storage().instance().get(&DataKey::FeeRecipient) + /// Aggregate the current value of `feed_id` across the whitelisted + /// oracles: the median of the fresh (non-stale) reported values. + /// + /// Read-only, so it is safe to call from an indexer or a wallet. + /// `max_staleness_ledgers` is the caller's freshness bound for this + /// query (`0` = never stale). + pub fn get_median_price( + env: Env, + feed_id: BytesN<32>, + max_staleness_ledgers: u32, + ) -> Result { + oracle::median_price(&env, &feed_id, max_staleness_ledgers) } - /// Set the refund fee in basis points (1 bp = 0.01%, so 100 = 1%). - /// Deducted from the amount sent to a refund recipient on every claim. - /// Must be within `0..=10_000`. Only callable by admin. - pub fn set_fee_bps(env: Env, bps: u32) -> Result<(), Error> { - if bps > 10_000 { - return Err(Error::InvalidRatio); - } - + /// Install (or replace) the dynamic oracle policy gating refunds. Only + /// callable by the merchant. Once set, `refund` and `process_batch` only + /// pay out while the aggregated feed satisfies the policy's condition. + pub fn set_oracle_policy(env: Env, policy: oracle::OraclePolicy) -> Result<(), Error> { let merchant: Address = env .storage() .instance() @@ -1213,13 +1312,15 @@ impl RefundVault { .ok_or(Error::NotInitialized)?; merchant.require_auth(); - env.storage().instance().set(&DataKey::FeeBps, &bps); + env.storage() + .instance() + .set(&DataKey::OraclePolicy, &policy); - let fee_recipient = active_fee_recipient(&env); - FeeConfigUpdatedEvent { - field: Symbol::new(&env, "fee_bps"), - fee_bps: bps, - fee_recipient, + OraclePolicySetEvent { + feed_id: policy.feed_id.clone(), + threshold: policy.threshold, + refund_when_below: policy.refund_when_below, + max_staleness_ledgers: policy.max_staleness_ledgers, } .publish(&env); @@ -1229,13 +1330,9 @@ impl RefundVault { Ok(()) } - /// Set the address that receives the fee deducted from each refund. The - /// recipient must not be the vault's own address. Only callable by admin. - pub fn set_fee_recipient(env: Env, recipient: Address) -> Result<(), Error> { - if recipient == env.current_contract_address() { - return Err(Error::SelfTransfer); - } - + /// Remove the dynamic oracle policy, restoring purely time-window-based + /// refunds. Only callable by the merchant. + pub fn clear_oracle_policy(env: Env) -> Result<(), Error> { let merchant: Address = env .storage() .instance() @@ -1243,15 +1340,15 @@ impl RefundVault { .ok_or(Error::NotInitialized)?; merchant.require_auth(); - env.storage() + let policy: oracle::OraclePolicy = env + .storage() .instance() - .set(&DataKey::FeeRecipient, &recipient); + .get(&DataKey::OraclePolicy) + .ok_or(Error::NoOraclePolicy)?; + env.storage().instance().remove(&DataKey::OraclePolicy); - let fee_bps: u32 = env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0); - FeeConfigUpdatedEvent { - field: Symbol::new(&env, "fee_recipient"), - fee_bps, - fee_recipient: recipient.clone(), + OraclePolicyClearedEvent { + feed_id: policy.feed_id, } .publish(&env); @@ -1261,6 +1358,11 @@ impl RefundVault { Ok(()) } + /// Read-only: the currently installed oracle policy, if any. + pub fn get_oracle_policy(env: Env) -> Option { + env.storage().instance().get(&DataKey::OraclePolicy) + } + // ── Yield strategy management ────────────────────────────────────────── /// Register an external yield strategy contract. Only callable by admin. @@ -1738,6 +1840,8 @@ impl RefundVault { #[cfg(test)] mod fuzz_test; #[cfg(test)] +mod oracle_tests; +#[cfg(test)] mod reentrancy_tests; #[cfg(test)] mod test; diff --git a/contracts/refund-vault/src/oracle.rs b/contracts/refund-vault/src/oracle.rs new file mode 100644 index 00000000..909b8d71 --- /dev/null +++ b/contracts/refund-vault/src/oracle.rs @@ -0,0 +1,178 @@ +//! Oracle integration for dynamic, SLA-based refund policies. +//! +//! Static policies (the refund window measured from `paid_at_ledger`) cannot +//! express refunds that depend on external facts — a network outage, an asset +//! price drop, a QoS breach. This module adds that capability without trusting +//! a single centralized price feed: +//! +//! - [`Oracle`] is the standard, pluggable interface any price/data oracle +//! contract implements. The merchant whitelists oracle contracts via +//! `RefundVault::add_oracle`. +//! - [`median_price`] is the aggregator: it queries **every** whitelisted +//! oracle for the same `feed_id`, drops oracles whose value is older than +//! the configured staleness bound, and returns the **median** of the +//! remaining values. The median is robust to a single compromised or broken +//! provider: to move the aggregated price an attacker must control a +//! majority of the whitelist, not just one member. +//! - [`evaluate_policy`] feeds that median into an [`OraclePolicy`] condition, +//! which the vault's `refund` path evaluates before any payout. +//! +//! # Trust and failure model +//! +//! The whitelist is maintained under merchant auth, so a *whitelisted* oracle +//! is a merchant-chosen counterparty (the same trust tier as the yield +//! strategy — see `docs/SECURITY_MODEL.md` §5). What the aggregator defends +//! against is any *single* whitelisted provider unilaterally moving the price: +//! the median neutralizes one outlier, and staleness filtering drops a +//! provider that stopped updating. The vault **fails closed**: if no oracle +//! is whitelisted, or every whitelisted oracle is stale, the policy cannot be +//! evaluated and `refund` rejects rather than guessing. +//! +//! A whitelisted oracle that *panics* during `get_price` aborts the whole +//! transaction (Soroban has no cross-contract catch), so a broken oracle +//! halts refunds rather than being silently skipped. That is intentional: +//! the merchant is expected to remove the broken oracle. + +use crate::{DataKey, Error}; +use soroban_sdk::{contractclient, contracttype, Address, BytesN, Env, Vec}; + +/// Standard interface for a price/data oracle that `RefundVault` can query. +/// +/// Any contract implementing these two methods can be whitelisted via +/// `RefundVault::add_oracle`. The aggregator calls both on every whitelisted +/// oracle for the same `feed_id` and takes the median of the fresh values. +/// +/// A `feed_id` is an opaque 32-byte identifier for the value being queried +/// (conventionally the SHA-256 of a canonical string such as `"XLM/USDC"`). +/// The reported value is in the feed's own fixed-point scale; the merchant +/// configures `OraclePolicy.threshold` in that same scale. +#[contractclient(name = "OracleClient")] +pub trait Oracle { + /// Latest value of the feed identified by `feed_id` (e.g. the price of + /// the base asset denominated in the quote asset, in the feed's scale). + fn get_price(env: Env, feed_id: BytesN<32>) -> i128; + + /// Ledger sequence at which the feed's value was last updated. The + /// aggregator uses this to drop stale oracles: a value older than the + /// policy's `max_staleness_ledgers` is excluded from the median. + fn get_last_update_ledger(env: Env, feed_id: BytesN<32>) -> u32; +} + +/// Dynamic, oracle-gated refund condition. +/// +/// When set via `RefundVault::set_oracle_policy`, `refund` only pays out +/// while the aggregated median of the whitelisted oracles for `feed_id` +/// satisfies the configured comparison. The condition is *strict*: with +/// `refund_when_below` the median must be `< threshold`; with +/// `refund_when_above` it must be `> threshold`. +/// +/// `max_staleness_ledgers == 0` disables the staleness check (mirroring how a +/// `0` refund window means "no time bound"); any other value excludes +/// oracles whose last update is older than that many ledgers from the median. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OraclePolicy { + /// The feed the policy evaluates (e.g. the hash of `"XLM/USDC"`). + pub feed_id: BytesN<32>, + /// The median value (in the feed's scale) at which the condition flips. + pub threshold: i128, + /// Maximum allowed age of a feed value in ledgers; `0` = never stale. + pub max_staleness_ledgers: u32, + /// Comparison direction. `true`: refunds are permitted while the median is + /// strictly **below** `threshold` (e.g. "refund when the asset price + /// drops"). `false`: refunds are permitted while the median is strictly + /// **above** `threshold` (e.g. "refund when the SLA metric exceeds its + /// ceiling"). + pub refund_when_below: bool, +} + +/// Aggregates the median of the fresh values reported by every whitelisted +/// oracle for `feed_id`. +/// +/// - No whitelist (or an empty one) → [`Error::NoOraclesConfigured`]. +/// - Every whitelisted oracle is stale → [`Error::StaleOracleData`]. +/// - Otherwise the median of the fresh values, computed over a tiny +/// insertion-sorted host `Vec` (the whitelist is small by design, so O(n²) +/// is cheaper than a general-purpose sort or a guest-heap allocation). +pub(crate) fn median_price( + env: &Env, + feed_id: &BytesN<32>, + max_staleness_ledgers: u32, +) -> Result { + let oracles: Vec
= env + .storage() + .instance() + .get(&DataKey::Oracles) + .ok_or(Error::NoOraclesConfigured)?; + if oracles.is_empty() { + return Err(Error::NoOraclesConfigured); + } + + let current_ledger = env.ledger().sequence(); + let mut prices: Vec = Vec::new(env); + for oracle in oracles.iter() { + let client = OracleClient::new(env, &oracle); + let last_update = client.get_last_update_ledger(feed_id); + // Stale = updated more than `max_staleness_ledgers` ledgers ago. + // `saturating_add` keeps a maliciously huge `last_update` from + // overflowing; such a value is never stale and simply stays eligible. + let stale = max_staleness_ledgers > 0 + && last_update.saturating_add(max_staleness_ledgers) < current_ledger; + if stale { + continue; + } + prices.push_back(client.get_price(feed_id)); + } + + if prices.is_empty() { + return Err(Error::StaleOracleData); + } + + Ok(median(&mut prices)) +} + +/// Evaluates an [`OraclePolicy`] against the aggregated median, returning +/// `true` when the condition holds (refunds permitted). +pub(crate) fn evaluate_policy(env: &Env, policy: &OraclePolicy) -> Result { + let median = median_price(env, &policy.feed_id, policy.max_staleness_ledgers)?; + if policy.refund_when_below { + Ok(median < policy.threshold) + } else { + Ok(median > policy.threshold) + } +} + +/// Median of a small collection via in-place insertion sort. +/// +/// The oracle whitelist is tiny (a handful of merchant-chosen contracts), so +/// the O(n²) insertion sort on the host-managed `Vec` avoids both a +/// general-purpose sort and any guest-heap allocation. Even-length inputs +/// average the two middle values: `lo + (hi - lo) / 2` stays overflow-safe +/// for the non-negative prices the sort guarantees `hi >= lo`. +fn median(prices: &mut Vec) -> i128 { + let n = prices.len(); + let mut i = 1u32; + while i < n { + let key = prices.get_unchecked(i); + let mut j = i; + while j > 0 { + let prev = prices.get_unchecked(j - 1); + if prev <= key { + break; + } + prices.set(j, prev); + j -= 1; + } + prices.set(j, key); + i += 1; + } + + let mid = n / 2; + if n % 2 == 1 { + prices.get_unchecked(mid) + } else { + let hi = prices.get_unchecked(mid); + let lo = prices.get_unchecked(mid - 1); + lo + (hi - lo) / 2 + } +} diff --git a/contracts/refund-vault/src/oracle_tests.rs b/contracts/refund-vault/src/oracle_tests.rs new file mode 100644 index 00000000..e157f54e --- /dev/null +++ b/contracts/refund-vault/src/oracle_tests.rs @@ -0,0 +1,756 @@ +#![cfg(test)] +#![allow(unused_imports, unused_variables, dead_code)] + +//! Tests for the oracle aggregator and the dynamic (oracle-gated) refund +//! policy engine. +//! +//! A `MockOracle` contract stands in for a real price feed: the tests set +//! prices per feed, advance the ledger, and then exercise the vault's +//! whitelist, median aggregation, staleness filtering and policy gating. + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, + testutils::{Address as _, Events, Ledger}, + token::{StellarAssetClient, TokenClient}, + vec, Address, BytesN, Env, IntoVal, Map, Symbol, Val, Vec, +}; + +use crate::{oracle::OraclePolicy, Error, RefundParam, RefundVault, RefundVaultClient}; + +// ── Mock oracle contract ─────────────────────────────────────────────────── + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum MockOracleError { + Unauthorized = 1, + FeedNotFound = 2, +} + +#[contracttype] +pub enum MockOracleDataKey { + Admin, + Price(BytesN<32>), + LastUpdate(BytesN<32>), +} + +/// A trivially simple oracle for tests: the admin sets a price per feed, and +/// the last-update ledger is recorded as the current ledger at set time. +/// Implements the same two methods the vault's [`OracleClient`] expects. +#[contract] +pub struct MockOracle; + +#[contractimpl] +impl MockOracle { + pub fn initialize(env: Env, admin: Address) { + env.storage() + .instance() + .set(&MockOracleDataKey::Admin, &admin); + } + + /// Set (or overwrite) the reported price for a feed. Records the current + /// ledger as the last-update time, so advancing the ledger after setting + /// makes the value age. + pub fn set_price(env: Env, feed_id: BytesN<32>, price: i128) -> Result<(), MockOracleError> { + let admin: Address = env + .storage() + .instance() + .get(&MockOracleDataKey::Admin) + .unwrap(); + admin.require_auth(); + + env.storage() + .instance() + .set(&MockOracleDataKey::Price(feed_id.clone()), &price); + env.storage().instance().set( + &MockOracleDataKey::LastUpdate(feed_id), + &env.ledger().sequence(), + ); + Ok(()) + } + + pub fn get_price(env: Env, feed_id: BytesN<32>) -> i128 { + env.storage() + .instance() + .get(&MockOracleDataKey::Price(feed_id)) + .unwrap_or(0) + } + + pub fn get_last_update_ledger(env: Env, feed_id: BytesN<32>) -> u32 { + env.storage() + .instance() + .get(&MockOracleDataKey::LastUpdate(feed_id)) + .unwrap_or(0) + } +} + +// ── Test helpers ─────────────────────────────────────────────────────────── + +const FLOAT: i128 = 10_000_000; + +fn setup() -> (Env, RefundVaultClient<'static>, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let merchant = Address::generate(&env); + let token_admin = Address::generate(&env); + let sac = env.register_stellar_asset_contract_v2(token_admin); + let token = sac.address(); + StellarAssetClient::new(&env, &token).mint(&merchant, &FLOAT); + + let vault_id = env.register(RefundVault, ()); + let vault_client = RefundVaultClient::new(&env, &vault_id); + vault_client.initialize(&merchant, &token, &17_280); + + (env, vault_client, merchant, token) +} + +/// Deploys a mock oracle whose admin is the merchant, so `set_price` mimics a +/// merchant-operated feed during tests. +fn deploy_oracle(env: &Env, merchant: &Address) -> Address { + let oracle_id = env.register(MockOracle, ()); + MockOracleClient::new(env, &oracle_id).initialize(merchant); + oracle_id +} + +fn feed(env: &Env) -> BytesN<32> { + BytesN::from_array(env, &[0xAAu8; 32]) +} + +fn set_feed_price(env: &Env, oracle: &Address, feed_id: &BytesN<32>, price: &i128) { + MockOracleClient::new(env, oracle).set_price(feed_id, price); +} + +// ── Whitelist management ─────────────────────────────────────────────────── + +#[test] +fn test_add_oracle_whitelists_and_reads() { + let (env, vault_client, merchant, _token) = setup(); + let oracle = deploy_oracle(&env, &merchant); + + vault_client.add_oracle(&oracle); + + assert_eq!(vault_client.get_oracles(), vec![&env, oracle.clone()]); + assert_eq!( + vault_client.try_add_oracle(&oracle), + Err(Ok(Error::OracleAlreadyAdded)) + ); +} + +#[test] +fn test_add_oracle_uninitialized_fails() { + let env = Env::default(); + env.mock_all_auths(); + let vault_id = env.register(RefundVault, ()); + let vault_client = RefundVaultClient::new(&env, &vault_id); + let oracle = Address::generate(&env); + + assert_eq!( + vault_client.try_add_oracle(&oracle), + Err(Ok(Error::NotInitialized)) + ); +} + +#[test] +#[should_panic] +fn test_add_oracle_requires_auth() { + let (env, vault_client, _merchant, _token) = setup(); + env.set_auths(&[]); + let oracle = Address::generate(&env); + vault_client.add_oracle(&oracle); +} + +#[test] +fn test_remove_oracle_works() { + let (env, vault_client, merchant, _token) = setup(); + let oracle = deploy_oracle(&env, &merchant); + vault_client.add_oracle(&oracle); + + vault_client.remove_oracle(&oracle); + assert_eq!(vault_client.get_oracles().len(), 0); +} + +#[test] +fn test_remove_missing_oracle_fails() { + let (env, vault_client, merchant, _token) = setup(); + let oracle = deploy_oracle(&env, &merchant); + vault_client.add_oracle(&oracle); + + let stranger = Address::generate(&env); + assert_eq!( + vault_client.try_remove_oracle(&stranger), + Err(Ok(Error::OracleNotFound)) + ); +} + +#[test] +fn test_remove_oracle_with_empty_whitelist_fails() { + let (env, vault_client, _merchant, _token) = setup(); + let oracle = Address::generate(&env); + + assert_eq!( + vault_client.try_remove_oracle(&oracle), + Err(Ok(Error::NoOraclesConfigured)) + ); +} + +// ── Median aggregation ───────────────────────────────────────────────────── + +#[test] +fn test_median_without_oracles_fails() { + let (env, vault_client, _merchant, _token) = setup(); + assert_eq!( + vault_client.try_get_median_price(&feed(&env), &0), + Err(Ok(Error::NoOraclesConfigured)) + ); +} + +#[test] +fn test_median_single_oracle() { + let (env, vault_client, merchant, _token) = setup(); + let oracle = deploy_oracle(&env, &merchant); + set_feed_price(&env, &oracle, &feed(&env), &100); + vault_client.add_oracle(&oracle); + + assert_eq!(vault_client.get_median_price(&feed(&env), &0), 100); +} + +#[test] +fn test_median_of_odd_number_of_oracles() { + let (env, vault_client, merchant, _token) = setup(); + let feed_id = feed(&env); + + let a = deploy_oracle(&env, &merchant); + let b = deploy_oracle(&env, &merchant); + let c = deploy_oracle(&env, &merchant); + set_feed_price(&env, &a, &feed_id, &300); + set_feed_price(&env, &b, &feed_id, &100); + set_feed_price(&env, &c, &feed_id, &200); + + // Added in a different order than the prices, to prove order of the + // whitelist (and of the reported values) does not matter. + vault_client.add_oracle(&a); + vault_client.add_oracle(&c); + vault_client.add_oracle(&b); + + assert_eq!(vault_client.get_median_price(&feed_id, &0), 200); +} + +#[test] +fn test_median_of_even_number_of_oracles_averages_middle() { + let (env, vault_client, merchant, _token) = setup(); + let feed_id = feed(&env); + + let a = deploy_oracle(&env, &merchant); + let b = deploy_oracle(&env, &merchant); + let c = deploy_oracle(&env, &merchant); + let d = deploy_oracle(&env, &merchant); + set_feed_price(&env, &a, &feed_id, &400); + set_feed_price(&env, &b, &feed_id, &100); + set_feed_price(&env, &c, &feed_id, &200); + set_feed_price(&env, &d, &feed_id, &300); + vault_client.add_oracle(&a); + vault_client.add_oracle(&b); + vault_client.add_oracle(&c); + vault_client.add_oracle(&d); + + // (200 + 300) / 2 + assert_eq!(vault_client.get_median_price(&feed_id, &0), 250); +} + +/// The whole point of median aggregation: one wildly wrong provider cannot +/// move the price. An oracle reporting 100_000 gets neutralised by three +/// honest providers reporting ~250. +#[test] +fn test_median_ignores_single_extreme_outlier() { + let (env, vault_client, merchant, _token) = setup(); + let feed_id = feed(&env); + + let honest_a = deploy_oracle(&env, &merchant); + let honest_b = deploy_oracle(&env, &merchant); + let honest_c = deploy_oracle(&env, &merchant); + let outlier = deploy_oracle(&env, &merchant); + set_feed_price(&env, &honest_a, &feed_id, &250); + set_feed_price(&env, &honest_b, &feed_id, &260); + set_feed_price(&env, &honest_c, &feed_id, &270); + set_feed_price(&env, &outlier, &feed_id, &100_000); + vault_client.add_oracle(&honest_a); + vault_client.add_oracle(&honest_b); + vault_client.add_oracle(&honest_c); + vault_client.add_oracle(&outlier); + + // 4 values: median is the average of the two middles, (260 + 270) / 2. + // The 100_000 outlier is completely neutralised. + assert_eq!(vault_client.get_median_price(&feed_id, &0), 265); +} + +#[test] +fn test_remove_oracle_updates_median() { + let (env, vault_client, merchant, _token) = setup(); + let feed_id = feed(&env); + + let a = deploy_oracle(&env, &merchant); + let b = deploy_oracle(&env, &merchant); + let c = deploy_oracle(&env, &merchant); + set_feed_price(&env, &a, &feed_id, &100); + set_feed_price(&env, &b, &feed_id, &200); + set_feed_price(&env, &c, &feed_id, &300); + vault_client.add_oracle(&a); + vault_client.add_oracle(&b); + vault_client.add_oracle(&c); + + assert_eq!(vault_client.get_median_price(&feed_id, &0), 200); + + vault_client.remove_oracle(&b); + // [100, 300] -> (100 + 300) / 2 + assert_eq!(vault_client.get_median_price(&feed_id, &0), 200); + + vault_client.remove_oracle(&a); + // [300] + assert_eq!(vault_client.get_median_price(&feed_id, &0), 300); +} + +// ── Staleness filtering ──────────────────────────────────────────────────── + +#[test] +fn test_stale_oracle_excluded_from_median() { + let (env, vault_client, merchant, _token) = setup(); + let feed_id = feed(&env); + + let fresh = deploy_oracle(&env, &merchant); + let stale = deploy_oracle(&env, &merchant); + + // Both set at ledger 100. + env.ledger().with_mut(|li| li.sequence_number = 100); + set_feed_price(&env, &fresh, &feed_id, &50); + set_feed_price(&env, &stale, &feed_id, &250); + + // Only the fresh oracle updates at ledger 200. + env.ledger().with_mut(|li| li.sequence_number = 200); + set_feed_price(&env, &fresh, &feed_id, &100); + + vault_client.add_oracle(&fresh); + vault_client.add_oracle(&stale); + + // max_staleness = 50: fresh is 0 ledgers old, stale is 100 old -> excluded. + assert_eq!(vault_client.get_median_price(&feed_id, &50), 100); + + // max_staleness = 150: both are fresh enough -> median of (100, 250). + assert_eq!(vault_client.get_median_price(&feed_id, &150), 175); +} + +#[test] +fn test_all_oracles_stale_fails() { + let (env, vault_client, merchant, _token) = setup(); + let feed_id = feed(&env); + + env.ledger().with_mut(|li| li.sequence_number = 100); + let a = deploy_oracle(&env, &merchant); + let b = deploy_oracle(&env, &merchant); + set_feed_price(&env, &a, &feed_id, &100); + set_feed_price(&env, &b, &feed_id, &200); + + // Current ledger is now far past both last-updates. + env.ledger().with_mut(|li| li.sequence_number = 500); + + vault_client.add_oracle(&a); + vault_client.add_oracle(&b); + + assert_eq!( + vault_client.try_get_median_price(&feed_id, &10), + Err(Ok(Error::StaleOracleData)) + ); +} + +/// `max_staleness = 0` disables freshness filtering entirely, mirroring how a +/// `0` refund window means "no time bound". +#[test] +fn test_zero_staleness_disables_filtering() { + let (env, vault_client, merchant, _token) = setup(); + let feed_id = feed(&env); + + env.ledger().with_mut(|li| li.sequence_number = 100); + let oracle = deploy_oracle(&env, &merchant); + set_feed_price(&env, &oracle, &feed_id, &300); + + env.ledger().with_mut(|li| li.sequence_number = 10_000); + vault_client.add_oracle(&oracle); + + assert_eq!(vault_client.get_median_price(&feed_id, &0), 300); +} + +// ── Oracle policy management ─────────────────────────────────────────────── + +#[test] +fn test_set_and_get_oracle_policy_roundtrip() { + let (env, vault_client, _merchant, _token) = setup(); + let feed_id = feed(&env); + + let policy = OraclePolicy { + feed_id: feed_id.clone(), + threshold: 250, + max_staleness_ledgers: 100, + refund_when_below: true, + }; + vault_client.set_oracle_policy(&policy); + + assert_eq!(vault_client.get_oracle_policy(), Some(policy)); +} + +#[test] +fn test_clear_oracle_policy() { + let (env, vault_client, _merchant, _token) = setup(); + let feed_id = feed(&env); + + let policy = OraclePolicy { + feed_id: feed_id.clone(), + threshold: 250, + max_staleness_ledgers: 0, + refund_when_below: true, + }; + vault_client.set_oracle_policy(&policy); + vault_client.clear_oracle_policy(); + + assert_eq!(vault_client.get_oracle_policy(), None); + assert_eq!( + vault_client.try_clear_oracle_policy(), + Err(Ok(Error::NoOraclePolicy)) + ); +} + +#[test] +#[should_panic] +fn test_set_oracle_policy_requires_auth() { + let (env, vault_client, _merchant, _token) = setup(); + env.set_auths(&[]); + + vault_client.set_oracle_policy(&OraclePolicy { + feed_id: feed(&env), + threshold: 250, + max_staleness_ledgers: 0, + refund_when_below: true, + }); +} + +#[test] +#[should_panic] +fn test_clear_oracle_policy_requires_auth() { + let (env, vault_client, _merchant, _token) = setup(); + env.set_auths(&[]); + vault_client.clear_oracle_policy(); +} + +// ── Policy-gated refunds ─────────────────────────────────────────────────── + +fn deposit_and_buyer( + env: &Env, + vault_client: &RefundVaultClient<'static>, + merchant: &Address, +) -> (BytesN<32>, Address) { + vault_client.deposit(merchant, &1_000_000); + let payment_ref = BytesN::from_array(env, &[0xBBu8; 32]); + let buyer = Address::generate(env); + (payment_ref, buyer) +} + +/// The headline SLA case: "refund buyers while the asset price is below the +/// floor". While the price is above the threshold the refund is denied with +/// `OraclePolicyDenied`; once the price drops, the same refund succeeds. +#[test] +fn test_refund_gated_by_price_drop_policy() { + let (env, vault_client, merchant, _token) = setup(); + let feed_id = feed(&env); + + let oracle = deploy_oracle(&env, &merchant); + vault_client.add_oracle(&oracle); + set_feed_price(&env, &oracle, &feed_id, &300); + + let policy = OraclePolicy { + feed_id: feed_id.clone(), + threshold: 250, + max_staleness_ledgers: 0, + refund_when_below: true, + }; + vault_client.set_oracle_policy(&policy); + + let (payment_ref, buyer) = deposit_and_buyer(&env, &vault_client, &merchant); + + // Price 300 >= 250: condition not met, refund denied and nothing recorded. + assert_eq!( + vault_client.try_refund(&payment_ref, &buyer, &100_000, &0, &100_000), + Err(Ok(Error::OraclePolicyDenied)) + ); + assert!(vault_client.get_refund(&payment_ref).is_none()); + + // The price drops below the floor: the same refund now succeeds. + set_feed_price(&env, &oracle, &feed_id, &200); + vault_client.refund(&payment_ref, &buyer, &100_000, &0, &100_000); + + let record = vault_client.get_refund(&payment_ref).unwrap(); + assert_eq!(record.amount_refunded, 100_000); + assert_eq!(TokenClient::new(&env, &_token).balance(&buyer), 100_000); +} + +/// The mirror-image policy: refunds only while the metric is *above* its +/// ceiling (e.g. "refund when network downtime exceeds the SLA allowance"). +#[test] +fn test_refund_gated_by_rise_policy() { + let (env, vault_client, merchant, _token) = setup(); + let feed_id = feed(&env); + + let oracle = deploy_oracle(&env, &merchant); + vault_client.add_oracle(&oracle); + set_feed_price(&env, &oracle, &feed_id, &100); + + let policy = OraclePolicy { + feed_id: feed_id.clone(), + threshold: 250, + max_staleness_ledgers: 0, + refund_when_below: false, + }; + vault_client.set_oracle_policy(&policy); + + let (payment_ref, buyer) = deposit_and_buyer(&env, &vault_client, &merchant); + + // Metric 100 <= 250: condition not met (refunds only above the ceiling). + assert_eq!( + vault_client.try_refund(&payment_ref, &buyer, &100_000, &0, &100_000), + Err(Ok(Error::OraclePolicyDenied)) + ); + + set_feed_price(&env, &oracle, &feed_id, &300); + vault_client.refund(&payment_ref, &buyer, &100_000, &0, &100_000); + assert!(vault_client.get_refund(&payment_ref).is_some()); +} + +/// A policy at exactly the threshold is a strict comparison in both +/// directions: `refund_when_below` requires `<`, not `<=`. +#[test] +fn test_policy_comparisons_are_strict() { + let (env, vault_client, merchant, _token) = setup(); + let feed_id = feed(&env); + + let oracle = deploy_oracle(&env, &merchant); + vault_client.add_oracle(&oracle); + set_feed_price(&env, &oracle, &feed_id, &250); + + vault_client.set_oracle_policy(&OraclePolicy { + feed_id: feed_id.clone(), + threshold: 250, + max_staleness_ledgers: 0, + refund_when_below: true, + }); + + let (payment_ref, buyer) = deposit_and_buyer(&env, &vault_client, &merchant); + // 250 is not < 250. + assert_eq!( + vault_client.try_refund(&payment_ref, &buyer, &100_000, &0, &100_000), + Err(Ok(Error::OraclePolicyDenied)) + ); +} + +/// No policy installed -> the oracle whitelist has no effect on refunds +/// (existing behaviour is preserved when the feature is unused). +#[test] +fn test_no_policy_means_no_gating() { + let (env, vault_client, merchant, _token) = setup(); + let feed_id = feed(&env); + + let oracle = deploy_oracle(&env, &merchant); + vault_client.add_oracle(&oracle); + set_feed_price(&env, &oracle, &feed_id, &1); + + let (payment_ref, buyer) = deposit_and_buyer(&env, &vault_client, &merchant); + vault_client.refund(&payment_ref, &buyer, &100_000, &0, &100_000); + assert!(vault_client.get_refund(&payment_ref).is_some()); +} + +/// A policy with no whitelisted oracles fails closed: the vault refuses to +/// guess at a price it cannot read. +#[test] +fn test_policy_without_oracles_fails_closed() { + let (env, vault_client, merchant, _token) = setup(); + let feed_id = feed(&env); + + vault_client.set_oracle_policy(&OraclePolicy { + feed_id: feed_id.clone(), + threshold: 250, + max_staleness_ledgers: 0, + refund_when_below: true, + }); + + let (payment_ref, buyer) = deposit_and_buyer(&env, &vault_client, &merchant); + assert_eq!( + vault_client.try_refund(&payment_ref, &buyer, &100_000, &0, &100_000), + Err(Ok(Error::NoOraclesConfigured)) + ); +} + +/// A policy whose every whitelisted oracle is stale also fails closed. +#[test] +fn test_policy_with_all_stale_oracles_fails_closed() { + let (env, vault_client, merchant, _token) = setup(); + let feed_id = feed(&env); + + env.ledger().with_mut(|li| li.sequence_number = 100); + let oracle = deploy_oracle(&env, &merchant); + set_feed_price(&env, &oracle, &feed_id, &100); + env.ledger().with_mut(|li| li.sequence_number = 500); + vault_client.add_oracle(&oracle); + + vault_client.set_oracle_policy(&OraclePolicy { + feed_id: feed_id.clone(), + threshold: 250, + max_staleness_ledgers: 10, + refund_when_below: true, + }); + + let (payment_ref, buyer) = deposit_and_buyer(&env, &vault_client, &merchant); + assert_eq!( + vault_client.try_refund(&payment_ref, &buyer, &100_000, &0, &100_000), + Err(Ok(Error::StaleOracleData)) + ); +} + +/// Clearing the policy restores unconditional (window-only) refunds. +#[test] +fn test_clearing_policy_disables_gating() { + let (env, vault_client, merchant, _token) = setup(); + let feed_id = feed(&env); + + let oracle = deploy_oracle(&env, &merchant); + vault_client.add_oracle(&oracle); + set_feed_price(&env, &oracle, &feed_id, &300); + + vault_client.set_oracle_policy(&OraclePolicy { + feed_id: feed_id.clone(), + threshold: 250, + max_staleness_ledgers: 0, + refund_when_below: true, + }); + + let (payment_ref, buyer) = deposit_and_buyer(&env, &vault_client, &merchant); + assert_eq!( + vault_client.try_refund(&payment_ref, &buyer, &100_000, &0, &100_000), + Err(Ok(Error::OraclePolicyDenied)) + ); + + vault_client.clear_oracle_policy(); + vault_client.refund(&payment_ref, &buyer, &100_000, &0, &100_000); + assert!(vault_client.get_refund(&payment_ref).is_some()); +} + +/// `process_batch` inherits the gate: items denied by the policy come back +/// `false` in the per-item result vector, and succeed once the condition +/// holds. This proves the dynamic policy is enforced on the batched path, +/// not just the single `refund` entry point. +#[test] +fn test_process_batch_respects_oracle_policy() { + let (env, vault_client, merchant, _token) = setup(); + let feed_id = feed(&env); + + let oracle = deploy_oracle(&env, &merchant); + vault_client.add_oracle(&oracle); + set_feed_price(&env, &oracle, &feed_id, &300); + + vault_client.set_oracle_policy(&OraclePolicy { + feed_id: feed_id.clone(), + threshold: 250, + max_staleness_ledgers: 0, + refund_when_below: true, + }); + vault_client.deposit(&merchant, &1_000_000); + + let buyer1 = Address::generate(&env); + let buyer2 = Address::generate(&env); + let p1 = RefundParam { + payment_ref: BytesN::from_array(&env, &[0x11u8; 32]), + recipient: buyer1.clone(), + amount: 100_000, + paid_at_ledger: 0, + payment_amount: 100_000, + }; + let p2 = RefundParam { + payment_ref: BytesN::from_array(&env, &[0x22u8; 32]), + recipient: buyer2.clone(), + amount: 200_000, + paid_at_ledger: 0, + payment_amount: 200_000, + }; + let batch = vec![&env, p1.clone(), p2.clone()]; + + // Price 300 >= 250: every item denied, nothing recorded, no payouts. + assert_eq!(vault_client.process_batch(&batch), vec![&env, false, false]); + assert!(vault_client.get_refund(&p1.payment_ref).is_none()); + assert!(vault_client.get_refund(&p2.payment_ref).is_none()); + + // Price drops: the identical batch now succeeds end to end. + set_feed_price(&env, &oracle, &feed_id, &200); + assert_eq!(vault_client.process_batch(&batch), vec![&env, true, true]); + assert!(vault_client.get_refund(&p1.payment_ref).is_some()); + assert!(vault_client.get_refund(&p2.payment_ref).is_some()); +} + +// ── Events ───────────────────────────────────────────────────────────────── + +#[test] +fn test_oracle_policy_events_emitted() { + let (env, vault_client, _merchant, _token) = setup(); + let feed_id = feed(&env); + + let policy = OraclePolicy { + feed_id: feed_id.clone(), + threshold: 250, + max_staleness_ledgers: 100, + refund_when_below: true, + }; + vault_client.set_oracle_policy(&policy); + + let mut data = Map::::new(&env); + data.set( + Symbol::new(&env, "threshold").into_val(&env), + 250i128.into_val(&env), + ); + data.set( + Symbol::new(&env, "refund_when_below").into_val(&env), + true.into_val(&env), + ); + data.set( + Symbol::new(&env, "max_staleness_ledgers").into_val(&env), + 100u32.into_val(&env), + ); + + assert_eq!( + env.events().all().filter_by_contract(&vault_client.address), + vec![ + &env, + ( + vault_client.address.clone(), + ( + Symbol::new(&env, "oracle_policy_set_event"), + feed_id.clone() + ) + .into_val(&env), + data.into_val(&env) + ) + ] + ); + + vault_client.clear_oracle_policy(); + + let empty_data: Map = Map::new(&env); + assert_eq!( + env.events().all().filter_by_contract(&vault_client.address), + vec![ + &env, + ( + vault_client.address.clone(), + ( + Symbol::new(&env, "oracle_policy_cleared_event"), + feed_id.clone() + ) + .into_val(&env), + empty_data.into_val(&env) + ) + ] + ); +} diff --git a/contracts/refund-vault/src/test.rs b/contracts/refund-vault/src/test.rs index 9b04b4b5..92696321 100644 --- a/contracts/refund-vault/src/test.rs +++ b/contracts/refund-vault/src/test.rs @@ -2,7 +2,7 @@ use super::*; use soroban_sdk::{ - testutils::{storage::Persistent as _, Address as _, Ledger}, + testutils::{storage::Persistent as _, Address as _, Events, Ledger}, token::{StellarAssetClient, TokenClient}, vec, Address, Env, Val, }; diff --git a/docs/EVENTS.md b/docs/EVENTS.md index cb47ba7a..9d6ee2ee 100644 --- a/docs/EVENTS.md +++ b/docs/EVENTS.md @@ -106,26 +106,25 @@ Emitted when the merchant changes the refund window. Both values are carried so a reader can tell whether a refund rejected at a given ledger was rejected under the old rule or the new one. -### 9. `PolicyProposedEvent` -Emitted when the merchant proposes a new refund policy (window and deadline). The change is not applied until the matching `PolicyExecutedEvent`. +### 9. `OraclePolicySetEvent` +Emitted when the merchant installs (or replaces) the dynamic oracle policy +that gates refunds. -- **Topics**: `("policy_proposed_event", window: u32)` +- **Topics**: `("oracle_policy_set_event", feed_id: BytesN<32>)` - **Data Map**: - - `deadline` (`u64`): The wall-clock deadline (Unix timestamp) after which refund claims are rejected; `0` disables the deadline. - - `proposed_at_ledger` (`u32`): The ledger sequence when the proposal was made. - - `execute_after_ledger` (`u32`): The earliest ledger at which `execute_policy` may succeed (proposal + timelock). + - `threshold` (`i128`): The median value (in the feed's scale) at which the condition flips. + - `refund_when_below` (`bool`): `true` = refunds allowed while the median is strictly below the threshold; `false` = allowed while strictly above. + - `max_staleness_ledgers` (`u32`): Maximum allowed age of a feed value; `0` = never stale. -### 10. `PolicyExecutedEvent` -Emitted when the merchant executes a pending policy change after the timelock. +The data map carries the full condition, so an indexer can reconstruct the +policy in force from the event log alone. -- **Topics**: `("policy_executed_event", window: u32)` -- **Data Map**: - - `deadline` (`u64`): The wall-clock deadline (Unix timestamp) now in force; `0` means no deadline. +### 10. `OraclePolicyClearedEvent` +Emitted when the merchant removes the dynamic oracle policy, restoring purely +time-window-based refunds. -### 11. `FeeConfigUpdatedEvent` -Emitted when the merchant changes the refund fee configuration (the basis-point rate or the recipient address). Each emission carries the **full effective configuration** — including the current value of the other field — keyed by the field that changed. +- **Topics**: `("oracle_policy_cleared_event", feed_id: BytesN<32>)` +- **Data Map**: *(empty)* -- **Topics**: `("fee_config_updated_event", field: Symbol)` where `field` is `"fee_bps"` (rate changed) or `"fee_recipient"` (recipient changed). -- **Data Map**: - - `fee_bps` (`u32`): The fee rate in basis points in force after the change; `0` means no fee. - - `fee_recipient` (`Address`): The effective fee recipient, resolved via the merchant fallback when none is configured. +The `feed_id` is the feed of the policy that was in force, captured before it +was removed, so a reader can correlate the clear with the preceding set event. diff --git a/docs/SECURITY_MODEL.md b/docs/SECURITY_MODEL.md index 554919da..e26ba577 100644 --- a/docs/SECURITY_MODEL.md +++ b/docs/SECURITY_MODEL.md @@ -80,6 +80,40 @@ trusted to that strategy's contract. The vault enforces reserve and deployment ratios but cannot enforce strategy solvency, and the strategy is a potential re-entrancy surface. See [AUDIT.md](AUDIT.md) §2 and §5 for the full treatment. +### 6. The Oracle Aggregator (optional) +The `RefundVault` oracle integration (for dynamic, SLA-based refund policies) +adds one more trust assumption: the **median** of the values reported by the +merchant-whitelisted oracles is treated as ground truth for the configured +feed. The design deliberately avoids trusting any single provider: + +- The whitelist is merchant-maintained (`add_oracle` / `remove_oracle`), + same trust tier as the yield strategy — a whitelisted oracle is a + merchant-chosen counterparty, and a *compromised* one can only contribute + one value to the aggregate. +- The aggregator queries **every** whitelisted oracle and takes the median + of the fresh values, so moving the aggregated price requires controlling a + majority of the whitelist, not one member. A single wildly-wrong value + (e.g. a buggy or exploited provider) is neutralised. +- **Staleness filtering**: a value older than the policy's + `max_staleness_ledgers` is excluded from the median, so a provider that + stopped updating cannot hold the aggregate hostage at an old price. +- **Fail closed**: with no whitelist, or with every whitelisted oracle stale, + the policy cannot be evaluated and `refund` rejects (`NoOraclesConfigured` + / `StaleOracleData`) instead of guessing. A refund whose condition is not + met is rejected with `OraclePolicyDenied`. +- **No catch for a panicking oracle**: a whitelisted oracle that aborts + during `get_price` aborts the whole transaction (Soroban has no + cross-contract catch). This is deliberate fail-closed behaviour — the + merchant must remove the broken oracle. +- The oracle queries run inside `refund`'s reentrancy lock (the policy check + sits in `refund_internal`, which `refund` reaches with the guard held), so + a whitelisted oracle cannot re-enter the vault from its `get_price` + callback. + +The `OraclePolicy` (feed, threshold, staleness bound, comparison direction) +is merchant-configured and can be cleared at any time to restore purely +window-based refunds. + ## Attack Vectors and Mitigations ### Replay Attacks diff --git a/docs/contracts.mdx b/docs/contracts.mdx index c95552a0..b8e10dd1 100644 --- a/docs/contracts.mdx +++ b/docs/contracts.mdx @@ -254,3 +254,62 @@ Looks up the refund record for a payment reference, if one exists. ```rust fn get_refund(env: Env, payment_ref: BytesN<32>) -> Option ``` + +### `add_oracle` +Whitelists an oracle contract implementing the standard `Oracle` interface (`get_price(feed_id)` and `get_last_update_ledger(feed_id)`). Merchant auth required. The aggregator queries every whitelisted oracle and takes the median of the fresh values, so a single provider cannot unilaterally move the aggregated price. + +```rust +fn add_oracle(env: Env, oracle: Address) -> Result<(), Error> +``` +- **Authentication:** Requires the merchant's signature. +- **Errors:** `OracleAlreadyAdded` if the oracle is already whitelisted. + +### `remove_oracle` +Removes an oracle from the whitelist. Merchant auth required. + +```rust +fn remove_oracle(env: Env, oracle: Address) -> Result<(), Error> +``` +- **Authentication:** Requires the merchant's signature. +- **Errors:** `OracleNotFound` if the oracle is not whitelisted, `NoOraclesConfigured` if the whitelist is empty. + +### `get_oracles` +Returns the current oracle whitelist, in insertion order (read-only). + +```rust +fn get_oracles(env: Env) -> Vec
+``` + +### `get_median_price` +Queries every whitelisted oracle for `feed_id` and returns the median of the values fresher than `max_staleness_ledgers` (`0` = never stale). Read-only. + +```rust +fn get_median_price(env: Env, feed_id: BytesN<32>, max_staleness_ledgers: u32) -> Result +``` +- **Errors:** `NoOraclesConfigured` if the whitelist is empty, `StaleOracleData` if every whitelisted oracle is stale. + +### `set_oracle_policy` +Installs (or replaces) the dynamic oracle policy gating refunds: while a policy is set, `refund` and `process_batch` only pay out when the aggregated median satisfies the condition (e.g. the asset price is below the SLA floor). Merchant auth required. + +```rust +fn set_oracle_policy(env: Env, policy: OraclePolicy) -> Result<(), Error> +``` +- **Authentication:** Requires the merchant's signature. +- **Emits:** `oracle_policy_set_event` carrying the feed and the full condition. + +### `clear_oracle_policy` +Removes the dynamic oracle policy, restoring purely time-window-based refunds. Merchant auth required. + +```rust +fn clear_oracle_policy(env: Env) -> Result<(), Error> +``` +- **Authentication:** Requires the merchant's signature. +- **Errors:** `NoOraclePolicy` if no policy is installed. +- **Emits:** `oracle_policy_cleared_event` carrying the feed that was cleared. + +### `get_oracle_policy` +Returns the currently installed oracle policy, if any (read-only). + +```rust +fn get_oracle_policy(env: Env) -> Option +```