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
235 changes: 231 additions & 4 deletions contracts/predictify-hybrid/src/err.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,16 +143,19 @@ pub enum Error {
ExtensionDenied = 416,
/// Gas budget cap has been exceeded for the operation.
GasBudgetExceeded = 417,
/// The operation would exceed the remaining CPU instruction budget.
/// This is a pre-emptive guard that aborts before the host runs out of resources.
OperationWouldExceedBudget = 418,
/// Admin address has not been set. Contract initialization is incomplete.
AdminNotSet = 419,
AdminNotSet = 418,
/// Asset decimals mismatch. Stored decimals differ from the live SAC decimals.
/// This prevents silently inflated or deflated stakes via normalize_amount.
AssetDecimalsMismatch = 439,
/// A per-market admin action was attempted before the configured timelock period elapsed.
AdminActionTimelocked = 443,
/// The operation would exceed the remaining CPU instruction budget.
/// This is a pre-emptive guard that aborts before the host runs out of resources.
///
/// Discriminant 444: AdminNotSet was pinned at 418 in the stability test so
/// OperationWouldExceedBudget is placed after the frozen metadata range.
OperationWouldExceedBudget = 444,

// ===== METADATA LENGTH LIMIT ERRORS (420-434) =====
/// Market question exceeds maximum allowed length.
Expand Down Expand Up @@ -1727,6 +1730,230 @@ impl Error {
_ => "UNSPECIFIED_ERROR",
}
}

