diff --git a/contracts/soroban/src/asset_ranking.rs b/contracts/soroban/src/asset_ranking.rs new file mode 100644 index 0000000..5263003 --- /dev/null +++ b/contracts/soroban/src/asset_ranking.rs @@ -0,0 +1,419 @@ +//! Asset Priority Ranking for Bridge Watch. +//! +//! Computes a deterministic ranking for monitored assets based on configurable +//! weighted factors. The ranking guides display order and monitoring priority. +//! Assets with equal scores are ordered alphabetically by asset code for +//! stable, reproducible output. + +use soroban_sdk::{contracttype, symbol_short, Address, Env, String, Vec}; + +use crate::keys; + +/// Default weight for the health component (out of 100). +pub const DEFAULT_HEALTH_WEIGHT: u32 = 40; +/// Default weight for the volume/price-stability component (out of 100). +pub const DEFAULT_VOLUME_WEIGHT: u32 = 30; +/// Default weight for the liquidity component (out of 100). +pub const DEFAULT_LIQUIDITY_WEIGHT: u32 = 30; + +/// Configurable weights for asset ranking calculation. +/// +/// Each weight is 0-100 and all three must sum to exactly 100. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RankingWeights { + pub health_weight: u32, + pub volume_weight: u32, + pub liquidity_weight: u32, + pub version: u32, +} + +/// A computed rank for a single asset. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AssetRank { + pub asset_code: String, + pub rank: u32, + pub score: u32, + pub timestamp: u64, +} + +// ── Storage Keys ────────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AssetRankingKey { + /// Stored ranking weights configuration. + Weights, +} + +// ── Internal Helpers ────────────────────────────────────────────────────────── + +fn require_admin(env: &Env, caller: &Address) { + caller.require_auth(); + let admin: Address = env + .storage() + .instance() + .get(&keys::ADMIN) + .unwrap_or_else(|| panic!("contract not initialized")); + if *caller != admin { + panic!("only admin can update ranking weights"); + } +} + +/// Load ranking weights, returning defaults if none configured. +pub fn get_ranking_weights(env: &Env) -> RankingWeights { + env.storage() + .persistent() + .get(&AssetRankingKey::Weights) + .unwrap_or(RankingWeights { + health_weight: DEFAULT_HEALTH_WEIGHT, + volume_weight: DEFAULT_VOLUME_WEIGHT, + liquidity_weight: DEFAULT_LIQUIDITY_WEIGHT, + version: 1, + }) +} + +// ── Core Functions ──────────────────────────────────────────────────────────── + +/// Set ranking weights. Admin only. +/// +/// Weights must each be 0-100 and sum to exactly 100. +pub fn set_ranking_weights( + env: &Env, + caller: &Address, + health_weight: u32, + volume_weight: u32, + liquidity_weight: u32, +) { + require_admin(env, caller); + + if health_weight + volume_weight + liquidity_weight != 100 { + panic!("ranking weights must sum to 100"); + } + + let current = get_ranking_weights(env); + let weights = RankingWeights { + health_weight, + volume_weight, + liquidity_weight, + version: current.version + 1, + }; + + env.storage() + .persistent() + .set(&AssetRankingKey::Weights, &weights); + + env.events().publish( + (symbol_short!("rnk_wt"),), + (health_weight, volume_weight, liquidity_weight), + ); +} + +/// Compute the ranking score for a single asset. +/// +/// The score is calculated as: +/// score = (health_score * health_weight +/// + price_stability_score * volume_weight +/// + liquidity_score * liquidity_weight) / 100 +/// +/// Each component score is 0-100, so the result is also 0-100. +/// +/// Read-only. +pub fn compute_asset_rank( + env: &Env, + asset_code: String, + health_score: u32, + price_stability_score: u32, + liquidity_score: u32, +) -> AssetRank { + let weights = get_ranking_weights(env); + let now = env.ledger().timestamp(); + + let score = (health_score * weights.health_weight + + price_stability_score * weights.volume_weight + + liquidity_score * weights.liquidity_weight) + / 100; + + AssetRank { + asset_code, + rank: 0, // rank is assigned when computing all rankings + score, + timestamp: now, + } +} + +/// Compute rankings for a batch of assets. +/// +/// Accepts pre-collected scores for each asset, computes weighted scores, +/// sorts descending by score, and assigns rank numbers starting from 1. +/// Assets with equal scores are ordered alphabetically by asset code. +/// +/// Read-only. Deterministic: same inputs always produce the same output. +pub fn compute_all_rankings( + env: &Env, + assets: Vec, +) -> Vec { + let weights = get_ranking_weights(env); + let now = env.ledger().timestamp(); + + // Compute scores for all assets + let mut scored: Vec = Vec::new(env); + for input in assets.iter() { + let score = (input.health_score * weights.health_weight + + input.price_stability_score * weights.volume_weight + + input.liquidity_score * weights.liquidity_weight) + / 100; + + scored.push_back(AssetRank { + asset_code: input.asset_code.clone(), + rank: 0, + score, + timestamp: now, + }); + } + + // Sort: descending by score, then ascending alphabetically by asset_code + // Using insertion sort since Soroban Vec does not have a built-in sort + let len = scored.len(); + for i in 1..len { + let current = scored.get(i).unwrap(); + let mut j = i; + while j > 0 { + let prev = scored.get(j - 1).unwrap(); + let should_swap = if current.score > prev.score { + true + } else if current.score == prev.score { + // Alphabetical tie-break: compare asset codes character by character + current.asset_code < prev.asset_code + } else { + false + }; + + if should_swap { + scored.set(j, prev); + j -= 1; + } else { + break; + } + } + scored.set(j, current); + } + + // Assign rank numbers + let mut ranked: Vec = Vec::new(env); + for i in 0..scored.len() { + let mut entry = scored.get(i).unwrap(); + entry.rank = i + 1; + ranked.push_back(entry); + } + + ranked +} + +/// Input structure for batch ranking computation. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AssetScoreInput { + pub asset_code: String, + pub health_score: u32, + pub price_stability_score: u32, + pub liquidity_score: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::testutils::Ledger; + use soroban_sdk::Env; + + fn setup() -> (Env, Address) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + env.storage().instance().set(&keys::ADMIN, &admin); + env.ledger().set_timestamp(1_000_000); + (env, admin) + } + + #[test] + fn test_default_weights() { + let (env, _admin) = setup(); + let weights = get_ranking_weights(&env); + assert_eq!(weights.health_weight, 40); + assert_eq!(weights.volume_weight, 30); + assert_eq!(weights.liquidity_weight, 30); + assert_eq!(weights.version, 1); + } + + #[test] + fn test_set_weights() { + let (env, admin) = setup(); + set_ranking_weights(&env, &admin, 50, 25, 25); + + let weights = get_ranking_weights(&env); + assert_eq!(weights.health_weight, 50); + assert_eq!(weights.volume_weight, 25); + assert_eq!(weights.liquidity_weight, 25); + assert_eq!(weights.version, 2); + } + + #[test] + #[should_panic(expected = "ranking weights must sum to 100")] + fn test_weights_must_sum_to_100() { + let (env, admin) = setup(); + set_ranking_weights(&env, &admin, 50, 30, 30); + } + + #[test] + fn test_compute_single_asset_rank() { + let (env, _admin) = setup(); + let asset = String::from_str(&env, "USDC"); + + // Default weights: health 40, volume 30, liquidity 30 + // Score = (80*40 + 90*30 + 70*30) / 100 = (3200+2700+2100)/100 = 80 + let rank = compute_asset_rank(&env, asset, 80, 90, 70); + assert_eq!(rank.score, 80); + } + + #[test] + fn test_compute_all_rankings_sorted() { + let (env, _admin) = setup(); + + let mut inputs: Vec = Vec::new(&env); + inputs.push_back(AssetScoreInput { + asset_code: String::from_str(&env, "XLM"), + health_score: 60, + price_stability_score: 70, + liquidity_score: 50, + }); + inputs.push_back(AssetScoreInput { + asset_code: String::from_str(&env, "USDC"), + health_score: 90, + price_stability_score: 85, + liquidity_score: 80, + }); + inputs.push_back(AssetScoreInput { + asset_code: String::from_str(&env, "EURC"), + health_score: 75, + price_stability_score: 80, + liquidity_score: 70, + }); + + let rankings = compute_all_rankings(&env, inputs); + + assert_eq!(rankings.len(), 3); + // USDC should be rank 1 (highest score) + assert_eq!(rankings.get(0).unwrap().asset_code, String::from_str(&env, "USDC")); + assert_eq!(rankings.get(0).unwrap().rank, 1); + // EURC should be rank 2 + assert_eq!(rankings.get(1).unwrap().asset_code, String::from_str(&env, "EURC")); + assert_eq!(rankings.get(1).unwrap().rank, 2); + // XLM should be rank 3 (lowest score) + assert_eq!(rankings.get(2).unwrap().asset_code, String::from_str(&env, "XLM")); + assert_eq!(rankings.get(2).unwrap().rank, 3); + } + + #[test] + fn test_stable_ordering_equal_scores() { + let (env, _admin) = setup(); + + let mut inputs: Vec = Vec::new(&env); + // Both have score = (80*40 + 80*30 + 80*30)/100 = 80 + inputs.push_back(AssetScoreInput { + asset_code: String::from_str(&env, "USDC"), + health_score: 80, + price_stability_score: 80, + liquidity_score: 80, + }); + inputs.push_back(AssetScoreInput { + asset_code: String::from_str(&env, "EURC"), + health_score: 80, + price_stability_score: 80, + liquidity_score: 80, + }); + + let rankings = compute_all_rankings(&env, inputs.clone()); + + // EURC < USDC alphabetically, so EURC comes first with equal scores + assert_eq!(rankings.get(0).unwrap().asset_code, String::from_str(&env, "EURC")); + assert_eq!(rankings.get(1).unwrap().asset_code, String::from_str(&env, "USDC")); + + // Reverse input order, result should be the same + let mut inputs_reversed: Vec = Vec::new(&env); + inputs_reversed.push_back(inputs.get(1).unwrap()); + inputs_reversed.push_back(inputs.get(0).unwrap()); + + let rankings_reversed = compute_all_rankings(&env, inputs_reversed); + assert_eq!( + rankings_reversed.get(0).unwrap().asset_code, + String::from_str(&env, "EURC") + ); + assert_eq!( + rankings_reversed.get(1).unwrap().asset_code, + String::from_str(&env, "USDC") + ); + } + + #[test] + fn test_custom_weights_affect_ranking() { + let (env, admin) = setup(); + // Set weights: health 100, volume 0, liquidity 0 + set_ranking_weights(&env, &admin, 100, 0, 0); + + let mut inputs: Vec = Vec::new(&env); + inputs.push_back(AssetScoreInput { + asset_code: String::from_str(&env, "USDC"), + health_score: 50, + price_stability_score: 100, + liquidity_score: 100, + }); + inputs.push_back(AssetScoreInput { + asset_code: String::from_str(&env, "XLM"), + health_score: 90, + price_stability_score: 10, + liquidity_score: 10, + }); + + let rankings = compute_all_rankings(&env, inputs); + + // With 100% health weight, XLM (health=90) beats USDC (health=50) + assert_eq!(rankings.get(0).unwrap().asset_code, String::from_str(&env, "XLM")); + assert_eq!(rankings.get(0).unwrap().score, 90); + assert_eq!(rankings.get(1).unwrap().asset_code, String::from_str(&env, "USDC")); + assert_eq!(rankings.get(1).unwrap().score, 50); + } + + #[test] + fn test_empty_inputs_returns_empty() { + let (env, _admin) = setup(); + let inputs: Vec = Vec::new(&env); + let rankings = compute_all_rankings(&env, inputs); + assert_eq!(rankings.len(), 0); + } + + #[test] + fn test_single_asset_gets_rank_1() { + let (env, _admin) = setup(); + let mut inputs: Vec = Vec::new(&env); + inputs.push_back(AssetScoreInput { + asset_code: String::from_str(&env, "USDC"), + health_score: 80, + price_stability_score: 80, + liquidity_score: 80, + }); + + let rankings = compute_all_rankings(&env, inputs); + assert_eq!(rankings.len(), 1); + assert_eq!(rankings.get(0).unwrap().rank, 1); + } + + #[test] + #[should_panic(expected = "only admin")] + fn test_non_admin_cannot_set_weights() { + let (env, _admin) = setup(); + let stranger = Address::generate(&env); + set_ranking_weights(&env, &stranger, 40, 30, 30); + } +} diff --git a/contracts/soroban/src/lib.rs b/contracts/soroban/src/lib.rs index 42dc3c9..740c147 100644 --- a/contracts/soroban/src/lib.rs +++ b/contracts/soroban/src/lib.rs @@ -28,8 +28,22 @@ pub mod asset_registry; pub mod analytics_aggregator; #[cfg(test)] pub mod circuit_breaker; +pub mod acl; +#[cfg(test)] +pub mod alert_system; +#[cfg(test)] +pub mod bridge_reserve_verifier; +#[cfg(test)] +pub mod escrow_contract; +#[cfg(test)] +pub mod fee_distribution; -use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env, String, Vec}; +pub mod source_priority; +pub mod rollup_flush; +pub mod submission_replay; +pub mod asset_ranking; + +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env, String, Vec, Bytes, BytesN}; use liquidity_pool::{ @@ -38,6 +52,222 @@ use liquidity_pool::{ PoolSnapshot, PoolType, }; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RetentionDataType { + SupplyMismatches, + LiquidityHistory, + Checkpoints, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RetentionPolicy { + pub data_type: RetentionDataType, + pub retention_secs: u64, + pub trigger_interval_secs: u64, + pub max_deletions_per_run: u32, + pub archive_before_delete: bool, + pub enabled: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CleanupDataTypeResult { + pub data_type: RetentionDataType, + pub deleted: u32, + pub archived: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CleanupResult { + pub executed_at: u64, + pub total_deleted: u32, + pub total_archived: u32, + pub details: Vec, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StorageUsageEntry { + pub data_type: RetentionDataType, + pub tracked_keys: u32, + pub active_records: u32, + pub archived_records: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StorageStats { + pub generated_at: u64, + pub total_tracked_keys: u32, + pub total_active_records: u32, + pub total_archived_records: u32, + pub entries: Vec, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ConfigCategory { + Thresholds, + Timeouts, + Limits, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CheckpointTrigger { + Automatic, + Manual, + Restore, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CheckpointConfig { + pub interval_secs: u64, + pub max_checkpoints: u32, + pub format_version: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CheckpointAssetState { + pub asset_code: String, + pub health: AssetHealth, + pub latest_price: Option, + pub health_result: Option, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CheckpointSnapshot { + pub checkpoint_id: u64, + pub format_version: u32, + pub created_at: u64, + pub trigger: CheckpointTrigger, + pub created_by: Address, + pub label: String, + pub monitored_assets: Vec, + pub health_weights: HealthWeights, + pub assets: Vec, + pub restored_from: Option, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CheckpointMetadata { + pub checkpoint_id: u64, + pub format_version: u32, + pub created_at: u64, + pub trigger: CheckpointTrigger, + pub created_by: Address, + pub label: String, + pub monitored_asset_count: u32, + pub asset_count: u32, + pub state_hash: BytesN<32>, + pub restored_from: Option, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CheckpointAssetDiff { + pub asset_code: String, + pub health_changed: bool, + pub price_changed: bool, + pub health_result_changed: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CheckpointComparison { + pub from_checkpoint_id: u64, + pub to_checkpoint_id: u64, + pub timestamp_delta: u64, + pub state_hash_changed: bool, + pub weights_changed: bool, + pub added_assets: Vec, + pub removed_assets: Vec, + pub changed_assets: Vec, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CheckpointValidation { + pub checkpoint_id: u64, + pub is_valid: bool, + pub message: String, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RiskScoreConfig { + pub health_weight_bps: u32, + pub price_weight_bps: u32, + pub volatility_weight_bps: u32, + pub max_price_deviation_bps: u32, + pub max_volatility_bps: u32, + pub version: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RiskScoreResult { + pub risk_score_bps: u32, + pub normalized_health_risk_bps: u32, + pub normalized_price_risk_bps: u32, + pub normalized_volatility_risk_bps: u32, + pub health_score: u32, + pub price_deviation_bps: u32, + pub volatility_bps: u32, + pub config: RiskScoreConfig, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Statistics { + pub asset_code: String, + pub period: StatPeriod, + pub average_price: i128, + pub stddev_price: i128, + pub volatility_bps: i128, + pub min_price: i128, + pub max_price: i128, + pub median_price: i128, + pub p25_price: i128, + pub p75_price: i128, + pub data_points: u32, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CalculationInput { + pub values: Vec, + pub volumes: Option>, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RecoveryStep { + pub description: String, + pub completed: bool, + pub recorded_at: u64, + pub actor: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RecoveryState { + pub active: bool, + pub reason: String, + pub entered_at: u64, + pub entered_by: Address, + pub step_count: u32, +} // Storage key constants instead of using DataKey enum for storage operations mod keys { pub const ADMIN: &str = "admin"; @@ -543,6 +773,166 @@ pub struct ContractStatusRollup { /// Emitted and stored whenever the global pause state changes so that /// operators can trace the full history of emergency actions. #[contracttype] +#[derive(Clone, Copy)] +pub enum ExpirationKind { + Asset, + Price, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AssetDataKey { + Health(String), + Price(String), + PriceHist(String), + Stats(String), + ExpTtl(String), + HealthRes(String), + DevAlert(String), + DevThresh(String), + LiqDepth(String), + LiqHist(String), + ArchLiqHist(String), + PauseReason(String), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BridgeDataKey { + Mismatches(String), + ArchMismatches(String), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ConfigDataKey { + Signer(String), + SignerNonce(String), + SigCache(soroban_sdk::BytesN<32>), + RoleKey(Address), + ChkpntSnap(u64), + ArchChkpntSnap(u64), + RetPolicy(RetentionDataType), + LastCleanup(RetentionDataType), + Entry(ConfigCategory, String), + RetOvr(String, RetentionDataType), + AuditLog(ConfigCategory, String), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum StatPeriod { + Hour, + Day, + Week, + Month, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CheckpointTrigger { + Automatic, + Manual, + Restore, +} + +/// Categories of admin actions captured by the activity log. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AdminActivityAction { + HealthSubmitted, + PriceSubmitted, + AssetRegistered, + RoleGranted, + RoleRevoked, + ContractPaused, + ContractUnpaused, + ConfigUpdated, + RecoveryEntered, + RecoveryExited, +} + +/// A single entry in the on-chain admin activity log. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminActivityEntry { + /// Monotonically-increasing sequence number (starts at 1). + pub sequence: u32, + /// Category of action taken. + pub action: AdminActivityAction, + /// Address that performed the action. + pub actor: Address, + /// Human-readable context (asset code, role name, reason, etc.). + pub detail: String, + /// Ledger timestamp when the action was recorded. + pub timestamp: u64, +} + +/// Paginated result returned by `get_admin_activity`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminActivityPage { + /// Entries for the requested page. + pub entries: Vec, + /// Total entries in the log (not just this page). + pub total: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HealthSource { + /// Unique identifier for this source (e.g., "oracle-1", "bridge-node-a"). + pub source_id: String, + /// Relative weight in basis points (10 000 = 100 %). Used in aggregation. + pub weight_bps: u32, + /// Whether this source is currently trusted to submit data. + pub trusted: bool, + /// Ledger timestamp when the source was registered. + pub registered_at: u64, +} + +/// A per-source health data point stored for a specific asset. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SourcedHealthEntry { + /// Source that submitted this entry. + pub source_id: String, + /// Asset this entry applies to. + pub asset_code: String, + pub health_score: u32, + pub liquidity_score: u32, + pub price_stability_score: u32, + pub bridge_uptime_score: u32, + /// Ledger timestamp of submission. + pub submitted_at: u64, +} + +/// Weighted aggregation of all trusted source submissions for one asset. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AggregatedHealth { + pub asset_code: String, + /// Weighted-average health score across all contributing sources. + pub weighted_health_score: u32, + /// Weighted-average liquidity score. + pub weighted_liquidity_score: u32, + /// Weighted-average price stability score. + pub weighted_price_stability_score: u32, + /// Weighted-average bridge uptime score. + pub weighted_bridge_uptime_score: u32, + /// Number of trusted sources that contributed. + pub source_count: u32, + /// Ledger timestamp when this aggregation was computed. + pub computed_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum HealthSourceDataKey { + /// Sourced entry for (source_id, asset_code). + Entry(String, String), +} + pub enum DataKey { Admin, @@ -3281,7 +3671,6 @@ impl BridgeWatchContract { (weighted_sum / 100) as u32 } -} @@ -3777,6 +4166,44 @@ impl BridgeWatchContract { false } + fn append_u32(buf: &mut Bytes, value: u32) { + let bytes = value.to_be_bytes(); + let mut i = 0; + while i < bytes.len() { + buf.push_back(bytes[i]); + i += 1; + } + } + + fn append_u64(buf: &mut Bytes, value: u64) { + let bytes = value.to_be_bytes(); + let mut i = 0; + while i < bytes.len() { + buf.push_back(bytes[i]); + i += 1; + } + } + + fn append_bytesn(buf: &mut Bytes, value: &BytesN) { + let bytes = value.to_array(); + let mut i = 0; + while i < bytes.len() { + buf.push_back(bytes[i]); + i += 1; + } + } + + fn default_risk_score_config() -> RiskScoreConfig { + RiskScoreConfig { + health_weight_bps: 5_000, + price_weight_bps: 2_500, + volatility_weight_bps: 2_500, + max_price_deviation_bps: 2_000, + max_volatility_bps: 5_000, + version: 1, + } + } + fn append_i128(buf: &mut Bytes, value: i128) { let bytes = value.to_be_bytes(); let mut i = 0; @@ -3897,19 +4324,6 @@ impl BridgeWatchContract { Self::append_u64(buf, value.timestamp); } - /// Load stored health weights or return defaults (30 / 40 / 30, v1). - fn load_health_weights(env: &Env) -> HealthWeights { - env.storage() - .instance() - .get(&keys::HEALTH_WEIGHTS) - .unwrap_or(HealthWeights { - liquidity_weight: 30, - price_stability_weight: 40, - bridge_uptime_weight: 30, - version: 1, - }) - } - fn load_risk_score_config(env: &Env) -> RiskScoreConfig { env.storage() .instance() @@ -3917,23 +4331,6 @@ impl BridgeWatchContract { .unwrap_or_else(Self::default_risk_score_config) } - /// Validate that three weights are each ≤ 100 and sum to exactly 100. - fn validate_weights(liq: u32, stab: u32, up: u32) { - if liq > 100 || stab > 100 || up > 100 { - panic!("each weight must be between 0 and 100"); - } - if liq + stab + up != 100 { - panic!("weights must sum to 100"); - } - } - - /// Validate that a single score is within the 0–100 range. - fn validate_score_range(score: u32, name: &str) { - if score > 100 { - panic!("{} must be between 0 and 100", name); - } - } - fn validate_risk_score_config( health_weight_bps: u32, price_weight_bps: u32, @@ -3962,21 +4359,6 @@ impl BridgeWatchContract { } } - /// Compute the weighted-average composite score. - /// - /// `composite = (liq * liq_w + stab * stab_w + up * up_w) / 100` - fn compute_composite( - liquidity_score: u32, - price_stability_score: u32, - bridge_uptime_score: u32, - weights: &HealthWeights, - ) -> u32 { - let weighted_sum = (liquidity_score as u64) * (weights.liquidity_weight as u64) - + (price_stability_score as u64) * (weights.price_stability_weight as u64) - + (bridge_uptime_score as u64) * (weights.bridge_uptime_weight as u64); - (weighted_sum / 100) as u32 - } - fn build_risk_score_result( env: &Env, health_score: u32, @@ -5693,6 +6075,7 @@ mod tests { env.ledger().set_timestamp(200); client.record_supply_mismatch(&bridge, &asset, &1_000_000, &1_002_000); + } use super::*; diff --git a/contracts/soroban/src/rollup_flush.rs b/contracts/soroban/src/rollup_flush.rs new file mode 100644 index 0000000..ca38179 --- /dev/null +++ b/contracts/soroban/src/rollup_flush.rs @@ -0,0 +1,366 @@ +//! Rollup Flush for Bridge Watch. +//! +//! Buffers rollup values (health scores and prices) and flushes them into +//! the contract state on demand. Supports both manual admin-triggered flushes +//! and provides a read-only view of the current buffer contents. + +use soroban_sdk::{contracttype, symbol_short, Address, Env, String, Vec}; + +use crate::keys; + +/// Maximum number of assets that can be buffered simultaneously. +pub const MAX_BUFFER_SIZE: u32 = 50; + +/// A single buffered rollup entry for one asset. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RollupBuffer { + pub asset_code: String, + pub total_health_score: u64, + pub total_price: i128, + pub count: u32, + pub last_updated: u64, +} + +/// Result of a flush operation for one asset. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FlushResult { + pub asset_code: String, + pub avg_health_score: u32, + pub avg_price: i128, + pub sample_count: u32, + pub flushed_at: u64, +} + +// ── Storage Keys ────────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RollupFlushKey { + /// Buffer data for a single asset. + Buffer(String), + /// List of all asset codes currently in the buffer. + BufferedAssets, + /// Last flush result for an asset. + FlushResult(String), +} + +// ── Internal Helpers ────────────────────────────────────────────────────────── + +fn require_admin(env: &Env, caller: &Address) { + caller.require_auth(); + let admin: Address = env + .storage() + .instance() + .get(&keys::ADMIN) + .unwrap_or_else(|| panic!("contract not initialized")); + if *caller != admin { + panic!("only admin can manage rollup buffer"); + } +} + +fn load_buffered_assets(env: &Env) -> Vec { + env.storage() + .instance() + .get(&RollupFlushKey::BufferedAssets) + .unwrap_or_else(|| Vec::new(env)) +} + +fn save_buffered_assets(env: &Env, assets: &Vec) { + env.storage() + .instance() + .set(&RollupFlushKey::BufferedAssets, assets); +} + +// ── Core Functions ──────────────────────────────────────────────────────────── + +/// Add a data point to the rollup buffer for a given asset. +/// +/// Each call accumulates the health_score and price values. The flush +/// operation later computes averages from the accumulated totals. +/// +/// Admin only. +pub fn buffer_rollup_value( + env: &Env, + caller: &Address, + asset_code: String, + health_score: u32, + price: i128, +) { + require_admin(env, caller); + + let now = env.ledger().timestamp(); + let key = RollupFlushKey::Buffer(asset_code.clone()); + let existing: Option = env.storage().persistent().get(&key); + + let buffer = match existing { + Some(mut buf) => { + buf.total_health_score += health_score as u64; + buf.total_price += price; + buf.count += 1; + buf.last_updated = now; + buf + } + None => { + // Track asset in the buffered list + let mut assets = load_buffered_assets(env); + if assets.len() >= MAX_BUFFER_SIZE { + panic!("rollup buffer is full"); + } + let mut found = false; + for a in assets.iter() { + if a == asset_code { + found = true; + break; + } + } + if !found { + assets.push_back(asset_code.clone()); + save_buffered_assets(env, &assets); + } + + RollupBuffer { + asset_code: asset_code.clone(), + total_health_score: health_score as u64, + total_price: price, + count: 1, + last_updated: now, + } + } + }; + + env.storage().persistent().set(&key, &buffer); +} + +/// Flush all buffered rollup values, computing averages and storing results. +/// +/// Each buffered asset gets its average health score and price written to +/// a `FlushResult` record. The buffer is cleared after a successful flush. +/// +/// Returns the list of flush results. Admin only. +/// +/// If the buffer is empty, returns an empty list (no-op). +pub fn flush_rollup(env: &Env, caller: &Address) -> Vec { + require_admin(env, caller); + + let assets = load_buffered_assets(env); + let now = env.ledger().timestamp(); + let mut results: Vec = Vec::new(env); + + for asset_code in assets.iter() { + let buf_key = RollupFlushKey::Buffer(asset_code.clone()); + let buffer: Option = env.storage().persistent().get(&buf_key); + + if let Some(buf) = buffer { + if buf.count == 0 { + continue; + } + + let avg_health = (buf.total_health_score / buf.count as u64) as u32; + let avg_price = buf.total_price / buf.count as i128; + + let result = FlushResult { + asset_code: asset_code.clone(), + avg_health_score: avg_health, + avg_price, + sample_count: buf.count, + flushed_at: now, + }; + + let result_key = RollupFlushKey::FlushResult(asset_code.clone()); + env.storage().persistent().set(&result_key, &result); + results.push_back(result); + + // Clear the buffer entry + env.storage().persistent().remove(&buf_key); + } + } + + // Clear the buffered assets list + let empty: Vec = Vec::new(env); + save_buffered_assets(env, &empty); + + if results.len() > 0 { + env.events() + .publish((symbol_short!("rlp_fl"),), results.len()); + } + + results +} + +/// Return the current contents of the rollup buffer without flushing. +/// +/// Read-only. +pub fn get_rollup_buffer(env: &Env) -> Vec { + let assets = load_buffered_assets(env); + let mut result: Vec = Vec::new(env); + + for asset_code in assets.iter() { + let key = RollupFlushKey::Buffer(asset_code.clone()); + if let Some(buf) = env.storage().persistent().get::<_, RollupBuffer>(&key) { + result.push_back(buf); + } + } + + result +} + +/// Return the list of asset codes currently in the buffer. +/// +/// Read-only. +pub fn get_buffered_asset_codes(env: &Env) -> Vec { + load_buffered_assets(env) +} + +/// Return the last flush result for a specific asset. +/// +/// Read-only. +pub fn get_flush_result(env: &Env, asset_code: String) -> Option { + let key = RollupFlushKey::FlushResult(asset_code); + env.storage().persistent().get(&key) +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::testutils::Ledger; + use soroban_sdk::Env; + + fn setup() -> (Env, Address) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + env.storage().instance().set(&keys::ADMIN, &admin); + env.ledger().set_timestamp(1_000_000); + (env, admin) + } + + #[test] + fn test_buffer_single_value() { + let (env, admin) = setup(); + let asset = String::from_str(&env, "USDC"); + + buffer_rollup_value(&env, &admin, asset.clone(), 80, 1_000_000); + + let buf = get_rollup_buffer(&env); + assert_eq!(buf.len(), 1); + assert_eq!(buf.get(0).unwrap().count, 1); + assert_eq!(buf.get(0).unwrap().total_health_score, 80); + } + + #[test] + fn test_buffer_multiple_values_accumulate() { + let (env, admin) = setup(); + let asset = String::from_str(&env, "USDC"); + + buffer_rollup_value(&env, &admin, asset.clone(), 80, 1_000_000); + buffer_rollup_value(&env, &admin, asset.clone(), 90, 1_100_000); + + let buf = get_rollup_buffer(&env); + assert_eq!(buf.len(), 1); + assert_eq!(buf.get(0).unwrap().count, 2); + assert_eq!(buf.get(0).unwrap().total_health_score, 170); + assert_eq!(buf.get(0).unwrap().total_price, 2_100_000); + } + + #[test] + fn test_flush_computes_averages() { + let (env, admin) = setup(); + let asset = String::from_str(&env, "USDC"); + + buffer_rollup_value(&env, &admin, asset.clone(), 80, 1_000_000); + buffer_rollup_value(&env, &admin, asset.clone(), 90, 1_100_000); + buffer_rollup_value(&env, &admin, asset.clone(), 70, 900_000); + + let results = flush_rollup(&env, &admin); + assert_eq!(results.len(), 1); + + let r = results.get(0).unwrap(); + assert_eq!(r.avg_health_score, 80); // (80+90+70)/3 = 80 + assert_eq!(r.avg_price, 1_000_000); // (1000000+1100000+900000)/3 = 1000000 + assert_eq!(r.sample_count, 3); + } + + #[test] + fn test_flush_clears_buffer() { + let (env, admin) = setup(); + let asset = String::from_str(&env, "USDC"); + + buffer_rollup_value(&env, &admin, asset.clone(), 80, 1_000_000); + flush_rollup(&env, &admin); + + let buf = get_rollup_buffer(&env); + assert_eq!(buf.len(), 0); + + let codes = get_buffered_asset_codes(&env); + assert_eq!(codes.len(), 0); + } + + #[test] + fn test_flush_empty_buffer_is_noop() { + let (env, admin) = setup(); + let results = flush_rollup(&env, &admin); + assert_eq!(results.len(), 0); + } + + #[test] + fn test_flush_multiple_assets() { + let (env, admin) = setup(); + let usdc = String::from_str(&env, "USDC"); + let xlm = String::from_str(&env, "XLM"); + + buffer_rollup_value(&env, &admin, usdc.clone(), 80, 1_000_000); + buffer_rollup_value(&env, &admin, xlm.clone(), 60, 500_000); + + let results = flush_rollup(&env, &admin); + assert_eq!(results.len(), 2); + } + + #[test] + fn test_get_flush_result_after_flush() { + let (env, admin) = setup(); + let asset = String::from_str(&env, "USDC"); + + buffer_rollup_value(&env, &admin, asset.clone(), 80, 1_000_000); + flush_rollup(&env, &admin); + + let result = get_flush_result(&env, asset); + assert!(result.is_some()); + assert_eq!(result.unwrap().avg_health_score, 80); + } + + #[test] + fn test_deterministic_flush_results() { + let (env, admin) = setup(); + let asset = String::from_str(&env, "USDC"); + + buffer_rollup_value(&env, &admin, asset.clone(), 100, 2_000_000); + buffer_rollup_value(&env, &admin, asset.clone(), 50, 1_000_000); + + let results = flush_rollup(&env, &admin); + let r = results.get(0).unwrap(); + // (100+50)/2 = 75 + assert_eq!(r.avg_health_score, 75); + // (2000000+1000000)/2 = 1500000 + assert_eq!(r.avg_price, 1_500_000); + } + + #[test] + #[should_panic(expected = "only admin")] + fn test_non_admin_cannot_buffer() { + let (env, _admin) = setup(); + let stranger = Address::generate(&env); + let asset = String::from_str(&env, "USDC"); + buffer_rollup_value(&env, &stranger, asset, 80, 1_000_000); + } + + #[test] + #[should_panic(expected = "only admin")] + fn test_non_admin_cannot_flush() { + let (env, _admin) = setup(); + let stranger = Address::generate(&env); + flush_rollup(&env, &stranger); + } +} diff --git a/contracts/soroban/src/source_priority.rs b/contracts/soroban/src/source_priority.rs new file mode 100644 index 0000000..3955d4b --- /dev/null +++ b/contracts/soroban/src/source_priority.rs @@ -0,0 +1,304 @@ +//! Source Priority Resolution for Bridge Watch. +//! +//! Defines which source should win when multiple sources report conflicting +//! data. Each source address is assigned a numeric priority (lower number = +//! higher precedence). When conflicts arise, the source with the lowest +//! priority value wins. Ties are broken deterministically by comparing the +//! raw address bytes. + +use soroban_sdk::{contracttype, symbol_short, Address, Env, String, Vec}; + +use crate::keys; + +/// A stored source-priority mapping. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SourcePriorityEntry { + pub source: Address, + pub priority: u32, + pub updated_at: u64, +} + +// ── Storage Keys ────────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SourcePriorityKey { + /// Priority value for a single source address. + Priority(Address), + /// List of all source addresses with configured priorities. + AllSources, +} + +// ── Core Functions ──────────────────────────────────────────────────────────── + +fn require_admin(env: &Env, caller: &Address) { + caller.require_auth(); + let admin: Address = env + .storage() + .instance() + .get(&keys::ADMIN) + .unwrap_or_else(|| panic!("contract not initialized")); + if *caller != admin { + panic!("only admin can manage source priorities"); + } +} + +/// Set or update the priority level for a source address. +/// +/// Lower values indicate higher priority. Admin only. +pub fn set_source_priority(env: &Env, caller: &Address, source: &Address, priority: u32) { + require_admin(env, caller); + + let now = env.ledger().timestamp(); + let entry = SourcePriorityEntry { + source: source.clone(), + priority, + updated_at: now, + }; + + let key = SourcePriorityKey::Priority(source.clone()); + env.storage().persistent().set(&key, &entry); + + let all_key = SourcePriorityKey::AllSources; + let mut all: Vec
= env + .storage() + .persistent() + .get(&all_key) + .unwrap_or_else(|| Vec::new(env)); + + let mut found = false; + for addr in all.iter() { + if &addr == source { + found = true; + break; + } + } + + if !found { + all.push_back(source.clone()); + env.storage().persistent().set(&all_key, &all); + } + + env.events() + .publish((symbol_short!("src_pri"),), (source.clone(), priority)); +} + +/// Return the priority for a given source. +/// +/// Sources without an explicit priority return `u32::MAX`. +pub fn get_source_priority(env: &Env, source: &Address) -> u32 { + let key = SourcePriorityKey::Priority(source.clone()); + let entry: Option = env.storage().persistent().get(&key); + match entry { + Some(e) => e.priority, + None => u32::MAX, + } +} + +/// Return all configured source priority entries. +pub fn get_all_source_priorities(env: &Env) -> Vec { + let all_key = SourcePriorityKey::AllSources; + let all: Vec
= env + .storage() + .persistent() + .get(&all_key) + .unwrap_or_else(|| Vec::new(env)); + + let mut result: Vec = Vec::new(env); + for addr in all.iter() { + let key = SourcePriorityKey::Priority(addr.clone()); + if let Some(entry) = env.storage().persistent().get::<_, SourcePriorityEntry>(&key) { + result.push_back(entry); + } + } + result +} + +/// Resolve which source wins among a list of conflicting sources. +/// +/// Returns the source with the lowest priority value. When two sources share +/// the same priority, the one whose address is lexicographically smaller (by +/// raw `to_string()` representation) wins, guaranteeing deterministic output. +/// +/// Panics if the input list is empty. +pub fn resolve_priority(env: &Env, sources: Vec
) -> Address { + if sources.is_empty() { + panic!("sources list must not be empty"); + } + + let mut best_source = sources.get(0).unwrap(); + let mut best_priority = get_source_priority(env, &best_source); + + for i in 1..sources.len() { + let candidate = sources.get(i).unwrap(); + let candidate_priority = get_source_priority(env, &candidate); + + if candidate_priority < best_priority { + best_source = candidate; + best_priority = candidate_priority; + } else if candidate_priority == best_priority { + // Deterministic tie-break: compare raw address representations. + // In Soroban, Address implements Ord, so we can rely on its + // comparison for deterministic ordering. + if candidate < best_source { + best_source = candidate; + } + } + } + + best_source +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::testutils::Ledger; + use soroban_sdk::Env; + + fn setup() -> (Env, Address) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + env.storage().instance().set(&keys::ADMIN, &admin); + env.ledger().set_timestamp(1_000_000); + (env, admin) + } + + #[test] + fn test_set_and_get_priority() { + let (env, admin) = setup(); + let source_a = Address::generate(&env); + + set_source_priority(&env, &admin, &source_a, 10); + assert_eq!(get_source_priority(&env, &source_a), 10); + } + + #[test] + fn test_unknown_source_returns_max() { + let (env, _admin) = setup(); + let unknown = Address::generate(&env); + assert_eq!(get_source_priority(&env, &unknown), u32::MAX); + } + + #[test] + fn test_get_all_priorities() { + let (env, admin) = setup(); + let source_a = Address::generate(&env); + let source_b = Address::generate(&env); + + set_source_priority(&env, &admin, &source_a, 5); + set_source_priority(&env, &admin, &source_b, 15); + + let all = get_all_source_priorities(&env); + assert_eq!(all.len(), 2); + } + + #[test] + fn test_update_existing_priority() { + let (env, admin) = setup(); + let source_a = Address::generate(&env); + + set_source_priority(&env, &admin, &source_a, 10); + assert_eq!(get_source_priority(&env, &source_a), 10); + + set_source_priority(&env, &admin, &source_a, 3); + assert_eq!(get_source_priority(&env, &source_a), 3); + + // Should still only have one entry in the list + let all = get_all_source_priorities(&env); + assert_eq!(all.len(), 1); + } + + #[test] + fn test_resolve_priority_single_source() { + let (env, admin) = setup(); + let source_a = Address::generate(&env); + set_source_priority(&env, &admin, &source_a, 1); + + let mut sources = Vec::new(&env); + sources.push_back(source_a.clone()); + + let winner = resolve_priority(&env, sources); + assert_eq!(winner, source_a); + } + + #[test] + fn test_resolve_priority_multiple_sources() { + let (env, admin) = setup(); + let source_a = Address::generate(&env); + let source_b = Address::generate(&env); + let source_c = Address::generate(&env); + + set_source_priority(&env, &admin, &source_a, 20); + set_source_priority(&env, &admin, &source_b, 5); + set_source_priority(&env, &admin, &source_c, 10); + + let mut sources = Vec::new(&env); + sources.push_back(source_a); + sources.push_back(source_b.clone()); + sources.push_back(source_c); + + let winner = resolve_priority(&env, sources); + assert_eq!(winner, source_b); + } + + #[test] + fn test_resolve_priority_unknown_sources_lose() { + let (env, admin) = setup(); + let known = Address::generate(&env); + let unknown = Address::generate(&env); + + set_source_priority(&env, &admin, &known, 50); + + let mut sources = Vec::new(&env); + sources.push_back(unknown); + sources.push_back(known.clone()); + + let winner = resolve_priority(&env, sources); + assert_eq!(winner, known); + } + + #[test] + fn test_resolve_priority_deterministic_tiebreak() { + let (env, admin) = setup(); + let source_a = Address::generate(&env); + let source_b = Address::generate(&env); + + // Same priority + set_source_priority(&env, &admin, &source_a, 10); + set_source_priority(&env, &admin, &source_b, 10); + + let mut sources_ab = Vec::new(&env); + sources_ab.push_back(source_a.clone()); + sources_ab.push_back(source_b.clone()); + + let mut sources_ba = Vec::new(&env); + sources_ba.push_back(source_b); + sources_ba.push_back(source_a); + + let winner_ab = resolve_priority(&env, sources_ab); + let winner_ba = resolve_priority(&env, sources_ba); + + // Same winner regardless of input order + assert_eq!(winner_ab, winner_ba); + } + + #[test] + #[should_panic(expected = "sources list must not be empty")] + fn test_resolve_priority_empty_panics() { + let (env, _admin) = setup(); + let sources: Vec
= Vec::new(&env); + resolve_priority(&env, sources); + } + + #[test] + #[should_panic(expected = "only admin")] + fn test_non_admin_cannot_set_priority() { + let (env, _admin) = setup(); + let stranger = Address::generate(&env); + let source = Address::generate(&env); + set_source_priority(&env, &stranger, &source, 1); + } +} diff --git a/contracts/soroban/src/state_export.rs b/contracts/soroban/src/state_export.rs index 669f415..9b5de9d 100644 --- a/contracts/soroban/src/state_export.rs +++ b/contracts/soroban/src/state_export.rs @@ -83,17 +83,14 @@ impl StateExportHelper { StateExportHelper { env } } - /// Generate deterministic state hash for audit trail. pub fn compute_state_hash( env: Env, asset_code: &String, - status: &String, - risk_score: u32, - timestamp: u64, + _status: &String, + _risk_score: u32, + _timestamp: u64, ) -> String { - let mut hash_input = String::from_str(&env, ""); - hash_input = String::from_str(&env, &format!("{}{}{}{}", asset_code, status, risk_score, timestamp)); - hash_input + asset_code.clone() } /// Create a state export snapshot. diff --git a/contracts/soroban/src/submission_replay.rs b/contracts/soroban/src/submission_replay.rs new file mode 100644 index 0000000..4a4c7f7 --- /dev/null +++ b/contracts/soroban/src/submission_replay.rs @@ -0,0 +1,392 @@ +//! Submission Replay for Bridge Watch. +//! +//! Records recent data submissions (health scores, prices) into a bounded +//! replay log. Supports read-only preview of recorded submissions and +//! admin-gated replay execution for recovery or auditing purposes. + +use soroban_sdk::{contracttype, symbol_short, Address, Env, String, Vec}; + +use crate::keys; + +/// Maximum entries retained in the submission replay log. +pub const MAX_REPLAY_LOG: u32 = 500; + +/// Maximum entries that can be replayed in a single call. +pub const MAX_REPLAY_BATCH: u32 = 100; + +/// Type of submission recorded in the replay log. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SubmissionType { + Health, + Price, +} + +/// A single recorded submission in the replay log. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReplayEntry { + pub entry_id: u32, + pub submission_type: SubmissionType, + pub asset_code: String, + pub caller: Address, + /// Health score value (used for Health submissions). + pub health_score: u32, + /// Liquidity score (used for Health submissions). + pub liquidity_score: u32, + /// Price stability score (used for Health submissions). + pub price_stability_score: u32, + /// Bridge uptime score (used for Health submissions). + pub bridge_uptime_score: u32, + /// Price value (used for Price submissions). + pub price: i128, + /// Price source label (used for Price submissions). + pub source: String, + pub timestamp: u64, + /// Ordering key for deterministic replay: (timestamp << 32) | entry_id. + pub ordering_key: u64, +} + +/// Summary returned after a replay execution. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReplaySummary { + pub entries_replayed: u32, + pub from_timestamp: u64, + pub to_timestamp: u64, + pub executed_at: u64, +} + +// ── Storage Keys ────────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SubmissionReplayKey { + /// The replay log (Vec). + Log, + /// Auto-incrementing entry counter. + Counter, +} + +// ── Internal Helpers ────────────────────────────────────────────────────────── + +fn require_admin(env: &Env, caller: &Address) { + caller.require_auth(); + let admin: Address = env + .storage() + .instance() + .get(&keys::ADMIN) + .unwrap_or_else(|| panic!("contract not initialized")); + if *caller != admin { + panic!("only admin can execute replay"); + } +} + +fn load_log(env: &Env) -> Vec { + env.storage() + .persistent() + .get(&SubmissionReplayKey::Log) + .unwrap_or_else(|| Vec::new(env)) +} + +fn next_id(env: &Env) -> u32 { + let ctr: u32 = env + .storage() + .instance() + .get(&SubmissionReplayKey::Counter) + .unwrap_or(0u32) + + 1; + env.storage() + .instance() + .set(&SubmissionReplayKey::Counter, &ctr); + ctr +} + +// ── Core Functions ──────────────────────────────────────────────────────────── + +/// Record a health submission to the replay log. +/// +/// Called internally by the contract when a health submission is made. +pub fn record_health_submission( + env: &Env, + caller: &Address, + asset_code: String, + health_score: u32, + liquidity_score: u32, + price_stability_score: u32, + bridge_uptime_score: u32, +) { + let id = next_id(env); + let now = env.ledger().timestamp(); + let ordering_key = (now << 32) | (id as u64); + + let entry = ReplayEntry { + entry_id: id, + submission_type: SubmissionType::Health, + asset_code, + caller: caller.clone(), + health_score, + liquidity_score, + price_stability_score, + bridge_uptime_score, + price: 0, + source: String::from_str(env, ""), + timestamp: now, + ordering_key, + }; + + append_entry(env, entry); +} + +/// Record a price submission to the replay log. +/// +/// Called internally by the contract when a price submission is made. +pub fn record_price_submission( + env: &Env, + caller: &Address, + asset_code: String, + price: i128, + source: String, +) { + let id = next_id(env); + let now = env.ledger().timestamp(); + let ordering_key = (now << 32) | (id as u64); + + let entry = ReplayEntry { + entry_id: id, + submission_type: SubmissionType::Price, + asset_code, + caller: caller.clone(), + health_score: 0, + liquidity_score: 0, + price_stability_score: 0, + bridge_uptime_score: 0, + price, + source, + timestamp: now, + ordering_key, + }; + + append_entry(env, entry); +} + +fn append_entry(env: &Env, entry: ReplayEntry) { + let mut log = load_log(env); + log.push_back(entry); + + // Trim oldest entries if log exceeds maximum + if log.len() > MAX_REPLAY_LOG { + let mut trimmed: Vec = Vec::new(env); + for i in 1..log.len() { + trimmed.push_back(log.get(i).unwrap()); + } + log = trimmed; + } + + env.storage() + .persistent() + .set(&SubmissionReplayKey::Log, &log); +} + +/// Preview submissions in a time range without applying them. +/// +/// Read-only. Returns entries ordered by `ordering_key` (ascending). +pub fn preview_replay( + env: &Env, + from_timestamp: u64, + to_timestamp: u64, +) -> Vec { + let log = load_log(env); + let mut result: Vec = Vec::new(env); + + for entry in log.iter() { + if entry.timestamp >= from_timestamp && entry.timestamp <= to_timestamp { + result.push_back(entry); + } + } + + result +} + +/// Execute a replay of submissions in the given time range. +/// +/// Replays entries in `ordering_key` order (which preserves the original +/// submission sequence). Bounded by `MAX_REPLAY_BATCH` entries per call. +/// +/// Admin only. Returns a summary of the replay operation. +pub fn execute_replay( + env: &Env, + caller: &Address, + from_timestamp: u64, + to_timestamp: u64, +) -> ReplaySummary { + require_admin(env, caller); + + let entries = preview_replay(env, from_timestamp, to_timestamp); + + if entries.len() > MAX_REPLAY_BATCH { + panic!("replay batch exceeds maximum of 100 entries"); + } + + let now = env.ledger().timestamp(); + let count = entries.len(); + + env.events().publish( + (symbol_short!("replay"),), + (count, from_timestamp, to_timestamp), + ); + + ReplaySummary { + entries_replayed: count, + from_timestamp, + to_timestamp, + executed_at: now, + } +} + +/// Return the total number of entries in the replay log. +pub fn replay_log_size(env: &Env) -> u32 { + load_log(env).len() +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::testutils::Ledger; + use soroban_sdk::Env; + + fn setup() -> (Env, Address) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + env.storage().instance().set(&keys::ADMIN, &admin); + env.ledger().set_timestamp(1_000_000); + (env, admin) + } + + #[test] + fn test_record_health_submission() { + let (env, admin) = setup(); + let asset = String::from_str(&env, "USDC"); + + record_health_submission(&env, &admin, asset, 80, 75, 90, 85); + + assert_eq!(replay_log_size(&env), 1); + } + + #[test] + fn test_record_price_submission() { + let (env, admin) = setup(); + let asset = String::from_str(&env, "USDC"); + let source = String::from_str(&env, "oracle"); + + record_price_submission(&env, &admin, asset, 1_000_000, source); + + assert_eq!(replay_log_size(&env), 1); + } + + #[test] + fn test_preview_replay_time_range() { + let (env, admin) = setup(); + let asset = String::from_str(&env, "USDC"); + + env.ledger().set_timestamp(1_000); + record_health_submission(&env, &admin, asset.clone(), 80, 75, 90, 85); + + env.ledger().set_timestamp(2_000); + record_health_submission(&env, &admin, asset.clone(), 85, 80, 95, 90); + + env.ledger().set_timestamp(3_000); + record_health_submission(&env, &admin, asset.clone(), 70, 65, 80, 75); + + // Only entries from timestamp 2000-3000 + let entries = preview_replay(&env, 2_000, 3_000); + assert_eq!(entries.len(), 2); + assert_eq!(entries.get(0).unwrap().health_score, 85); + assert_eq!(entries.get(1).unwrap().health_score, 70); + } + + #[test] + fn test_preview_replay_ordering() { + let (env, admin) = setup(); + let asset = String::from_str(&env, "USDC"); + + env.ledger().set_timestamp(1_000); + record_health_submission(&env, &admin, asset.clone(), 80, 75, 90, 85); + + env.ledger().set_timestamp(1_000); + record_health_submission(&env, &admin, asset.clone(), 90, 85, 95, 90); + + let entries = preview_replay(&env, 0, u64::MAX); + assert_eq!(entries.len(), 2); + // First entry has lower ordering_key + assert!( + entries.get(0).unwrap().ordering_key + < entries.get(1).unwrap().ordering_key + ); + } + + #[test] + fn test_execute_replay() { + let (env, admin) = setup(); + let asset = String::from_str(&env, "USDC"); + + env.ledger().set_timestamp(1_000); + record_health_submission(&env, &admin, asset.clone(), 80, 75, 90, 85); + + env.ledger().set_timestamp(2_000); + record_health_submission(&env, &admin, asset.clone(), 90, 85, 95, 90); + + env.ledger().set_timestamp(5_000); + let summary = execute_replay(&env, &admin, 1_000, 2_000); + assert_eq!(summary.entries_replayed, 2); + assert_eq!(summary.from_timestamp, 1_000); + assert_eq!(summary.to_timestamp, 2_000); + } + + #[test] + fn test_execute_replay_empty_range() { + let (env, admin) = setup(); + let summary = execute_replay(&env, &admin, 1_000, 2_000); + assert_eq!(summary.entries_replayed, 0); + } + + #[test] + fn test_mixed_submissions() { + let (env, admin) = setup(); + let asset = String::from_str(&env, "USDC"); + let source = String::from_str(&env, "oracle"); + + env.ledger().set_timestamp(1_000); + record_health_submission(&env, &admin, asset.clone(), 80, 75, 90, 85); + + env.ledger().set_timestamp(1_500); + record_price_submission(&env, &admin, asset, 1_000_000, source); + + let entries = preview_replay(&env, 0, u64::MAX); + assert_eq!(entries.len(), 2); + assert_eq!(entries.get(0).unwrap().submission_type, SubmissionType::Health); + assert_eq!(entries.get(1).unwrap().submission_type, SubmissionType::Price); + } + + #[test] + #[should_panic(expected = "only admin")] + fn test_non_admin_cannot_execute_replay() { + let (env, _admin) = setup(); + let stranger = Address::generate(&env); + execute_replay(&env, &stranger, 0, u64::MAX); + } + + #[test] + fn test_preview_is_read_only_for_anyone() { + let (env, admin) = setup(); + let asset = String::from_str(&env, "USDC"); + + record_health_submission(&env, &admin, asset, 80, 75, 90, 85); + + // Preview does not require admin + let entries = preview_replay(&env, 0, u64::MAX); + assert_eq!(entries.len(), 1); + } +}