diff --git a/contracts/predictify-hybrid/METADATA_LIMITS.md b/contracts/predictify-hybrid/METADATA_LIMITS.md index b6fcd184..a7e38d8b 100644 --- a/contracts/predictify-hybrid/METADATA_LIMITS.md +++ b/contracts/predictify-hybrid/METADATA_LIMITS.md @@ -2,294 +2,83 @@ ## Overview -This document describes the metadata length limits implementation for the Predictify Hybrid smart contract. These limits are designed to control storage costs, prevent denial-of-service attacks, and ensure predictable gas consumption. +This document describes the metadata length limits and encoding validation implementation for the Predictify Hybrid smart contract. These limits control ledger storage footprints, prevent denial-of-service and byte-inflation attacks, ensure predictable gas consumption, and reject malformed inputs deterministically before any state mutation occurs. ## Security Rationale ### Threat Model -Without metadata length limits, the contract is vulnerable to several attack vectors: +Without strict byte limits and encoding validation, the contract is vulnerable to several attack vectors: -1. **Storage DoS**: Attackers could create markets with extremely large metadata (e.g., 10KB questions, hundreds of outcomes), consuming excessive storage and making the contract expensive or impossible to use. - -2. **Gas Exhaustion**: Operations that iterate over large vectors (e.g., validating 1000 outcomes) could exceed gas limits, causing legitimate transactions to fail. - -3. **Economic Attack**: Malicious actors could force the platform to pay high storage costs by creating markets with bloated metadata. - -4. **Data Integrity**: Unreasonably large inputs may indicate malformed or malicious data that should be rejected. +1. **Storage DoS and Byte-Inflation**: Attackers could submit inputs with multi-byte UTF-8 glyphs to inflate ledger storage beyond character-based expectations, consuming persistent contract storage. +2. **Malformed Encoding and Parser Exploits**: Unvalidated byte streams or non-canonical encodings could cause deserialization crashes or state corruption in indexers and downstream clients. +3. **Control Character Injection**: Embedded control characters (`0x00..=0x1F`) could desynchronize client UIs, break log parsers, or poison oracle feed matching. +4. **Unbounded Collections**: Iterating over unconstrained outcome vectors or tag lists risks gas exhaustion, blocking legitimate market resolution. ### Defense Strategy -The implemented limits provide defense-in-depth: +The implementation provides defense-in-depth: -- **Conservative Bounds**: Limits are set well above legitimate use cases but far below abuse thresholds -- **Early Validation**: Checks occur during market creation, before storage costs are incurred -- **Clear Error Messages**: Users receive specific feedback about which limit was exceeded -- **Auditor-Friendly**: All limits are defined as named constants with clear documentation +- **Byte-Accurate Bounds**: All text length constraints are measured and validated in raw UTF-8 bytes rather than Unicode scalar values, guaranteeing an upper bound on storage size. +- **Fail-Early Validation**: Encoding checks, control character rejections, and length bounds are enforced before any storage reads or mutations. +- **Deterministic Rejection**: Invalid UTF-8 and control characters immediately yield `Error::InvalidInput`. +- **Authorization and Versioning Integrity**: Administrative updates require primary admin signatures and immediately update the cryptographic metadata commitment (`refresh_metadata_commitment`), invalidating stale client state. ## Implemented Limits -### String Length Limits - -| Field | Limit | Rationale | -| ------------------- | --------- | -------------------------------------------------------------- | -| Question | 500 chars | Most questions are 50-150 chars; 500 allows detailed questions | -| Outcome Label | 100 chars | Labels like "yes", "no", "Team A wins" are typically <20 chars | -| Oracle Feed ID | 200 chars | Accommodates Pyth's 64-char hex IDs with headroom | -| Comparison Operator | 10 chars | Valid operators are 2-3 chars ("gt", "lt", "eq") | -| Category | 50 chars | Categories like "crypto", "sports" are typically <20 chars | -| Tag | 30 chars | Individual tags should be concise keywords | -| Extension Reason | 300 chars | Allows detailed justification for extensions | -| Source Identifier | 100 chars | Oracle source identifiers and URLs | -| Error Message | 200 chars | Informative error descriptions | -| Signature | 500 chars | Accommodates base64-encoded cryptographic signatures | - -### Vector Length Limits - -| Field | Limit | Rationale | -| ----------------- | ----- | ---------------------------------------------------------------- | -| Outcomes | 20 | Most markets are binary (2); multiple choice rarely needs >10 | -| Tags | 10 | Sufficient for comprehensive categorization | -| Extension History | 50 | Prevents unbounded growth; markets shouldn't extend indefinitely | -| Oracle Results | 10 | Multi-oracle consensus typically uses 3-5 sources | -| Winning Outcomes | 10 | Handles tie scenarios without excessive storage | - -## Implementation Details - -### Module Structure - -``` -contracts/predictify-hybrid/src/ -├── metadata_limits.rs # Core limits and validation functions -├── metadata_limits_tests.rs # Comprehensive test suite -├── types.rs # Integration with existing types -└── err.rs # New error codes -``` - -### New Error Codes - -The following error codes were added (420-434): - -- `QuestionTooLong` (420) -- `OutcomeTooLong` (421) -- `TooManyOutcomes` (422) -- `FeedIdTooLong` (423) -- `ComparisonTooLong` (424) -- `CategoryTooLong` (425) -- `TagTooLong` (426) -- `TooManyTags` (427) -- `ExtensionReasonTooLong` (428) -- `SourceTooLong` (429) -- `ErrorMessageTooLong` (430) -- `SignatureTooLong` (431) -- `TooManyExtensions` (432) -- `TooManyOracleResults` (433) -- `TooManyWinningOutcomes` (434) - -### Validation Integration - -Validation is integrated at multiple levels: - -1. **Type-Level Validation**: `OracleConfig::validate()` and `Market::validate()` call metadata limit checks -2. **Creation-Time Validation**: Market creation validates all metadata before storage -3. **Extension Validation**: `MarketExtension::validate()` checks extension reasons -4. **Explicit Validation**: Public validation functions can be called directly - -### Example Usage - -```rust -use predictify_hybrid::metadata_limits::*; - -// Validate a question -let question = String::from_str(&env, "Will BTC reach $100k?"); -validate_question_length(&question)?; - -// Validate outcomes -let outcomes = Vec::from_array(&env, [ - String::from_str(&env, "yes"), - String::from_str(&env, "no"), -]); -validate_outcomes_count(&outcomes)?; -validate_outcomes_length(&outcomes)?; - -// Validate tags -let tags = Vec::from_array(&env, [ - String::from_str(&env, "bitcoin"), - String::from_str(&env, "crypto"), -]); -validate_tags_count(&tags)?; -validate_tags_length(&tags)?; -``` - -## Testing - -### Test Coverage - -The implementation includes comprehensive tests: - -- **String Length Tests**: Valid, at-limit, and exceeds-limit cases for all string fields -- **Vector Length Tests**: Valid, at-limit, and exceeds-limit cases for all vector fields -- **Integration Tests**: Validation through `OracleConfig`, `Market`, and `MarketExtension` -- **Edge Case Tests**: Empty strings, empty vectors, zero counts - -### Running Tests - -```bash -cd contracts/predictify-hybrid -cargo test metadata_limits -``` - -### Test Results - -All tests pass, validating: - -- ✅ Valid inputs are accepted -- ✅ Inputs at limits are accepted -- ✅ Inputs exceeding limits are rejected with correct error codes -- ✅ Integration with existing types works correctly -- ✅ Edge cases are handled properly - -## Security Considerations - -### Audit Checklist - -- [x] All string fields have maximum length limits -- [x] All vector fields have maximum count limits -- [x] Limits are enforced before storage operations -- [x] Error messages clearly indicate which limit was exceeded -- [x] Limits are documented with rationale -- [x] Tests validate enforcement at boundaries -- [x] Integration with existing validation is complete - -### Known Limitations - -1. **UTF-8 Considerations**: Limits are based on byte length, not character count. Multi-byte UTF-8 characters may result in fewer visible characters than the limit suggests. - -2. **Gas Costs**: While limits prevent excessive gas consumption, they don't guarantee operations will complete within block gas limits in all scenarios. - -3. **Future Extensibility**: Increasing limits in future versions requires careful consideration of backward compatibility with existing markets. - -### Recommendations for Auditors - -1. **Verify Constant Values**: Review that limit constants are reasonable for the use case -2. **Check Validation Coverage**: Ensure all user-provided strings and vectors are validated -3. **Test Boundary Conditions**: Verify behavior at exact limit values -4. **Review Error Handling**: Confirm appropriate errors are returned for each violation -5. **Assess Gas Impact**: Consider gas costs of validation operations - -## Integration Guide - -### For Frontend Developers - -Implement client-side validation to provide immediate feedback: - -```javascript -const LIMITS = { - MAX_QUESTION_LENGTH: 500, - MAX_OUTCOME_LENGTH: 100, - MAX_OUTCOMES_COUNT: 20, - MAX_TAG_LENGTH: 30, - MAX_TAGS_COUNT: 10, - MAX_CATEGORY_LENGTH: 50, - MAX_EXTENSION_REASON_LENGTH: 300, -}; - -function validateMarketCreation(params) { - if (params.question.length > LIMITS.MAX_QUESTION_LENGTH) { - throw new Error( - `Question exceeds ${LIMITS.MAX_QUESTION_LENGTH} characters`, - ); - } - - if (params.outcomes.length > LIMITS.MAX_OUTCOMES_COUNT) { - throw new Error(`Too many outcomes (max ${LIMITS.MAX_OUTCOMES_COUNT})`); - } - - for (const outcome of params.outcomes) { - if (outcome.length > LIMITS.MAX_OUTCOME_LENGTH) { - throw new Error( - `Outcome "${outcome}" exceeds ${LIMITS.MAX_OUTCOME_LENGTH} characters`, - ); - } - } - - // ... additional validations -} -``` - -### For Backend Services - -When creating markets programmatically, validate inputs before submission: - -```rust -use predictify_hybrid::metadata_limits::*; - -fn create_market_safe(params: MarketParams) -> Result<(), Error> { - // Validate all metadata before contract call - validate_question_length(¶ms.question)?; - validate_outcomes_count(¶ms.outcomes)?; - validate_outcomes_length(¶ms.outcomes)?; - validate_tags_count(¶ms.tags)?; - validate_tags_length(¶ms.tags)?; - - // Proceed with contract call - contract.create_market(params) -} -``` - -## Performance Impact - -### Storage Savings - -Assuming average market metadata: - -- Question: 100 chars (vs potential 10KB without limits) -- Outcomes: 3 outcomes × 20 chars (vs potential 100 outcomes × 1KB) -- Tags: 5 tags × 15 chars (vs potential 50 tags × 100 chars) - -**Estimated storage savings per market**: ~95% reduction in worst-case storage - -### Gas Consumption - -Validation adds minimal gas overhead: - -- String length check: O(1) operation -- Vector length check: O(1) operation -- Per-element validation: O(n) where n is bounded by limits - -**Estimated gas overhead**: <1% of total market creation cost - -## Future Considerations - -### Potential Adjustments - -If usage patterns indicate limits are too restrictive: - -1. **Increase Limits**: Can be done in contract upgrade with backward compatibility -2. **Tiered Limits**: Different limits for different market types or user tiers -3. **Dynamic Limits**: Adjust limits based on network conditions or governance - -### Monitoring - -Track metrics to inform future adjustments: - -- Distribution of actual metadata sizes -- Frequency of limit violations -- User feedback on restrictiveness -- Storage cost trends - -## Conclusion - -The metadata length limits implementation provides robust protection against storage DoS attacks and excessive gas consumption while maintaining flexibility for legitimate use cases. The implementation is: - -- **Secure**: Prevents known attack vectors -- **Tested**: Comprehensive test coverage validates correctness -- **Documented**: Clear rationale and usage examples -- **Auditor-Friendly**: Easy to review and verify -- **User-Friendly**: Clear error messages guide users to valid inputs - -## References - -- [Soroban Storage Best Practices](https://soroban.stellar.org/docs/learn/storage) -- [Smart Contract Security Patterns](https://consensys.github.io/smart-contract-best-practices/) -- [Gas Optimization Techniques](https://soroban.stellar.org/docs/learn/optimization) +### String Byte Limits + +| Field | Min Bytes | Max Bytes | Error Code | Rationale | +| --- | --- | --- | --- | --- | +| Question / Description | 10 | 500 | `QuestionTooLong` (420) / `InvalidQuestion` (3) | Accommodates clear, detailed market questions | +| Outcome Label | 2 | 100 | `OutcomeTooLong` (421) / `InvalidOutcomes` (4) | Prevents bloated outcome strings | +| Category | 2 | 100 | `CategoryTooShort` (440) / `CategoryTooLong` (425) | Concise topic taxonomy | +| Tag | 2 | 50 | `TagTooShort` (441) / `TagTooLong` (426) | Keyword indexing | +| Oracle Feed ID | 1 | 200 | `FeedIdTooLong` (423) | Supports Pyth 64-char hex strings with headroom | +| Comparison Operator | 1 | 10 | `ComparisonTooLong` (424) | Operator tokens ("gt", "lt", "eq") | +| Extension Reason | 1 | 300 | `ExtensionReasonTooLong` (428) | Audit trail explanation for resolution delay | +| Source Identifier | 1 | 100 | `SourceTooLong` (429) | Oracle endpoint and identifier strings | +| Error Message | 1 | 200 | `ErrorMessageTooLong` (430) | Bounded failure diagnostic text | +| Signature | 1 | 500 | `SignatureTooLong` (431) | Base64-encoded cryptographic signatures | + +### Vector Collection Limits + +| Field | Max Count | Error Code | Rationale | +| --- | --- | --- | --- | +| Outcomes | 20 | `TooManyOutcomes` (422) | Minimum 2, maximum 20; prevents high computation loops | +| Tags | 10 | `TooManyTags` (427) | Bounded indexing set per event | +| Extension History | 50 | `TooManyExtensions` (432) | Prevents infinite market extensions | +| Oracle Results | 10 | `TooManyOracleResults` (433) | Multi-oracle quorum cap | +| Winning Outcomes | 10 | `TooManyWinningOutcomes` (434) | Handles tie distribution safely | + +## Encoding Validation and Normalization + +### UTF-8 and Control Character Scanning + +All user-supplied string fields are validated via `metadata_limits::scan_metadata_text`, which: +1. Validates that the underlying byte slice is valid UTF-8 via `core::str::from_utf8`. If decoding fails, it deterministically returns `Error::InvalidInput`. +2. Inspects characters for ASCII control codes (`0x00..=0x1F`). If any control code is detected, it returns `Error::InvalidInput`. +3. Returns the exact UTF-8 byte length for boundary comparison. + +### Outcome Normalization and Deduplication + +To prevent ambiguous outcomes (such as "Yes", "yes ", "YES!") and front-running on typographical variants: +1. Outcomes are trimmed of leading and trailing whitespace. +2. Internal whitespace sequences are collapsed to single spaces. +3. Common punctuation characters are stripped for comparison. +4. Levenshtein distance similarity (>80%) and semantic synonym clusters are rejected. + +## State Mutation and Versioning Preservation + +Metadata updates (`update_event_description`, `update_event_outcomes`, `update_event_category`, `update_event_tags`): +- Enforce caller authorization via `Self::require_primary_admin(&env, &admin)`. +- Execute all encoding and limit validations before modifying storage. +- Reject updates if any bets have already been placed (`Error::BetsAlreadyPlaced`). +- Compute and persist updated metadata hashes via `market.refresh_metadata_commitment(&env)`. + +## Verification and Testing + +The implementation is verified by unit, boundary, and property-based fuzz tests: +- **Exact Byte Boundaries**: Verifies acceptance at byte limit and rejection at limit + 1 byte for single-byte ASCII and multi-byte UTF-8 sequences. +- **Deterministic Rejection**: Verifies rejection of invalid UTF-8 bytes and ASCII control characters before mutation. +- **Property-Based Fuzzing**: Uses `proptest` over arbitrary printable ASCII, Unicode symbols, and control character injections. diff --git a/contracts/predictify-hybrid/src/bets.rs b/contracts/predictify-hybrid/src/bets.rs index f3eb6f82..1f35f22c 100644 --- a/contracts/predictify-hybrid/src/bets.rs +++ b/contracts/predictify-hybrid/src/bets.rs @@ -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); diff --git a/contracts/predictify-hybrid/src/config.rs b/contracts/predictify-hybrid/src/config.rs index 1f46cf6c..1bba94b0 100644 --- a/contracts/predictify-hybrid/src/config.rs +++ b/contracts/predictify-hybrid/src/config.rs @@ -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); diff --git a/contracts/predictify-hybrid/src/event_topic_compat_tests.rs b/contracts/predictify-hybrid/src/event_topic_compat_tests.rs index b460c3f8..ec086f77 100644 --- a/contracts/predictify-hybrid/src/event_topic_compat_tests.rs +++ b/contracts/predictify-hybrid/src/event_topic_compat_tests.rs @@ -25,6 +25,7 @@ #![cfg(test)] +use alloc::format; use soroban_sdk::{symbol_short, testutils::Events, Env, Symbol, Vec}; use crate::event_topic_compat::{ diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index b82600ba..18df3fa0 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -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() @@ -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(); diff --git a/contracts/predictify-hybrid/src/metadata_limits.rs b/contracts/predictify-hybrid/src/metadata_limits.rs index 3631c08c..fbe39566 100644 --- a/contracts/predictify-hybrid/src/metadata_limits.rs +++ b/contracts/predictify-hybrid/src/metadata_limits.rs @@ -1,47 +1,35 @@ -/// Metadata length limits for controlling storage costs and preventing denial-of-service attacks. +/// Metadata size and encoding limits for controlling storage costs and preventing denial-of-service attacks. /// -/// This module defines maximum length constraints for strings and vectors used throughout -/// the Predictify Hybrid smart contract. These limits serve multiple purposes: +/// This module defines maximum size constraints in bytes and vector limits used throughout +/// the Predictify Hybrid smart contract. /// -/// # Length semantics (Unicode scalar values, not UTF-8 bytes) +/// # Length Semantics (UTF-8 Bytes) /// -/// All string limits in this module are enforced on **Unicode scalar value count** (the same -/// notion as Rust's [`str::chars`]), **not** on [`String::len`] byte length. For example, -/// `"😀"` counts as **one** character toward the limit even though it occupies four UTF-8 bytes. -/// This matches the documented "N characters" limits and aligns with -/// [`crate::validation::CreationValidator`], which also counts `.chars()`. +/// All string limits in this module are measured and enforced in UTF-8 byte length +/// ([`String::len`]). This guarantees that storage allocations and serialized footprints +/// on the ledger are deterministically bounded, preventing byte inflation attacks where +/// multi-byte characters could consume excessive storage within a character-based limit. /// -/// Invalid UTF-8 and strings containing Unicode control characters (`char::is_control`) are -/// rejected with [`crate::Error::InvalidInput`]. +/// Malformed UTF-8 byte sequences and strings containing Unicode control characters +/// (`char::is_control`) are deterministically rejected with [`crate::Error::InvalidInput`] +/// before any mutation occurs. /// /// # Security Benefits /// -/// - **DoS Prevention**: Prevents attackers from creating markets with excessively large metadata -/// - **Storage Cost Control**: Caps storage requirements to predictable, manageable levels -/// - **Gas Optimization**: Ensures operations complete within reasonable gas budgets -/// - **Data Integrity**: Enforces reasonable bounds on user-provided data +/// - DoS Prevention: Prevents attackers from creating markets with excessively large metadata +/// - Storage Cost Control: Caps storage requirements to predictable, manageable levels +/// - Gas Optimization: Ensures operations complete within reasonable gas budgets +/// - Data Integrity: Enforces strict bounds on user-provided data /// /// # Design Principles /// -/// 1. **Conservative Limits**: Set to accommodate legitimate use cases while preventing abuse -/// 2. **Auditor-Friendly**: Clear, documented constants that are easy to review -/// 3. **Upgrade Path**: Limits can be adjusted in future contract versions if needed -/// 4. **User Experience**: Generous enough to not hinder normal usage patterns -/// -/// # Usage Example -/// -/// ```rust -/// use predictify_hybrid::metadata_limits::{MAX_QUESTION_LENGTH, validate_question_length}; -/// -/// let question = String::from_str(&env, "Will BTC reach $100k?"); -/// validate_question_length(&question)?; // Returns Ok if within limits -/// ``` +/// 1. Conservative Limits: Set to accommodate legitimate use cases while preventing abuse +/// 2. Auditor-Friendly: Clear, documented constants that are easy to review +/// 3. Upgrade Path: Limits can be adjusted in future contract versions if needed +/// 4. User Experience: Generous enough to not hinder normal usage patterns use soroban_sdk::{String, Vec}; // ===== CATEGORY / TAG LIMITS (canonical: `config`) ===== -// -// These re-exports keep a single source of truth in `config` while preserving the -// `metadata_limits` API for integrators and audit review. /// Maximum length of a market category name (from [`crate::config::MAX_CATEGORY_LENGTH`]). pub const MAX_CATEGORY_LENGTH: u32 = crate::config::MAX_CATEGORY_LENGTH; @@ -54,102 +42,118 @@ pub const MIN_TAG_LENGTH: u32 = crate::config::MIN_TAG_LENGTH; /// Maximum number of tags per market (from [`crate::config::MAX_TAGS_PER_MARKET`]). pub const MAX_TAGS_COUNT: u32 = crate::config::MAX_TAGS_PER_MARKET; -// ===== STRING LENGTH LIMITS ===== +// ===== BYTE LIMIT CONSTANTS ===== -/// Maximum length for market question text (500 characters) -/// -/// Rationale: Questions should be concise and clear. 500 characters allows for -/// detailed questions while preventing storage abuse. Most questions are 50-150 chars. -pub const MAX_QUESTION_LENGTH: u32 = 500; +/// Maximum size in bytes for market question text (500 bytes). +pub const MAX_QUESTION_BYTES: u32 = 500; +/// Minimum size in bytes for market question text (10 bytes). +pub const MIN_QUESTION_BYTES: u32 = 10; -/// Maximum length for outcome labels (100 characters) -/// -/// Rationale: Outcome labels should be short and descriptive. 100 characters is -/// generous for labels like "yes", "no", "under_50k", "Team A wins", etc. -pub const MAX_OUTCOME_LENGTH: u32 = 100; +/// Maximum size in bytes for outcome labels (100 bytes). +pub const MAX_OUTCOME_BYTES: u32 = 100; +/// Minimum size in bytes for outcome labels (2 bytes). +pub const MIN_OUTCOME_BYTES: u32 = 2; -/// Maximum length for oracle feed IDs (200 characters) -/// -/// Rationale: Most feed IDs are short (e.g., "BTC/USD" = 7 chars). Pyth uses -/// 64-char hex strings. 200 chars provides headroom for future oracle formats. -pub const MAX_FEED_ID_LENGTH: u32 = 200; +/// Maximum size in bytes for event description text (1000 bytes). +pub const MAX_DESCRIPTION_BYTES: u32 = 1000; +/// Minimum size in bytes for event description text (0 bytes). +pub const MIN_DESCRIPTION_BYTES: u32 = 0; -/// Maximum length for comparison operators (10 characters) -/// -/// Rationale: Valid operators are "gt", "lt", "eq" (2-3 chars). 10 chars allows -/// for future operators like "gte", "lte", "between" while preventing abuse. -pub const MAX_COMPARISON_LENGTH: u32 = 10; +/// Maximum size in bytes for a single tag (50 bytes). +pub const MAX_TAG_BYTES: u32 = 50; +/// Minimum size in bytes for a single tag (2 bytes). +pub const MIN_TAG_BYTES: u32 = 2; -/// Maximum length for extension reason text (300 characters) -/// -/// Rationale: Extension reasons should explain the justification. 300 chars allows -/// for detailed explanations like "Low participation detected, extending to allow -/// more users to participate and ensure fair market resolution." -pub const MAX_EXTENSION_REASON_LENGTH: u32 = 300; +/// Maximum size in bytes for a market category name (100 bytes). +pub const MAX_CATEGORY_BYTES: u32 = 100; +/// Minimum size in bytes for a market category name (2 bytes). +pub const MIN_CATEGORY_BYTES: u32 = 2; -/// Maximum length for oracle source identifiers (100 characters) -/// -/// Rationale: Source identifiers like "reflector-mainnet" or oracle URLs should -/// be reasonably short. 100 chars accommodates most identifier formats. -pub const MAX_SOURCE_LENGTH: u32 = 100; +/// Maximum size in bytes for oracle feed IDs (200 bytes). +pub const MAX_FEED_ID_BYTES: u32 = 200; -/// Maximum length for error messages (200 characters) -/// -/// Rationale: Error messages should be informative but concise. 200 chars allows -/// for detailed error descriptions without excessive storage costs. -pub const MAX_ERROR_MESSAGE_LENGTH: u32 = 200; +/// Maximum size in bytes for comparison operators (10 bytes). +pub const MAX_COMPARISON_BYTES: u32 = 10; -/// Maximum length for signature strings (500 characters) -/// -/// Rationale: Cryptographic signatures can be lengthy when encoded. 500 chars -/// accommodates most signature formats including base64-encoded signatures. -pub const MAX_SIGNATURE_LENGTH: u32 = 500; +/// Maximum size in bytes for extension reason text (300 bytes). +pub const MAX_EXTENSION_REASON_BYTES: u32 = 300; + +/// Maximum size in bytes for oracle source identifiers (100 bytes). +pub const MAX_SOURCE_BYTES: u32 = 100; + +/// Maximum size in bytes for error messages (200 bytes). +pub const MAX_ERROR_MESSAGE_BYTES: u32 = 200; + +/// Maximum size in bytes for signature strings (500 bytes). +pub const MAX_SIGNATURE_BYTES: u32 = 500; + +// ===== BACKWARD COMPATIBLE LENGTH ALIASES ===== + +/// Maximum length for market question text (alias to MAX_QUESTION_BYTES). +pub const MAX_QUESTION_LENGTH: u32 = MAX_QUESTION_BYTES; +/// Minimum length for market question text (alias to MIN_QUESTION_BYTES). +pub const MIN_QUESTION_LENGTH: u32 = MIN_QUESTION_BYTES; + +/// Maximum length for outcome labels (alias to MAX_OUTCOME_BYTES). +pub const MAX_OUTCOME_LENGTH: u32 = MAX_OUTCOME_BYTES; +/// Minimum length for outcome labels (alias to MIN_OUTCOME_BYTES). +pub const MIN_OUTCOME_LENGTH: u32 = MIN_OUTCOME_BYTES; + +/// Maximum length for oracle feed IDs (alias to MAX_FEED_ID_BYTES). +pub const MAX_FEED_ID_LENGTH: u32 = MAX_FEED_ID_BYTES; + +/// Maximum length for comparison operators (alias to MAX_COMPARISON_BYTES). +pub const MAX_COMPARISON_LENGTH: u32 = MAX_COMPARISON_BYTES; + +/// Maximum length for extension reason text (alias to MAX_EXTENSION_REASON_BYTES). +pub const MAX_EXTENSION_REASON_LENGTH: u32 = MAX_EXTENSION_REASON_BYTES; + +/// Maximum length for oracle source identifiers (alias to MAX_SOURCE_BYTES). +pub const MAX_SOURCE_LENGTH: u32 = MAX_SOURCE_BYTES; + +/// Maximum length for error messages (alias to MAX_ERROR_MESSAGE_BYTES). +pub const MAX_ERROR_MESSAGE_LENGTH: u32 = MAX_ERROR_MESSAGE_BYTES; + +/// Maximum length for signature strings (alias to MAX_SIGNATURE_BYTES). +pub const MAX_SIGNATURE_LENGTH: u32 = MAX_SIGNATURE_BYTES; + +/// Maximum length for event description text (alias to MAX_DESCRIPTION_BYTES). +pub const MAX_DESCRIPTION_LENGTH: u32 = MAX_DESCRIPTION_BYTES; +/// Minimum length for event description text (alias to MIN_DESCRIPTION_BYTES). +pub const MIN_DESCRIPTION_LENGTH: u32 = MIN_DESCRIPTION_BYTES; // ===== VECTOR LENGTH LIMITS ===== -/// Maximum number of outcomes per market (20 outcomes) -/// -/// Rationale: Most markets are binary (2 outcomes). Multiple choice markets rarely -/// need more than 5-10 options. 20 provides flexibility while preventing abuse. +/// Maximum number of outcomes per market (20 outcomes). pub const MAX_OUTCOMES_COUNT: u32 = 20; -/// Maximum number of extension history entries (50 extensions) -/// -/// Rationale: Markets should not be extended indefinitely. 50 extensions is -/// extremely generous and prevents unbounded growth of extension history. +/// Maximum number of extension history entries (50 extensions). pub const MAX_EXTENSION_HISTORY_COUNT: u32 = 50; -/// Maximum number of individual oracle results in multi-oracle aggregation (10 oracles) -/// -/// Rationale: Multi-oracle consensus typically uses 3-5 sources. 10 provides -/// headroom for high-security markets while preventing storage bloat. +/// Maximum number of individual oracle results in multi-oracle aggregation (10 oracles). pub const MAX_ORACLE_RESULTS_COUNT: u32 = 10; -/// Maximum number of winning outcomes (10 outcomes) -/// -/// Rationale: In tie scenarios, multiple outcomes can win. 10 is generous for -/// most tie-breaking scenarios while preventing abuse. +/// Maximum number of winning outcomes (10 outcomes). pub const MAX_WINNING_OUTCOMES_COUNT: u32 = 10; // ===== VALIDATION FUNCTIONS ===== fn scan_metadata_text(value: &String) -> Result<(u32, bool), crate::Error> { - let byte_len = value.len() as usize; + let byte_len = value.len(); if byte_len == 0 { return Ok((0, false)); } - let mut bytes = alloc::vec![0u8; byte_len]; + let mut bytes = alloc::vec![0u8; byte_len as usize]; value.copy_into_slice(&mut bytes); let text = core::str::from_utf8(&bytes).map_err(|_| crate::Error::InvalidInput)?; - let mut char_count = 0u32; let mut has_control = false; for c in text.chars() { - char_count = char_count.saturating_add(1); if c.is_control() { has_control = true; + break; } } - Ok((char_count, has_control)) + Ok((byte_len, has_control)) } fn reject_control_characters(value: &String) -> Result<(), crate::Error> { @@ -160,27 +164,25 @@ fn reject_control_characters(value: &String) -> Result<(), crate::Error> { Ok(()) } -/// Validates that a question string is within the maximum allowed length. -/// -/// Limits are measured in Unicode scalar values, not UTF-8 bytes. +/// Validates that a question string is within the maximum allowed byte length and contains valid UTF-8. pub fn validate_question_length(question: &String) -> Result<(), crate::Error> { - let (len, has_control) = scan_metadata_text(question)?; + let (byte_len, has_control) = scan_metadata_text(question)?; if has_control { return Err(crate::Error::InvalidInput); } - if len > MAX_QUESTION_LENGTH { + if byte_len > MAX_QUESTION_BYTES { return Err(crate::Error::QuestionTooLong); } Ok(()) } -/// Validates that an outcome string is within the maximum allowed length. +/// Validates that an outcome string is within the maximum allowed byte length and contains valid UTF-8. pub fn validate_outcome_length(outcome: &String) -> Result<(), crate::Error> { - let (len, has_control) = scan_metadata_text(outcome)?; + let (byte_len, has_control) = scan_metadata_text(outcome)?; if has_control { return Err(crate::Error::InvalidInput); } - if len > MAX_OUTCOME_LENGTH { + if byte_len > MAX_OUTCOME_BYTES { return Err(crate::Error::OutcomeTooLong); } Ok(()) @@ -202,41 +204,47 @@ pub fn validate_outcomes_count(outcomes: &Vec) -> Result<(), crate::Erro } pub fn validate_feed_id_length(feed_id: &String) -> Result<(), crate::Error> { - reject_control_characters(feed_id)?; - let (len, _) = scan_metadata_text(feed_id)?; - if len > MAX_FEED_ID_LENGTH { + let (byte_len, has_control) = scan_metadata_text(feed_id)?; + if has_control { + return Err(crate::Error::InvalidInput); + } + if byte_len > MAX_FEED_ID_BYTES { return Err(crate::Error::FeedIdTooLong); } Ok(()) } pub fn validate_comparison_length(comparison: &String) -> Result<(), crate::Error> { - reject_control_characters(comparison)?; - let (len, _) = scan_metadata_text(comparison)?; - if len > MAX_COMPARISON_LENGTH { + let (byte_len, has_control) = scan_metadata_text(comparison)?; + if has_control { + return Err(crate::Error::InvalidInput); + } + if byte_len > MAX_COMPARISON_BYTES { return Err(crate::Error::ComparisonTooLong); } Ok(()) } pub fn validate_category_length(category: &String) -> Result<(), crate::Error> { - reject_control_characters(category)?; - let (len, _) = scan_metadata_text(category)?; - if len > MAX_CATEGORY_LENGTH { + let (byte_len, has_control) = scan_metadata_text(category)?; + if has_control { + return Err(crate::Error::InvalidInput); + } + if byte_len > MAX_CATEGORY_BYTES { return Err(crate::Error::CategoryTooLong); } Ok(()) } pub fn validate_category_metadata(category: &String) -> Result<(), crate::Error> { - let (len, has_control) = scan_metadata_text(category)?; + let (byte_len, has_control) = scan_metadata_text(category)?; if has_control { return Err(crate::Error::InvalidInput); } - if len < MIN_CATEGORY_LENGTH { + if byte_len < MIN_CATEGORY_BYTES { return Err(crate::Error::CategoryTooShort); } - if len > MAX_CATEGORY_LENGTH { + if byte_len > MAX_CATEGORY_BYTES { return Err(crate::Error::CategoryTooLong); } Ok(()) @@ -251,26 +259,28 @@ pub fn validate_option_category_metadata(opt: &Option) -> Result<(), cra } pub fn validate_tag_length(tag: &String) -> Result<(), crate::Error> { - reject_control_characters(tag)?; - let (len, _) = scan_metadata_text(tag)?; - if len > MAX_TAG_LENGTH { + let (byte_len, has_control) = scan_metadata_text(tag)?; + if has_control { + return Err(crate::Error::InvalidInput); + } + if byte_len > MAX_TAG_BYTES { return Err(crate::Error::TagTooLong); } Ok(()) } pub fn validate_tag_metadata(tag: &String) -> Result<(), crate::Error> { - let (len, has_control) = scan_metadata_text(tag)?; + let (byte_len, has_control) = scan_metadata_text(tag)?; if has_control { return Err(crate::Error::InvalidInput); } - if len == 0 { + if byte_len == 0 { return Err(crate::Error::InvalidInput); } - if len < MIN_TAG_LENGTH { + if byte_len < MIN_TAG_BYTES { return Err(crate::Error::TagTooShort); } - if len > MAX_TAG_LENGTH { + if byte_len > MAX_TAG_BYTES { return Err(crate::Error::TagTooLong); } Ok(()) @@ -310,41 +320,60 @@ pub fn validate_event_tags(tags: &Vec) -> Result<(), crate::Error> { } pub fn validate_extension_reason_length(reason: &String) -> Result<(), crate::Error> { - reject_control_characters(reason)?; - let (len, _) = scan_metadata_text(reason)?; - if len > MAX_EXTENSION_REASON_LENGTH { + let (byte_len, has_control) = scan_metadata_text(reason)?; + if has_control { + return Err(crate::Error::InvalidInput); + } + if byte_len > MAX_EXTENSION_REASON_BYTES { return Err(crate::Error::ExtensionReasonTooLong); } Ok(()) } pub fn validate_source_length(source: &String) -> Result<(), crate::Error> { - reject_control_characters(source)?; - let (len, _) = scan_metadata_text(source)?; - if len > MAX_SOURCE_LENGTH { + let (byte_len, has_control) = scan_metadata_text(source)?; + if has_control { + return Err(crate::Error::InvalidInput); + } + if byte_len > MAX_SOURCE_BYTES { return Err(crate::Error::SourceTooLong); } Ok(()) } pub fn validate_error_message_length(error_message: &String) -> Result<(), crate::Error> { - reject_control_characters(error_message)?; - let (len, _) = scan_metadata_text(error_message)?; - if len > MAX_ERROR_MESSAGE_LENGTH { + let (byte_len, has_control) = scan_metadata_text(error_message)?; + if has_control { + return Err(crate::Error::InvalidInput); + } + if byte_len > MAX_ERROR_MESSAGE_BYTES { return Err(crate::Error::ErrorMessageTooLong); } Ok(()) } pub fn validate_signature_length(signature: &String) -> Result<(), crate::Error> { - reject_control_characters(signature)?; - let (len, _) = scan_metadata_text(signature)?; - if len > MAX_SIGNATURE_LENGTH { + let (byte_len, has_control) = scan_metadata_text(signature)?; + if has_control { + return Err(crate::Error::InvalidInput); + } + if byte_len > MAX_SIGNATURE_BYTES { return Err(crate::Error::SignatureTooLong); } Ok(()) } +pub fn validate_description_length(description: &String) -> Result<(), crate::Error> { + let (byte_len, has_control) = scan_metadata_text(description)?; + if has_control { + return Err(crate::Error::InvalidInput); + } + if byte_len > MAX_DESCRIPTION_BYTES { + return Err(crate::Error::InvalidQuestion); + } + Ok(()) +} + /// Validates that the number of extension history entries is within the maximum allowed count. /// /// # Arguments diff --git a/contracts/predictify-hybrid/src/metadata_limits_tests.rs b/contracts/predictify-hybrid/src/metadata_limits_tests.rs index 0bbe149b..5838a5d9 100644 --- a/contracts/predictify-hybrid/src/metadata_limits_tests.rs +++ b/contracts/predictify-hybrid/src/metadata_limits_tests.rs @@ -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), @@ -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()); } @@ -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)] @@ -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(()) @@ -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(()) @@ -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(()) @@ -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(()) diff --git a/contracts/predictify-hybrid/src/validation.rs b/contracts/predictify-hybrid/src/validation.rs index 6c50d534..68d4f6ae 100644 --- a/contracts/predictify-hybrid/src/validation.rs +++ b/contracts/predictify-hybrid/src/validation.rs @@ -5096,10 +5096,10 @@ 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 { 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( @@ -5107,13 +5107,13 @@ impl CreationValidator { 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); } @@ -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); } @@ -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 { 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. @@ -5291,8 +5292,7 @@ impl OutcomeDeduplicator { /// - Optimized for gas efficiency /// - Resistant to Unicode manipulation attacks pub fn normalize_outcome(outcome: &String) -> Result { - // 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(); @@ -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; @@ -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"]; @@ -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(())