Skip to content
Open
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
51 changes: 49 additions & 2 deletions contracts/earn-quest/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ use crate::storage::{get_badge_type, list_badge_types};

pub use crate::types::{
AggregatedPrice, Badge, BadgeType, BatchApprovalInput, BatchQuestInput, Commitment,
CreatorStats, Dispute, DisputeStatus, EscrowInfo, OracleConfig, PlatformStats, PriceData,
PriceFeedRequest, Quest, QuestMetadata, QuestStatus, Role, Submission, SubmissionStatus,
CreatorStats, Dispute, DisputeStatus, EscrowInfo, OracleConfig, PlatformStats, PriceData,
PriceFeedRequest, PushedPrice, Quest, QuestMetadata, QuestStatus, Role, Submission, SubmissionStatus,
UserBadges, UserCore, UserStats, VerifierStake,
};

Expand Down Expand Up @@ -1330,6 +1330,53 @@ impl EarnQuestContract {
Ok(())
}

//================================================================================
// Oracle Price Push (set_price) & Configurable TTL Circuit-Breaker (issue #1710)
//================================================================================

/// Pushes a price for `token` into the contract's instance storage
/// (OracleAdmin only). The push is what the circuit-breaker reads when
/// the price-feed TTL has been activated via `set_price_feed_ttl`.
///
/// Validates bounds (non-zero price, valid confidence, sane decimals,
/// non-future timestamp) and that `token` matches `price_data.base_asset`.
/// Refuses to update while the contract is paused.
pub fn set_price(
env: Env,
caller: Address,
token: Address,
price_data: PriceData,
) -> Result<(), Error> {
security::require_not_paused(&env)?;
admin::require_role(&env, &caller, Role::OracleAdmin)?;
oracle::Oracle::set_price(&env, &token, &price_data)
}

/// Sets the price-feed staleness TTL in seconds (OracleAdmin only).
/// A TTL of `0` disables the circuit-breaker (default). Any positive value
/// activates it; quest registration will fail with `StaleOracleData` when
/// no fresh pushed price exists for the reward asset.
pub fn set_price_feed_ttl(
env: Env,
caller: Address,
ttl_seconds: u64,
) -> Result<(), Error> {
security::require_not_paused(&env)?;
admin::require_role(&env, &caller, Role::OracleAdmin)?;
oracle::Oracle::set_price_ttl(&env, ttl_seconds);
Ok(())
}

/// Returns the currently configured price-feed staleness TTL in seconds.
pub fn get_price_feed_ttl(env: Env) -> u64 {
oracle::Oracle::get_price_ttl(&env)
}

/// Returns the latest pushed price for `token`, if any.
pub fn get_pushed_price(env: Env, token: Address) -> Option<PushedPrice> {
storage::get_pushed_price(&env, &token)
}

// ─────────────────────────────────────────────────────────────────────────────
// Token Interface (SEP-41)
// ─────────────────────────────────────────────────────────────────────────────
Expand Down
49 changes: 49 additions & 0 deletions contracts/earn-quest/src/oracle.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use crate::errors::Error;
use crate::storage;
use crate::types::{
AggregatedPrice, OracleConfig, OracleResponse, OracleType, PriceData, PriceFeedRequest,
PushedPrice,
};
use crate::validation;
use soroban_sdk::{Address, Env, Vec, U256};

#[allow(dead_code)]
Expand Down Expand Up @@ -262,4 +265,50 @@ impl Oracle {
// For now, return current price as fallback
Self::get_price(env, oracle_config, request)
}

/// Pushes a price for `token` from the OracleAdmin's own upstream feed
/// into the contract's instance storage (addresses GH #1710).
///
/// The pushed price lives at `DataKey::PushedPrice(token)` and is what
/// the circuit-breaker (`validate_price_feed_fresh`) reads when the
/// price-feed TTL is active.
///
/// Returns:
/// * `Err(Error::Paused)` if the contract is paused.
/// * `Err(Error::OracleRespMismatch)` if `token` != `price_data.base_asset`.
/// * `Err(Error::InvalidOracleData)` if any bounds check fails.
/// * `Ok(())` on successful push.
pub fn set_price(
env: &Env,
token: &Address,
price_data: &PriceData,
) -> Result<(), Error> {
if storage::is_paused(env) {
return Err(Error::Paused);
}

if price_data.base_asset != *token {
return Err(Error::OracleRespMismatch);
}

validation::validate_price_data_bounds(env, price_data)?;

let pushed = PushedPrice {
price: price_data.clone(),
pushed_at: env.ledger().timestamp(),
};
storage::set_pushed_price(env, token, &pushed);
Ok(())
}

/// Sets the price-feed staleness TTL in seconds. TTL = 0 disables the
/// circuit-breaker. See `validation::validate_price_feed_fresh`.
pub fn set_price_ttl(env: &Env, ttl_seconds: u64) {
storage::set_price_feed_ttl(env, ttl_seconds);
}

