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
72 changes: 59 additions & 13 deletions contracts/predictify-hybrid/src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@
//! - No `require_auth` call is needed in this module because all writes are
//! performed by contract-internal code paths that have already verified
//! caller authentication.
//! - Arithmetic for `new_index` uses `saturating_add` to stay overflow-safe; an
//! audit log that has reached `u32::MAX` entries stops silently (markets are
//! not expected to reach that cardinality).
//! - Arithmetic for `new_index` uses `checked_add` and panics on overflow so an
//! audit record is never dropped silently (markets are not expected to reach
//! that cardinality).
//! - Every successful append publishes an event, so storage-tier writes are
//! observable by off-chain consumers.
//!
//! ## Storage
//!
Expand Down Expand Up @@ -139,8 +141,9 @@ impl MarketAuditManager {
/// Append a new entry to the audit log for `market_id`.
///
/// Returns the 1-based index of the newly written entry.
/// If the entry counter would overflow `u32::MAX` the call is a no-op and
/// returns `u32::MAX` β€” that cardinality is not reachable in practice.
/// If the entry counter would overflow `u32::MAX`, the call panics rather
/// than silently dropping an audit record; that cardinality is not
/// reachable in practice.
///
/// # Parameters
///
Expand All @@ -164,11 +167,31 @@ impl MarketAuditManager {
.get(&head_key)
.unwrap_or(MarketAuditHead { total_entries: 0 });

// Overflow guard: stop silently at u32::MAX.
let new_index = match head.total_entries.checked_add(1) {
Some(i) => i,
None => return u32::MAX,
};
// If the head is missing but an entry exists, the log is corrupt; do not
// silently overwrite index 1.
if head.total_entries == 0
&& env
.storage()
.persistent()
.has(&DataKey::MarketAuditLog(market_id.clone(), 1))
{
panic!("audit log corruption: head missing but entry exists");
}

// Never append past a gap: the latest entry must exist before extending.
if head.total_entries > 0 {
let latest_key = DataKey::MarketAuditLog(market_id.clone(), head.total_entries);
if !env.storage().persistent().has(&latest_key) {
panic!("audit log corruption: latest entry missing");
}
}

// Overflow is unreachable in practice; fail loudly instead of silently
// dropping an audit record.
let new_index = head
.total_entries
.checked_add(1)
.expect("audit log overflow");

let entry = MarketAuditEntry {
index: new_index,
Expand All @@ -190,6 +213,16 @@ impl MarketAuditManager {
.persistent()
.extend_ttl(&head_key, MARKET_TTL_LEDGERS, MARKET_TTL_LEDGERS);

// Publish an event so off-chain consumers can observe the storage-tier
// write without exposing sensitive data.
env.events().publish(
(
Symbol::new(env, "market_audit_entry_appended"),
market_id.clone(),
),
entry,
);

new_index
}

Expand All @@ -205,10 +238,21 @@ impl MarketAuditManager {
/// Fetches one entry by its 1-based `index` from the market's audit log.
///
/// Returns `None` when `index` is 0 or exceeds `total_entries`.
/// Panics if an index within bounds is missing from storage.
pub fn get_entry(env: &Env, market_id: &Symbol, index: u32) -> Option<MarketAuditEntry> {
if index == 0 {
return None;
}
let head = Self::get_head(env, market_id)?;
if index > head.total_entries {
return None;
}
Self::get_entry_unchecked(env, market_id, index).unwrap_or_else(|| {
panic!("audit log corruption: missing entry {index}");
})
}

fn get_entry_unchecked(env: &Env, market_id: &Symbol, index: u32) -> Option<MarketAuditEntry> {
let key = DataKey::MarketAuditLog(market_id.clone(), index);
env.storage().persistent().get(&key)
}
Expand All @@ -219,6 +263,7 @@ impl MarketAuditManager {
///
/// - `limit` is capped at 100 to bound ledger computation cost.
/// - Returns an empty `Vec` if the market has no audit entries.
/// - Panics if an expected entry is missing, so corruption is never silently skipped.
///
/// # Parameters
///
Expand All @@ -244,9 +289,10 @@ impl MarketAuditManager {
let mut count = 0u32;

while idx >= 1 && count < effective_limit {
if let Some(entry) = Self::get_entry(env, market_id, idx) {
result.push_back(entry);
}
let entry = Self::get_entry_unchecked(env, market_id, idx).unwrap_or_else(|| {
panic!("audit log corruption: missing entry {idx}");
});
result.push_back(entry);
idx = idx.saturating_sub(1);
count = count.saturating_add(1);
}
Expand Down
100 changes: 88 additions & 12 deletions contracts/predictify-hybrid/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,15 @@ pub const RESOLUTION_ANALYTICS_STORAGE_KEY: &str = "ResolutionAnalytics";
/// Storage key for oracle statistics
pub const ORACLE_STATS_STORAGE_KEY: &str = "OracleStats";

/// Storage key for the complete contract configuration
pub const CONFIG_STORAGE_KEY: &str = "ContractConfig";

/// Storage key for the configuration update audit history
pub const CONFIG_HISTORY_STORAGE_KEY: &str = "ConfigHistory";

/// Maximum number of configuration update history records retained
pub const CONFIG_HISTORY_MAX_LEN: u32 = 100;

// ===== CONFIGURATION STRUCTS =====

/// Deployment environment specification for the Predictify Hybrid contract.
Expand Down Expand Up @@ -2280,7 +2289,8 @@ impl ConfigManager {
/// - Environment-specific deployments
/// - Configuration resets and migrations
pub fn store_config(env: &Env, config: &ContractConfig) -> Result<(), Error> {
let key = Symbol::new(env, "ContractConfig");
ConfigValidator::validate_contract_config(config)?;
let key = Symbol::new(env, CONFIG_STORAGE_KEY);
env.storage().persistent().set(&key, config);
Ok(())
}
Expand Down Expand Up @@ -2338,7 +2348,7 @@ impl ConfigManager {
/// - Oracle integration and resolution
/// - Admin operations and updates
pub fn get_config(env: &Env) -> Result<ContractConfig, Error> {
let key = Symbol::new(env, "ContractConfig");
let key = Symbol::new(env, CONFIG_STORAGE_KEY);
// Check if key exists before trying to get it to avoid segfaults
match env
.storage()
Expand Down Expand Up @@ -2478,15 +2488,15 @@ impl ConfigManager {

/// Internal helper: push a history record, keep last 100 entries
fn push_history(env: &Env, record: &ConfigUpdateRecord) {
let key = Symbol::new(env, "ConfigHistory");
let key = Symbol::new(env, CONFIG_HISTORY_STORAGE_KEY);
let mut history: soroban_sdk::Vec<ConfigUpdateRecord> = env
.storage()
.persistent()
.get(&key)
.unwrap_or_else(|| soroban_sdk::Vec::new(env));

history.push_back(record.clone());
if history.len() > 100 {
if history.len() > CONFIG_HISTORY_MAX_LEN {
history.remove(0);
}
env.storage().persistent().set(&key, &history);
Expand All @@ -2501,7 +2511,7 @@ impl ConfigManager {
pub fn get_configuration_history(
env: &Env,
) -> Result<soroban_sdk::Vec<ConfigUpdateRecord>, Error> {
let key = Symbol::new(env, "ConfigHistory");
let key = Symbol::new(env, CONFIG_HISTORY_STORAGE_KEY);
Ok(env
.storage()
.persistent()
Expand Down Expand Up @@ -2778,19 +2788,37 @@ impl ConfigValidator {

/// Validate market configuration
pub fn validate_market_config(config: &MarketConfig) -> Result<(), Error> {
if config.max_duration_days < config.min_duration_days {
if config.max_duration_days < MIN_MARKET_DURATION_DAYS
|| config.max_duration_days > MAX_MARKET_DURATION_DAYS
{
return Err(Error::InvalidInput);
}

if config.max_outcomes < config.min_outcomes {
if config.min_duration_days < MIN_MARKET_DURATION_DAYS
|| config.min_duration_days > config.max_duration_days
{
return Err(Error::InvalidInput);
}

if config.max_question_length == 0 {
if config.max_outcomes < MIN_MARKET_OUTCOMES
|| config.max_outcomes > MAX_MARKET_OUTCOMES
{
return Err(Error::InvalidInput);
}

if config.max_outcome_length == 0 {
if config.min_outcomes < MIN_MARKET_OUTCOMES
|| config.min_outcomes > config.max_outcomes
{
return Err(Error::InvalidInput);
}
if config.max_question_length < MIN_QUESTION_LENGTH
|| config.max_question_length > MAX_QUESTION_LENGTH
{
return Err(Error::InvalidInput);
}
if config.max_outcome_length < MIN_OUTCOME_LENGTH
|| config.max_outcome_length > MAX_OUTCOME_LENGTH
{
return Err(Error::InvalidInput);
}
if config.max_active_events_per_creator == 0 {
return Err(Error::InvalidInput);
}

Expand Down Expand Up @@ -3217,4 +3245,52 @@ mod tests {
assert_eq!(ORACLE_RETRY_ATTEMPTS, 3);
assert_eq!(ORACLE_TIMEOUT_SECONDS, 30);
}

#[test]
fn test_store_config_rejects_invalid_configuration() {
let env = Env::default();
let contract_id = env.register(crate::PredictifyHybrid, ());
let mut invalid = ConfigManager::get_development_config(&env);
invalid.fees.platform_fee_percentage = MAX_PLATFORM_FEE_PERCENTAGE + 1;

env.as_contract(&contract_id, || {
assert!(ConfigManager::store_config(&env, &invalid).is_err());
assert!(matches!(
ConfigManager::get_config(&env),
Err(Error::ConfigNotFound)
));
});
}

#[test]
fn test_config_history_retains_last_hundred_records() {
let env = Env::default();
let contract_id = env.register(crate::PredictifyHybrid, ());
let admin = Address::generate(&env);

env.as_contract(&contract_id, || {
let cfg = ConfigManager::get_development_config(&env);
ConfigManager::store_config(&env, &cfg).unwrap();

for i in 0..CONFIG_HISTORY_MAX_LEN + 5 {
let old = String::from_str(&env, &alloc::format!("old-{}", i));
let new = String::from_str(&env, &alloc::format!("new-{}", i));
ConfigManager::push_history(
&env,
&ConfigUpdateRecord {
updated_by: admin.clone(),
change_type: String::from_str(&env, "test"),
old_value: old,
new_value: new,
timestamp: i as u64,
},
);
}

let history = ConfigManager::get_configuration_history(&env).unwrap();
assert_eq!(history.len(), CONFIG_HISTORY_MAX_LEN);
let first_old = history.get(0).unwrap().old_value.clone();
assert_eq!(first_old, String::from_str(&env, "old-5"));
});
}
}
Loading
Loading