From dab2f300f6e28f0f7f383ca4b1eb3de9fb1a4918 Mon Sep 17 00:00:00 2001 From: Justice Date: Mon, 1 Jun 2026 15:32:45 +0100 Subject: [PATCH 1/2] feat(contracts): add prediction market contract --- Cargo.lock | 8 + Cargo.toml | 1 + examples/prediction-market/Cargo.toml | 13 ++ examples/prediction-market/src/lib.rs | 224 ++++++++++++++++++ examples/prediction-market/src/test.rs | 300 +++++++++++++++++++++++++ 5 files changed, 546 insertions(+) create mode 100644 examples/prediction-market/Cargo.toml create mode 100644 examples/prediction-market/src/lib.rs create mode 100644 examples/prediction-market/src/test.rs diff --git a/Cargo.lock b/Cargo.lock index 6218def..1f649e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1059,6 +1059,14 @@ dependencies = [ "soroban-sdk", ] +[[package]] +name = "crucible-example-prediction-market" +version = "0.1.0" +dependencies = [ + "crucible", + "soroban-sdk", +] + [[package]] name = "crucible-example-token" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 522e8b4..4029f06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "examples/token", "examples/escrow", "examples/vesting", + "examples/prediction-market", ] resolver = "2" diff --git a/examples/prediction-market/Cargo.toml b/examples/prediction-market/Cargo.toml new file mode 100644 index 0000000..bfde68b --- /dev/null +++ b/examples/prediction-market/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "crucible-example-prediction-market" +version.workspace = true +edition.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +crucible = { path = "../../contracts/crucible" } diff --git a/examples/prediction-market/src/lib.rs b/examples/prediction-market/src/lib.rs new file mode 100644 index 0000000..a16571e --- /dev/null +++ b/examples/prediction-market/src/lib.rs @@ -0,0 +1,224 @@ +#![no_std] +#![allow(deprecated)] +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, token, Address, Env}; + +/// Binary outcome supported by the prediction market. +#[contracttype] +#[derive(Clone, PartialEq, Debug)] +pub enum Outcome { + Yes, + No, +} + +/// Lifecycle state for a market. +#[contracttype] +#[derive(Clone, PartialEq, Debug)] +pub enum MarketStatus { + Open, + Resolved, +} + +/// Market-level state stored under a single instance key. +#[contracttype] +#[derive(Clone, Debug)] +pub struct MarketState { + pub admin: Address, + pub token: Address, + pub close_time: u64, + pub status: MarketStatus, + pub winning_outcome: Outcome, + pub yes_total: i128, + pub no_total: i128, +} + +#[contracttype] +#[derive(Clone)] +struct PositionKey { + trader: Address, + outcome: Outcome, +} + +#[contracttype] +enum DataKey { + State, + Position(PositionKey), +} + +/// A minimal binary prediction market with escrowed collateral. +/// +/// Traders buy YES or NO exposure before `close_time`. After the market closes, +/// the admin resolves the winning outcome and winners claim a proportional share +/// of the full collateral pool. +#[contract] +#[derive(Default)] +pub struct PredictionMarket; + +#[contractimpl] +impl PredictionMarket { + /// Initialize the market. + /// + /// `admin` acts as the resolver/oracle, `token` is the collateral asset, + /// and `close_time` is the earliest timestamp at which resolution is valid. + pub fn initialize(env: Env, admin: Address, token: Address, close_time: u64) { + if env.storage().instance().has(&DataKey::State) { + panic!("market already initialized"); + } + if close_time <= env.ledger().timestamp() { + panic!("close time must be in the future"); + } + admin.require_auth(); + + env.storage().instance().set( + &DataKey::State, + &MarketState { + admin, + token, + close_time, + status: MarketStatus::Open, + winning_outcome: Outcome::No, + yes_total: 0, + no_total: 0, + }, + ); + env.events().publish((symbol_short!("init"),), close_time); + } + + /// Buy exposure to an outcome before the market closes. + /// + /// The caller's collateral is transferred into the contract and their + /// position for the selected outcome is increased by `amount`. + pub fn buy(env: Env, trader: Address, outcome: Outcome, amount: i128) { + let mut state = Self::require_state(&env); + if state.status != MarketStatus::Open { + panic!("market is not open"); + } + if env.ledger().timestamp() >= state.close_time { + panic!("market is closed"); + } + if amount <= 0 { + panic!("amount must be positive"); + } + trader.require_auth(); + + token::Client::new(&env, &state.token).transfer( + &trader, + env.current_contract_address(), + &amount, + ); + + let key = DataKey::Position(PositionKey { + trader: trader.clone(), + outcome: outcome.clone(), + }); + let position: i128 = env.storage().instance().get(&key).unwrap_or(0); + env.storage() + .instance() + .set(&key, &Self::checked_add(position, amount)); + + match outcome { + Outcome::Yes => state.yes_total = Self::checked_add(state.yes_total, amount), + Outcome::No => state.no_total = Self::checked_add(state.no_total, amount), + } + env.storage().instance().set(&DataKey::State, &state); + env.events().publish((symbol_short!("buy"), trader), amount); + } + + /// Resolve the market after close. Admin only. + pub fn resolve(env: Env, admin: Address, winning_outcome: Outcome) { + let mut state = Self::require_state(&env); + if state.status != MarketStatus::Open { + panic!("market already resolved"); + } + if admin != state.admin { + panic!("only the admin can resolve"); + } + if env.ledger().timestamp() < state.close_time { + panic!("market is still open"); + } + admin.require_auth(); + + state.status = MarketStatus::Resolved; + state.winning_outcome = winning_outcome.clone(); + env.storage().instance().set(&DataKey::State, &state); + env.events() + .publish((symbol_short!("resolved"),), winning_outcome); + } + + /// Claim the caller's proportional payout after resolution. + pub fn claim(env: Env, trader: Address) -> i128 { + let state = Self::require_state(&env); + if state.status != MarketStatus::Resolved { + panic!("market is not resolved"); + } + trader.require_auth(); + + let key = DataKey::Position(PositionKey { + trader: trader.clone(), + outcome: state.winning_outcome.clone(), + }); + let position: i128 = env.storage().instance().get(&key).unwrap_or(0); + if position <= 0 { + panic!("no winning position"); + } + + let payout = Self::payout(&state, position); + env.storage().instance().set(&key, &0_i128); + token::Client::new(&env, &state.token).transfer( + &env.current_contract_address(), + &trader, + &payout, + ); + env.events() + .publish((symbol_short!("claim"), trader), payout); + payout + } + + /// Return the complete market state. + pub fn get_state(env: Env) -> MarketState { + Self::require_state(&env) + } + + /// Return a trader's position for one outcome. + pub fn position(env: Env, trader: Address, outcome: Outcome) -> i128 { + env.storage() + .instance() + .get(&DataKey::Position(PositionKey { trader, outcome })) + .unwrap_or(0) + } + + /// Return total collateral escrowed across both sides. + pub fn pool_total(env: Env) -> i128 { + let state = Self::require_state(&env); + Self::checked_add(state.yes_total, state.no_total) + } + + fn require_state(env: &Env) -> MarketState { + env.storage() + .instance() + .get(&DataKey::State) + .unwrap_or_else(|| panic!("market is not initialized")) + } + + fn payout(state: &MarketState, position: i128) -> i128 { + let winning_total = match state.winning_outcome { + Outcome::Yes => state.yes_total, + Outcome::No => state.no_total, + }; + if winning_total <= 0 { + panic!("no winning liquidity"); + } + let pool = Self::checked_add(state.yes_total, state.no_total); + position + .checked_mul(pool) + .unwrap_or_else(|| panic!("payout overflow")) + / winning_total + } + + fn checked_add(left: i128, right: i128) -> i128 { + left.checked_add(right) + .unwrap_or_else(|| panic!("amount overflow")) + } +} + +#[cfg(test)] +mod test; diff --git a/examples/prediction-market/src/test.rs b/examples/prediction-market/src/test.rs new file mode 100644 index 0000000..e54a84c --- /dev/null +++ b/examples/prediction-market/src/test.rs @@ -0,0 +1,300 @@ +#![cfg(test)] +extern crate std; + +use crucible::prelude::*; +use crucible::{assert_emitted, assert_reverts}; +use soroban_sdk::{symbol_short, Address}; + +use crate::{MarketStatus, Outcome, PredictionMarket, PredictionMarketClient}; + +const BASE_TIME: u64 = 1_000_000; +const CLOSE_DELAY: u64 = 86_400; +const ALICE_STAKE: i128 = 600_000; +const BOB_STAKE: i128 = 400_000; +const CAROL_STAKE: i128 = 500_000; + +struct Ctx { + pub env: MockEnv, + pub id: Address, + pub admin: AccountHandle, + pub alice: AccountHandle, + pub bob: AccountHandle, + pub carol: AccountHandle, + pub token: MockToken, +} + +impl Ctx { + fn setup() -> Self { + let env = MockEnv::builder() + .at_timestamp(BASE_TIME) + .with_contract::() + .with_account("admin", Stroops::xlm(100)) + .with_account("alice", Stroops::xlm(10)) + .with_account("bob", Stroops::xlm(10)) + .with_account("carol", Stroops::xlm(10)) + .build(); + + let id = env.contract_id::(); + let admin = env.account("admin"); + let alice = env.account("alice"); + let bob = env.account("bob"); + let carol = env.account("carol"); + + let token = MockToken::new(&env, "USDC", 6); + token.mint(&alice, 2_000_000); + token.mint(&bob, 2_000_000); + token.mint(&carol, 2_000_000); + + Ctx { + env, + id, + admin, + alice, + bob, + carol, + token, + } + } + + fn client(&self) -> PredictionMarketClient<'_> { + PredictionMarketClient::new(self.env.inner(), &self.id) + } + + fn initialize(&self) { + self.env.mock_all_auths(); + self.client().initialize( + &self.admin, + &self.token.address(), + &(BASE_TIME + CLOSE_DELAY), + ); + } + + fn fund_market(&self) { + self.initialize(); + self.client().buy(&self.alice, &Outcome::Yes, &ALICE_STAKE); + self.client().buy(&self.bob, &Outcome::Yes, &BOB_STAKE); + self.client().buy(&self.carol, &Outcome::No, &CAROL_STAKE); + } + + fn resolve_yes(&self) { + self.env.advance_time(Duration::seconds(CLOSE_DELAY)); + self.env.mock_all_auths(); + self.client().resolve(&self.admin, &Outcome::Yes); + } +} + +#[test] +fn test_initialize_sets_open_market_state() { + let ctx = Ctx::setup(); + ctx.initialize(); + + let state = ctx.client().get_state(); + assert_eq!(state.admin, ctx.admin.address()); + assert_eq!(state.token, ctx.token.address()); + assert_eq!(state.close_time, BASE_TIME + CLOSE_DELAY); + assert_eq!(state.status, MarketStatus::Open); + assert_eq!(state.yes_total, 0); + assert_eq!(state.no_total, 0); +} + +#[test] +fn test_initialize_rejects_past_close_time() { + let ctx = Ctx::setup(); + ctx.env.mock_all_auths(); + assert_reverts!( + ctx.client() + .initialize(&ctx.admin, &ctx.token.address(), &BASE_TIME), + "close time" + ); +} + +#[test] +fn test_double_initialize_reverts() { + let ctx = Ctx::setup(); + ctx.initialize(); + assert_reverts!( + ctx.client() + .initialize(&ctx.admin, &ctx.token.address(), &(BASE_TIME + CLOSE_DELAY)), + "already initialized" + ); +} + +#[test] +fn test_buy_transfers_collateral_and_tracks_position() { + let ctx = Ctx::setup(); + ctx.initialize(); + ctx.env.mock_all_auths(); + ctx.client().buy(&ctx.alice, &Outcome::Yes, &ALICE_STAKE); + + assert_eq!( + ctx.client().position(&ctx.alice, &Outcome::Yes), + ALICE_STAKE + ); + assert_eq!(ctx.client().pool_total(), ALICE_STAKE); + assert_eq!(ctx.token.balance(&ctx.id), ALICE_STAKE); + assert_eq!(ctx.token.balance(&ctx.alice), 2_000_000 - ALICE_STAKE); +} + +#[test] +fn test_buy_accumulates_multiple_positions() { + let ctx = Ctx::setup(); + ctx.initialize(); + ctx.env.mock_all_auths(); + ctx.client().buy(&ctx.alice, &Outcome::Yes, &ALICE_STAKE); + ctx.client().buy(&ctx.alice, &Outcome::Yes, &BOB_STAKE); + ctx.client().buy(&ctx.carol, &Outcome::No, &CAROL_STAKE); + + let state = ctx.client().get_state(); + assert_eq!(ctx.client().position(&ctx.alice, &Outcome::Yes), 1_000_000); + assert_eq!(state.yes_total, 1_000_000); + assert_eq!(state.no_total, CAROL_STAKE); +} + +#[test] +fn test_buy_rejects_zero_amount() { + let ctx = Ctx::setup(); + ctx.initialize(); + ctx.env.mock_all_auths(); + assert_reverts!( + ctx.client().buy(&ctx.alice, &Outcome::Yes, &0_i128), + "positive" + ); +} + +#[test] +fn test_buy_after_close_reverts() { + let ctx = Ctx::setup(); + ctx.initialize(); + ctx.env.advance_time(Duration::seconds(CLOSE_DELAY)); + ctx.env.mock_all_auths(); + assert_reverts!( + ctx.client().buy(&ctx.alice, &Outcome::Yes, &ALICE_STAKE), + "closed" + ); +} + +#[test] +fn test_only_admin_can_resolve() { + let ctx = Ctx::setup(); + ctx.fund_market(); + ctx.env.advance_time(Duration::seconds(CLOSE_DELAY)); + ctx.env.mock_all_auths(); + assert_reverts!( + ctx.client().resolve(&ctx.alice, &Outcome::Yes), + "only the admin" + ); +} + +#[test] +fn test_resolve_before_close_reverts() { + let ctx = Ctx::setup(); + ctx.fund_market(); + ctx.env.mock_all_auths(); + assert_reverts!( + ctx.client().resolve(&ctx.admin, &Outcome::Yes), + "still open" + ); +} + +#[test] +fn test_resolve_sets_winning_outcome() { + let ctx = Ctx::setup(); + ctx.fund_market(); + ctx.resolve_yes(); + + let state = ctx.client().get_state(); + assert_eq!(state.status, MarketStatus::Resolved); + assert_eq!(state.winning_outcome, Outcome::Yes); +} + +#[test] +fn test_double_resolve_reverts() { + let ctx = Ctx::setup(); + ctx.fund_market(); + ctx.resolve_yes(); + assert_reverts!( + ctx.client().resolve(&ctx.admin, &Outcome::No), + "already resolved" + ); +} + +#[test] +fn test_claim_before_resolution_reverts() { + let ctx = Ctx::setup(); + ctx.fund_market(); + ctx.env.mock_all_auths(); + assert_reverts!(ctx.client().claim(&ctx.alice), "not resolved"); +} + +#[test] +fn test_winners_claim_proportional_payouts() { + let ctx = Ctx::setup(); + ctx.fund_market(); + ctx.resolve_yes(); + + let pool = ALICE_STAKE + BOB_STAKE + CAROL_STAKE; + let alice_expected = ALICE_STAKE * pool / (ALICE_STAKE + BOB_STAKE); + let bob_expected = BOB_STAKE * pool / (ALICE_STAKE + BOB_STAKE); + + ctx.env.mock_all_auths(); + assert_eq!(ctx.client().claim(&ctx.alice), alice_expected); + assert_eq!(ctx.client().claim(&ctx.bob), bob_expected); + + assert_eq!( + ctx.token.balance(&ctx.alice), + 2_000_000 - ALICE_STAKE + alice_expected + ); + assert_eq!( + ctx.token.balance(&ctx.bob), + 2_000_000 - BOB_STAKE + bob_expected + ); + assert_eq!(ctx.client().position(&ctx.alice, &Outcome::Yes), 0); + assert_eq!(ctx.client().position(&ctx.bob, &Outcome::Yes), 0); +} + +#[test] +fn test_losing_position_cannot_claim() { + let ctx = Ctx::setup(); + ctx.fund_market(); + ctx.resolve_yes(); + ctx.env.mock_all_auths(); + assert_reverts!(ctx.client().claim(&ctx.carol), "no winning position"); +} + +#[test] +fn test_double_claim_reverts() { + let ctx = Ctx::setup(); + ctx.fund_market(); + ctx.resolve_yes(); + ctx.env.mock_all_auths(); + ctx.client().claim(&ctx.alice); + assert_reverts!(ctx.client().claim(&ctx.alice), "no winning position"); +} + +#[test] +fn test_no_side_can_win_and_claim_full_pool() { + let ctx = Ctx::setup(); + ctx.fund_market(); + ctx.env.advance_time(Duration::seconds(CLOSE_DELAY)); + ctx.env.mock_all_auths(); + ctx.client().resolve(&ctx.admin, &Outcome::No); + + let pool = ALICE_STAKE + BOB_STAKE + CAROL_STAKE; + assert_eq!(ctx.client().claim(&ctx.carol), pool); + assert_eq!( + ctx.token.balance(&ctx.carol), + 2_000_000 - CAROL_STAKE + pool + ); +} + +#[test] +fn test_initialize_emits_event() { + let ctx = Ctx::setup(); + ctx.initialize(); + assert_emitted!( + ctx.env, + ctx.id, + (symbol_short!("init"),), + BASE_TIME + CLOSE_DELAY + ); +} From a8038c349ba64688e91e75a3f299992851b539ea Mon Sep 17 00:00:00 2001 From: Justice Date: Mon, 1 Jun 2026 17:17:57 +0100 Subject: [PATCH 2/2] feat(backend): add contract upgrade path management --- backend/src/api/handlers/contracts.rs | 19 +- backend/src/api/handlers/mod.rs | 3 +- backend/src/main.rs | 29 +- backend/src/services/contract_upgrade.rs | 618 +++++++++++++++++++++++ backend/src/services/mod.rs | 14 +- backend/tests/contract_upgrade_tests.rs | 90 ++++ 6 files changed, 746 insertions(+), 27 deletions(-) create mode 100644 backend/src/services/contract_upgrade.rs create mode 100644 backend/tests/contract_upgrade_tests.rs diff --git a/backend/src/api/handlers/contracts.rs b/backend/src/api/handlers/contracts.rs index db48da1..dee252a 100644 --- a/backend/src/api/handlers/contracts.rs +++ b/backend/src/api/handlers/contracts.rs @@ -3,10 +3,11 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use crate::api::contracts::ApiResponse; -use crate::error::AppError; -use crate::services::compilation::{CompilationResult, CompilationService}; -use crate::services::dependency_analyzer::{DependencyAnalysis, DependencyAnalyzer}; use crate::api::handlers::profiling::AppState; +use crate::error::AppError; +use crate::services::compilation::CompilationService; +use crate::services::contract_upgrade::{ContractUpgradeManager, ContractUpgradeRequest}; +use crate::services::dependency_analyzer::DependencyAnalyzer; #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -21,6 +22,18 @@ pub struct AnalyzeRequest { pub cargo_toml: String, } +/// POST /api/v1/contracts/upgrade-plan +pub async fn create_upgrade_plan( + Json(payload): Json, +) -> Result { + let service = ContractUpgradeManager::new(); + let result = service + .plan_upgrade(payload) + .map_err(|err| AppError::ValidationError(err.to_string()))?; + + Ok(Json(ApiResponse::new(result))) +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NetworkConfig { diff --git a/backend/src/api/handlers/mod.rs b/backend/src/api/handlers/mod.rs index 511d4c0..abc683b 100644 --- a/backend/src/api/handlers/mod.rs +++ b/backend/src/api/handlers/mod.rs @@ -1,8 +1,7 @@ +pub mod admin; pub mod contracts; pub mod dashboard; pub mod errors; pub mod profiling; pub mod stellar; pub mod ws; -pub mod contracts; -pub mod admin; diff --git a/backend/src/main.rs b/backend/src/main.rs index ae67fd9..80a0f84 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -196,18 +196,23 @@ async fn main() -> Result<(), anyhow::Error> { "/analyze-dependencies", post(backend::api::handlers::contracts::analyze_dependencies), ) - .with_state(state.clone()), - ) - .route( - "/api/v1/networks", - get(backend::api::handlers::contracts::get_networks), - ) - .route("/compile", post(backend::api::handlers::contracts::compile_contract)) - .route("/analyze-dependencies", post(backend::api::handlers::contracts::analyze_dependencies)) - .route("/compliance-check", post(backend::api::handlers::contracts::check_compliance)) - .route("/logs", post(backend::api::handlers::contracts::log_contract_call)) - .route("/logs", get(backend::api::handlers::contracts::get_contract_logs)) - .route("/templates", get(backend::api::handlers::contracts::get_templates)) + .route( + "/upgrade-plan", + post(backend::api::handlers::contracts::create_upgrade_plan), + ) + .route( + "/compliance-check", + post(backend::api::handlers::contracts::check_compliance), + ) + .route( + "/logs", + post(backend::api::handlers::contracts::log_contract_call) + .get(backend::api::handlers::contracts::get_contract_logs), + ) + .route( + "/templates", + get(backend::api::handlers::contracts::get_templates), + ) .with_state(state.clone()), ) .route( diff --git a/backend/src/services/contract_upgrade.rs b/backend/src/services/contract_upgrade.rs new file mode 100644 index 0000000..beead11 --- /dev/null +++ b/backend/src/services/contract_upgrade.rs @@ -0,0 +1,618 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fmt; + +const REQUIRED_CHECKS: [&str; 3] = ["storage_layout", "public_interface", "authorization"]; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ContractUpgradeRequest { + pub contract_id: String, + pub current_version: String, + pub target_version: String, + pub current_wasm_hash: String, + pub target_wasm_hash: String, + pub requested_by: String, + pub strategy: UpgradeStrategy, + #[serde(default)] + pub migration_required: bool, + pub state_migration_hash: Option, + #[serde(default)] + pub compatibility_checks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompatibilityCheck { + pub name: String, + pub passed: bool, + pub notes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum UpgradeStrategy { + InPlace, + StateMigration, + RedeployAndMigrate, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum UpgradePlanStatus { + Ready, + Blocked, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum UpgradeRiskLevel { + Low, + Medium, + High, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum UpgradeAction { + FreezeWrites, + SnapshotState, + VerifyCompatibility, + UploadWasm, + RunStateMigration, + SwitchContract, + VerifyPostUpgrade, + UnfreezeWrites, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UpgradeStep { + pub order: u8, + pub action: UpgradeAction, + pub description: String, + pub required: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RollbackPlan { + pub available: bool, + pub restore_wasm_hash: String, + pub restore_version: String, + pub steps: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SecurityReviewSummary { + pub required: bool, + pub blocking_findings: Vec, + pub notes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ContractUpgradePlan { + pub plan_id: String, + pub contract_id: String, + pub from_version: String, + pub to_version: String, + pub from_wasm_hash: String, + pub to_wasm_hash: String, + pub requested_by: String, + pub strategy: UpgradeStrategy, + pub status: UpgradePlanStatus, + pub risk_level: UpgradeRiskLevel, + pub approvals_required: u8, + pub blockers: Vec, + pub steps: Vec, + pub rollback: RollbackPlan, + pub security_review: SecurityReviewSummary, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContractUpgradeError { + EmptyField(&'static str), + InvalidVersion(String), + NonIncreasingVersion, + IdenticalWasmHash, + MissingMigrationHash, + InvalidStrategy(String), +} + +impl fmt::Display for ContractUpgradeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyField(field) => write!(f, "{field} is required"), + Self::InvalidVersion(version) => write!(f, "invalid semantic version: {version}"), + Self::NonIncreasingVersion => { + write!(f, "target version must be greater than current version") + } + Self::IdenticalWasmHash => write!(f, "target wasm hash must differ from current hash"), + Self::MissingMigrationHash => { + write!(f, "state migration hash is required for this strategy") + } + Self::InvalidStrategy(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for ContractUpgradeError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Version { + major: u64, + minor: u64, + patch: u64, +} + +impl Version { + fn parse(input: &str) -> Result { + let normalized = input.strip_prefix('v').unwrap_or(input); + let mut parts = normalized.split('.'); + let major = Self::parse_part(parts.next(), input)?; + let minor = Self::parse_part(parts.next(), input)?; + let patch = Self::parse_part(parts.next(), input)?; + if parts.next().is_some() { + return Err(ContractUpgradeError::InvalidVersion(input.to_string())); + } + Ok(Self { + major, + minor, + patch, + }) + } + + fn parse_part(part: Option<&str>, original: &str) -> Result { + let part = + part.ok_or_else(|| ContractUpgradeError::InvalidVersion(original.to_string()))?; + if part.is_empty() || !part.chars().all(|ch| ch.is_ascii_digit()) { + return Err(ContractUpgradeError::InvalidVersion(original.to_string())); + } + part.parse() + .map_err(|_| ContractUpgradeError::InvalidVersion(original.to_string())) + } + + fn is_greater_than(self, other: Self) -> bool { + (self.major, self.minor, self.patch) > (other.major, other.minor, other.patch) + } + + fn is_major_upgrade_from(self, other: Self) -> bool { + self.major > other.major + } + + fn is_patch_upgrade_from(self, other: Self) -> bool { + self.major == other.major && self.minor == other.minor && self.patch > other.patch + } +} + +#[derive(Debug, Default, Clone)] +pub struct ContractUpgradeManager; + +impl ContractUpgradeManager { + pub fn new() -> Self { + Self + } + + pub fn plan_upgrade( + &self, + request: ContractUpgradeRequest, + ) -> Result { + validate_required_fields(&request)?; + validate_strategy(&request)?; + + let current = Version::parse(&request.current_version)?; + let target = Version::parse(&request.target_version)?; + if !target.is_greater_than(current) { + return Err(ContractUpgradeError::NonIncreasingVersion); + } + if request.current_wasm_hash == request.target_wasm_hash { + return Err(ContractUpgradeError::IdenticalWasmHash); + } + + let blockers = compatibility_blockers(&request); + let status = if blockers.is_empty() { + UpgradePlanStatus::Ready + } else { + UpgradePlanStatus::Blocked + }; + let risk_level = risk_level(&request, current, target, &blockers); + let approvals_required = approvals_required(&risk_level, &status); + + let security_review = SecurityReviewSummary { + required: true, + blocking_findings: blockers.clone(), + notes: security_notes(&request, current, target), + }; + + Ok(ContractUpgradePlan { + plan_id: plan_id(&request), + contract_id: request.contract_id.clone(), + from_version: request.current_version.clone(), + to_version: request.target_version.clone(), + from_wasm_hash: request.current_wasm_hash.clone(), + to_wasm_hash: request.target_wasm_hash.clone(), + requested_by: request.requested_by.clone(), + strategy: request.strategy.clone(), + status, + risk_level, + approvals_required, + blockers: blockers.clone(), + steps: upgrade_steps(&request), + rollback: RollbackPlan { + available: true, + restore_wasm_hash: request.current_wasm_hash, + restore_version: request.current_version, + steps: vec![ + "freeze contract writes".to_string(), + "restore previous wasm hash".to_string(), + "replay pre-upgrade state snapshot if migration was applied".to_string(), + "run post-rollback health checks".to_string(), + ], + }, + security_review, + created_at: Utc::now(), + }) + } +} + +fn validate_required_fields(request: &ContractUpgradeRequest) -> Result<(), ContractUpgradeError> { + for (field, value) in [ + ("contract_id", request.contract_id.as_str()), + ("current_version", request.current_version.as_str()), + ("target_version", request.target_version.as_str()), + ("current_wasm_hash", request.current_wasm_hash.as_str()), + ("target_wasm_hash", request.target_wasm_hash.as_str()), + ("requested_by", request.requested_by.as_str()), + ] { + if value.trim().is_empty() { + return Err(ContractUpgradeError::EmptyField(field)); + } + } + Ok(()) +} + +fn validate_strategy(request: &ContractUpgradeRequest) -> Result<(), ContractUpgradeError> { + let migration_strategy = matches!( + request.strategy, + UpgradeStrategy::StateMigration | UpgradeStrategy::RedeployAndMigrate + ); + + if request.migration_required && !migration_strategy { + return Err(ContractUpgradeError::InvalidStrategy( + "migration_required cannot use in-place strategy".to_string(), + )); + } + + if migration_strategy + && request + .state_migration_hash + .as_ref() + .map(|hash| hash.trim().is_empty()) + .unwrap_or(true) + { + return Err(ContractUpgradeError::MissingMigrationHash); + } + + Ok(()) +} + +fn compatibility_blockers(request: &ContractUpgradeRequest) -> Vec { + let mut blockers = Vec::new(); + + for required in REQUIRED_CHECKS { + match request + .compatibility_checks + .iter() + .find(|check| check.name == required) + { + Some(check) if check.passed => {} + Some(check) => blockers.push(format!( + "compatibility check failed: {}{}", + check.name, + check + .notes + .as_ref() + .map(|notes| format!(" ({notes})")) + .unwrap_or_default() + )), + None => blockers.push(format!("compatibility check missing: {required}")), + } + } + + blockers +} + +fn risk_level( + request: &ContractUpgradeRequest, + current: Version, + target: Version, + blockers: &[String], +) -> UpgradeRiskLevel { + if !blockers.is_empty() + || target.is_major_upgrade_from(current) + || matches!(request.strategy, UpgradeStrategy::RedeployAndMigrate) + { + return UpgradeRiskLevel::High; + } + + if request.migration_required + || matches!(request.strategy, UpgradeStrategy::StateMigration) + || !target.is_patch_upgrade_from(current) + { + return UpgradeRiskLevel::Medium; + } + + UpgradeRiskLevel::Low +} + +fn approvals_required(risk_level: &UpgradeRiskLevel, status: &UpgradePlanStatus) -> u8 { + if *status == UpgradePlanStatus::Blocked { + return 0; + } + + match risk_level { + UpgradeRiskLevel::Low => 1, + UpgradeRiskLevel::Medium => 2, + UpgradeRiskLevel::High => 3, + } +} + +fn upgrade_steps(request: &ContractUpgradeRequest) -> Vec { + let mut steps = vec![ + step(1, UpgradeAction::FreezeWrites, "freeze contract writes"), + step( + 2, + UpgradeAction::SnapshotState, + "snapshot current contract state", + ), + step( + 3, + UpgradeAction::VerifyCompatibility, + "verify storage, interface, and authorization compatibility", + ), + step(4, UpgradeAction::UploadWasm, "upload target contract wasm"), + ]; + + if matches!( + request.strategy, + UpgradeStrategy::StateMigration | UpgradeStrategy::RedeployAndMigrate + ) { + steps.push(step( + 5, + UpgradeAction::RunStateMigration, + "run audited state migration artifact", + )); + } + + steps.push(step( + next_order(&steps), + UpgradeAction::SwitchContract, + "activate target implementation", + )); + steps.push(step( + next_order(&steps), + UpgradeAction::VerifyPostUpgrade, + "run post-upgrade health and invariant checks", + )); + steps.push(step( + next_order(&steps), + UpgradeAction::UnfreezeWrites, + "unfreeze contract writes", + )); + + steps +} + +fn step(order: u8, action: UpgradeAction, description: &str) -> UpgradeStep { + UpgradeStep { + order, + action, + description: description.to_string(), + required: true, + } +} + +fn next_order(steps: &[UpgradeStep]) -> u8 { + steps.len() as u8 + 1 +} + +fn security_notes( + request: &ContractUpgradeRequest, + current: Version, + target: Version, +) -> Vec { + let mut notes = vec![ + "verify target wasm hash against build provenance before execution".to_string(), + "confirm rollback artifact remains deployable until upgrade is finalized".to_string(), + ]; + + if target.is_major_upgrade_from(current) { + notes.push("major version upgrade requires expanded reviewer sign-off".to_string()); + } + if request.migration_required { + notes.push("state migration must be reviewed and dry-run before activation".to_string()); + } + + notes +} + +fn plan_id(request: &ContractUpgradeRequest) -> String { + let mut hasher = Sha256::new(); + hasher.update(request.contract_id.as_bytes()); + hasher.update(request.current_version.as_bytes()); + hasher.update(request.target_version.as_bytes()); + hasher.update(request.current_wasm_hash.as_bytes()); + hasher.update(request.target_wasm_hash.as_bytes()); + let digest = hasher.finalize(); + format!("upg-{:x}", digest)[..20].to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn passing_check(name: &str) -> CompatibilityCheck { + CompatibilityCheck { + name: name.to_string(), + passed: true, + notes: None, + } + } + + fn base_request() -> ContractUpgradeRequest { + ContractUpgradeRequest { + contract_id: "CCONTRACT123".to_string(), + current_version: "1.2.3".to_string(), + target_version: "1.2.4".to_string(), + current_wasm_hash: "wasm-old".to_string(), + target_wasm_hash: "wasm-new".to_string(), + requested_by: "GADMIN123".to_string(), + strategy: UpgradeStrategy::InPlace, + migration_required: false, + state_migration_hash: None, + compatibility_checks: REQUIRED_CHECKS + .iter() + .map(|name| passing_check(name)) + .collect(), + } + } + + #[test] + fn creates_ready_low_risk_patch_plan() { + let plan = ContractUpgradeManager::new() + .plan_upgrade(base_request()) + .unwrap(); + + assert_eq!(plan.status, UpgradePlanStatus::Ready); + assert_eq!(plan.risk_level, UpgradeRiskLevel::Low); + assert_eq!(plan.approvals_required, 1); + assert_eq!(plan.steps.len(), 7); + assert_eq!(plan.rollback.restore_version, "1.2.3"); + } + + #[test] + fn migration_strategy_adds_migration_step_and_medium_risk() { + let mut request = base_request(); + request.target_version = "1.3.0".to_string(); + request.strategy = UpgradeStrategy::StateMigration; + request.migration_required = true; + request.state_migration_hash = Some("migration-wasm".to_string()); + + let plan = ContractUpgradeManager::new().plan_upgrade(request).unwrap(); + + assert_eq!(plan.risk_level, UpgradeRiskLevel::Medium); + assert_eq!(plan.approvals_required, 2); + assert!(plan + .steps + .iter() + .any(|step| step.action == UpgradeAction::RunStateMigration)); + } + + #[test] + fn major_upgrade_is_high_risk() { + let mut request = base_request(); + request.target_version = "2.0.0".to_string(); + + let plan = ContractUpgradeManager::new().plan_upgrade(request).unwrap(); + + assert_eq!(plan.risk_level, UpgradeRiskLevel::High); + assert_eq!(plan.approvals_required, 3); + } + + #[test] + fn failed_compatibility_check_blocks_plan() { + let mut request = base_request(); + request.compatibility_checks[0].passed = false; + request.compatibility_checks[0].notes = Some("layout slot changed".to_string()); + + let plan = ContractUpgradeManager::new().plan_upgrade(request).unwrap(); + + assert_eq!(plan.status, UpgradePlanStatus::Blocked); + assert_eq!(plan.approvals_required, 0); + assert!(plan.blockers[0].contains("storage_layout")); + } + + #[test] + fn missing_required_check_blocks_plan() { + let mut request = base_request(); + request + .compatibility_checks + .retain(|check| check.name != "authorization"); + + let plan = ContractUpgradeManager::new().plan_upgrade(request).unwrap(); + + assert_eq!(plan.status, UpgradePlanStatus::Blocked); + assert!(plan + .blockers + .iter() + .any(|blocker| blocker.contains("authorization"))); + } + + #[test] + fn rejects_non_increasing_versions() { + let mut request = base_request(); + request.target_version = "1.2.3".to_string(); + + let err = ContractUpgradeManager::new() + .plan_upgrade(request) + .unwrap_err(); + + assert_eq!(err, ContractUpgradeError::NonIncreasingVersion); + } + + #[test] + fn rejects_identical_wasm_hashes() { + let mut request = base_request(); + request.target_wasm_hash = request.current_wasm_hash.clone(); + + let err = ContractUpgradeManager::new() + .plan_upgrade(request) + .unwrap_err(); + + assert_eq!(err, ContractUpgradeError::IdenticalWasmHash); + } + + #[test] + fn rejects_migration_without_migration_hash() { + let mut request = base_request(); + request.strategy = UpgradeStrategy::StateMigration; + + let err = ContractUpgradeManager::new() + .plan_upgrade(request) + .unwrap_err(); + + assert_eq!(err, ContractUpgradeError::MissingMigrationHash); + } + + #[test] + fn rejects_in_place_strategy_when_migration_is_required() { + let mut request = base_request(); + request.migration_required = true; + + let err = ContractUpgradeManager::new() + .plan_upgrade(request) + .unwrap_err(); + + assert_eq!( + err, + ContractUpgradeError::InvalidStrategy( + "migration_required cannot use in-place strategy".to_string() + ) + ); + } + + #[test] + fn accepts_versions_with_v_prefix() { + let mut request = base_request(); + request.current_version = "v1.2.3".to_string(); + request.target_version = "v1.2.4".to_string(); + + let plan = ContractUpgradeManager::new().plan_upgrade(request).unwrap(); + + assert_eq!(plan.status, UpgradePlanStatus::Ready); + } +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 78bbc58..d73bbcf 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -2,16 +2,16 @@ pub mod alerts; pub mod analytics_aggregator; pub mod business_metrics; pub mod cache_metrics; +pub mod circuit_breaker; pub mod compilation; +pub mod compliance; +pub mod contract_call_logger; pub mod contract_monitor; pub mod contract_registry; +pub mod contract_upgrade; pub mod dedup; pub mod dependency_analyzer; pub mod doc_generator; -pub mod circuit_breaker; -pub mod compilation; -pub mod dedup; -pub mod dependency_analyzer; pub mod error_recovery; pub mod event_indexer; pub mod feature_flags; @@ -20,9 +20,3 @@ pub mod log_alerts; pub mod security_scanner; pub mod sys_metrics; pub mod tracing; -pub mod sys_metrics; -pub mod tracing; -pub mod compilation; -pub mod dependency_analyzer; -pub mod contract_call_logger; -pub mod compliance; diff --git a/backend/tests/contract_upgrade_tests.rs b/backend/tests/contract_upgrade_tests.rs new file mode 100644 index 0000000..5d9f3bf --- /dev/null +++ b/backend/tests/contract_upgrade_tests.rs @@ -0,0 +1,90 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, + routing::post, + Router, +}; +use backend::api::handlers::contracts::create_upgrade_plan; +use serde_json::json; +use tower::ServiceExt; + +#[tokio::test] +async fn create_upgrade_plan_returns_ready_plan() { + let app = Router::new().route("/api/v1/contracts/upgrade-plan", post(create_upgrade_plan)); + + let payload = json!({ + "contractId": "CCONTRACT123", + "currentVersion": "1.2.3", + "targetVersion": "1.2.4", + "currentWasmHash": "wasm-old", + "targetWasmHash": "wasm-new", + "requestedBy": "GADMIN123", + "strategy": "inPlace", + "migrationRequired": false, + "stateMigrationHash": null, + "compatibilityChecks": [ + { "name": "storage_layout", "passed": true, "notes": null }, + { "name": "public_interface", "passed": true, "notes": null }, + { "name": "authorization", "passed": true, "notes": null } + ] + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/contracts/upgrade-plan") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&payload).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(json["status"], "success"); + assert_eq!(json["data"]["status"], "ready"); + assert_eq!(json["data"]["riskLevel"], "low"); + assert_eq!(json["data"]["approvalsRequired"], 1); +} + +#[tokio::test] +async fn create_upgrade_plan_rejects_invalid_version_order() { + let app = Router::new().route("/api/v1/contracts/upgrade-plan", post(create_upgrade_plan)); + + let payload = json!({ + "contractId": "CCONTRACT123", + "currentVersion": "1.2.3", + "targetVersion": "1.2.3", + "currentWasmHash": "wasm-old", + "targetWasmHash": "wasm-new", + "requestedBy": "GADMIN123", + "strategy": "inPlace", + "migrationRequired": false, + "stateMigrationHash": null, + "compatibilityChecks": [ + { "name": "storage_layout", "passed": true, "notes": null }, + { "name": "public_interface", "passed": true, "notes": null }, + { "name": "authorization", "passed": true, "notes": null } + ] + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/contracts/upgrade-plan") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&payload).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); +}