/// Returns the currently configured price-feed staleness TTL in seconds.
pub fn get_price_ttl(env: &Env) -> u64 {
storage::get_price_feed_ttl(env)
}
}
4 changes: 4 additions & 0 deletions contracts/earn-quest/src/quest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ pub fn register_quest_with_category(
validation::validate_deadline(env, deadline)?;
validation::validate_addresses_distinct(creator, verifier)?;

// Circuit-breaker (issue #1710): halt registration if the reward asset's
// price feed is stale or missing. No-op when TTL == 0 (default).
validation::validate_price_feed_fresh(env, reward_asset)?;

// Check minimum creator level requirement
let min_level = storage::get_min_creator_level(env);
if min_level > 0 && !storage::is_creator_whitelisted(env, creator) {
Expand Down
65 changes: 62 additions & 3 deletions contracts/earn-quest/src/storage.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use crate::errors::Error;
use crate::types::{
BadgeType, Commitment, CreatorStats, EscrowBalances, EscrowInfo, EscrowMeta, OracleConfig,
PlatformStats, Quest, QuestMetadata, QuestMetadataCore, QuestMetadataExtended, QuestStatus,
Role, Submission, SubmissionStatus, UserBadges, UserCore, VerifierStake,
PushedPrice, PlatformStats, PriceData, Quest, QuestMetadata, QuestMetadataCore,
QuestMetadataExtended, QuestStatus, Role, Submission, SubmissionStatus, UserBadges, UserCore,
VerifierStake,
};

use crate::validation;
Expand Down Expand Up @@ -103,6 +104,12 @@ pub enum DataKey {
ClawbackPending(Symbol, Address),
/// Category index keyed by a numeric category, storing quest ids in insertion order
QuestCategory(u32),
/// Latest price data pushed by an OracleAdmin, keyed by base asset address.
/// `pushed_at` records the ledger timestamp when the push happened so the
/// circuit-breaker can detect stale prices.
PushedPrice(Address),
/// Configurable price-feed staleness TTL in seconds. 0 disables the breaker.
PriceFeedTtl,
}

//================================================================================
Expand Down Expand Up @@ -1511,9 +1518,11 @@ mod layout_tests {
"CreatorWhitelist",
"ClawbackPending",
"QuestCategory",
"PushedPrice",
"PriceFeedTtl",
];

const EXPECTED_VARIANT_COUNT: usize = 46;
const EXPECTED_VARIANT_COUNT: usize = 48;

/// One sample instance per `DataKey` variant for layout audits.
fn all_data_keys(env: &Env) -> Vec<DataKey> {
Expand Down Expand Up @@ -1568,6 +1577,8 @@ mod layout_tests {
keys.push_back(DataKey::CreatorWhitelist(addr.clone()));
keys.push_back(DataKey::ClawbackPending(quest_id.clone(), addr.clone()));
keys.push_back(DataKey::QuestCategory(1));
keys.push_back(DataKey::PushedPrice(addr.clone()));
keys.push_back(DataKey::PriceFeedTtl);
keys
}

Expand Down Expand Up @@ -1653,3 +1664,51 @@ pub fn delete_clawback(env: &Env, quest_id: &Symbol, recipient: &Address) {
recipient.clone(),
));
}

//================================================================================
// Pushed Price Storage (set_price entrypoint)
//================================================================================

/// Returns true if a pushed price exists for `base_asset` (i.e. an OracleAdmin
/// has called `set_price` at least once for this asset).
pub fn has_pushed_price(env: &Env, base_asset: &Address) -> bool {
env.storage()
.instance()
.has(&DataKey::PushedPrice(base_asset.clone()))
}

/// Retrieves the latest `PushedPrice` for `base_asset`.
pub fn get_pushed_price(env: &Env, base_asset: &Address) -> Option<PushedPrice> {
env.storage()
.instance()
.get(&DataKey::PushedPrice(base_asset.clone()))
}

/// Stores (or overwrites) the latest pushed price for `base_asset`.
pub fn set_pushed_price(env: &Env, base_asset: &Address, pushed: &PushedPrice) {
env.storage()
.instance()
.set(&DataKey::PushedPrice(base_asset.clone()), pushed);
}

/// Returns the configured price-feed staleness TTL in seconds.
///
/// A TTL of `0` means the circuit-breaker is disabled (no quest-registration
/// check is performed). The default after contract init is `0`.
pub fn get_price_feed_ttl(env: &Env) -> u64 {
env.storage()
.instance()
.get(&DataKey::PriceFeedTtl)
.unwrap_or(0u64)
}

/// Sets the price-feed staleness TTL in seconds.
///
/// Setting `ttl_seconds = 0` disables the circuit-breaker; setting a
/// positive value activates enforcement (quest registration fails with
/// `StaleOracleData` when no fresh pushed price exists for the reward asset).
pub fn set_price_feed_ttl(env: &Env, ttl_seconds: u64) {
env.storage()
.instance()
.set(&DataKey::PriceFeedTtl, &ttl_seconds);
}
18 changes: 18 additions & 0 deletions contracts/earn-quest/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,24 @@ pub struct OracleResponse {
pub response_timestamp: u64,
}

/// Price data pushed into the contract by an OracleAdmin via `set_price`.
///
/// `pushed_at` records the ledger timestamp at the moment of the push,
/// while `price.timestamp` reflects the time the price was signed off by
/// the upstream feed. Keeping both lets the circuit-breaker distinguish
/// between an old push that was just refreshed (`price.timestamp` recent,
/// `pushed_at` recent) and a push that has aged out.
///
/// `pushed_by` records the OracleAdmin address that authorised the push
/// for audit/accountability.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PushedPrice {
pub price: PriceData,
pub pushed_at: u64,
pub pushed_by: Address,
}