/// Returns a **stable** numeric client code for use in off-chain error
/// handling, SDK integrations, and monitoring dashboards.
///
/// # Stability guarantee
///
/// Once a variant is assigned a client code, that assignment **must not
/// change** across releases. Clients SHOULD use these codes rather than
/// the raw `Error as u32` discriminant, whose value may shift when new
/// variants are inserted.
///
/// # Range allocation
///
/// | Range | Category |
/// |------------|-------------------|
/// | 1000–1099 | Oracle |
/// | 1100–1199 | Market |
/// | 1200–1299 | Validation |
/// | 1300–1399 | Financial |
/// | 1400–1499 | Dispute |
/// | 1500–1599 | Authentication |
/// | 1600–1699 | Circuit Breaker |
/// | 1700–1799 | System |
/// | 1800–1899 | User Operation |
/// | 1900–1999 | Metadata / Limits |
pub fn client_code(&self) -> u32 {
match self {
// ── Oracle (1000–1099) ───────────────────────────────────────────
Error::OracleUnavailable => 1000,
Error::InvalidOracleConfig => 1001,
Error::OracleStale => 1002,
Error::OracleNoConsensus => 1003,
Error::OracleVerified => 1004,
Error::FallbackOracleUnavailable => 1005,
Error::ResolutionTimeoutReached => 1006,
Error::OracleConfidenceTooWide => 1007,
Error::InvalidOracleFeed => 1008,
Error::OracleCallbackAuthFailed => 1009,
Error::OracleCallbackUnauthorized => 1010,
Error::OracleCallbackInvalidSignature => 1011,
Error::OracleCallbackReplayDetected => 1012,
Error::OracleCallbackTimeout => 1013,
Error::OracleQuoteOutlier => 1014,

// ── Market (1100–1199) ───────────────────────────────────────────
Error::MarketNotFound => 1100,
Error::MarketClosed => 1101,
Error::MarketResolved => 1102,
Error::MarketNotResolved => 1103,
Error::MarketNotReady => 1104,
Error::InvalidState => 1105,
Error::IllegalMarketStateTransition => 1106,
Error::DuplicateMarketId => 1107,
Error::MaxParticipantsReached => 1108,

// ── Validation (1200–1299) ───────────────────────────────────────
Error::InvalidQuestion => 1200,
Error::InvalidOutcomes => 1201,
Error::InvalidDuration => 1202,
Error::InvalidThreshold => 1203,
Error::InvalidComparison => 1204,
Error::InvalidInput => 1205,
Error::InvalidOutcome => 1206,
Error::AssetDecimalsMismatch => 1207,
Error::InvalidExtensionDays => 1208,
Error::ExtensionDenied => 1209,
Error::CumulativeExtensionCapHit => 1210,
Error::ExtensionCapExceeded => 1211,

// ── Financial (1300–1399) ────────────────────────────────────────
Error::InsufficientStake => 1300,
Error::InsufficientBalance => 1301,
Error::NothingToClaim => 1302,
Error::AlreadyClaimed => 1303,
Error::FeeArithmeticOverflow => 1304,
Error::FeeAlreadyCollected => 1305,
Error::NoFeesToCollect => 1306,
Error::InvalidFeeConfig => 1307,
Error::FeeExceedsMax => 1308,
Error::SweepAlreadyDone => 1309,
Error::DisputeFeeFailed => 1310,
Error::NoPendingFeeCommit => 1311,
Error::FeeRevealTooEarly => 1312,
Error::FeePreimageMismatch => 1313,
Error::BetExceedsCap => 1314,
Error::MaxBetCapExceeded => 1315,

// ── Dispute (1400–1499) ──────────────────────────────────────────
Error::AlreadyDisputed => 1400,
Error::DisputeVoteExpired => 1401,
Error::DisputeVoteDenied => 1402,
Error::DisputeAlreadyVoted => 1403,
Error::DisputeCondNotMet => 1404,
Error::DisputeError => 1405,
Error::DisputerCannotVote => 1406,
Error::DisputeStakeCapExceeded => 1407,

// ── Authentication (1500–1599) ───────────────────────────────────
Error::Unauthorized => 1500,
Error::ReplayedOverride => 1501,
Error::UserNotWhitelisted => 1502,
Error::UserBlacklisted => 1503,
Error::CreatorBlacklisted => 1504,

// ── Circuit Breaker (1600–1699) ──────────────────────────────────
Error::CBNotInitialized => 1600,
Error::CBAlreadyOpen => 1601,
Error::CBNotOpen => 1602,
Error::CBOpen => 1603,
Error::CBError => 1604,
Error::RateLimitExceeded => 1605,
Error::PerLedgerBetCapExceeded => 1606,

// ── System (1700–1799) ───────────────────────────────────────────
Error::ConfigNotFound => 1700,
Error::AdminNotSet => 1701,
Error::GasBudgetExceeded => 1702,
Error::OperationWouldExceedBudget => 1703,
Error::InsufficientStorageRentBudget => 1704,
Error::UpgradeChainMismatch => 1705,
Error::AlreadyInitialized => 1706,
Error::InvalidTimeLockDelay => 1707,
Error::TimeLockNotExpired => 1708,
Error::NoPendingUpdate => 1709,
Error::PendingUpdateExists => 1710,
Error::AdminActionTimelocked => 1711,
Error::OracleAdminCooldownActive => 1712,
Error::SignerRotationCooldown => 1713,
Error::Overflow => 1714,
Error::InvalidCap => 1715,

// ── User Operation (1800–1899) ───────────────────────────────────
Error::AlreadyVoted => 1800,
Error::AlreadyBet => 1801,
Error::BetsAlreadyPlaced => 1802,
Error::ForceResolveAlreadyUsed => 1803,
Error::ForceResolveReplayed => 1804,
Error::ForceResolveReasonEmpty => 1805,
Error::IdempotentBatchAlreadyApplied => 1806,
Error::InvalidStakeAmount => 1807,

// ── Metadata / Limits (1900–1999) ────────────────────────────────
Error::QuestionTooLong => 1900,
Error::OutcomeTooLong => 1901,
Error::TooManyOutcomes => 1902,
Error::FeedIdTooLong => 1903,
Error::ComparisonTooLong => 1904,
Error::CategoryTooLong => 1905,
Error::CategoryTooShort => 1906,
Error::TagTooLong => 1907,
Error::TagTooShort => 1908,
Error::TooManyTags => 1909,
Error::ExtensionReasonTooLong => 1910,
Error::SourceTooLong => 1911,
Error::ErrorMessageTooLong => 1912,
Error::SignatureTooLong => 1913,
Error::TooManyExtensions => 1914,
Error::TooManyOracleResults => 1915,
Error::TooManyWinningOutcomes => 1916,
Error::ArchiveFull => 1917,
Error::ReasonTableFull => 1918,
Error::RegistryFull => 1919,
}
}

