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
30 changes: 20 additions & 10 deletions contracts/predictify-hybrid/src/oracles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,10 @@ impl OracleFactory {
oracle_config: &OracleConfig,
contract_id: Address,
) -> Result<OracleInstance, Error> {
if oracle_config.is_none_sentinel() {
return Err(Error::InvalidOracleConfig);
}

Self::create_oracle(oracle_config.provider.clone(), contract_id)
}

Expand Down Expand Up @@ -1178,6 +1182,10 @@ impl OracleFactory {
/// # Returns
/// Result indicating if the configuration is valid for Stellar
pub fn validate_stellar_compatibility(oracle_config: &OracleConfig) -> Result<(), Error> {
if oracle_config.is_none_sentinel() {
return Err(Error::InvalidOracleConfig);
}

match oracle_config.provider {
OracleProvider::Reflector => {
// Reflector is fully supported
Expand Down Expand Up @@ -2293,10 +2301,7 @@ impl OracleValidationConfigManager {
}

/// Get per-event validation config override.
pub fn get_event_config(
env: &Env,
market_id: &Symbol,
) -> Option<EventOracleValidationConfig> {
pub fn get_event_config(env: &Env, market_id: &Symbol) -> Option<EventOracleValidationConfig> {
let per_event: soroban_sdk::Map<Symbol, EventOracleValidationConfig> = env
.storage()
.persistent()
Expand Down Expand Up @@ -2325,10 +2330,7 @@ impl OracleValidationConfigManager {
}

/// Resolve effective validation config for a market.
pub fn get_effective_config(
env: &Env,
market_id: &Symbol,
) -> GlobalOracleValidationConfig {
pub fn get_effective_config(env: &Env, market_id: &Symbol) -> GlobalOracleValidationConfig {
if let Some(event_cfg) = Self::get_event_config(env, market_id) {
GlobalOracleValidationConfig {
max_staleness_secs: event_cfg.max_staleness_secs,
Expand Down Expand Up @@ -2375,11 +2377,19 @@ impl OracleValidationConfigManager {

if *provider == OracleProvider::Pyth {
if let Some(confidence) = data.confidence {
let price_abs = if data.price < 0 { -data.price } else { data.price };
let price_abs = if data.price < 0 {
-data.price
} else {
data.price
};
if price_abs == 0 {
return Err(Error::InvalidInput);
}
let conf_abs = if confidence < 0 { -confidence } else { confidence };
let conf_abs = if confidence < 0 {
-confidence
} else {
confidence
};
let confidence_bps =
((conf_abs * 10_000) / price_abs).min(Self::MAX_CONFIDENCE_BPS as i128);
let confidence_bps_u32 = confidence_bps as u32;
Expand Down
38 changes: 35 additions & 3 deletions contracts/predictify-hybrid/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,17 @@ impl OracleConfig {
}
}

/// Sentinel value for "no fallback" (used when has_fallback is false). Do not use for resolution.
/// Returns the reserved sentinel used to encode "no fallback oracle" in storage.
///
/// The contracts persist fallback-oracle state as a `(has_fallback, fallback_oracle_config)`
/// pair instead of `Option<OracleConfig>` for Soroban contract-type compatibility. When
/// `has_fallback` is `false`, this sentinel is written into `fallback_oracle_config`.
///
/// Sentinel semantics are reserved by the tuple `(provider=Reflector, feed_id="", threshold=0,
/// comparison="")`. Those values are intentionally outside the valid oracle-config domain, so
/// the sentinel cannot collide with any configuration that passes validation.
///
/// This value must never be used for live oracle creation or resolution.
pub fn none_sentinel(env: &Env) -> Self {
Self {
provider: OracleProvider::Reflector,
Expand All @@ -504,11 +514,27 @@ impl OracleConfig {
comparison: String::from_str(env, ""),
}
}

/// Returns `true` when this configuration is the reserved "no fallback" sentinel.
///
/// The sentinel identity intentionally ignores `oracle_address`: the reserved semantics are
/// carried by the invalid `feed_id` / `threshold` / `comparison` tuple, which keeps the
/// sentinel unambiguous even if a caller reuses the placeholder address elsewhere.
pub fn is_none_sentinel(&self) -> bool {
self.provider == OracleProvider::Reflector
&& self.feed_id.is_empty()
&& self.threshold == 0
&& self.comparison.is_empty()
}
}

impl OracleConfig {
/// Validate the oracle configuration
pub fn validate(&self, env: &Env) -> Result<(), crate::Error> {
if self.is_none_sentinel() || self.feed_id.is_empty() {
return Err(crate::Error::InvalidOracleConfig);
}

// Validate threshold
if self.threshold <= 0 {
return Err(crate::Error::InvalidThreshold);
Expand Down Expand Up @@ -732,7 +758,10 @@ pub struct Market {
pub oracle_config: OracleConfig,
/// Whether a fallback oracle is configured (avoids Option in contract type for SDK compatibility)
pub has_fallback: bool,
/// Fallback oracle configuration (only valid when has_fallback is true)
/// Fallback oracle configuration.
///
/// When `has_fallback` is `false`, this field is populated with `OracleConfig::none_sentinel()`
/// so the stored representation stays collision-free without using `Option<OracleConfig>`.
pub fallback_oracle_config: OracleConfig,
/// Resolution timeout in seconds after end_time
pub resolution_timeout: u64,
Expand Down Expand Up @@ -3169,7 +3198,10 @@ pub struct Event {
pub oracle_config: OracleConfig,
/// Whether a fallback oracle is configured
pub has_fallback: bool,
/// Fallback oracle configuration (only valid when has_fallback is true)
/// Fallback oracle configuration.
///
/// When `has_fallback` is `false`, this field is populated with `OracleConfig::none_sentinel()`
/// so the stored representation stays collision-free without using `Option<OracleConfig>`.
pub fallback_oracle_config: OracleConfig,
/// Resolution timeout in seconds after end_time
pub resolution_timeout: u64,
Expand Down
92 changes: 46 additions & 46 deletions contracts/predictify-hybrid/src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ use crate::{
errors::Error,
types::{BetLimits, Market, OracleConfig, OracleProvider},
};
// use alloc::string::ToString; // Removed to fix Display/ToString trait errors
use soroban_sdk::{contracttype, vec, Address, Env, Map, String, Symbol, Vec};

// ===== VALIDATION ERROR TYPES =====
Expand Down Expand Up @@ -1391,16 +1390,16 @@ impl InputValidator {
/// ```
pub fn validate_description_length(description: &String) -> Result<(), ValidationError> {
let length = description.len() as u32;

// Description is optional, so empty is allowed
if length == 0 {
return Ok(());
}

if length > config::MAX_DESCRIPTION_LENGTH {
return Err(ValidationError::StringTooLong);
}

Ok(())
}

Expand All @@ -1426,11 +1425,7 @@ impl InputValidator {
/// assert!(InputValidator::validate_tag_length(&tag).is_ok());
/// ```
pub fn validate_tag_length(tag: &String) -> Result<(), ValidationError> {
Self::validate_string_length_range(
tag,
config::MIN_TAG_LENGTH,
config::MAX_TAG_LENGTH,
)
Self::validate_string_length_range(tag, config::MIN_TAG_LENGTH, config::MAX_TAG_LENGTH)
}

/// Validate market category with length limits
Expand Down Expand Up @@ -1489,17 +1484,17 @@ impl InputValidator {
pub fn validate_outcomes(outcomes: &Vec<String>) -> Result<(), ValidationError> {
// Validate array size
Self::validate_array_size(outcomes, config::MAX_MARKET_OUTCOMES)?;

// Validate minimum number of outcomes
if (outcomes.len() as u32) < config::MIN_MARKET_OUTCOMES {
return Err(ValidationError::ArrayTooSmall);
}

// Validate each outcome length
for outcome in outcomes.iter() {
Self::validate_outcome_length(&outcome)?;
}

Ok(())
}

Expand Down Expand Up @@ -1532,17 +1527,17 @@ impl InputValidator {
if tags.is_empty() {
return Ok(());
}

// Validate maximum number of tags
if (tags.len() as u32) > config::MAX_TAGS_PER_MARKET {
return Err(ValidationError::ArrayTooLarge);
}

// Validate each tag length
for tag in tags.iter() {
Self::validate_tag_length(&tag)?;
}

Ok(())
}

Expand Down Expand Up @@ -1572,7 +1567,7 @@ impl InputValidator {
/// let description = String::from_str(&env, "Market about Bitcoin price prediction");
/// let category = String::from_str(&env, "Cryptocurrency");
/// let tags = vec![&env, String::from_str(&env, "crypto"), String::from_str(&env, "bitcoin")];
///
///
/// assert!(InputValidator::validate_market_metadata(
/// &question,
/// &outcomes,
Expand All @@ -1590,23 +1585,23 @@ impl InputValidator {
) -> Result<(), ValidationError> {
// Validate question
Self::validate_question_length(question)?;

// Validate outcomes
Self::validate_outcomes(outcomes)?;

// Validate description if provided
if let Some(desc) = description {
Self::validate_description_length(desc)?;
}

// Validate category if provided
if let Some(cat) = category {
Self::validate_category_length(cat)?;
}

// Validate tags
Self::validate_tags(tags)?;

Ok(())
}
}
Expand Down Expand Up @@ -4880,7 +4875,7 @@ impl OracleConfigValidator {
let supported_operators = Self::get_supported_operators_for_provider(&config.provider);

// Validate comparison operator
Self::validate_comparison_operator(&config.comparison, &supported_operators)?;
Self::validate_comparison_operator_literals(&config.comparison, supported_operators)?;

// Additional consistency checks
match config.provider {
Expand Down Expand Up @@ -5070,6 +5065,11 @@ impl OracleConfigValidator {
pub fn validate_oracle_config_all_together(
config: &OracleConfig,
) -> Result<(), ValidationError> {
// Reserve the storage-only sentinel so it can never become a live oracle config.
if config.is_none_sentinel() {
return Err(ValidationError::InvalidConfig);
}

// Step 1: Validate provider support
Self::validate_oracle_provider(&config.provider)?;

Expand All @@ -5081,7 +5081,7 @@ impl OracleConfigValidator {

// Step 4: Get supported operators and validate comparison
let supported_operators = Self::get_supported_operators_for_provider(&config.provider);
Self::validate_comparison_operator(&config.comparison, &supported_operators)?;
Self::validate_comparison_operator_literals(&config.comparison, supported_operators)?;

// Step 5: Validate configuration consistency
Self::validate_config_consistency(config)?;
Expand All @@ -5095,7 +5095,7 @@ impl OracleConfigValidator {
/// * `provider` - The oracle provider to get operators for
///
/// # Returns
/// * `Vec<String>` - Vector of supported comparison operators
/// * `&[&str]` - Provider-specific supported comparison operators
///
/// # Provider-Specific Operators
///
Expand All @@ -5113,29 +5113,29 @@ impl OracleConfigValidator {
///
/// **Band Protocol & DIA:**
/// - Empty vector (not supported)
fn get_supported_operators_for_provider(provider: &OracleProvider) -> Vec<String> {
fn validate_comparison_operator_literals(
comparison: &String,
supported_operators: &[&str],
) -> Result<(), ValidationError> {
if comparison.is_empty() {
return Err(ValidationError::InvalidOracle);
}

if supported_operators
.iter()
.any(|operator| comparison == &String::from_str(comparison.env(), operator))
{
return Ok(());
}

Err(ValidationError::InvalidOracle)
}

fn get_supported_operators_for_provider(provider: &OracleProvider) -> &'static [&'static str] {
match provider {
OracleProvider::Reflector => {
vec![
&soroban_sdk::Env::default(),
String::from_str(&soroban_sdk::Env::default(), "gt"),
String::from_str(&soroban_sdk::Env::default(), "lt"),
String::from_str(&soroban_sdk::Env::default(), "eq"),
]
}
OracleProvider::Pyth => {
vec![
&soroban_sdk::Env::default(),
String::from_str(&soroban_sdk::Env::default(), "gt"),
String::from_str(&soroban_sdk::Env::default(), "gte"),
String::from_str(&soroban_sdk::Env::default(), "lt"),
String::from_str(&soroban_sdk::Env::default(), "lte"),
String::from_str(&soroban_sdk::Env::default(), "eq"),
]
}
OracleProvider::BandProtocol | OracleProvider::DIA => {
vec![&soroban_sdk::Env::default()]
}
OracleProvider::Reflector => &["gt", "lt", "eq"],
OracleProvider::Pyth => &["gt", "gte", "lt", "lte", "eq"],
OracleProvider::BandProtocol | OracleProvider::DIA => &[],
}
}
}
35 changes: 35 additions & 0 deletions contracts/predictify-hybrid/src/validation_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,7 @@ fn test_validation_error_messages() {
#[cfg(test)]
mod oracle_config_validator_tests {
use super::*;
use crate::oracles::OracleFactory;
use crate::types::{OracleConfig, OracleProvider};
use crate::validation::OracleConfigValidator;

Expand Down Expand Up @@ -1435,6 +1436,40 @@ mod oracle_config_validator_tests {
// Overall validation should fail due to provider not being supported
assert!(OracleConfigValidator::validate_oracle_config_all_together(&pyth_config).is_err());
}

#[test]
fn test_none_sentinel_is_reserved_and_invalid() {
let env = soroban_sdk::Env::default();
let sentinel = OracleConfig::none_sentinel(&env);

assert!(sentinel.is_none_sentinel());
assert_eq!(sentinel.validate(&env), Err(Error::InvalidOracleConfig));
assert!(OracleConfigValidator::validate_oracle_config_all_together(&sentinel).is_err());
assert!(
OracleFactory::create_from_config(&sentinel, sentinel.oracle_address.clone()).is_err()
);
assert!(OracleFactory::validate_stellar_compatibility(&sentinel).is_err());
}

#[test]
fn test_valid_config_does_not_collide_with_none_sentinel() {
let env = soroban_sdk::Env::default();
let sentinel = OracleConfig::none_sentinel(&env);
let valid_with_placeholder_address = OracleConfig::new(
OracleProvider::Reflector,
sentinel.oracle_address.clone(),
String::from_str(&env, "BTC/USD"),
50_000_00,
String::from_str(&env, "gt"),
);

assert!(!valid_with_placeholder_address.is_none_sentinel());
assert!(valid_with_placeholder_address.validate(&env).is_ok());
assert!(OracleConfigValidator::validate_oracle_config_all_together(
&valid_with_placeholder_address
)
.is_ok());
}
}

// ===== COMPREHENSIVE INPUT VALIDATION TESTS =====
Expand Down
Loading
Loading