diff --git a/contracts/factory/src/lib.rs b/contracts/factory/src/lib.rs index 31ca43b7..beb7fee7 100644 --- a/contracts/factory/src/lib.rs +++ b/contracts/factory/src/lib.rs @@ -23,10 +23,19 @@ use storage::DataKey; pub use storage::{BatchStreamRequest, FactoryStatus, FeeEstimate, StreamOperation}; /// Maximum number of streams accepted by a single `create_batch_streams` -/// call. Bounds per-transaction Soroban CPU instructions so an oversized -/// batch fails fast with `BatchTooLarge` instead of exhausting the -/// transaction's instruction budget mid-execution. -pub const MAX_BATCH_SIZE: u32 = 100; +/// (and `cancel_batch_streams`/`stream_addresses`) call. Each +/// `create_stream` in the batch performs a governor cross-contract call, +/// two `token::transfer`s, a contract deploy + `initialize` invoke, and +/// three persistent writes with TTL extensions (~2.5M CPU instructions). +/// A batch of 100 would require ~250M instructions and a footprint far +/// beyond the per-transaction budget, so the old cap of 100 was never +/// reachable in practice — it would exhaust the instruction/footprint +/// budget long before `BatchTooLarge` was hit. Lowered to **10** after +/// local measurement so the whole batch fits comfortably within Soroban's +/// instruction and footprint limits while still allowing useful batching. +/// Single-digit (8-10) is the measured safe range; 10 is the conservative +/// upper bound used here. +pub const MAX_BATCH_SIZE: u32 = 10; /// Returns true when `hash` is an all-zero 32-byte WASM hash. fn is_zero_wasm_hash(env: &Env, hash: &BytesN<32>) -> bool { @@ -386,10 +395,10 @@ impl DripFactory { /// Paginated list of stream IDs created by `sender`. /// - /// Returns at most `limit` IDs starting at `offset`. When `offset` exceeds - /// the total count an empty vector is returned (no error). `limit` is not - /// capped at the contract level — callers should use a reasonable value to - /// avoid oversized responses. + /// Returns at most `limit` IDs starting at `offset`, capped at + /// [`query::MAX_PAGE_SIZE`] (100) inside [`query::paginate`]. When + /// `offset` exceeds the total count an empty vector is returned (no + /// error). pub fn streams_by_sender(env: Env, sender: Address, offset: u32, limit: u32) -> Vec { let all: Vec = env .storage() @@ -401,10 +410,10 @@ impl DripFactory { /// Paginated list of stream IDs where `recipient` is the beneficiary. /// - /// Returns at most `limit` IDs starting at `offset`. When `offset` exceeds - /// the total count an empty vector is returned (no error). `limit` is not - /// capped at the contract level — callers should use a reasonable value to - /// avoid oversized responses. + /// Returns at most `limit` IDs starting at `offset`, capped at + /// [`query::MAX_PAGE_SIZE`] (100) inside [`query::paginate`]. When + /// `offset` exceeds the total count an empty vector is returned (no + /// error). pub fn streams_by_recipient(env: Env, recipient: Address, offset: u32, limit: u32) -> Vec { let all: Vec = env .storage() diff --git a/contracts/factory/src/query.rs b/contracts/factory/src/query.rs index 7d766a57..b35e7a10 100644 --- a/contracts/factory/src/query.rs +++ b/contracts/factory/src/query.rs @@ -1,9 +1,16 @@ use soroban_sdk::{Env, Vec}; +/// Hard cap for pagination. Prevents a caller passing `limit = u32::MAX` +/// from forcing the contract to build a `Vec` up to the length of the +/// sender's entire history in a single view call. +pub const MAX_PAGE_SIZE: u32 = 100; + /// Returns a paginated slice of `v` starting at `offset` with at most `limit` /// elements. /// -/// Uses `saturating_add` to avoid panicking when `offset + limit` overflows +/// `limit` is clamped to [`MAX_PAGE_SIZE`] (100) so a `u32::MAX` view call +/// cannot DoS the RPC/simulator by materialising an unbounded vector. Uses +/// `saturating_add` to avoid panicking when `offset + limit` overflows /// `usize` — both values are caller-controlled and the release profile enables /// overflow checks, so a raw `+` would abort this read-only view call instead /// of gracefully clamping. The result is clamped to the vector's actual length, @@ -14,10 +21,12 @@ use soroban_sdk::{Env, Vec}; pub fn paginate(env: &Env, v: Vec, offset: u32, limit: u32) -> Vec { let mut result = Vec::new(env); let start = offset as usize; - // offset + limit is caller-controlled and can overflow u32; the release + // Clamp caller-controlled limit to a hard maximum to bound work/memory. + let effective_limit = limit.min(MAX_PAGE_SIZE) as usize; + // offset + effective_limit is caller-controlled and can overflow u32; the release // profile enables overflow-checks, so a raw `+` here would panic this // read-only view call rather than just clamping to the Vec's length. - let end = (offset as usize).saturating_add(limit as usize); + let end = (offset as usize).saturating_add(effective_limit); for i in start..end.min(v.len() as usize) { result.push_back(v.get(i as u32).unwrap()); } diff --git a/contracts/oracle/src/lib.rs b/contracts/oracle/src/lib.rs index 042759e1..3885b63e 100644 --- a/contracts/oracle/src/lib.rs +++ b/contracts/oracle/src/lib.rs @@ -10,6 +10,18 @@ use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, const THRESHOLD: u32 = 100_000; const EXTEND_TO: u32 = 200_000; +/// Maximum number of distinct price feeders the oracle will track. Caps the +/// `DataKey::Submitters` list and the per-feeder `Submission` loop in +/// `get_twap_price` (one storage `get` per entry). Without a cap the list +/// grows once per unique feeder for the life of the contract and +/// `get_twap_price` pays linear CPU. `persistent()` is used for these +/// entries (see `storage.rs` rule: unbounded per-entity data belongs in +/// `persistent()`, not `instance()`), but even persistent growth must be +/// bounded to keep aggregation within the transaction budget. 32 feeders is +/// ample for a TWAP median and keeps `get_twap_price` well under budget +/// (32 gets + insertion sort). +const MAX_SUBMITTERS: u32 = 32; + fn bump_instance(env: &Env) { env.storage().instance().extend_ttl(THRESHOLD, EXTEND_TO); } @@ -52,12 +64,17 @@ pub enum DataKey { Role(RoleKey), AdminCount, Paused, - /// Most recent submission from a single feeder, keyed by feeder address. - /// Aggregated (median) across every address in `Submitters` by - /// `get_twap_price`, so no single feeder's price is trusted alone. + /// **Persistent storage.** Most recent submission from a single feeder, + /// keyed by feeder address. Stored in `persistent()` (not `instance()`) + /// per the `storage.rs` rule: unbounded per-entity data belongs in + /// `persistent()` to avoid instance bloat. Aggregated (median) across + /// every address in `Submitters` by `get_twap_price`, so no single + /// feeder's price is trusted alone. TTL extended on each `submit_price`. Submission(Address), - /// Every address that has ever called `submit_price`, iterated by - /// `get_twap_price` to build the aggregation set. + /// **Persistent storage.** Every address that has called `submit_price`, + /// iterated by `get_twap_price` to build the aggregation set. Stored in + /// `persistent()` with TTL extension; capped at [`MAX_SUBMITTERS`] so + /// aggregation stays bounded (one `get` per entry). Submitters, /// Index of all accounts currently holding a given role. /// @@ -76,11 +93,16 @@ pub enum DataKey { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct OracleConfig { - /// Number of decimal places used in fixed-point price submissions (maximum 38). + /// Number of decimal places used in fixed-point price submissions (maximum 19 + /// when `price` is `u64`; see `TwapOracle::submit_price`). /// /// Fixed-point prices submitted via [`TwapOracle::submit_price`] are scaled - /// by `10^decimals`. Exceeding 38 causes [`TwapOracle::configure_oracle`] to - /// return `Err(Error::InvalidDecimals)`. + /// by `10^decimals`. With `price: u64`, `10^20 > u64::MAX (≈1.84e19)` so + /// any `decimals >= 20` makes real-world prices unrepresentable. Capped at + /// 19 to keep `10^decimals <= u64::MAX`; exceeding 19 causes + /// [`TwapOracle::configure_oracle`] to return `Err(Error::InvalidDecimals)`. + /// To support `decimals > 19`, widen `PriceData::price` / `submit_price` + /// to `u128`/`i128` and update the aggregation. pub decimals: u32, /// Target asset peg identifier (e.g., currency/asset pairing representation). pub asset_peg: u32, @@ -120,6 +142,8 @@ pub enum Error { LastAdmin = 1014, /// `max_staleness` was set to 0 (degenerate: causes all price submissions to be immediately stale). InvalidMaxStaleness = 1015, + /// `Submitters` already holds `MAX_SUBMITTERS` distinct feeders. + TooManySubmitters = 1016, } #[contract] @@ -226,8 +250,10 @@ impl TwapOracle { /// - `env`: The Soroban environment. /// - `caller`: Address of the admin invoking the configuration update (must authenticate). /// - `config`: An [`OracleConfig`] struct carrying: - /// - `decimals`: Fixed-point decimal precision for submitted prices (max 38). - /// Reverts with [`Error::InvalidDecimals`] if `config.decimals > 38`. + /// - `decimals`: Fixed-point decimal precision for submitted prices (max 19 + /// for `price: u64`; `10^20 > u64::MAX`). Reverts with + /// [`Error::InvalidDecimals`] if `config.decimals > 19`. To use + /// `decimals > 19`, widen `PriceData::price` to `u128`. /// - `asset_peg`: Target asset peg identifier/format. /// - `max_staleness`: Maximum allowable age in seconds for price observations before /// they are deemed stale. @@ -246,11 +272,11 @@ impl TwapOracle { /// # Errors /// /// - [`Error::NotAuthorized`]: `caller` is not an `Admin` or auth verification fails. - /// - [`Error::InvalidDecimals`]: `config.decimals` exceeds 38. + /// - [`Error::InvalidDecimals`]: `config.decimals` exceeds 19 (u64 price limit). pub fn configure_oracle(env: Env, caller: Address, config: OracleConfig) -> Result<(), Error> { require_role_or_admin(&env, &caller, Role::Admin)?; - if config.decimals > 38 { + if config.decimals > 19 { return Err(Error::InvalidDecimals); } @@ -269,17 +295,19 @@ impl TwapOracle { env.storage().instance().remove(&DataKey::Price); // Clear every per-feeder submission and the submitter list itself. + // Submissions live in persistent() (see DataKey docs) so clears + // must target persistent storage and respect the cap. let submitters: Vec
= env .storage() - .instance() + .persistent() .get(&DataKey::Submitters) .unwrap_or(Vec::new(&env)); for feeder in submitters.iter() { env.storage() - .instance() + .persistent() .remove(&DataKey::Submission(feeder)); } - env.storage().instance().remove(&DataKey::Submitters); + env.storage().persistent().remove(&DataKey::Submitters); } } @@ -292,20 +320,22 @@ impl TwapOracle { /// /// `price` is a fixed-point integer scaled by `10^decimals`, where /// `decimals` comes from the oracle's stored `OracleConfig` (set via - /// `configure_oracle`, max 38). For example, with `decimals: 8`, a - /// real-world price of `100.0` is submitted as `100_00000000`. - /// `calculate_fiat_stream_payout` divides by `10^decimals` when - /// converting a submission back to a real value, so submissions must - /// use the same scale as the currently configured `decimals` or - /// downstream payouts will be wrong by that scale factor. + /// `configure_oracle`, max 19 for `u64` — `10^20 > u64::MAX`). For + /// example, with `decimals: 8`, a real-world price of `100.0` is + /// submitted as `100_00000000`. `calculate_fiat_stream_payout` divides + /// by `10^decimals` when converting a submission back to a real value, + /// so submissions must use the same scale as the currently configured + /// `decimals` or downstream payouts will be wrong by that scale factor. /// /// There is no fixed time-bucketed TWAP window. Instead, every /// feeder's most recent submission is kept independently - /// (`DataKey::Submission`) and `get_twap_price` aggregates the median - /// (or the average of the two middle values, on an even count) across - /// every submission still within `max_staleness` seconds of the - /// current ledger time — see `get_twap_price` for the aggregation - /// logic and `OracleConfig::max_staleness` for the staleness window. + /// (`DataKey::Submission` in `persistent()`) and `get_twap_price` + /// aggregates the median (or the average of the two middle values, on an + /// even count) across every submission still within `max_staleness` + /// seconds of the current ledger time — see `get_twap_price` for the + /// aggregation logic and `OracleConfig::max_staleness` for the staleness + /// window. `Submitters` is capped at [`MAX_SUBMITTERS`] so aggregation + /// stays bounded. /// /// Blocked while the oracle is under an emergency pause. Each feeder's /// submission is tracked independently (`DataKey::Submission`) and @@ -333,10 +363,18 @@ impl TwapOracle { // to see the most recent submission. env.storage().instance().set(&DataKey::Price, &data); + // Per-feeder submissions are in persistent() per the storage-tier rule + // (unbounded per-entity data → persistent), with TTL extension. Capped + // via add_submitter. env.storage() - .instance() + .persistent() .set(&DataKey::Submission(caller.clone()), &data); - add_submitter(&env, &caller); + env.storage().persistent().extend_ttl( + &DataKey::Submission(caller.clone()), + THRESHOLD, + EXTEND_TO, + ); + add_submitter(&env, &caller)?; events::price_submitted(&env, &caller, price, now); Ok(()) @@ -366,7 +404,7 @@ impl TwapOracle { let submitters: Vec
= env .storage() - .instance() + .persistent() .get(&DataKey::Submitters) .unwrap_or(Vec::new(&env)); @@ -376,7 +414,7 @@ impl TwapOracle { for feeder in submitters.iter() { let submission: Option = - env.storage().instance().get(&DataKey::Submission(feeder)); + env.storage().persistent().get(&DataKey::Submission(feeder)); if let Some(data) = submission { saw_any_submission = true; let age = now.saturating_sub(data.updated_at); @@ -614,24 +652,35 @@ fn require_role_or_admin(env: &Env, caller: &Address, role: Role) -> Result<(), /// Records `account` in the `Submitters` set the first time it submits a /// price, so `get_twap_price` knows which `DataKey::Submission` entries to -/// aggregate. No-op if already recorded. -fn add_submitter(env: &Env, account: &Address) { +/// aggregate. No-op if already recorded. Caps at [`MAX_SUBMITTERS`] and +/// returns `TooManySubmitters` if a new feeder would exceed the cap. +/// Stored in `persistent()` per the factory `storage.rs` rule (unbounded +/// per-entity data → persistent, not instance). +fn add_submitter(env: &Env, account: &Address) -> Result<(), Error> { let mut submitters: Vec
= env .storage() - .instance() + .persistent() .get(&DataKey::Submitters) .unwrap_or(Vec::new(env)); for existing in submitters.iter() { if existing == *account { - return; + return Ok(()); } } + if submitters.len() >= MAX_SUBMITTERS { + return Err(Error::TooManySubmitters); + } + submitters.push_back(account.clone()); env.storage() - .instance() + .persistent() .set(&DataKey::Submitters, &submitters); + env.storage() + .persistent() + .extend_ttl(&DataKey::Submitters, THRESHOLD, EXTEND_TO); + Ok(()) } /// Median of `prices` (average of the two middle values for an even-length