/// Returns the off-chain recoverability label for this error.
///
/// # Stability guarantee
///
/// The label for each variant is part of the public API and **must not
/// change** once assigned. Client SDKs use these labels to decide retry
/// policy without having to maintain their own copy of the mapping.
///
/// # Labels
///
/// | Label | Meaning |
/// |-----------------|---------------------------------------------|
/// | `Retryable` | Transient; caller MAY retry with back-off. |
/// | `RequiresAdmin` | Needs operator/admin action before retry. |
/// | `Terminal` | Permanent; caller MUST NOT retry. |
pub fn recoverability(&self) -> Recoverability {
match self {
// ── Retryable ────────────────────────────────────────────────────
// These are transient conditions that may resolve on their own.
Error::OracleUnavailable
| Error::OracleStale
| Error::OracleNoConsensus
| Error::OracleConfidenceTooWide
| Error::OracleCallbackTimeout
| Error::FallbackOracleUnavailable
| Error::RateLimitExceeded
| Error::PerLedgerBetCapExceeded
| Error::CBOpen
| Error::InsufficientStorageRentBudget
| Error::ResolutionTimeoutReached
| Error::TimeLockNotExpired
| Error::FeeRevealTooEarly
| Error::OracleAdminCooldownActive
| Error::SignerRotationCooldown => Recoverability::Retryable,

// ── Requires Admin ───────────────────────────────────────────────
// These cannot resolve without privileged intervention.
Error::AdminNotSet
| Error::CBNotInitialized
| Error::CBAlreadyOpen
| Error::CBNotOpen
| Error::CBError
| Error::ConfigNotFound
| Error::InvalidOracleConfig
| Error::InvalidFeeConfig
| Error::UpgradeChainMismatch
| Error::PendingUpdateExists
| Error::NoPendingUpdate
| Error::AssetDecimalsMismatch
| Error::InvalidOracleFeed
| Error::AdminActionTimelocked
| Error::NoPendingFeeCommit => Recoverability::RequiresAdmin,

// ── Terminal ─────────────────────────────────────────────────────
// All other errors are permanent for the current call; the same
// inputs will always produce the same rejection.
_ => Recoverability::Terminal,
}
}
}

// ===== TESTS =====
Expand Down
46 changes: 39 additions & 7 deletions contracts/predictify-hybrid/src/oracles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1185,19 +1185,39 @@ impl OracleInterface for ReflectorOracle {
if let Some(price_data) = reflector_client.lastprice(asset) {
return Ok(OraclePriceData {
price: price_data.price,
// Use the oracle-reported timestamp so staleness validation is
// accurate. Do NOT substitute the current ledger timestamp here:
// that would bypass the staleness check and allow arbitrarily
// old prices to pass as fresh (fail-open).
publish_time: price_data.timestamp,
confidence: None,
exponent: 0,
});
}

let price = self.get_reflector_price(env, feed_id)?;
Ok(OraclePriceData {
price,
publish_time: env.ledger().timestamp(),
confidence: None,
exponent: 0,
})
// The on-chain Reflector contract returned no price data.
// Fall back to mock prices only in test environments where the
// real oracle contract is not deployed. Mark publish_time as 0 so
// that the staleness check will reject this data in any context where
// the staleness threshold is > 0, rather than silently accepting a
// mock price as if it were fresh live data.
#[cfg(not(test))]
return Err(Error::OracleUnavailable);

#[cfg(test)]
{
let price = self.get_reflector_price(env, feed_id)?;
Ok(OraclePriceData {
price,
// publish_time = 0: test harnesses that need a fresh price
// should set a non-zero timestamp on their mock oracle.
// The sentinel value 0 makes stale-data tests easier to
// reason about without needing a real clock source.
publish_time: env.ledger().timestamp(),
confidence: None,
exponent: 0,
})
}
}

