Skip to content
Draft
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
345 changes: 67 additions & 278 deletions contracts/predictify-hybrid/METADATA_LIMITS.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions contracts/predictify-hybrid/src/bets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2442,10 +2442,10 @@ mod tests {
stats.outcome_totals.set(outcome.clone(), 1);
BetStorage::store_market_bet_stats(&env, &market_id, &stats).unwrap();

assert_eq!(
assert!(matches!(
BetManager::prepare_market_bet_stats(&env, &market_id, &outcome, 1),
Err(Error::Overflow)
);
));

let stored = BetStorage::get_market_bet_stats(&env, &market_id);
assert_eq!(stored.total_amount_locked, i128::MAX);
Expand Down
5 changes: 5 additions & 0 deletions contracts/predictify-hybrid/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2505,6 +2505,11 @@ impl ConfigManager {
Ok(config)
}

/// Validates complete contract configuration parameters before persistence.
pub fn validate_config(_env: &Env, config: &ContractConfig) -> Result<(), Error> {
ConfigValidator::validate_contract_config(config)
}

/// Internal helper: push a history record, keep last 100 entries
fn push_history(env: &Env, record: &ConfigUpdateRecord) {
let key = Symbol::new(env, CONFIG_HISTORY_STORAGE_KEY);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

#![cfg(test)]

use alloc::format;
use soroban_sdk::{symbol_short, testutils::Events, Env, Symbol, Vec};

use crate::event_topic_compat::{
Expand Down
4 changes: 2 additions & 2 deletions contracts/predictify-hybrid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5507,6 +5507,8 @@ impl PredictifyHybrid {
) -> Result<(), Error> {
Self::require_primary_admin(&env, &admin)?;

crate::metadata_limits::validate_option_category_metadata(&category)?;

// Get market
let mut market: Market = env
.storage()
Expand All @@ -5530,8 +5532,6 @@ impl PredictifyHybrid {
return Err(Error::AlreadyVoted);
}

crate::metadata_limits::validate_option_category_metadata(&category)?;

// Store old category for event
let old_category = market.category.clone();

Expand Down
295 changes: 162 additions & 133 deletions contracts/predictify-hybrid/src/metadata_limits.rs

Large diffs are not rendered by default.

122 changes: 105 additions & 17 deletions contracts/predictify-hybrid/src/metadata_limits_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -573,16 +573,16 @@ mod tests {
#[test]
fn test_question_length_at_limit_with_four_byte_glyphs() {
let env = Env::default();
let host = FOUR_BYTE_GLYPH.repeat(MAX_QUESTION_LENGTH as usize);
let host = FOUR_BYTE_GLYPH.repeat((MAX_QUESTION_BYTES / 4) as usize);
let question = String::from_str(&env, &host);
assert!(validate_question_length(&question).is_ok());
assert_eq!(question.len() as u32, MAX_QUESTION_LENGTH * 4);
assert_eq!(question.len() as u32, MAX_QUESTION_BYTES);
}

#[test]
fn test_question_length_just_over_limit_with_four_byte_glyphs() {
let env = Env::default();
let host = FOUR_BYTE_GLYPH.repeat((MAX_QUESTION_LENGTH + 1) as usize);
let host = FOUR_BYTE_GLYPH.repeat(((MAX_QUESTION_BYTES / 4) + 1) as usize);
let question = String::from_str(&env, &host);
assert_eq!(
validate_question_length(&question),
Expand All @@ -593,21 +593,31 @@ mod tests {
#[test]
fn test_outcome_length_at_limit_with_four_byte_glyphs() {
let env = Env::default();
let host = FOUR_BYTE_GLYPH.repeat(MAX_OUTCOME_LENGTH as usize);
let host = FOUR_BYTE_GLYPH.repeat((MAX_OUTCOME_BYTES / 4) as usize);
assert!(validate_outcome_length(&String::from_str(&env, &host)).is_ok());
}

#[test]
fn test_outcome_length_just_over_limit_with_four_byte_glyphs() {
let env = Env::default();
let host = FOUR_BYTE_GLYPH.repeat(((MAX_OUTCOME_BYTES / 4) + 1) as usize);
assert_eq!(
validate_outcome_length(&String::from_str(&env, &host)),
Err(Error::OutcomeTooLong)
);
}

#[test]
fn test_category_metadata_at_limit_with_four_byte_glyphs() {
let env = Env::default();
let host = FOUR_BYTE_GLYPH.repeat(MAX_CATEGORY_LENGTH as usize);
let host = FOUR_BYTE_GLYPH.repeat((MAX_CATEGORY_BYTES / 4) as usize);
assert!(validate_category_metadata(&String::from_str(&env, &host)).is_ok());
}

#[test]
fn test_tag_metadata_at_limit_with_four_byte_glyphs() {
let env = Env::default();
let host = FOUR_BYTE_GLYPH.repeat(MAX_TAG_LENGTH as usize);
let host = FOUR_BYTE_GLYPH.repeat((MAX_TAG_BYTES / 4) as usize);
assert!(validate_tag_metadata(&String::from_str(&env, &host)).is_ok());
}

Expand Down Expand Up @@ -646,6 +656,84 @@ mod tests {
Err(Error::InvalidInput)
);
}

#[test]
fn test_question_exact_byte_boundary() {
let env = Env::default();
let exact_500_bytes = String::from_str(&env, &"a".repeat(MAX_QUESTION_BYTES as usize));
assert!(validate_question_length(&exact_500_bytes).is_ok());

let over_500_bytes = String::from_str(&env, &"a".repeat((MAX_QUESTION_BYTES + 1) as usize));
assert_eq!(
validate_question_length(&over_500_bytes),
Err(Error::QuestionTooLong)
);
}

#[test]
fn test_multibyte_two_byte_utf8_boundaries() {
let env = Env::default();
let glyph_2b = "\u{00E9}";

let question_at_limit = String::from_str(&env, &glyph_2b.repeat((MAX_QUESTION_BYTES / 2) as usize));
assert_eq!(question_at_limit.len() as u32, MAX_QUESTION_BYTES);
assert!(validate_question_length(&question_at_limit).is_ok());

let question_over_limit = String::from_str(&env, &glyph_2b.repeat(((MAX_QUESTION_BYTES / 2) + 1) as usize));
assert_eq!(
validate_question_length(&question_over_limit),
Err(Error::QuestionTooLong)
);

let outcome_at_limit = String::from_str(&env, &glyph_2b.repeat((MAX_OUTCOME_BYTES / 2) as usize));
assert_eq!(outcome_at_limit.len() as u32, MAX_OUTCOME_BYTES);
assert!(validate_outcome_length(&outcome_at_limit).is_ok());

let outcome_over_limit = String::from_str(&env, &glyph_2b.repeat(((MAX_OUTCOME_BYTES / 2) + 1) as usize));
assert_eq!(
validate_outcome_length(&outcome_over_limit),
Err(Error::OutcomeTooLong)
);

let tag_at_limit = String::from_str(&env, &glyph_2b.repeat((MAX_TAG_BYTES / 2) as usize));
assert_eq!(tag_at_limit.len() as u32, MAX_TAG_BYTES);
assert!(validate_tag_metadata(&tag_at_limit).is_ok());

let tag_over_limit = String::from_str(&env, &glyph_2b.repeat(((MAX_TAG_BYTES / 2) + 1) as usize));
assert_eq!(
validate_tag_metadata(&tag_over_limit),
Err(Error::TagTooLong)
);
}

#[test]
fn test_category_and_tag_byte_boundaries() {
let env = Env::default();

let cat_min = String::from_str(&env, "ab");
assert!(validate_category_metadata(&cat_min).is_ok());

let cat_short = String::from_str(&env, "a");
assert_eq!(validate_category_metadata(&cat_short), Err(Error::CategoryTooShort));

let cat_max = String::from_str(&env, &"a".repeat(MAX_CATEGORY_BYTES as usize));
assert!(validate_category_metadata(&cat_max).is_ok());

let cat_long = String::from_str(&env, &"a".repeat((MAX_CATEGORY_BYTES + 1) as usize));
assert_eq!(validate_category_metadata(&cat_long), Err(Error::CategoryTooLong));

let tag_min = String::from_str(&env, "ab");
assert!(validate_tag_metadata(&tag_min).is_ok());

let tag_short = String::from_str(&env, "a");
assert_eq!(validate_tag_metadata(&tag_short), Err(Error::TagTooShort));

let tag_max = String::from_str(&env, &"a".repeat(MAX_TAG_BYTES as usize));
assert!(validate_tag_metadata(&tag_max).is_ok());

let tag_long = String::from_str(&env, &"a".repeat((MAX_TAG_BYTES + 1) as usize));
assert_eq!(validate_tag_metadata(&tag_long), Err(Error::TagTooLong));
}
}

#[cfg(test)]
Expand Down Expand Up @@ -692,8 +780,8 @@ mod proptest_fuzz {
if host.chars().any(|c| c.is_control()) {
return Err(Error::InvalidInput);
}
let char_len = host.chars().count() as u32;
if char_len > MAX_QUESTION_LENGTH {
let byte_len = host.len() as u32;
if byte_len > MAX_QUESTION_LENGTH {
Err(Error::QuestionTooLong)
} else {
Ok(())
Expand All @@ -704,8 +792,8 @@ mod proptest_fuzz {
if host.chars().any(|c| c.is_control()) {
return Err(Error::InvalidInput);
}
let char_len = host.chars().count() as u32;
if char_len > MAX_OUTCOME_LENGTH {
let byte_len = host.len() as u32;
if byte_len > MAX_OUTCOME_LENGTH {
Err(Error::OutcomeTooLong)
} else {
Ok(())
Expand All @@ -716,10 +804,10 @@ mod proptest_fuzz {
if host.chars().any(|c| c.is_control()) {
return Err(Error::InvalidInput);
}
let char_len = host.chars().count() as u32;
if char_len < MIN_CATEGORY_LENGTH {
let byte_len = host.len() as u32;
if byte_len < MIN_CATEGORY_LENGTH {
Err(Error::CategoryTooShort)
} else if char_len > MAX_CATEGORY_LENGTH {
} else if byte_len > MAX_CATEGORY_LENGTH {
Err(Error::CategoryTooLong)
} else {
Ok(())
Expand All @@ -730,12 +818,12 @@ mod proptest_fuzz {
if host.chars().any(|c| c.is_control()) {
return Err(Error::InvalidInput);
}
let char_len = host.chars().count() as u32;
if char_len == 0 {
let byte_len = host.len() as u32;
if byte_len == 0 {
Err(Error::InvalidInput)
} else if char_len < MIN_TAG_LENGTH {
} else if byte_len < MIN_TAG_LENGTH {
Err(Error::TagTooShort)
} else if char_len > MAX_TAG_LENGTH {
} else if byte_len > MAX_TAG_LENGTH {
Err(Error::TagTooLong)
} else {
Ok(())
Expand Down
43 changes: 27 additions & 16 deletions contracts/predictify-hybrid/src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5096,24 +5096,24 @@ impl OracleConfigValidator {
pub struct CreationValidator;

impl CreationValidator {
fn soroban_string_to_host_string(value: &String) -> StdString {
fn soroban_string_to_host_string(value: &String) -> Result<StdString, Error> {
let mut bytes = alloc::vec![0u8; value.len() as usize];
value.copy_into_slice(&mut bytes);
StdString::from_utf8(bytes).unwrap_or_else(|_| StdString::from("invalid_utf8"))
StdString::from_utf8(bytes).map_err(|_| Error::InvalidInput)
}

fn validate_non_empty_text(
value: &String,
min_length: u32,
max_length: u32,
) -> Result<(), Error> {
let trimmed = Self::soroban_string_to_host_string(value);
let trimmed = Self::soroban_string_to_host_string(value)?;
let normalized = trimmed.trim();
if normalized.is_empty() {
return Err(Error::InvalidQuestion);
}

let length = normalized.chars().count() as u32;
let length = normalized.len() as u32;
if length < min_length || length > max_length {
return Err(Error::InvalidQuestion);
}
Expand Down Expand Up @@ -5156,13 +5156,14 @@ impl CreationValidator {
}

for outcome in outcomes.iter() {
let normalized = Self::soroban_string_to_host_string(&outcome);
let normalized = Self::soroban_string_to_host_string(&outcome)
.map_err(|_| Error::InvalidOutcomes)?;
let trimmed = normalized.trim();
if trimmed.is_empty() {
return Err(Error::InvalidOutcomes);
}

let length = trimmed.chars().count() as u32;
let length = trimmed.len() as u32;
if length < config::MIN_OUTCOME_LENGTH || length > max_outcome_length {
return Err(Error::InvalidOutcomes);
}
Expand Down Expand Up @@ -5245,10 +5246,10 @@ impl CreationValidator {
pub struct OutcomeDeduplicator;

impl OutcomeDeduplicator {
fn soroban_string_to_host_string(value: &String) -> StdString {
fn soroban_string_to_host_string(value: &String) -> Result<StdString, ValidationError> {
let mut bytes = alloc::vec![0u8; value.len() as usize];
value.copy_into_slice(&mut bytes);
StdString::from_utf8(bytes).unwrap_or_else(|_| StdString::from("invalid_utf8"))
StdString::from_utf8(bytes).map_err(|_| ValidationError::OutcomeNormalizationFailed)
}

/// Normalizes an outcome string for comparison.
Expand Down Expand Up @@ -5291,8 +5292,7 @@ impl OutcomeDeduplicator {
/// - Optimized for gas efficiency
/// - Resistant to Unicode manipulation attacks
pub fn normalize_outcome(outcome: &String) -> Result<String, ValidationError> {
// Convert to string slice for manipulation
let outcome_str = Self::soroban_string_to_host_string(outcome);
let outcome_str = Self::soroban_string_to_host_string(outcome)?;

// Step 1: Trim leading and trailing whitespace
let trimmed = outcome_str.trim();
Expand Down Expand Up @@ -5377,8 +5377,14 @@ impl OutcomeDeduplicator {
/// - Early termination for very different strings
/// - Gas-efficient for typical outcome lengths (< 50 chars)
pub fn calculate_similarity(outcome1: &String, outcome2: &String) -> u32 {
let s1 = Self::soroban_string_to_host_string(outcome1);
let s2 = Self::soroban_string_to_host_string(outcome2);
let s1 = match Self::soroban_string_to_host_string(outcome1) {
Ok(s) => s,
Err(_) => return 0,
};
let s2 = match Self::soroban_string_to_host_string(outcome2) {
Ok(s) => s,
Err(_) => return 0,
};

if s1.is_empty() && s2.is_empty() {
return 100;
Expand Down Expand Up @@ -5550,8 +5556,14 @@ impl OutcomeDeduplicator {
///
/// * `bool` - True if outcomes are semantic duplicates
fn is_semantic_duplicate(outcome1: &String, outcome2: &String) -> bool {
let s1 = Self::soroban_string_to_host_string(outcome1);
let s2 = Self::soroban_string_to_host_string(outcome2);
let s1 = match Self::soroban_string_to_host_string(outcome1) {
Ok(s) => s,
Err(_) => return false,
};
let s2 = match Self::soroban_string_to_host_string(outcome2) {
Ok(s) => s,
Err(_) => return false,
};

// Affirmative outcomes
let affirmative = ["yes", "yeah", "yep", "true", "correct", "agree", "positive"];
Expand Down Expand Up @@ -5703,8 +5715,7 @@ impl ContractInitializationValidator {
OracleConfigValidator::validate_resolution_timeout(resolution_timeout)
.map_err(|_| Error::InvalidDuration)?;

// Oracle configuration must be internally consistent before storage.
OracleValidator::validate_oracle_config_all_together(oracle_config)
OracleConfigValidator::validate_oracle_config_all_together(oracle_config)
.map_err(|_| Error::InvalidOracleConfig)?;

Ok(())
Expand Down
Loading