Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions contracts/factory/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<u64> {
let all: Vec<u64> = env
.storage()
Expand All @@ -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<u64> {
let all: Vec<u64> = env
.storage()
Expand Down
15 changes: 12 additions & 3 deletions contracts/factory/src/query.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -14,10 +21,12 @@ use soroban_sdk::{Env, Vec};
pub fn paginate(env: &Env, v: Vec<u64>, offset: u32, limit: u32) -> Vec<u64> {
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());
}
Expand Down
119 changes: 84 additions & 35 deletions contracts/oracle/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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.
///
Expand All @@ -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,
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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.
Expand All @@ -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);
}

Expand All @@ -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<Address> = 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);
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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(())
Expand Down Expand Up @@ -366,7 +404,7 @@ impl TwapOracle {

let submitters: Vec<Address> = env
.storage()
.instance()
.persistent()
.get(&DataKey::Submitters)
.unwrap_or(Vec::new(&env));

Expand All @@ -376,7 +414,7 @@ impl TwapOracle {

for feeder in submitters.iter() {
let submission: Option<PriceData> =
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);
Expand Down Expand Up @@ -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<Address> = 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
Expand Down