diff --git a/.gitignore b/.gitignore index 82eedf7..956c9f4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,9 @@ node_modules/ # Local-only context (LLM analyses, workspace archives, private notes) .local/ +# Rust build artifacts +target/ +Cargo.lock + # Claude Code CLAUDE.local.md diff --git a/contracts/reputation-signal/.cargo/config.toml b/contracts/reputation-signal/.cargo/config.toml new file mode 100644 index 0000000..946af0f --- /dev/null +++ b/contracts/reputation-signal/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" diff --git a/contracts/reputation-signal/Cargo.toml b/contracts/reputation-signal/Cargo.toml new file mode 100644 index 0000000..3cd2268 --- /dev/null +++ b/contracts/reputation-signal/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "reputation-signal" +version = "0.1.0" +edition = "2021" +description = "M010 Reputation Signal — stake-weighted endorsement signals with challenge/dispute lifecycle for Regen Network" +license = "Apache-2.0" +repository = "https://github.com/regen-network/agentic-tokenomics" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# Use library feature to disable entry points when imported as dependency +library = [] + +[dependencies] +cosmwasm-schema = "2.2" +cosmwasm-std = "2.2" +cw-storage-plus = "2.0" +cw2 = "2.0" +schemars = "0.8" +serde = { version = "1.0", default-features = false, features = ["derive"] } +thiserror = "2.0" + +[dev-dependencies] +cosmwasm-std = { version = "2.2", features = ["staking"] } diff --git a/contracts/reputation-signal/src/contract.rs b/contracts/reputation-signal/src/contract.rs new file mode 100644 index 0000000..7bd05d5 --- /dev/null +++ b/contracts/reputation-signal/src/contract.rs @@ -0,0 +1,777 @@ +use cosmwasm_std::{ + entry_point, to_json_binary, BankMsg, Binary, Coin, Deps, DepsMut, Env, MessageInfo, Order, + Response, StdResult, Timestamp, Uint128, +}; +use cw2::set_contract_version; +use cw_storage_plus::Bound; + +use crate::error::ContractError; +use crate::msg::*; +use crate::state::*; + +// --------------------------------------------------------------------------- +// Entry points +// --------------------------------------------------------------------------- + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + let admin = deps.api.addr_validate(&msg.admin)?; + let config = Config { + admin, + activation_delay_seconds: msg.activation_delay_seconds.unwrap_or(86_400), + challenge_window_seconds: msg.challenge_window_seconds.unwrap_or(15_552_000), + resolution_deadline_seconds: msg.resolution_deadline_seconds.unwrap_or(1_209_600), + challenge_bond_denom: msg.challenge_bond_denom.unwrap_or_else(|| "uregen".to_string()), + challenge_bond_amount: msg.challenge_bond_amount.unwrap_or(Uint128::zero()), + decay_half_life_seconds: msg.decay_half_life_seconds.unwrap_or(1_209_600), + default_min_stake: msg.default_min_stake.unwrap_or(Uint128::zero()), + arbiters: vec![], + }; + + CONFIG.save(deps.storage, &config)?; + NEXT_SIGNAL_ID.save(deps.storage, &1u64)?; + NEXT_CHALLENGE_ID.save(deps.storage, &1u64)?; + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("admin", config.admin.as_str())) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::SubmitSignal { + subject_type, + subject_id, + category, + endorsement_level, + evidence, + } => exec_submit_signal( + deps, + env, + info, + subject_type, + subject_id, + category, + endorsement_level, + evidence, + ), + ExecuteMsg::ActivateSignal { signal_id } => exec_activate_signal(deps, env, signal_id), + ExecuteMsg::WithdrawSignal { signal_id } => { + exec_withdraw_signal(deps, env, info, signal_id) + } + ExecuteMsg::SubmitChallenge { + signal_id, + rationale, + evidence, + } => exec_submit_challenge(deps, env, info, signal_id, rationale, evidence), + ExecuteMsg::ResolveChallenge { + challenge_id, + outcome_valid, + rationale, + } => exec_resolve_challenge(deps, env, info, challenge_id, outcome_valid, rationale), + ExecuteMsg::EscalateChallenge { challenge_id } => { + exec_escalate_challenge(deps, env, challenge_id) + } + ExecuteMsg::InvalidateSignal { + signal_id, + rationale, + } => exec_invalidate_signal(deps, info, signal_id, rationale), + ExecuteMsg::UpdateConfig { + activation_delay_seconds, + challenge_window_seconds, + resolution_deadline_seconds, + challenge_bond_denom, + challenge_bond_amount, + decay_half_life_seconds, + default_min_stake, + } => exec_update_config( + deps, + info, + activation_delay_seconds, + challenge_window_seconds, + resolution_deadline_seconds, + challenge_bond_denom, + challenge_bond_amount, + decay_half_life_seconds, + default_min_stake, + ), + ExecuteMsg::SetCategoryMinStake { + category, + min_stake, + } => exec_set_category_min_stake(deps, info, category, min_stake), + ExecuteMsg::AddArbiter { address } => exec_add_arbiter(deps, info, address), + ExecuteMsg::RemoveArbiter { address } => exec_remove_arbiter(deps, info, address), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => to_json_binary(&query_config(deps)?), + QueryMsg::Signal { signal_id } => to_json_binary(&query_signal(deps, signal_id)?), + QueryMsg::SignalsBySubject { + subject_type, + subject_id, + category, + } => to_json_binary(&query_signals_by_subject( + deps, + subject_type, + subject_id, + category, + )?), + QueryMsg::ReputationScore { + subject_type, + subject_id, + category, + } => to_json_binary(&query_reputation_score( + deps, + env, + subject_type, + subject_id, + category, + )?), + QueryMsg::Challenge { challenge_id } => { + to_json_binary(&query_challenge(deps, challenge_id)?) + } + QueryMsg::ActiveChallenges { start_after, limit } => { + to_json_binary(&query_active_challenges(deps, start_after, limit)?) + } + QueryMsg::CategoryMinStake { category } => { + to_json_binary(&query_category_min_stake(deps, category)?) + } + } +} + +// --------------------------------------------------------------------------- +// Execute handlers +// --------------------------------------------------------------------------- + +fn exec_submit_signal( + deps: DepsMut, + env: Env, + info: MessageInfo, + subject_type: SubjectType, + subject_id: String, + category: String, + endorsement_level: u8, + evidence: Evidence, +) -> Result { + // Validate endorsement level 1-5 + if endorsement_level < 1 || endorsement_level > 5 { + return Err(ContractError::InvalidEndorsementLevel { + level: endorsement_level, + }); + } + + // Validate evidence + if !evidence.has_required_refs() { + return Err(ContractError::InsufficientEvidence {}); + } + + let config = CONFIG.load(deps.storage)?; + let now = env.block.time; + + // Allocate signal ID + let signal_id = NEXT_SIGNAL_ID.load(deps.storage)?; + NEXT_SIGNAL_ID.save(deps.storage, &(signal_id + 1))?; + + let activates_at = Timestamp::from_seconds(now.seconds() + config.activation_delay_seconds); + + let signal = Signal { + id: signal_id, + signaler: info.sender.clone(), + subject_type: subject_type.clone(), + subject_id: subject_id.clone(), + category: category.clone(), + endorsement_level, + evidence, + status: SignalStatus::Submitted, + submitted_at: now, + activates_at, + }; + + SIGNALS.save(deps.storage, signal_id, &signal)?; + + // Update subject index + let key = subject_key(&subject_type, &subject_id, &category); + let mut ids = SUBJECT_SIGNALS + .may_load(deps.storage, &key)? + .unwrap_or_default(); + ids.push(signal_id); + SUBJECT_SIGNALS.save(deps.storage, &key, &ids)?; + + Ok(Response::new() + .add_attribute("action", "submit_signal") + .add_attribute("signal_id", signal_id.to_string()) + .add_attribute("signaler", info.sender.as_str()) + .add_attribute("subject_type", subject_type.to_string()) + .add_attribute("subject_id", &subject_id) + .add_attribute("category", &category) + .add_attribute("endorsement_level", endorsement_level.to_string())) +} + +fn exec_activate_signal( + deps: DepsMut, + env: Env, + signal_id: u64, +) -> Result { + let mut signal = SIGNALS + .may_load(deps.storage, signal_id)? + .ok_or(ContractError::SignalNotFound { id: signal_id })?; + + // Must be in Submitted state + if !matches!(signal.status, SignalStatus::Submitted) { + return Err(ContractError::SignalNotYetActive { id: signal_id }); + } + + // Activation delay must have passed + if env.block.time < signal.activates_at { + return Err(ContractError::SignalNotYetActive { id: signal_id }); + } + + signal.status = SignalStatus::Active; + SIGNALS.save(deps.storage, signal_id, &signal)?; + + Ok(Response::new() + .add_attribute("action", "activate_signal") + .add_attribute("signal_id", signal_id.to_string())) +} + +fn exec_withdraw_signal( + deps: DepsMut, + _env: Env, + info: MessageInfo, + signal_id: u64, +) -> Result { + let mut signal = SIGNALS + .may_load(deps.storage, signal_id)? + .ok_or(ContractError::SignalNotFound { id: signal_id })?; + + // Only the signaler can withdraw + if info.sender != signal.signaler { + return Err(ContractError::NotSignalOwner { id: signal_id }); + } + + // Cannot withdraw if terminal + if signal.status.is_terminal() { + return Err(ContractError::SignalTerminal { id: signal_id }); + } + + // Cannot withdraw if currently challenged (spec section 6.1) + if matches!( + signal.status, + SignalStatus::Challenged | SignalStatus::Escalated + ) { + return Err(ContractError::WithdrawWhileChallenged { id: signal_id }); + } + + signal.status = SignalStatus::Withdrawn; + SIGNALS.save(deps.storage, signal_id, &signal)?; + + Ok(Response::new() + .add_attribute("action", "withdraw_signal") + .add_attribute("signal_id", signal_id.to_string())) +} + +fn exec_submit_challenge( + deps: DepsMut, + env: Env, + info: MessageInfo, + signal_id: u64, + rationale: String, + evidence: Evidence, +) -> Result { + let signal = SIGNALS + .may_load(deps.storage, signal_id)? + .ok_or(ContractError::SignalNotFound { id: signal_id })?; + + let config = CONFIG.load(deps.storage)?; + + // Only one active challenge per signal (check before state check for specificity) + if SIGNAL_CHALLENGE.may_load(deps.storage, signal_id)?.is_some() { + return Err(ContractError::SignalAlreadyChallenged { id: signal_id }); + } + + // Signal must be in SUBMITTED or ACTIVE state (spec section 6.1) + if !matches!( + signal.status, + SignalStatus::Submitted | SignalStatus::Active + ) { + return Err(ContractError::SignalNotChallengeable { id: signal_id }); + } + + // Cannot self-challenge + if info.sender == signal.signaler { + return Err(ContractError::SelfChallenge {}); + } + + // Must be within challenge window + let challenge_deadline = Timestamp::from_seconds( + signal.submitted_at.seconds() + config.challenge_window_seconds, + ); + if env.block.time > challenge_deadline { + return Err(ContractError::ChallengeWindowExpired { id: signal_id }); + } + + // Evidence required + if !evidence.has_required_refs() { + return Err(ContractError::InsufficientEvidence {}); + } + + // Rationale minimum 50 chars + if rationale.len() < 50 { + return Err(ContractError::RationaleTooShort {}); + } + + // Validate bond if required + if !config.challenge_bond_amount.is_zero() { + let sent = info + .funds + .iter() + .find(|c| c.denom == config.challenge_bond_denom) + .map(|c| c.amount) + .unwrap_or(Uint128::zero()); + + if sent < config.challenge_bond_amount { + return Err(ContractError::InsufficientBond { + required: config.challenge_bond_amount.to_string(), + sent: sent.to_string(), + }); + } + } + + // Create challenge + let challenge_id = NEXT_CHALLENGE_ID.load(deps.storage)?; + NEXT_CHALLENGE_ID.save(deps.storage, &(challenge_id + 1))?; + + let resolution_deadline = Timestamp::from_seconds( + env.block.time.seconds() + config.resolution_deadline_seconds, + ); + + let challenge = Challenge { + id: challenge_id, + signal_id, + challenger: info.sender.clone(), + rationale, + evidence, + bond_amount: config.challenge_bond_amount, + outcome: ChallengeOutcome::Pending, + challenged_at: env.block.time, + resolution_deadline, + resolution_rationale: None, + }; + + CHALLENGES.save(deps.storage, challenge_id, &challenge)?; + SIGNAL_CHALLENGE.save(deps.storage, signal_id, &challenge_id)?; + + // Transition signal to Challenged + let mut signal = signal; + signal.status = SignalStatus::Challenged; + SIGNALS.save(deps.storage, signal_id, &signal)?; + + Ok(Response::new() + .add_attribute("action", "submit_challenge") + .add_attribute("challenge_id", challenge_id.to_string()) + .add_attribute("signal_id", signal_id.to_string()) + .add_attribute("challenger", info.sender.as_str())) +} + +fn exec_resolve_challenge( + deps: DepsMut, + _env: Env, + info: MessageInfo, + challenge_id: u64, + outcome_valid: bool, + rationale: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + // Must be admin or arbiter + let is_admin = info.sender == config.admin; + let is_arbiter = config.arbiters.contains(&info.sender); + if !is_admin && !is_arbiter { + return Err(ContractError::NotResolver {}); + } + + let mut challenge = CHALLENGES + .may_load(deps.storage, challenge_id)? + .ok_or(ContractError::ChallengeNotFound { id: challenge_id })?; + + // Must be pending + if !matches!(challenge.outcome, ChallengeOutcome::Pending) { + return Err(ContractError::ChallengeNotPending { id: challenge_id }); + } + + let signal_id = challenge.signal_id; + let mut signal = SIGNALS.load(deps.storage, signal_id)?; + + let mut msgs: Vec = vec![]; + + if outcome_valid { + // Signal found valid — restore + challenge.outcome = ChallengeOutcome::Valid; + signal.status = SignalStatus::ResolvedValid; + + // v1: challenger forfeits bond. In v0 with zero bond this is a no-op. + // Bond stays in contract (could be distributed to community pool). + } else { + // Signal found invalid — permanently remove + challenge.outcome = ChallengeOutcome::Invalid; + signal.status = SignalStatus::ResolvedInvalid; + + // v1: return bond to challenger (or reward them). In v0 this is a no-op. + if !challenge.bond_amount.is_zero() { + msgs.push(BankMsg::Send { + to_address: challenge.challenger.to_string(), + amount: vec![Coin { + denom: config.challenge_bond_denom.clone(), + amount: challenge.bond_amount, + }], + }); + } + } + + challenge.resolution_rationale = Some(rationale); + CHALLENGES.save(deps.storage, challenge_id, &challenge)?; + SIGNALS.save(deps.storage, signal_id, &signal)?; + + // Remove active challenge index + SIGNAL_CHALLENGE.remove(deps.storage, signal_id); + + let mut resp = Response::new() + .add_attribute("action", "resolve_challenge") + .add_attribute("challenge_id", challenge_id.to_string()) + .add_attribute("signal_id", signal_id.to_string()) + .add_attribute( + "outcome", + if outcome_valid { "valid" } else { "invalid" }, + ); + + for msg in msgs { + resp = resp.add_message(msg); + } + + Ok(resp) +} + +fn exec_escalate_challenge( + deps: DepsMut, + env: Env, + challenge_id: u64, +) -> Result { + let challenge = CHALLENGES + .may_load(deps.storage, challenge_id)? + .ok_or(ContractError::ChallengeNotFound { id: challenge_id })?; + + // Must be pending + if !matches!(challenge.outcome, ChallengeOutcome::Pending) { + return Err(ContractError::ChallengeNotPending { id: challenge_id }); + } + + // Deadline must have passed + if env.block.time <= challenge.resolution_deadline { + return Err(ContractError::DeadlineNotExceeded { id: challenge_id }); + } + + // Transition signal to Escalated + let signal_id = challenge.signal_id; + let mut signal = SIGNALS.load(deps.storage, signal_id)?; + signal.status = SignalStatus::Escalated; + SIGNALS.save(deps.storage, signal_id, &signal)?; + + Ok(Response::new() + .add_attribute("action", "escalate_challenge") + .add_attribute("challenge_id", challenge_id.to_string()) + .add_attribute("signal_id", signal_id.to_string())) +} + +fn exec_invalidate_signal( + deps: DepsMut, + info: MessageInfo, + signal_id: u64, + rationale: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + // Admin only + if info.sender != config.admin { + return Err(ContractError::Unauthorized {}); + } + + // Rationale required + if rationale.is_empty() { + return Err(ContractError::InvalidationRationaleRequired {}); + } + + let mut signal = SIGNALS + .may_load(deps.storage, signal_id)? + .ok_or(ContractError::SignalNotFound { id: signal_id })?; + + // Cannot invalidate terminal signals + if signal.status.is_terminal() { + return Err(ContractError::SignalTerminal { id: signal_id }); + } + + signal.status = SignalStatus::Invalidated; + SIGNALS.save(deps.storage, signal_id, &signal)?; + + Ok(Response::new() + .add_attribute("action", "invalidate_signal") + .add_attribute("signal_id", signal_id.to_string()) + .add_attribute("rationale", &rationale)) +} + +fn exec_update_config( + deps: DepsMut, + info: MessageInfo, + activation_delay_seconds: Option, + challenge_window_seconds: Option, + resolution_deadline_seconds: Option, + challenge_bond_denom: Option, + challenge_bond_amount: Option, + decay_half_life_seconds: Option, + default_min_stake: Option, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + + if info.sender != config.admin { + return Err(ContractError::Unauthorized {}); + } + + if let Some(v) = activation_delay_seconds { + config.activation_delay_seconds = v; + } + if let Some(v) = challenge_window_seconds { + config.challenge_window_seconds = v; + } + if let Some(v) = resolution_deadline_seconds { + config.resolution_deadline_seconds = v; + } + if let Some(v) = challenge_bond_denom { + config.challenge_bond_denom = v; + } + if let Some(v) = challenge_bond_amount { + config.challenge_bond_amount = v; + } + if let Some(v) = decay_half_life_seconds { + config.decay_half_life_seconds = v; + } + if let Some(v) = default_min_stake { + config.default_min_stake = v; + } + + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new().add_attribute("action", "update_config")) +} + +fn exec_set_category_min_stake( + deps: DepsMut, + info: MessageInfo, + category: String, + min_stake: Uint128, +) -> Result { + let config = CONFIG.load(deps.storage)?; + if info.sender != config.admin { + return Err(ContractError::Unauthorized {}); + } + + CATEGORY_MIN_STAKE.save(deps.storage, &category, &min_stake)?; + + Ok(Response::new() + .add_attribute("action", "set_category_min_stake") + .add_attribute("category", &category) + .add_attribute("min_stake", min_stake.to_string())) +} + +fn exec_add_arbiter( + deps: DepsMut, + info: MessageInfo, + address: String, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + if info.sender != config.admin { + return Err(ContractError::Unauthorized {}); + } + + let addr = deps.api.addr_validate(&address)?; + if !config.arbiters.contains(&addr) { + config.arbiters.push(addr.clone()); + CONFIG.save(deps.storage, &config)?; + } + + Ok(Response::new() + .add_attribute("action", "add_arbiter") + .add_attribute("arbiter", addr.as_str())) +} + +fn exec_remove_arbiter( + deps: DepsMut, + info: MessageInfo, + address: String, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + if info.sender != config.admin { + return Err(ContractError::Unauthorized {}); + } + + let addr = deps.api.addr_validate(&address)?; + config.arbiters.retain(|a| a != &addr); + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new() + .add_attribute("action", "remove_arbiter") + .add_attribute("arbiter", addr.as_str())) +} + +// --------------------------------------------------------------------------- +// Query handlers +// --------------------------------------------------------------------------- + +fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + Ok(ConfigResponse { config }) +} + +fn query_signal(deps: Deps, signal_id: u64) -> StdResult { + let signal = SIGNALS.load(deps.storage, signal_id)?; + Ok(SignalResponse { signal }) +} + +fn query_signals_by_subject( + deps: Deps, + subject_type: SubjectType, + subject_id: String, + category: String, +) -> StdResult { + let key = subject_key(&subject_type, &subject_id, &category); + let ids = SUBJECT_SIGNALS + .may_load(deps.storage, &key)? + .unwrap_or_default(); + + let mut signals = Vec::with_capacity(ids.len()); + for id in ids { + if let Ok(s) = SIGNALS.load(deps.storage, id) { + signals.push(s); + } + } + + Ok(SignalsBySubjectResponse { signals }) +} + +fn query_reputation_score( + deps: Deps, + env: Env, + subject_type: SubjectType, + subject_id: String, + category: String, +) -> StdResult { + let config = CONFIG.load(deps.storage)?; + let key = subject_key(&subject_type, &subject_id, &category); + let ids = SUBJECT_SIGNALS + .may_load(deps.storage, &key)? + .unwrap_or_default(); + + let total_signals = ids.len() as u32; + let now = env.block.time; + + // v0 scoring: decay-weighted average of endorsement_level/5 (no stake weighting) + // score = sum(decay * endorsement_level / 5) / sum(decay) + // decay = exp(-lambda * age_seconds) where lambda = ln(2) / half_life_seconds + let half_life = config.decay_half_life_seconds as f64; + let lambda = (2.0_f64).ln() / half_life; + + let mut w_sum: f64 = 0.0; + let mut d_sum: f64 = 0.0; + let mut contributing: u32 = 0; + + for id in &ids { + if let Ok(signal) = SIGNALS.load(deps.storage, *id) { + if !signal.status.contributes_to_score() { + continue; + } + contributing += 1; + + let age_secs = now.seconds().saturating_sub(signal.submitted_at.seconds()) as f64; + let decay = (-lambda * age_secs).exp(); + let w = signal.endorsement_level as f64 / 5.0; + + w_sum += w * decay; + d_sum += decay; + } + } + + let score_0_1 = if d_sum > 0.0 { w_sum / d_sum } else { 0.0 }; + // Scale to 0-1000 + let score = (score_0_1 * 1000.0).round().min(1000.0) as u64; + + Ok(ReputationScoreResponse { + score, + contributing_signals: contributing, + total_signals, + }) +} + +fn query_challenge(deps: Deps, challenge_id: u64) -> StdResult { + let challenge = CHALLENGES.load(deps.storage, challenge_id)?; + Ok(ChallengeResponse { challenge }) +} + +fn query_active_challenges( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(30).min(100) as usize; + let start = start_after.map(|s| s + 1).unwrap_or(0); + + let mut challenges = Vec::new(); + // Iterate challenges from start + for result in CHALLENGES + .range(deps.storage, Some(Bound::inclusive(start)), None, Order::Ascending) + { + let (_, challenge) = result?; + if matches!(challenge.outcome, ChallengeOutcome::Pending) { + challenges.push(challenge); + if challenges.len() >= limit { + break; + } + } + } + + Ok(ActiveChallengesResponse { challenges }) +} + +fn query_category_min_stake(deps: Deps, category: String) -> StdResult { + let config = CONFIG.load(deps.storage)?; + let min_stake = CATEGORY_MIN_STAKE + .may_load(deps.storage, &category)? + .unwrap_or(config.default_min_stake); + + Ok(CategoryMinStakeResponse { + category, + min_stake, + }) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Build the composite key for subject signal indexing. +fn subject_key(subject_type: &SubjectType, subject_id: &str, category: &str) -> String { + format!("{}:{}:{}", subject_type, subject_id, category) +} diff --git a/contracts/reputation-signal/src/error.rs b/contracts/reputation-signal/src/error.rs new file mode 100644 index 0000000..03ed653 --- /dev/null +++ b/contracts/reputation-signal/src/error.rs @@ -0,0 +1,68 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("unauthorized: only admin can perform this action")] + Unauthorized {}, + + #[error("invalid endorsement level {level}: must be between 1 and 5")] + InvalidEndorsementLevel { level: u8 }, + + #[error("signal not found: {id}")] + SignalNotFound { id: u64 }, + + #[error("challenge not found: {id}")] + ChallengeNotFound { id: u64 }, + + #[error("signal {id} is in terminal state and cannot be modified")] + SignalTerminal { id: u64 }, + + #[error("signal {id} is not in a challengeable state")] + SignalNotChallengeable { id: u64 }, + + #[error("signal {id} already has an active challenge")] + SignalAlreadyChallenged { id: u64 }, + + #[error("cannot challenge your own signal")] + SelfChallenge {}, + + #[error("challenge window expired for signal {id}")] + ChallengeWindowExpired { id: u64 }, + + #[error("insufficient bond: required {required}, sent {sent}")] + InsufficientBond { required: String, sent: String }, + + #[error("wrong bond denomination: expected {expected}, got {got}")] + WrongBondDenom { expected: String, got: String }, + + #[error("evidence must include at least one koi_link or ledger_ref")] + InsufficientEvidence {}, + + #[error("rationale must be at least 50 characters")] + RationaleTooShort {}, + + #[error("only the original signaler can withdraw signal {id}")] + NotSignalOwner { id: u64 }, + + #[error("cannot withdraw signal {id}: currently challenged")] + WithdrawWhileChallenged { id: u64 }, + + #[error("challenge {id} is not pending resolution")] + ChallengeNotPending { id: u64 }, + + #[error("invalidation rationale is required")] + InvalidationRationaleRequired {}, + + #[error("signal {id} is not yet active (still in activation delay)")] + SignalNotYetActive { id: u64 }, + + #[error("challenge {id} resolution deadline has not been exceeded")] + DeadlineNotExceeded { id: u64 }, + + #[error("only admin or arbiter can resolve challenges")] + NotResolver {}, +} diff --git a/contracts/reputation-signal/src/lib.rs b/contracts/reputation-signal/src/lib.rs new file mode 100644 index 0000000..f0e2fd3 --- /dev/null +++ b/contracts/reputation-signal/src/lib.rs @@ -0,0 +1,7 @@ +pub mod contract; +pub mod error; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; diff --git a/contracts/reputation-signal/src/msg.rs b/contracts/reputation-signal/src/msg.rs new file mode 100644 index 0000000..a18afab --- /dev/null +++ b/contracts/reputation-signal/src/msg.rs @@ -0,0 +1,195 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Uint128; + +use crate::state::{Challenge, Config, Evidence, Signal, SubjectType}; + +// --------------------------------------------------------------------------- +// Instantiate +// --------------------------------------------------------------------------- + +#[cw_serde] +pub struct InstantiateMsg { + /// Admin address — sole resolver and invalidator in v0 + pub admin: String, + /// Activation delay in seconds (default: 86400 = 24h) + pub activation_delay_seconds: Option, + /// Challenge window in seconds (default: 15_552_000 = 180 days) + pub challenge_window_seconds: Option, + /// Resolution deadline in seconds (default: 1_209_600 = 14 days) + pub resolution_deadline_seconds: Option, + /// Bond denom for challenges (default: "uregen") + pub challenge_bond_denom: Option, + /// Bond amount for challenges (default: 0 in v0) + pub challenge_bond_amount: Option, + /// Decay half-life in seconds (default: 1_209_600 = 14 days / 336 hours) + pub decay_half_life_seconds: Option, + /// Default minimum stake to submit a signal + pub default_min_stake: Option, +} + +// --------------------------------------------------------------------------- +// Execute +// --------------------------------------------------------------------------- + +#[cw_serde] +pub enum ExecuteMsg { + /// Submit a new reputation signal. Enters SUBMITTED state with activation delay. + SubmitSignal { + subject_type: SubjectType, + subject_id: String, + category: String, + endorsement_level: u8, + evidence: Evidence, + }, + + /// Activate a signal whose activation delay has passed. + /// Can be called by anyone (permissionless crank). + ActivateSignal { signal_id: u64 }, + + /// Withdraw a signal. Only the original signaler can withdraw. + /// Cannot withdraw a signal that is currently challenged. + WithdrawSignal { signal_id: u64 }, + + /// Submit a challenge against a signal. + /// Challenger must not be the signaler, must provide evidence, and + /// signal must be within the challenge window. + SubmitChallenge { + signal_id: u64, + rationale: String, + evidence: Evidence, + }, + + /// Resolve a pending challenge (admin or arbiter). + ResolveChallenge { + challenge_id: u64, + outcome_valid: bool, + rationale: String, + }, + + /// Escalate a challenge whose resolution deadline has passed. + /// Can be called by anyone (permissionless crank). + EscalateChallenge { challenge_id: u64 }, + + /// Admin-only: invalidate a signal with required rationale. + InvalidateSignal { + signal_id: u64, + rationale: String, + }, + + /// Admin-only: update config parameters. + UpdateConfig { + activation_delay_seconds: Option, + challenge_window_seconds: Option, + resolution_deadline_seconds: Option, + challenge_bond_denom: Option, + challenge_bond_amount: Option, + decay_half_life_seconds: Option, + default_min_stake: Option, + }, + + /// Admin-only: set per-category minimum stake. + SetCategoryMinStake { + category: String, + min_stake: Uint128, + }, + + /// Admin-only: add an arbiter to the resolver set. + AddArbiter { address: String }, + + /// Admin-only: remove an arbiter from the resolver set. + RemoveArbiter { address: String }, +} + +// --------------------------------------------------------------------------- +// Query +// --------------------------------------------------------------------------- + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Get contract config. + #[returns(ConfigResponse)] + Config {}, + + /// Get a single signal by ID. + #[returns(SignalResponse)] + Signal { signal_id: u64 }, + + /// List signals for a given subject (type + id + category). + #[returns(SignalsBySubjectResponse)] + SignalsBySubject { + subject_type: SubjectType, + subject_id: String, + category: String, + }, + + /// Compute the aggregate reputation score for a subject. + /// Uses v0 decay-weighted average (no stake weighting). + #[returns(ReputationScoreResponse)] + ReputationScore { + subject_type: SubjectType, + subject_id: String, + category: String, + }, + + /// Get a single challenge by ID. + #[returns(ChallengeResponse)] + Challenge { challenge_id: u64 }, + + /// List active (pending) challenges. + #[returns(ActiveChallengesResponse)] + ActiveChallenges { + start_after: Option, + limit: Option, + }, + + /// Get the category minimum stake. + #[returns(CategoryMinStakeResponse)] + CategoryMinStake { category: String }, +} + +// --------------------------------------------------------------------------- +// Response types +// --------------------------------------------------------------------------- + +#[cw_serde] +pub struct ConfigResponse { + pub config: Config, +} + +#[cw_serde] +pub struct SignalResponse { + pub signal: Signal, +} + +#[cw_serde] +pub struct SignalsBySubjectResponse { + pub signals: Vec, +} + +#[cw_serde] +pub struct ReputationScoreResponse { + /// v0 score normalized to 0..1000 (integer millibels for precision) + /// Internally computed as decay-weighted average of endorsement_level/5, scaled to 1000. + pub score: u64, + /// Number of signals that contributed to this score + pub contributing_signals: u32, + /// Total signals (including non-contributing) for this subject + pub total_signals: u32, +} + +#[cw_serde] +pub struct ChallengeResponse { + pub challenge: Challenge, +} + +#[cw_serde] +pub struct ActiveChallengesResponse { + pub challenges: Vec, +} + +#[cw_serde] +pub struct CategoryMinStakeResponse { + pub category: String, + pub min_stake: Uint128, +} diff --git a/contracts/reputation-signal/src/state.rs b/contracts/reputation-signal/src/state.rs new file mode 100644 index 0000000..0542029 --- /dev/null +++ b/contracts/reputation-signal/src/state.rs @@ -0,0 +1,197 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Timestamp, Uint128}; +use cw_storage_plus::{Item, Map}; + +// --------------------------------------------------------------------------- +// Subject types (spec section 3) +// --------------------------------------------------------------------------- + +#[cw_serde] +pub enum SubjectType { + CreditClass, + Project, + Verifier, + Methodology, + Address, +} + +impl std::fmt::Display for SubjectType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SubjectType::CreditClass => write!(f, "CreditClass"), + SubjectType::Project => write!(f, "Project"), + SubjectType::Verifier => write!(f, "Verifier"), + SubjectType::Methodology => write!(f, "Methodology"), + SubjectType::Address => write!(f, "Address"), + } + } +} + +// --------------------------------------------------------------------------- +// Signal lifecycle (spec section 6.1) +// --------------------------------------------------------------------------- + +#[cw_serde] +pub enum SignalStatus { + /// Submitted but within 24h activation delay + Submitted, + /// Active and contributing to reputation score + Active, + /// Under challenge — score contribution paused + Challenged, + /// Challenge unresolved past deadline — escalated to governance + Escalated, + /// Challenge resolved: signal found valid — score restored + ResolvedValid, + /// Challenge resolved: signal found invalid — permanently removed (terminal) + ResolvedInvalid, + /// Voluntarily withdrawn by signaler (terminal) + Withdrawn, + /// Admin override invalidation (terminal) + Invalidated, +} + +impl SignalStatus { + /// Whether the signal is in a terminal state (no further transitions). + pub fn is_terminal(&self) -> bool { + matches!( + self, + SignalStatus::ResolvedInvalid | SignalStatus::Withdrawn | SignalStatus::Invalidated + ) + } + + /// Whether the signal contributes to reputation score. + pub fn contributes_to_score(&self) -> bool { + matches!(self, SignalStatus::Active | SignalStatus::ResolvedValid) + } +} + +// --------------------------------------------------------------------------- +// Evidence +// --------------------------------------------------------------------------- + +#[cw_serde] +pub struct Evidence { + /// KOI knowledge-graph IRIs + pub koi_links: Vec, + /// On-chain ledger transaction references + pub ledger_refs: Vec, + /// Optional supporting web links + pub web_links: Vec, +} + +impl Evidence { + /// At least one koi_link or ledger_ref is required (spec section 6.4). + pub fn has_required_refs(&self) -> bool { + !self.koi_links.is_empty() || !self.ledger_refs.is_empty() + } +} + +// --------------------------------------------------------------------------- +// Signal +// --------------------------------------------------------------------------- + +#[cw_serde] +pub struct Signal { + pub id: u64, + pub signaler: Addr, + pub subject_type: SubjectType, + pub subject_id: String, + pub category: String, + /// Endorsement level 1-5 + pub endorsement_level: u8, + pub evidence: Evidence, + pub status: SignalStatus, + /// Block time at which the signal was submitted + pub submitted_at: Timestamp, + /// Block time at which the signal became active (submitted_at + activation_delay) + pub activates_at: Timestamp, +} + +// --------------------------------------------------------------------------- +// Challenge +// --------------------------------------------------------------------------- + +#[cw_serde] +pub enum ChallengeOutcome { + /// Pending resolution + Pending, + /// Signal found valid — restored + Valid, + /// Signal found invalid — permanently removed + Invalid, +} + +#[cw_serde] +pub struct Challenge { + pub id: u64, + pub signal_id: u64, + pub challenger: Addr, + pub rationale: String, + pub evidence: Evidence, + pub bond_amount: Uint128, + pub outcome: ChallengeOutcome, + pub challenged_at: Timestamp, + /// Deadline for admin/arbiter resolution (challenged_at + resolution_deadline) + pub resolution_deadline: Timestamp, + /// Resolution rationale (filled on resolve) + pub resolution_rationale: Option, +} + +// --------------------------------------------------------------------------- +// Config (admin controls) +// --------------------------------------------------------------------------- + +#[cw_serde] +pub struct Config { + /// Admin address (v0: sole resolver + invalidator) + pub admin: Addr, + /// Activation delay in seconds (default 24h = 86400s) + pub activation_delay_seconds: u64, + /// Challenge window in seconds (default 180 days = 15_552_000s) + pub challenge_window_seconds: u64, + /// Resolution deadline in seconds (default 14 days = 1_209_600s) + pub resolution_deadline_seconds: u64, + /// Required bond amount for challenges (v0: 0, v1: 10% of stake) + pub challenge_bond_denom: String, + pub challenge_bond_amount: Uint128, + /// Decay half-life in seconds (default 14 days = 1_209_600s) + pub decay_half_life_seconds: u64, + /// Minimum stake required to submit a signal (per-category override via CATEGORY_MIN_STAKE) + pub default_min_stake: Uint128, + /// Authorized arbiter addresses (v0: admin only; v1: DAO members) + pub arbiters: Vec, +} + +// --------------------------------------------------------------------------- +// Storage layout +// --------------------------------------------------------------------------- + +/// Contract version info (cw2) +pub const CONTRACT_NAME: &str = "crates.io:reputation-signal"; +pub const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Global config +pub const CONFIG: Item = Item::new("config"); + +/// Auto-incrementing signal ID counter +pub const NEXT_SIGNAL_ID: Item = Item::new("next_signal_id"); + +/// Auto-incrementing challenge ID counter +pub const NEXT_CHALLENGE_ID: Item = Item::new("next_challenge_id"); + +/// Signal storage: signal_id -> Signal +pub const SIGNALS: Map = Map::new("signals"); + +/// Index: (subject_type_str, subject_id, category) -> Vec +/// We store a composite key as a string: "{subject_type}:{subject_id}:{category}" +pub const SUBJECT_SIGNALS: Map<&str, Vec> = Map::new("subject_signals"); + +/// Challenge storage: challenge_id -> Challenge +pub const CHALLENGES: Map = Map::new("challenges"); + +/// Index: signal_id -> active challenge_id (at most one active challenge per signal) +pub const SIGNAL_CHALLENGE: Map = Map::new("signal_challenge"); + +/// Per-category minimum stake overrides: category -> Uint128 +pub const CATEGORY_MIN_STAKE: Map<&str, Uint128> = Map::new("category_min_stake"); diff --git a/contracts/reputation-signal/src/tests.rs b/contracts/reputation-signal/src/tests.rs new file mode 100644 index 0000000..2e2fa19 --- /dev/null +++ b/contracts/reputation-signal/src/tests.rs @@ -0,0 +1,1545 @@ +use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env, MockApi}; +use cosmwasm_std::{from_json, Addr, Coin, Timestamp, Uint128}; + +use crate::contract::{execute, instantiate, query}; +use crate::error::ContractError; +use crate::msg::*; +use crate::state::*; + +// --------------------------------------------------------------------------- +// Helpers — use MockApi::addr_make to produce bech32-valid addresses +// --------------------------------------------------------------------------- + +fn admin() -> Addr { + MockApi::default().addr_make("admin") +} + +fn signaler_a() -> Addr { + MockApi::default().addr_make("signaler_a") +} + +fn signaler_b() -> Addr { + MockApi::default().addr_make("signaler_b") +} + +fn challenger() -> Addr { + MockApi::default().addr_make("challenger") +} + +fn arbiter() -> Addr { + MockApi::default().addr_make("arbiter") +} + +fn random_addr(label: &str) -> Addr { + MockApi::default().addr_make(label) +} + +fn default_instantiate_msg() -> InstantiateMsg { + InstantiateMsg { + admin: admin().to_string(), + activation_delay_seconds: Some(100), // short for tests + challenge_window_seconds: Some(10_000), + resolution_deadline_seconds: Some(1_000), + challenge_bond_denom: Some("uregen".to_string()), + challenge_bond_amount: Some(Uint128::zero()), + decay_half_life_seconds: Some(1_000_000), // large so decay is negligible in tests + default_min_stake: Some(Uint128::zero()), + } +} + +fn default_evidence() -> Evidence { + Evidence { + koi_links: vec!["koi://note/test".to_string()], + ledger_refs: vec!["ledger://tx/1".to_string()], + web_links: vec![], + } +} + +fn env_at(seconds: u64) -> cosmwasm_std::Env { + let mut env = mock_env(); + env.block.time = Timestamp::from_seconds(seconds); + env +} + +/// Instantiate the contract and return deps. +fn setup() -> cosmwasm_std::OwnedDeps< + cosmwasm_std::MemoryStorage, + cosmwasm_std::testing::MockApi, + cosmwasm_std::testing::MockQuerier, +> { + let mut deps = mock_dependencies(); + let env = env_at(1_000_000); + let info = message_info(&admin(), &[]); + instantiate(deps.as_mut(), env, info, default_instantiate_msg()).unwrap(); + deps +} + +fn submit_signal( + deps: &mut cosmwasm_std::OwnedDeps< + cosmwasm_std::MemoryStorage, + cosmwasm_std::testing::MockApi, + cosmwasm_std::testing::MockQuerier, + >, + sender: &Addr, + level: u8, + time: u64, +) -> u64 { + let env = env_at(time); + let info = message_info(sender, &[]); + let resp = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitSignal { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + endorsement_level: level, + evidence: default_evidence(), + }, + ) + .unwrap(); + + // Extract signal_id from attributes + resp.attributes + .iter() + .find(|a| a.key == "signal_id") + .unwrap() + .value + .parse::() + .unwrap() +} + +fn activate_signal( + deps: &mut cosmwasm_std::OwnedDeps< + cosmwasm_std::MemoryStorage, + cosmwasm_std::testing::MockApi, + cosmwasm_std::testing::MockQuerier, + >, + signal_id: u64, + time: u64, +) { + let env = env_at(time); + let info = message_info(&random_addr("crank"), &[]); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ActivateSignal { signal_id }, + ) + .unwrap(); +} + +// --------------------------------------------------------------------------- +// Instantiation +// --------------------------------------------------------------------------- + +#[test] +fn test_instantiate() { + let deps = setup(); + let env = env_at(1_000_000); + let res: ConfigResponse = + from_json(query(deps.as_ref(), env, QueryMsg::Config {}).unwrap()).unwrap(); + assert_eq!(res.config.admin, admin()); + assert_eq!(res.config.activation_delay_seconds, 100); + assert_eq!(res.config.challenge_window_seconds, 10_000); + assert_eq!(res.config.resolution_deadline_seconds, 1_000); +} + +// --------------------------------------------------------------------------- +// Signal submission +// --------------------------------------------------------------------------- + +#[test] +fn test_submit_signal() { + let mut deps = setup(); + let id = submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + assert_eq!(id, 1); + + // Query signal + let env = env_at(1_000_000); + let res: SignalResponse = + from_json(query(deps.as_ref(), env, QueryMsg::Signal { signal_id: 1 }).unwrap()).unwrap(); + assert_eq!(res.signal.endorsement_level, 4); + assert!(matches!(res.signal.status, SignalStatus::Submitted)); + assert_eq!(res.signal.signaler, signaler_a()); +} + +#[test] +fn test_invalid_endorsement_level_zero() { + let mut deps = setup(); + let env = env_at(1_000_000); + let info = message_info(&signaler_a(), &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitSignal { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + endorsement_level: 0, + evidence: default_evidence(), + }, + ) + .unwrap_err(); + assert_eq!( + err, + ContractError::InvalidEndorsementLevel { level: 0 } + ); +} + +#[test] +fn test_invalid_endorsement_level_six() { + let mut deps = setup(); + let env = env_at(1_000_000); + let info = message_info(&signaler_a(), &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitSignal { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + endorsement_level: 6, + evidence: default_evidence(), + }, + ) + .unwrap_err(); + assert_eq!( + err, + ContractError::InvalidEndorsementLevel { level: 6 } + ); +} + +#[test] +fn test_submit_signal_no_evidence() { + let mut deps = setup(); + let env = env_at(1_000_000); + let info = message_info(&signaler_a(), &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitSignal { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + endorsement_level: 3, + evidence: Evidence { + koi_links: vec![], + ledger_refs: vec![], + web_links: vec!["https://example.com".to_string()], + }, + }, + ) + .unwrap_err(); + assert_eq!(err, ContractError::InsufficientEvidence {}); +} + +// --------------------------------------------------------------------------- +// Activation +// --------------------------------------------------------------------------- + +#[test] +fn test_activate_signal_before_delay() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + + // Try to activate too early (delay is 100s, so at 1_000_050 should fail) + let env = env_at(1_000_050); + let info = message_info(&random_addr("crank"), &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ActivateSignal { signal_id: 1 }, + ) + .unwrap_err(); + assert_eq!(err, ContractError::SignalNotYetActive { id: 1 }); +} + +#[test] +fn test_activate_signal_after_delay() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + let env = env_at(1_000_101); + let res: SignalResponse = + from_json(query(deps.as_ref(), env, QueryMsg::Signal { signal_id: 1 }).unwrap()).unwrap(); + assert!(matches!(res.signal.status, SignalStatus::Active)); +} + +// --------------------------------------------------------------------------- +// Submitted signals do not contribute to score +// --------------------------------------------------------------------------- + +#[test] +fn test_submitted_signal_does_not_score() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 5, 1_000_000); + + let env = env_at(1_000_000); + let res: ReputationScoreResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::ReputationScore { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(res.score, 0); + assert_eq!(res.contributing_signals, 0); + assert_eq!(res.total_signals, 1); +} + +// --------------------------------------------------------------------------- +// Reputation score computation +// --------------------------------------------------------------------------- + +#[test] +fn test_reputation_score_single_signal() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + let env = env_at(1_000_101); + let res: ReputationScoreResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::ReputationScore { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + + // 4/5 = 0.8, scaled to 1000 = 800 + assert_eq!(res.score, 800); + assert_eq!(res.contributing_signals, 1); +} + +#[test] +fn test_reputation_score_multiple_signals() { + let mut deps = setup(); + // Signal 1: level 5 + submit_signal(&mut deps, &signaler_a(), 5, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + // Signal 2: level 3 + submit_signal(&mut deps, &signaler_b(), 3, 1_000_000); + activate_signal(&mut deps, 2, 1_000_101); + + let env = env_at(1_000_101); + let res: ReputationScoreResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::ReputationScore { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + + // Same submit time, large half-life => equal decay + // Average: (5/5 + 3/5) / 2 = (1.0 + 0.6) / 2 = 0.8 => 800 + assert_eq!(res.score, 800); + assert_eq!(res.contributing_signals, 2); +} + +// --------------------------------------------------------------------------- +// Withdrawal +// --------------------------------------------------------------------------- + +#[test] +fn test_withdraw_signal() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + // Withdraw + let env = env_at(1_000_200); + let info = message_info(&signaler_a(), &[]); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::WithdrawSignal { signal_id: 1 }, + ) + .unwrap(); + + // Score should drop to 0 + let res: ReputationScoreResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::ReputationScore { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(res.score, 0); + assert_eq!(res.contributing_signals, 0); +} + +#[test] +fn test_withdraw_not_owner() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + + let env = env_at(1_000_200); + let info = message_info(&signaler_b(), &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::WithdrawSignal { signal_id: 1 }, + ) + .unwrap_err(); + assert_eq!(err, ContractError::NotSignalOwner { id: 1 }); +} + +// --------------------------------------------------------------------------- +// Challenge submission +// --------------------------------------------------------------------------- + +#[test] +fn test_submit_challenge() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + let env = env_at(1_000_200); + let info = message_info(&challenger(), &[]); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::SubmitChallenge { + signal_id: 1, + rationale: "This endorsement is based on outdated information that no longer applies to the project.".to_string(), + evidence: default_evidence(), + }, + ) + .unwrap(); + + // Signal should be Challenged + let res: SignalResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::Signal { signal_id: 1 }).unwrap()) + .unwrap(); + assert!(matches!(res.signal.status, SignalStatus::Challenged)); + + // Score should be 0 (paused) + let score_res: ReputationScoreResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::ReputationScore { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(score_res.score, 0); +} + +#[test] +fn test_challenge_self_signal() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + let env = env_at(1_000_200); + let info = message_info(&signaler_a(), &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitChallenge { + signal_id: 1, + rationale: "I am challenging my own signal for some reason that I should not be able to.".to_string(), + evidence: default_evidence(), + }, + ) + .unwrap_err(); + assert_eq!(err, ContractError::SelfChallenge {}); +} + +#[test] +fn test_challenge_no_evidence() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + let env = env_at(1_000_200); + let info = message_info(&challenger(), &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitChallenge { + signal_id: 1, + rationale: "This endorsement is based on outdated information that no longer applies to the project.".to_string(), + evidence: Evidence { + koi_links: vec![], + ledger_refs: vec![], + web_links: vec!["https://example.com".to_string()], + }, + }, + ) + .unwrap_err(); + assert_eq!(err, ContractError::InsufficientEvidence {}); +} + +#[test] +fn test_challenge_rationale_too_short() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + let env = env_at(1_000_200); + let info = message_info(&challenger(), &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitChallenge { + signal_id: 1, + rationale: "Too short".to_string(), + evidence: default_evidence(), + }, + ) + .unwrap_err(); + assert_eq!(err, ContractError::RationaleTooShort {}); +} + +#[test] +fn test_challenge_expired_window() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + // Challenge window is 10_000s, submitted at 1_000_000 + // So window expires at 1_010_000 + let env = env_at(1_010_001); + let info = message_info(&challenger(), &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitChallenge { + signal_id: 1, + rationale: "This endorsement is based on outdated information that no longer applies to the project.".to_string(), + evidence: default_evidence(), + }, + ) + .unwrap_err(); + assert_eq!(err, ContractError::ChallengeWindowExpired { id: 1 }); +} + +#[test] +fn test_challenge_withdrawn_signal() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + // Withdraw + let env = env_at(1_000_200); + let info = message_info(&signaler_a(), &[]); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::WithdrawSignal { signal_id: 1 }, + ) + .unwrap(); + + // Try to challenge withdrawn signal + let env = env_at(1_000_300); + let info = message_info(&challenger(), &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitChallenge { + signal_id: 1, + rationale: "This endorsement is based on outdated information that no longer applies to the project.".to_string(), + evidence: default_evidence(), + }, + ) + .unwrap_err(); + assert_eq!(err, ContractError::SignalNotChallengeable { id: 1 }); +} + +#[test] +fn test_challenge_already_challenged() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + // First challenge + let env = env_at(1_000_200); + let info = message_info(&challenger(), &[]); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitChallenge { + signal_id: 1, + rationale: "This endorsement is based on outdated information that no longer applies to the project.".to_string(), + evidence: default_evidence(), + }, + ) + .unwrap(); + + // Second challenge attempt + let env = env_at(1_000_300); + let info = message_info(&random_addr("another_challenger"), &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitChallenge { + signal_id: 1, + rationale: "Another challenge on the same signal which should not be allowed because one is pending.".to_string(), + evidence: default_evidence(), + }, + ) + .unwrap_err(); + assert_eq!(err, ContractError::SignalAlreadyChallenged { id: 1 }); +} + +#[test] +fn test_challenge_during_submitted_state() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 5, 1_000_000); + + // Challenge during SUBMITTED state (before activation) + let env = env_at(1_000_050); + let info = message_info(&challenger(), &[]); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::SubmitChallenge { + signal_id: 1, + rationale: "This signal was submitted by a known bad actor and should be challenged before activation.".to_string(), + evidence: default_evidence(), + }, + ) + .unwrap(); + + // Signal should be Challenged, score still 0 (never contributed) + let res: SignalResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::Signal { signal_id: 1 }).unwrap()) + .unwrap(); + assert!(matches!(res.signal.status, SignalStatus::Challenged)); +} + +// --------------------------------------------------------------------------- +// Withdraw during challenge +// --------------------------------------------------------------------------- + +#[test] +fn test_cannot_withdraw_while_challenged() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + // Challenge + let env = env_at(1_000_200); + let info = message_info(&challenger(), &[]); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitChallenge { + signal_id: 1, + rationale: "This endorsement is based on outdated information that no longer applies to the project.".to_string(), + evidence: default_evidence(), + }, + ) + .unwrap(); + + // Try to withdraw + let env = env_at(1_000_300); + let info = message_info(&signaler_a(), &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::WithdrawSignal { signal_id: 1 }, + ) + .unwrap_err(); + assert_eq!(err, ContractError::WithdrawWhileChallenged { id: 1 }); +} + +// --------------------------------------------------------------------------- +// Challenge resolution +// --------------------------------------------------------------------------- + +#[test] +fn test_resolve_challenge_valid() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + // Challenge + let env = env_at(1_000_200); + let info = message_info(&challenger(), &[]); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitChallenge { + signal_id: 1, + rationale: "This endorsement is based on outdated information that no longer applies to the project.".to_string(), + evidence: default_evidence(), + }, + ) + .unwrap(); + + // Resolve as valid (admin) + let env = env_at(1_000_500); + let info = message_info(&admin(), &[]); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::ResolveChallenge { + challenge_id: 1, + outcome_valid: true, + rationale: "Signal verified correct.".to_string(), + }, + ) + .unwrap(); + + // Signal should be ResolvedValid + let res: SignalResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::Signal { signal_id: 1 }).unwrap()) + .unwrap(); + assert!(matches!(res.signal.status, SignalStatus::ResolvedValid)); + + // Score should be restored: 4/5 = 0.8 => 800 + let score_res: ReputationScoreResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::ReputationScore { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(score_res.score, 800); +} + +#[test] +fn test_resolve_challenge_invalid() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 5, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + // Challenge + let env = env_at(1_000_200); + let info = message_info(&challenger(), &[]); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitChallenge { + signal_id: 1, + rationale: "This endorsement is based on false claims that have been conclusively disproven by audit.".to_string(), + evidence: default_evidence(), + }, + ) + .unwrap(); + + // Resolve as invalid + let env = env_at(1_000_500); + let info = message_info(&admin(), &[]); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::ResolveChallenge { + challenge_id: 1, + outcome_valid: false, + rationale: "Confirmed invalid.".to_string(), + }, + ) + .unwrap(); + + // Signal should be ResolvedInvalid + let res: SignalResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::Signal { signal_id: 1 }).unwrap()) + .unwrap(); + assert!(matches!(res.signal.status, SignalStatus::ResolvedInvalid)); + + // Score should be 0 (permanently removed) + let score_res: ReputationScoreResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::ReputationScore { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(score_res.score, 0); +} + +#[test] +fn test_resolve_by_arbiter() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + // Add arbiter + let env = env_at(1_000_150); + let info = message_info(&admin(), &[]); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::AddArbiter { + address: arbiter().to_string(), + }, + ) + .unwrap(); + + // Challenge + let env = env_at(1_000_200); + let info = message_info(&challenger(), &[]); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitChallenge { + signal_id: 1, + rationale: "This endorsement is based on outdated information that no longer applies to the project.".to_string(), + evidence: default_evidence(), + }, + ) + .unwrap(); + + // Arbiter resolves + let env = env_at(1_000_500); + let info = message_info(&arbiter(), &[]); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::ResolveChallenge { + challenge_id: 1, + outcome_valid: true, + rationale: "Signal verified by arbiter.".to_string(), + }, + ) + .unwrap(); + + let res: SignalResponse = + from_json(query(deps.as_ref(), env, QueryMsg::Signal { signal_id: 1 }).unwrap()).unwrap(); + assert!(matches!(res.signal.status, SignalStatus::ResolvedValid)); +} + +#[test] +fn test_resolve_by_unauthorized() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + // Challenge + let env = env_at(1_000_200); + let info = message_info(&challenger(), &[]); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitChallenge { + signal_id: 1, + rationale: "This endorsement is based on outdated information that no longer applies to the project.".to_string(), + evidence: default_evidence(), + }, + ) + .unwrap(); + + // Non-admin/non-arbiter tries to resolve + let env = env_at(1_000_500); + let info = message_info(&random_addr("random"), &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::ResolveChallenge { + challenge_id: 1, + outcome_valid: true, + rationale: "Unauthorized resolution attempt.".to_string(), + }, + ) + .unwrap_err(); + assert_eq!(err, ContractError::NotResolver {}); +} + +// --------------------------------------------------------------------------- +// Escalation +// --------------------------------------------------------------------------- + +#[test] +fn test_escalate_after_deadline() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + // Challenge at 1_000_200 + let env = env_at(1_000_200); + let info = message_info(&challenger(), &[]); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitChallenge { + signal_id: 1, + rationale: "This endorsement is based on outdated information that no longer applies to the project.".to_string(), + evidence: default_evidence(), + }, + ) + .unwrap(); + + // Escalation deadline = 1_000_200 + 1_000 = 1_001_200 + // Try before deadline + let env = env_at(1_001_100); + let info = message_info(&random_addr("crank"), &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::EscalateChallenge { challenge_id: 1 }, + ) + .unwrap_err(); + assert_eq!(err, ContractError::DeadlineNotExceeded { id: 1 }); + + // After deadline + let env = env_at(1_001_201); + let info = message_info(&random_addr("crank"), &[]); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::EscalateChallenge { challenge_id: 1 }, + ) + .unwrap(); + + // Signal should be Escalated + let res: SignalResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::Signal { signal_id: 1 }).unwrap()) + .unwrap(); + assert!(matches!(res.signal.status, SignalStatus::Escalated)); + + // Score still 0 (paused during escalation) + let score_res: ReputationScoreResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::ReputationScore { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(score_res.score, 0); +} + +// --------------------------------------------------------------------------- +// Admin invalidation +// --------------------------------------------------------------------------- + +#[test] +fn test_admin_invalidate() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + let env = env_at(1_000_200); + let info = message_info(&admin(), &[]); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::InvalidateSignal { + signal_id: 1, + rationale: "Administrative override due to policy violation.".to_string(), + }, + ) + .unwrap(); + + let res: SignalResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::Signal { signal_id: 1 }).unwrap()) + .unwrap(); + assert!(matches!(res.signal.status, SignalStatus::Invalidated)); + + // Score 0 + let score_res: ReputationScoreResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::ReputationScore { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(score_res.score, 0); +} + +#[test] +fn test_invalidate_not_admin() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + + let env = env_at(1_000_200); + let info = message_info(&signaler_b(), &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::InvalidateSignal { + signal_id: 1, + rationale: "Unauthorized invalidation attempt.".to_string(), + }, + ) + .unwrap_err(); + assert_eq!(err, ContractError::Unauthorized {}); +} + +#[test] +fn test_invalidate_no_rationale() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + + let env = env_at(1_000_200); + let info = message_info(&admin(), &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::InvalidateSignal { + signal_id: 1, + rationale: "".to_string(), + }, + ) + .unwrap_err(); + assert_eq!(err, ContractError::InvalidationRationaleRequired {}); +} + +// --------------------------------------------------------------------------- +// Multi-signal: one challenged, others unaffected +// --------------------------------------------------------------------------- + +#[test] +fn test_multi_signal_partial_challenge() { + let mut deps = setup(); + + // Signal 1: level 5 from signaler_a + submit_signal(&mut deps, &signaler_a(), 5, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + // Signal 2: level 3 from signaler_b + submit_signal(&mut deps, &signaler_b(), 3, 1_000_000); + activate_signal(&mut deps, 2, 1_000_101); + + // Score: (5/5 + 3/5) / 2 = 0.8 => 800 + let env = env_at(1_000_101); + let res: ReputationScoreResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::ReputationScore { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(res.score, 800); + + // Challenge signal 2 only + let env = env_at(1_000_200); + let info = message_info(&challenger(), &[]); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitChallenge { + signal_id: 2, + rationale: "Signal B is based on incorrect methodology assessment and should be challenged.".to_string(), + evidence: default_evidence(), + }, + ) + .unwrap(); + + // Score should only reflect signal 1: 5/5 = 1.0 => 1000 + let env = env_at(1_000_200); + let res: ReputationScoreResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::ReputationScore { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(res.score, 1000); + assert_eq!(res.contributing_signals, 1); + + // Resolve challenge as valid -- signal 2 restored + let env = env_at(1_000_500); + let info = message_info(&admin(), &[]); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::ResolveChallenge { + challenge_id: 1, + outcome_valid: true, + rationale: "Methodology confirmed correct.".to_string(), + }, + ) + .unwrap(); + + // Score back to ~800 + let res: ReputationScoreResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::ReputationScore { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(res.score, 800); + assert_eq!(res.contributing_signals, 2); +} + +// --------------------------------------------------------------------------- +// Config updates +// --------------------------------------------------------------------------- + +#[test] +fn test_update_config() { + let mut deps = setup(); + + let env = env_at(1_000_000); + let info = message_info(&admin(), &[]); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::UpdateConfig { + activation_delay_seconds: Some(200), + challenge_window_seconds: None, + resolution_deadline_seconds: None, + challenge_bond_denom: None, + challenge_bond_amount: Some(Uint128::new(1000)), + decay_half_life_seconds: None, + default_min_stake: None, + }, + ) + .unwrap(); + + let res: ConfigResponse = + from_json(query(deps.as_ref(), env, QueryMsg::Config {}).unwrap()).unwrap(); + assert_eq!(res.config.activation_delay_seconds, 200); + assert_eq!(res.config.challenge_bond_amount, Uint128::new(1000)); +} + +#[test] +fn test_update_config_not_admin() { + let mut deps = setup(); + + let env = env_at(1_000_000); + let info = message_info(&signaler_a(), &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::UpdateConfig { + activation_delay_seconds: Some(200), + challenge_window_seconds: None, + resolution_deadline_seconds: None, + challenge_bond_denom: None, + challenge_bond_amount: None, + decay_half_life_seconds: None, + default_min_stake: None, + }, + ) + .unwrap_err(); + assert_eq!(err, ContractError::Unauthorized {}); +} + +// --------------------------------------------------------------------------- +// Category min stake +// --------------------------------------------------------------------------- + +#[test] +fn test_set_category_min_stake() { + let mut deps = setup(); + + let env = env_at(1_000_000); + let info = message_info(&admin(), &[]); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::SetCategoryMinStake { + category: "quality".to_string(), + min_stake: Uint128::new(5000), + }, + ) + .unwrap(); + + let res: CategoryMinStakeResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::CategoryMinStake { + category: "quality".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(res.min_stake, Uint128::new(5000)); +} + +// --------------------------------------------------------------------------- +// Arbiter management +// --------------------------------------------------------------------------- + +#[test] +fn test_add_remove_arbiter() { + let mut deps = setup(); + + // Add arbiter + let env = env_at(1_000_000); + let info = message_info(&admin(), &[]); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::AddArbiter { + address: arbiter().to_string(), + }, + ) + .unwrap(); + + let res: ConfigResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::Config {}).unwrap()).unwrap(); + assert_eq!(res.config.arbiters.len(), 1); + assert_eq!(res.config.arbiters[0], arbiter()); + + // Remove arbiter + let info = message_info(&admin(), &[]); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::RemoveArbiter { + address: arbiter().to_string(), + }, + ) + .unwrap(); + + let res: ConfigResponse = + from_json(query(deps.as_ref(), env, QueryMsg::Config {}).unwrap()).unwrap(); + assert_eq!(res.config.arbiters.len(), 0); +} + +// --------------------------------------------------------------------------- +// Active challenges query +// --------------------------------------------------------------------------- + +#[test] +fn test_query_active_challenges() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + submit_signal(&mut deps, &signaler_b(), 3, 1_000_000); + activate_signal(&mut deps, 2, 1_000_101); + + // Challenge signal 1 + let env = env_at(1_000_200); + let info = message_info(&challenger(), &[]); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitChallenge { + signal_id: 1, + rationale: "This endorsement is based on outdated information that no longer applies to the project.".to_string(), + evidence: default_evidence(), + }, + ) + .unwrap(); + + let env = env_at(1_000_300); + let res: ActiveChallengesResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::ActiveChallenges { + start_after: None, + limit: None, + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(res.challenges.len(), 1); + assert_eq!(res.challenges[0].signal_id, 1); +} + +// --------------------------------------------------------------------------- +// Signals by subject query +// --------------------------------------------------------------------------- + +#[test] +fn test_query_signals_by_subject() { + let mut deps = setup(); + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + submit_signal(&mut deps, &signaler_b(), 3, 1_000_000); + + let env = env_at(1_000_000); + let res: SignalsBySubjectResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::SignalsBySubject { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(res.signals.len(), 2); +} + +// --------------------------------------------------------------------------- +// Bond enforcement (v1-ready) +// --------------------------------------------------------------------------- + +#[test] +fn test_challenge_bond_required() { + let mut deps = setup(); + + // Update config to require bond + let env = env_at(1_000_000); + let info = message_info(&admin(), &[]); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::UpdateConfig { + activation_delay_seconds: None, + challenge_window_seconds: None, + resolution_deadline_seconds: None, + challenge_bond_denom: Some("uregen".to_string()), + challenge_bond_amount: Some(Uint128::new(1000)), + decay_half_life_seconds: None, + default_min_stake: None, + }, + ) + .unwrap(); + + submit_signal(&mut deps, &signaler_a(), 4, 1_000_000); + activate_signal(&mut deps, 1, 1_000_101); + + // Challenge without bond + let env = env_at(1_000_200); + let info = message_info(&challenger(), &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitChallenge { + signal_id: 1, + rationale: "This endorsement is based on outdated information that no longer applies to the project.".to_string(), + evidence: default_evidence(), + }, + ) + .unwrap_err(); + assert_eq!( + err, + ContractError::InsufficientBond { + required: "1000".to_string(), + sent: "0".to_string(), + } + ); + + // Challenge with bond + let env = env_at(1_000_200); + let info = message_info( + &challenger(), + &[Coin { + denom: "uregen".to_string(), + amount: Uint128::new(1000), + }], + ); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitChallenge { + signal_id: 1, + rationale: "This endorsement is based on outdated information that no longer applies to the project.".to_string(), + evidence: default_evidence(), + }, + ) + .unwrap(); +} + +// --------------------------------------------------------------------------- +// Full lifecycle: submit -> activate -> challenge -> resolve invalid -> score gone +// --------------------------------------------------------------------------- + +#[test] +fn test_full_lifecycle_invalid() { + let mut deps = setup(); + + // Submit + let id = submit_signal(&mut deps, &signaler_a(), 5, 1_000_000); + assert_eq!(id, 1); + + // Activate + activate_signal(&mut deps, 1, 1_000_101); + + // Verify score + let env = env_at(1_000_101); + let res: ReputationScoreResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::ReputationScore { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(res.score, 1000); + + // Challenge + let env = env_at(1_000_200); + let info = message_info(&challenger(), &[]); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SubmitChallenge { + signal_id: 1, + rationale: "This signal was submitted with fabricated evidence and should be invalidated.".to_string(), + evidence: default_evidence(), + }, + ) + .unwrap(); + + // Score paused + let env = env_at(1_000_200); + let res: ReputationScoreResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::ReputationScore { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(res.score, 0); + + // Resolve invalid + let env = env_at(1_000_500); + let info = message_info(&admin(), &[]); + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::ResolveChallenge { + challenge_id: 1, + outcome_valid: false, + rationale: "Fabricated evidence confirmed.".to_string(), + }, + ) + .unwrap(); + + // Score permanently 0 + let res: ReputationScoreResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::ReputationScore { + subject_type: SubjectType::Project, + subject_id: "P-001".to_string(), + category: "quality".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(res.score, 0); + assert_eq!(res.contributing_signals, 0); +}