/// Aggregated price result from multiple oracle sources.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
Expand Down
104 changes: 102 additions & 2 deletions contracts/earn-quest/src/validation.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::errors::Error;
use crate::types::{QuestStatus, SubmissionStatus};
use soroban_sdk::Env;
use crate::storage;
use crate::types::{PriceData, QuestStatus, SubmissionStatus};
use soroban_sdk::{Address, Env, U256};

//================================================================================
// Constants — Validation Limits
Expand Down Expand Up @@ -388,3 +389,102 @@ pub fn is_quest_terminal(status: &QuestStatus) -> bool {
QuestStatus::Completed | QuestStatus::Expired | QuestStatus::Cancelled
)
}

//================================================================================
// Price Feed Staleness & Circuit-Breaker Validation
//================================================================================

/// Validates the bounds of a price pushed by an OracleAdmin.
///
/// Ensures:
/// * price is non-zero
/// * confidence is in `[0, 100]`
/// * decimals is non-zero and sane (<= 38, matching Soroban U256 limits)
/// * timestamp is not in the future
///
/// # Arguments
/// * `env` - The contract environment (to read current ledger timestamp)
/// * `price_data` - The price data to validate
///
/// # Returns
/// * `Ok(())` if all invariants hold
/// * `Err(Error::InvalidOracleData)` otherwise
pub fn validate_price_data_bounds(env: &Env, price_data: &PriceData) -> Result<(), Error> {
// Confidence must fit on the [0, 100] precentage scale.
if price_data.confidence > 100 {
return Err(Error::InvalidOracleData);
}

// decimals > 0; Soroban-cap-friendly upper bound prevents misuse.
if price_data.decimals == 0 || price_data.decimals > 38 {
return Err(Error::InvalidOracleData);
}

// timestamp must be at or before current ledger time (no future timestamps).
let current_time = env.ledger().timestamp();
if price_data.timestamp > current_time {
return Err(Error::InvalidOracleData);
}

// Price value must be non-zero.
let zero = U256::from_u32(env, 0);
if price_data.price.eq(&zero) {
return Err(Error::InvalidOracleData);
}

Ok(())
}

/// Returns true if there is a fresh, valid pushed price for `reward_asset`.
///
/// Reads the configured TTL from `DataKey::PriceFeedTtl`. A TTL of `0`
/// disables the breaker entirely (returns true). Otherwise, looks up the
/// most recent pushed price for the asset and compares its timestamp against
/// `(now - TTL)`.
///
/// # Arguments
/// * `env` - The contract environment
/// * `reward_asset` - The reward asset whose price feed freshness is checked
///
/// # Returns
/// * `true` if no TTL is configured OR a pushed price exists within TTL
/// * `false` if a TTL is configured and the latest price is missing or stale
pub fn is_price_feed_fresh(env: &Env, reward_asset: &Address) -> bool {
let ttl = storage::get_price_feed_ttl(env);
if ttl == 0 {
return true;
}
let Some(pushed) = storage::get_pushed_price(env, reward_asset) else {
return false;
};

let current_time = env.ledger().timestamp();
if pushed.price.timestamp > current_time {
// Future timestamp is invalid; treat as stale.
return false;
}
let age = current_time - pushed.price.timestamp;
age <= ttl
}

/// Circuit-breaker: fail quest registration if the price feed for
/// `reward_asset` is stale or missing.
///
/// The breaker is opt-in: when the configured TTL is `0` (default after init)
/// the check passes unconditionally. SuperAdmins activate enforcement by
/// calling `set_price_feed_ttl(seconds > 0)` through an OracleAdmin.
///
/// # Arguments
/// * `env` - The contract environment
/// * `reward_asset` - The asset whose price feed must be fresh
///
/// # Returns
/// * `Ok(())` if the breaker is disabled or the feed is fresh
/// * `Err(Error::StaleOracleData)` if the feed is stale or missing
pub fn validate_price_feed_fresh(env: &Env, reward_asset: &Address) -> Result<(), Error> {
if is_price_feed_fresh(env, reward_asset) {
Ok(())
} else {
Err(Error::StaleOracleData)
}
}
Loading