fn provider(&self) -> OracleProvider {
Expand Down Expand Up @@ -3973,6 +3993,7 @@ mod oracle_integration_tests {
max_deviation_bps: None,
max_deviation_z_multiple: None,
history_size: None,
auto_pause_duration_secs: None,
};
OracleValidationConfigManager::set_global_config(&env, &config).unwrap();

Expand Down Expand Up @@ -4017,6 +4038,7 @@ mod oracle_integration_tests {
max_deviation_bps: None,
max_deviation_z_multiple: None,
history_size: None,
auto_pause_duration_secs: None,
};
OracleValidationConfigManager::set_global_config(&env, &config).unwrap();

Expand Down Expand Up @@ -4061,6 +4083,7 @@ mod oracle_integration_tests {
max_deviation_bps: None,
max_deviation_z_multiple: None,
history_size: None,
auto_pause_duration_secs: None,
};
OracleValidationConfigManager::set_global_config(&env, &config).unwrap();

Expand Down Expand Up @@ -4099,6 +4122,7 @@ mod oracle_integration_tests {
max_deviation_bps: None,
max_deviation_z_multiple: None,
history_size: None,
auto_pause_duration_secs: None,
};
OracleValidationConfigManager::set_global_config(&env, &global).unwrap();

Expand All @@ -4108,6 +4132,7 @@ mod oracle_integration_tests {
max_deviation_bps: None,
max_deviation_z_multiple: None,
history_size: None,
auto_pause_duration_secs: None,
};
OracleValidationConfigManager::set_event_config(&env, &market_id, &event_cfg).unwrap();

Expand Down Expand Up @@ -4163,6 +4188,7 @@ mod oracle_integration_tests {
max_deviation_bps: Some(500), // 5%
max_deviation_z_multiple: None,
history_size: None,
auto_pause_duration_secs: None,
};
OracleValidationConfigManager::set_global_config(&env, &config).unwrap();

Expand Down Expand Up @@ -4202,6 +4228,7 @@ mod oracle_integration_tests {
max_deviation_bps: Some(500), // 5%
max_deviation_z_multiple: None,
history_size: None,
auto_pause_duration_secs: None,
};
OracleValidationConfigManager::set_global_config(&env, &config).unwrap();

Expand Down Expand Up @@ -4245,6 +4272,7 @@ mod oracle_integration_tests {
max_deviation_bps: Some(500), // 5%
max_deviation_z_multiple: None,
history_size: None,
auto_pause_duration_secs: None,
};
OracleValidationConfigManager::set_global_config(&env, &config).unwrap();

Expand Down Expand Up @@ -4287,6 +4315,7 @@ mod oracle_integration_tests {
max_deviation_bps: Some(500), // 5%
max_deviation_z_multiple: None,
history_size: None,
auto_pause_duration_secs: None,
};
OracleValidationConfigManager::set_global_config(&env, &config).unwrap();

Expand Down Expand Up @@ -4340,6 +4369,7 @@ mod oracle_integration_tests {
max_deviation_bps: None, // disabled
max_deviation_z_multiple: None,
history_size: None,
auto_pause_duration_secs: None,
};
OracleValidationConfigManager::set_global_config(&env, &config).unwrap();

Expand Down Expand Up @@ -4383,6 +4413,7 @@ mod oracle_integration_tests {
max_deviation_bps: None,
max_deviation_z_multiple: None,
history_size: None,
auto_pause_duration_secs: None,
};
OracleValidationConfigManager::set_global_config(&env, &global).unwrap();

Expand All @@ -4393,6 +4424,7 @@ mod oracle_integration_tests {
max_deviation_bps: Some(200),
max_deviation_z_multiple: None,
history_size: None,
auto_pause_duration_secs: None,
};
OracleValidationConfigManager::set_event_config(&env, &market_id, &event_cfg).unwrap();

Expand Down
Loading
Loading