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/dynamic-supply/.cargo/config.toml b/contracts/dynamic-supply/.cargo/config.toml new file mode 100644 index 0000000..946af0f --- /dev/null +++ b/contracts/dynamic-supply/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" diff --git a/contracts/dynamic-supply/Cargo.toml b/contracts/dynamic-supply/Cargo.toml new file mode 100644 index 0000000..39b47f6 --- /dev/null +++ b/contracts/dynamic-supply/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "dynamic-supply" +version = "0.1.0" +edition = "2021" +description = "M012 Fixed Cap Dynamic Supply CosmWasm contract for Regen Network" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# Use library feature to disable entry points when imported as dependency +library = [] + +[dependencies] +cosmwasm-std = "2.2" +cosmwasm-schema = "2.2" +cw-storage-plus = "2.0" +schemars = "0.8" +serde = { version = "1.0", default-features = false, features = ["derive"] } +thiserror = "2" + +[dev-dependencies] +cosmwasm-std = { version = "2.2", features = ["staking"] } diff --git a/contracts/dynamic-supply/src/contract.rs b/contracts/dynamic-supply/src/contract.rs new file mode 100644 index 0000000..6c5d406 --- /dev/null +++ b/contracts/dynamic-supply/src/contract.rs @@ -0,0 +1,618 @@ +use cosmwasm_std::{ + entry_point, to_json_binary, Binary, Decimal, Deps, DepsMut, Env, MessageInfo, Response, + StdError, StdResult, Uint128, +}; + +use crate::error::ContractError; +use crate::msg::{ + ExecuteMsg, InstantiateMsg, MintBurnRecordResponse, QueryMsg, SimulatePeriodResponse, + SupplyParamsResponse, SupplyStateResponse, +}; +use crate::state::{ + M014Phase, MintBurnRecord, SupplyParams, SupplyPhase, SupplyState, MINT_BURN_HISTORY, + SUPPLY_PARAMS, SUPPLY_STATE, +}; + +/// Maximum allowed base regrowth rate: 10% (0.10). +/// Decimal stores 18 decimal places, so 0.1 = 100_000_000_000_000_000. +const MAX_REGROWTH_RATE: Decimal = Decimal::raw(100_000_000_000_000_000); + +/// Maximum effective multiplier (staking or stability): 2.0 +const MAX_MULTIPLIER: Decimal = Decimal::raw(2_000_000_000_000_000_000); + +/// Minimum multiplier floor: 1.0 +const MIN_MULTIPLIER: Decimal = Decimal::raw(1_000_000_000_000_000_000); + +// --------------------------------------------------------------------------- +// Instantiate +// --------------------------------------------------------------------------- + +#[entry_point] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + // Validate hard cap > 0 + if msg.hard_cap.is_zero() { + return Err(ContractError::ZeroCap {}); + } + + // Validate initial supply <= hard cap + if msg.initial_supply > msg.hard_cap { + return Err(ContractError::SupplyExceedsCap { + current: msg.initial_supply.to_string(), + cap: msg.hard_cap.to_string(), + }); + } + + // Validate regrowth rate in [0, 0.10] + validate_regrowth_rate(msg.base_regrowth_rate)?; + + // Validate ecological reference value > 0 if multiplier is enabled + if msg.ecological_multiplier_enabled && msg.ecological_reference_value.is_zero() { + return Err(ContractError::ZeroReferenceValue {}); + } + + let params = SupplyParams { + admin: info.sender.clone(), + hard_cap: msg.hard_cap, + base_regrowth_rate: msg.base_regrowth_rate, + ecological_multiplier_enabled: msg.ecological_multiplier_enabled, + ecological_reference_value: msg.ecological_reference_value, + m014_phase: msg.m014_phase, + equilibrium_threshold: msg.equilibrium_threshold, + equilibrium_periods_required: msg.equilibrium_periods_required, + }; + SUPPLY_PARAMS.save(deps.storage, ¶ms)?; + + let state = SupplyState { + current_supply: msg.initial_supply, + total_minted: Uint128::zero(), + total_burned: Uint128::zero(), + period_count: 0, + phase: SupplyPhase::Transition, + consecutive_equilibrium_periods: 0, + }; + SUPPLY_STATE.save(deps.storage, &state)?; + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("admin", info.sender) + .add_attribute("hard_cap", msg.hard_cap) + .add_attribute("initial_supply", msg.initial_supply) + .add_attribute("base_regrowth_rate", msg.base_regrowth_rate.to_string())) +} + +// --------------------------------------------------------------------------- +// Execute +// --------------------------------------------------------------------------- + +#[entry_point] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::ExecutePeriod { + burn_amount, + staked_amount, + stability_committed, + delta_co2, + } => execute_period( + deps, + env, + info, + burn_amount, + staked_amount, + stability_committed, + delta_co2, + ), + ExecuteMsg::UpdateRegrowthRate { rate } => execute_update_regrowth_rate(deps, info, rate), + ExecuteMsg::UpdateM014Phase { phase } => execute_update_m014_phase(deps, info, phase), + ExecuteMsg::SetEcologicalMultiplier { + enabled, + reference_value, + } => execute_set_ecological_multiplier(deps, info, enabled, reference_value), + ExecuteMsg::UpdateEquilibriumParams { + threshold, + periods_required, + } => execute_update_equilibrium_params(deps, info, threshold, periods_required), + } +} + +/// Execute a mint/burn period. +/// +/// Implements the core supply algorithm from M012 SPEC section 5: +/// S[t+1] = S[t] + M[t] - B[t] +/// M[t] = r * (C - S[t]) +/// r = r_base * effective_multiplier * ecological_multiplier +fn execute_period( + deps: DepsMut, + env: Env, + info: MessageInfo, + burn_amount: Uint128, + staked_amount: Uint128, + stability_committed: Uint128, + delta_co2: Option, +) -> Result { + let params = SUPPLY_PARAMS.load(deps.storage)?; + + // Admin-only + if info.sender != params.admin { + return Err(ContractError::Unauthorized {}); + } + + let mut state = SUPPLY_STATE.load(deps.storage)?; + + // Compute the period + let (mint_amount, effective_mult, eco_mult, regrowth_rate) = + compute_mint(¶ms, &state, staked_amount, stability_committed, delta_co2); + + // Apply supply adjustment: S[t+1] = S[t] + M[t] - B[t] + let supply_before = state.current_supply; + let supply_after_mint = state.current_supply + mint_amount; + + // Cap enforcement: S[t+1] <= hard_cap + let capped_supply = if supply_after_mint > params.hard_cap { + params.hard_cap + } else { + supply_after_mint + }; + + // Non-negative supply: S[t+1] >= 0 + let supply_after = if burn_amount >= capped_supply { + Uint128::zero() + } else { + capped_supply - burn_amount + }; + + // Effective mint (may be reduced by cap) + let effective_mint = if capped_supply > state.current_supply { + capped_supply - state.current_supply + } else { + Uint128::zero() + }; + + // Effective burn (may be reduced by zero-floor) + let effective_burn = if burn_amount >= capped_supply { + capped_supply + } else { + burn_amount + }; + + // Update state + state.current_supply = supply_after; + state.total_minted += effective_mint; + state.total_burned += effective_burn; + state.period_count += 1; + + // Phase transitions + let mint_burn_diff = if effective_mint > effective_burn { + effective_mint - effective_burn + } else { + effective_burn - effective_mint + }; + + // TRANSITION -> DYNAMIC: first successful burn period + if state.phase == SupplyPhase::Transition && effective_burn > Uint128::zero() { + state.phase = SupplyPhase::Dynamic; + state.consecutive_equilibrium_periods = 0; + } + + // DYNAMIC <-> EQUILIBRIUM detection + if state.phase == SupplyPhase::Dynamic || state.phase == SupplyPhase::Equilibrium { + if mint_burn_diff < params.equilibrium_threshold { + state.consecutive_equilibrium_periods += 1; + if state.consecutive_equilibrium_periods >= params.equilibrium_periods_required { + state.phase = SupplyPhase::Equilibrium; + } + } else { + state.consecutive_equilibrium_periods = 0; + if state.phase == SupplyPhase::Equilibrium { + state.phase = SupplyPhase::Dynamic; + } + } + } + + // Record period history + let record = MintBurnRecord { + period_id: state.period_count, + block_height: env.block.height, + minted: effective_mint, + burned: effective_burn, + supply_before, + supply_after, + regrowth_rate, + effective_multiplier: effective_mult, + ecological_multiplier: eco_mult, + }; + MINT_BURN_HISTORY.save(deps.storage, state.period_count, &record)?; + + SUPPLY_STATE.save(deps.storage, &state)?; + + Ok(Response::new() + .add_attribute("action", "execute_period") + .add_attribute("period_id", state.period_count.to_string()) + .add_attribute("minted", effective_mint) + .add_attribute("burned", effective_burn) + .add_attribute("supply_before", supply_before) + .add_attribute("supply_after", supply_after) + .add_attribute("phase", format!("{:?}", state.phase)) + .add_attribute("regrowth_rate", regrowth_rate.to_string()) + .add_attribute("effective_multiplier", effective_mult.to_string()) + .add_attribute("ecological_multiplier", eco_mult.to_string())) +} + +fn execute_update_regrowth_rate( + deps: DepsMut, + info: MessageInfo, + rate: Decimal, +) -> Result { + let mut params = SUPPLY_PARAMS.load(deps.storage)?; + + if info.sender != params.admin { + return Err(ContractError::Unauthorized {}); + } + + validate_regrowth_rate(rate)?; + params.base_regrowth_rate = rate; + SUPPLY_PARAMS.save(deps.storage, ¶ms)?; + + Ok(Response::new() + .add_attribute("action", "update_regrowth_rate") + .add_attribute("rate", rate.to_string())) +} + +fn execute_update_m014_phase( + deps: DepsMut, + info: MessageInfo, + phase: M014Phase, +) -> Result { + let mut params = SUPPLY_PARAMS.load(deps.storage)?; + + if info.sender != params.admin { + return Err(ContractError::Unauthorized {}); + } + + params.m014_phase = phase.clone(); + SUPPLY_PARAMS.save(deps.storage, ¶ms)?; + + Ok(Response::new() + .add_attribute("action", "update_m014_phase") + .add_attribute("phase", format!("{:?}", phase))) +} + +fn execute_set_ecological_multiplier( + deps: DepsMut, + info: MessageInfo, + enabled: bool, + reference_value: Option, +) -> Result { + let mut params = SUPPLY_PARAMS.load(deps.storage)?; + + if info.sender != params.admin { + return Err(ContractError::Unauthorized {}); + } + + if let Some(ref_val) = reference_value { + if ref_val.is_zero() { + return Err(ContractError::ZeroReferenceValue {}); + } + params.ecological_reference_value = ref_val; + } + + if enabled && params.ecological_reference_value.is_zero() { + return Err(ContractError::ZeroReferenceValue {}); + } + + params.ecological_multiplier_enabled = enabled; + SUPPLY_PARAMS.save(deps.storage, ¶ms)?; + + Ok(Response::new() + .add_attribute("action", "set_ecological_multiplier") + .add_attribute("enabled", enabled.to_string()) + .add_attribute( + "reference_value", + params.ecological_reference_value.to_string(), + )) +} + +fn execute_update_equilibrium_params( + deps: DepsMut, + info: MessageInfo, + threshold: Option, + periods_required: Option, +) -> Result { + let mut params = SUPPLY_PARAMS.load(deps.storage)?; + + if info.sender != params.admin { + return Err(ContractError::Unauthorized {}); + } + + if let Some(t) = threshold { + params.equilibrium_threshold = t; + } + if let Some(p) = periods_required { + params.equilibrium_periods_required = p; + } + + SUPPLY_PARAMS.save(deps.storage, ¶ms)?; + + Ok(Response::new() + .add_attribute("action", "update_equilibrium_params") + .add_attribute("threshold", params.equilibrium_threshold) + .add_attribute( + "periods_required", + params.equilibrium_periods_required.to_string(), + )) +} + +// --------------------------------------------------------------------------- +// Query +// --------------------------------------------------------------------------- + +#[entry_point] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::SupplyState {} => to_json_binary(&query_supply_state(deps)?), + QueryMsg::SupplyParams {} => to_json_binary(&query_supply_params(deps)?), + QueryMsg::PeriodHistory { period_id } => { + to_json_binary(&query_period_history(deps, period_id)?) + } + QueryMsg::SimulatePeriod { + burn_amount, + staked_amount, + stability_committed, + delta_co2, + } => to_json_binary(&query_simulate_period( + deps, + burn_amount, + staked_amount, + stability_committed, + delta_co2, + )?), + } +} + +fn query_supply_state(deps: Deps) -> StdResult { + let state = SUPPLY_STATE.load(deps.storage)?; + let params = SUPPLY_PARAMS.load(deps.storage)?; + + let cap_headroom = if params.hard_cap > state.current_supply { + params.hard_cap - state.current_supply + } else { + Uint128::zero() + }; + + Ok(SupplyStateResponse { + current_supply: state.current_supply, + hard_cap: params.hard_cap, + total_minted: state.total_minted, + total_burned: state.total_burned, + period_count: state.period_count, + phase: state.phase, + cap_headroom, + consecutive_equilibrium_periods: state.consecutive_equilibrium_periods, + }) +} + +fn query_supply_params(deps: Deps) -> StdResult { + let params = SUPPLY_PARAMS.load(deps.storage)?; + + Ok(SupplyParamsResponse { + admin: params.admin.to_string(), + hard_cap: params.hard_cap, + base_regrowth_rate: params.base_regrowth_rate, + ecological_multiplier_enabled: params.ecological_multiplier_enabled, + ecological_reference_value: params.ecological_reference_value, + m014_phase: params.m014_phase, + equilibrium_threshold: params.equilibrium_threshold, + equilibrium_periods_required: params.equilibrium_periods_required, + }) +} + +fn query_period_history(deps: Deps, period_id: u64) -> StdResult { + let record = MINT_BURN_HISTORY + .load(deps.storage, period_id) + .map_err(|_| StdError::not_found(format!("MintBurnRecord for period {}", period_id)))?; + + Ok(MintBurnRecordResponse { record }) +} + +fn query_simulate_period( + deps: Deps, + burn_amount: Uint128, + staked_amount: Uint128, + stability_committed: Uint128, + delta_co2: Option, +) -> StdResult { + let params = SUPPLY_PARAMS.load(deps.storage)?; + let state = SUPPLY_STATE.load(deps.storage)?; + + let (mint_amount, effective_mult, eco_mult, regrowth_rate) = + compute_mint(¶ms, &state, staked_amount, stability_committed, delta_co2); + + let supply_after_mint = state.current_supply + mint_amount; + let capped_supply = if supply_after_mint > params.hard_cap { + params.hard_cap + } else { + supply_after_mint + }; + + let supply_after = if burn_amount >= capped_supply { + Uint128::zero() + } else { + capped_supply - burn_amount + }; + + // Check if this period would move toward equilibrium + let effective_mint = if capped_supply > state.current_supply { + capped_supply - state.current_supply + } else { + Uint128::zero() + }; + let effective_burn = if burn_amount >= capped_supply { + capped_supply + } else { + burn_amount + }; + let diff = if effective_mint > effective_burn { + effective_mint - effective_burn + } else { + effective_burn - effective_mint + }; + let would_reach_equilibrium = diff < params.equilibrium_threshold + && state.consecutive_equilibrium_periods + 1 >= params.equilibrium_periods_required; + + Ok(SimulatePeriodResponse { + mint_amount, + burn_amount, + supply_before: state.current_supply, + supply_after, + regrowth_rate, + effective_multiplier: effective_mult, + ecological_multiplier: eco_mult, + would_reach_equilibrium, + }) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Compute the mint amount for a period. +/// +/// M[t] = r * (C - S[t]) +/// r = r_base * effective_multiplier * ecological_multiplier +/// +/// Returns: (mint_amount, effective_multiplier, ecological_multiplier, regrowth_rate) +fn compute_mint( + params: &SupplyParams, + state: &SupplyState, + staked_amount: Uint128, + stability_committed: Uint128, + delta_co2: Option, +) -> (Uint128, Decimal, Decimal, Decimal) { + // Headroom: C - S[t] + let headroom = if params.hard_cap > state.current_supply { + params.hard_cap - state.current_supply + } else { + return (Uint128::zero(), Decimal::one(), Decimal::one(), Decimal::zero()); + }; + + // Compute staking multiplier: clamp(1 + S_staked / S_total, 1.0, 2.0) + let staking_multiplier = compute_staking_multiplier(staked_amount, state.current_supply); + + // Compute stability multiplier: clamp(1 + S_stability / S_total, 1.0, 2.0) + let stability_multiplier = + compute_stability_multiplier(stability_committed, state.current_supply); + + // Phase-gated effective multiplier selection (SPEC 5.3) + let effective_multiplier = match params.m014_phase { + M014Phase::Inactive => staking_multiplier, + M014Phase::Transition => { + if staking_multiplier > stability_multiplier { + staking_multiplier + } else { + stability_multiplier + } + } + M014Phase::Active | M014Phase::Equilibrium => stability_multiplier, + }; + + // Ecological multiplier (SPEC 5.4) + let ecological_multiplier = if params.ecological_multiplier_enabled { + compute_ecological_multiplier(delta_co2, params.ecological_reference_value) + } else { + Decimal::one() + }; + + // r = r_base * effective_multiplier * ecological_multiplier + let regrowth_rate = params.base_regrowth_rate * effective_multiplier * ecological_multiplier; + + // M[t] = r * headroom (floor division via mul_floor) + let mint_amount = headroom.mul_floor(regrowth_rate); + + ( + mint_amount, + effective_multiplier, + ecological_multiplier, + regrowth_rate, + ) +} + +/// Compute staking multiplier: clamp(1 + S_staked / S_total, 1.0, 2.0) +/// +/// If current_supply is zero, returns 1.0 (minimum). +fn compute_staking_multiplier(staked: Uint128, current_supply: Uint128) -> Decimal { + if current_supply.is_zero() { + return MIN_MULTIPLIER; + } + + let ratio = Decimal::from_ratio(staked, current_supply); + let raw = Decimal::one() + ratio; + + clamp_decimal(raw, MIN_MULTIPLIER, MAX_MULTIPLIER) +} + +/// Compute stability multiplier: clamp(1 + S_stability / S_total, 1.0, 2.0) +/// +/// If current_supply is zero, returns 1.0 (minimum). +fn compute_stability_multiplier(stability_committed: Uint128, current_supply: Uint128) -> Decimal { + if current_supply.is_zero() { + return MIN_MULTIPLIER; + } + + let ratio = Decimal::from_ratio(stability_committed, current_supply); + let raw = Decimal::one() + ratio; + + clamp_decimal(raw, MIN_MULTIPLIER, MAX_MULTIPLIER) +} + +/// Compute ecological multiplier: max(0, 1 - delta_co2 / reference_value) +/// +/// Returns 1.0 if delta_co2 is None (disabled / no data). +fn compute_ecological_multiplier( + delta_co2: Option, + reference_value: Decimal, +) -> Decimal { + match delta_co2 { + None => Decimal::one(), + Some(delta) => { + if reference_value.is_zero() { + return Decimal::one(); + } + let ratio = delta / reference_value; + if ratio >= Decimal::one() { + Decimal::zero() + } else { + Decimal::one() - ratio + } + } + } +} + +/// Clamp a Decimal to [min, max]. +fn clamp_decimal(val: Decimal, min: Decimal, max: Decimal) -> Decimal { + if val < min { + min + } else if val > max { + max + } else { + val + } +} + +/// Validate that the regrowth rate is within [0, MAX_REGROWTH_RATE]. +fn validate_regrowth_rate(rate: Decimal) -> Result<(), ContractError> { + if rate > MAX_REGROWTH_RATE { + return Err(ContractError::RegrowthRateExceedsBound { + rate: rate.to_string(), + }); + } + Ok(()) +} diff --git a/contracts/dynamic-supply/src/error.rs b/contracts/dynamic-supply/src/error.rs new file mode 100644 index 0000000..4a6af50 --- /dev/null +++ b/contracts/dynamic-supply/src/error.rs @@ -0,0 +1,42 @@ +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("Supply cap must be greater than zero")] + ZeroCap {}, + + #[error("Current supply exceeds the hard cap ({current} > {cap})")] + SupplyExceedsCap { current: String, cap: String }, + + #[error("Regrowth rate {rate} exceeds maximum bound of 0.10 (10%)")] + RegrowthRateExceedsBound { rate: String }, + + #[error("Burn amount must be greater than zero")] + ZeroBurnAmount {}, + + #[error("Mint amount must be greater than zero")] + ZeroMintAmount {}, + + #[error("Ecological reference value must be greater than zero")] + ZeroReferenceValue {}, + + #[error("Invalid M014 phase: {phase}")] + InvalidPhase { phase: String }, + + #[error("Mint would exceed hard cap (supply {supply} + mint {mint} > cap {cap})")] + MintExceedsCap { + supply: String, + mint: String, + cap: String, + }, + + #[error("Burn amount {burn} exceeds current supply {supply}")] + BurnExceedsSupply { burn: String, supply: String }, +} diff --git a/contracts/dynamic-supply/src/lib.rs b/contracts/dynamic-supply/src/lib.rs new file mode 100644 index 0000000..f0e2fd3 --- /dev/null +++ b/contracts/dynamic-supply/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/dynamic-supply/src/msg.rs b/contracts/dynamic-supply/src/msg.rs new file mode 100644 index 0000000..dd81c19 --- /dev/null +++ b/contracts/dynamic-supply/src/msg.rs @@ -0,0 +1,147 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Decimal, Uint128}; + +use crate::state::{M014Phase, MintBurnRecord, SupplyPhase}; + +/// Instantiate the dynamic supply contract with initial configuration. +/// +/// Sets the hard cap, initial supply, regrowth rate, and M014 phase. +/// Ecological multiplier is disabled by default (v0). +#[cw_serde] +pub struct InstantiateMsg { + /// Hard cap on total supply (uregen). E.g., 221_000_000_000_000 for 221M REGEN. + pub hard_cap: Uint128, + /// Initial circulating supply (uregen). Must be <= hard_cap. + pub initial_supply: Uint128, + /// Base regrowth rate per period. Must be in [0, 0.10]. + pub base_regrowth_rate: Decimal, + /// Whether to enable the ecological multiplier (v0: false). + pub ecological_multiplier_enabled: bool, + /// Reference value for ecological multiplier (ppm). Default: 50. + pub ecological_reference_value: Decimal, + /// Initial M014 phase. Defaults to Inactive. + pub m014_phase: M014Phase, + /// Threshold for equilibrium detection (uregen). + pub equilibrium_threshold: Uint128, + /// Consecutive near-balance periods required for equilibrium. Default: 12. + pub equilibrium_periods_required: u64, +} + +/// Execute messages for the dynamic supply contract. +#[cw_serde] +pub enum ExecuteMsg { + /// Execute a mint/burn period. + /// + /// Computes M[t] = r * (cap - supply), applies the provided burn amount, + /// updates supply state, and records the period in history. + ExecutePeriod { + /// Tokens burned this period (from M013 fee routing aggregate) + burn_amount: Uint128, + /// Current staked amount (for staking multiplier) + staked_amount: Uint128, + /// Current stability commitment amount (for M014/M015 stability multiplier) + stability_committed: Uint128, + /// Ecological metric (delta_co2 ppm). Ignored when ecological multiplier disabled. + delta_co2: Option, + }, + + /// Update the base regrowth rate (admin only). + /// Must be in [0, 0.10] (Layer 3 governance). + UpdateRegrowthRate { + rate: Decimal, + }, + + /// Update the M014 phase (admin only). + /// Determines which multiplier is used for regrowth calculation. + UpdateM014Phase { + phase: M014Phase, + }, + + /// Toggle the ecological multiplier (admin only, Layer 3). + SetEcologicalMultiplier { + enabled: bool, + /// Optional: update the reference value when enabling + reference_value: Option, + }, + + /// Update the equilibrium detection parameters (admin only). + UpdateEquilibriumParams { + threshold: Option, + periods_required: Option, + }, +} + +/// Query messages for the dynamic supply contract. +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Returns current supply state (supply, total minted/burned, phase, period count). + #[returns(SupplyStateResponse)] + SupplyState {}, + + /// Returns current supply parameters (cap, rates, multiplier config). + #[returns(SupplyParamsResponse)] + SupplyParams {}, + + /// Returns the mint/burn record for a specific period. + #[returns(MintBurnRecordResponse)] + PeriodHistory { period_id: u64 }, + + /// Simulate a period without executing (dry run). + #[returns(SimulatePeriodResponse)] + SimulatePeriod { + burn_amount: Uint128, + staked_amount: Uint128, + stability_committed: Uint128, + delta_co2: Option, + }, +} + +// --------------------------------------------------------------------------- +// Response types +// --------------------------------------------------------------------------- + +/// Response for SupplyState query. +#[cw_serde] +pub struct SupplyStateResponse { + pub current_supply: Uint128, + pub hard_cap: Uint128, + pub total_minted: Uint128, + pub total_burned: Uint128, + pub period_count: u64, + pub phase: SupplyPhase, + pub cap_headroom: Uint128, + pub consecutive_equilibrium_periods: u64, +} + +/// Response for SupplyParams query. +#[cw_serde] +pub struct SupplyParamsResponse { + pub admin: String, + pub hard_cap: Uint128, + pub base_regrowth_rate: Decimal, + pub ecological_multiplier_enabled: bool, + pub ecological_reference_value: Decimal, + pub m014_phase: M014Phase, + pub equilibrium_threshold: Uint128, + pub equilibrium_periods_required: u64, +} + +/// Response for PeriodHistory query. +#[cw_serde] +pub struct MintBurnRecordResponse { + pub record: MintBurnRecord, +} + +/// Response for SimulatePeriod query. +#[cw_serde] +pub struct SimulatePeriodResponse { + pub mint_amount: Uint128, + pub burn_amount: Uint128, + pub supply_before: Uint128, + pub supply_after: Uint128, + pub regrowth_rate: Decimal, + pub effective_multiplier: Decimal, + pub ecological_multiplier: Decimal, + pub would_reach_equilibrium: bool, +} diff --git a/contracts/dynamic-supply/src/state.rs b/contracts/dynamic-supply/src/state.rs new file mode 100644 index 0000000..aa9ed77 --- /dev/null +++ b/contracts/dynamic-supply/src/state.rs @@ -0,0 +1,104 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Decimal, Uint128}; +use cw_storage_plus::{Item, Map}; + +/// M014 phase state — determines which multiplier is used for regrowth. +/// +/// - INACTIVE: only staking_multiplier (pre-PoA) +/// - TRANSITION: max(staking, stability) — prevents regrowth discontinuity +/// - ACTIVE: only stability_multiplier (full PoA) +/// - EQUILIBRIUM: only stability_multiplier (steady-state PoA) +#[cw_serde] +pub enum M014Phase { + Inactive, + Transition, + Active, + Equilibrium, +} + +/// Supply mechanism state machine. +/// +/// INFLATIONARY -> TRANSITION -> DYNAMIC -> EQUILIBRIUM (-> DYNAMIC on shock) +#[cw_serde] +pub enum SupplyPhase { + /// Legacy inflationary PoS (before M012 activation) + Inflationary, + /// M012 activated, waiting for first successful burn period + Transition, + /// Active algorithmic mint/burn cycles + Dynamic, + /// Mint ~= burn for N consecutive periods (self-sustaining) + Equilibrium, +} + +/// Core supply state tracking. +/// +/// All values are in uregen (1 REGEN = 1,000,000 uregen). +#[cw_serde] +pub struct SupplyState { + /// Current circulating supply (uregen) + pub current_supply: Uint128, + /// Cumulative tokens minted since activation + pub total_minted: Uint128, + /// Cumulative tokens burned since activation + pub total_burned: Uint128, + /// Number of completed mint/burn periods + pub period_count: u64, + /// Current supply mechanism phase + pub phase: SupplyPhase, + /// Number of consecutive near-equilibrium periods (for DYNAMIC -> EQUILIBRIUM) + pub consecutive_equilibrium_periods: u64, +} + +/// Configurable parameters for the supply algorithm. +/// +/// All rates are Decimal values. The hard cap is in uregen. +#[cw_serde] +pub struct SupplyParams { + /// Admin address (governance module) + pub admin: Addr, + /// Absolute upper bound on supply (uregen). Constitutional parameter (Layer 4). + pub hard_cap: Uint128, + /// Base regrowth rate per period, bounded to [0, 0.10]. Layer 3. + pub base_regrowth_rate: Decimal, + /// Whether the ecological multiplier is enabled (v0: false) + pub ecological_multiplier_enabled: bool, + /// Reference value for ecological multiplier (ppm). Layer 3. + pub ecological_reference_value: Decimal, + /// Current M014 phase (determines multiplier selection) + pub m014_phase: M014Phase, + /// Threshold for equilibrium detection: abs(mint - burn) < threshold + pub equilibrium_threshold: Uint128, + /// Number of consecutive near-balance periods required for equilibrium + pub equilibrium_periods_required: u64, +} + +/// Record of a single mint/burn period. +/// +/// Stored per period_id for history queries. +#[cw_serde] +pub struct MintBurnRecord { + /// Period sequence number + pub period_id: u64, + /// Block height at which this period was executed + pub block_height: u64, + /// Tokens minted (regrowth) this period (uregen) + pub minted: Uint128, + /// Tokens burned this period (uregen) + pub burned: Uint128, + /// Supply before this period's adjustment + pub supply_before: Uint128, + /// Supply after this period's adjustment + pub supply_after: Uint128, + /// Regrowth rate applied (r = r_base * effective_multiplier * ecological_multiplier) + pub regrowth_rate: Decimal, + /// Effective multiplier used (staking or stability, phase-gated) + pub effective_multiplier: Decimal, + /// Ecological multiplier used (1.0 when disabled) + pub ecological_multiplier: Decimal, +} + +pub const SUPPLY_STATE: Item = Item::new("supply_state"); +pub const SUPPLY_PARAMS: Item = Item::new("supply_params"); +/// Per-period mint/burn history, keyed by period_id. +pub const MINT_BURN_HISTORY: Map = Map::new("mint_burn_history"); diff --git a/contracts/dynamic-supply/src/tests.rs b/contracts/dynamic-supply/src/tests.rs new file mode 100644 index 0000000..98dfd68 --- /dev/null +++ b/contracts/dynamic-supply/src/tests.rs @@ -0,0 +1,1122 @@ +use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env}; +use cosmwasm_std::{from_json, Addr, Decimal, Uint128}; + +use crate::contract::{execute, instantiate, query}; +use crate::error::ContractError; +use crate::msg::{ + ExecuteMsg, InstantiateMsg, MintBurnRecordResponse, QueryMsg, SimulatePeriodResponse, + SupplyParamsResponse, SupplyStateResponse, +}; +use crate::state::{M014Phase, SupplyPhase}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Default instantiation: 221M REGEN cap, 200M initial supply, 2% regrowth, +/// ecological multiplier disabled (v0), M014 Inactive. +fn default_instantiate_msg() -> InstantiateMsg { + InstantiateMsg { + hard_cap: Uint128::new(221_000_000_000_000), // 221M REGEN in uregen + initial_supply: Uint128::new(200_000_000_000_000), // 200M REGEN in uregen + base_regrowth_rate: Decimal::percent(2), // 0.02 + ecological_multiplier_enabled: false, + ecological_reference_value: Decimal::from_atomics(50u128, 0).unwrap(), // 50 ppm + m014_phase: M014Phase::Inactive, + equilibrium_threshold: Uint128::new(1_000_000_000), // 1000 REGEN tolerance + equilibrium_periods_required: 12, + } +} + +fn setup_contract() -> (cosmwasm_std::OwnedDeps, cosmwasm_std::Env) { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + instantiate(deps.as_mut(), env.clone(), info, default_instantiate_msg()).unwrap(); + (deps, env) +} + +// ========================================================================= +// SPEC Acceptance Test 1: Basic mint/burn +// Given a supply state with known staking ratio, compute M[t] and B[t]; +// verify S[t+1] = S[t] + M[t] - B[t]. +// ========================================================================= +#[test] +fn test_basic_mint_burn() { + let (mut deps, env) = setup_contract(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + // 200M supply, 221M cap, headroom = 21M REGEN = 21_000_000_000_000 uregen + // staked = 100M (50% staking ratio) -> staking_multiplier = 1.5 + // M014 Inactive -> effective_multiplier = staking_multiplier = 1.5 + // ecological disabled -> eco_mult = 1.0 + // r = 0.02 * 1.5 * 1.0 = 0.03 + // M[t] = 0.03 * 21_000_000_000_000 = 630_000_000_000 + // burn = 500_000_000_000 (500M uregen = 500 REGEN) + let burn = Uint128::new(500_000_000_000); + let staked = Uint128::new(100_000_000_000_000); // 100M REGEN + + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::ExecutePeriod { + burn_amount: burn, + staked_amount: staked, + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap(); + + let state: SupplyStateResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::SupplyState {}).unwrap()).unwrap(); + + // S[t+1] = 200_000_000_000_000 + 630_000_000_000 - 500_000_000_000 + // = 200_130_000_000_000 + assert_eq!(state.current_supply, Uint128::new(200_130_000_000_000)); + assert_eq!(state.total_minted, Uint128::new(630_000_000_000)); + assert_eq!(state.total_burned, Uint128::new(500_000_000_000)); + assert_eq!(state.period_count, 1); + + // Verify history record + let record: MintBurnRecordResponse = from_json( + query(deps.as_ref(), env, QueryMsg::PeriodHistory { period_id: 1 }).unwrap(), + ) + .unwrap(); + assert_eq!(record.record.minted, Uint128::new(630_000_000_000)); + assert_eq!(record.record.burned, Uint128::new(500_000_000_000)); + assert_eq!( + record.record.supply_before, + Uint128::new(200_000_000_000_000) + ); + assert_eq!( + record.record.supply_after, + Uint128::new(200_130_000_000_000) + ); +} + +// ========================================================================= +// SPEC Acceptance Test 2: Cap enforcement +// If S[t] + M[t] - B[t] > C, then S[t+1] = C (cap inviolability). +// ========================================================================= +#[test] +fn test_cap_enforcement() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + // Set supply very close to cap: cap = 221M, supply = 220.9M + let msg = InstantiateMsg { + hard_cap: Uint128::new(221_000_000_000_000), + initial_supply: Uint128::new(220_900_000_000_000), + base_regrowth_rate: Decimal::percent(10), // 10% to force near-cap + ecological_multiplier_enabled: false, + ecological_reference_value: Decimal::from_atomics(50u128, 0).unwrap(), + m014_phase: M014Phase::Inactive, + equilibrium_threshold: Uint128::new(1_000_000_000), + equilibrium_periods_required: 12, + }; + instantiate(deps.as_mut(), env.clone(), info.clone(), msg).unwrap(); + + // headroom = 100_000_000_000 (0.1M REGEN) + // staked = 220.9M (100%) -> multiplier = 2.0 + // r = 0.10 * 2.0 = 0.20 + // M[t] = 0.20 * 100_000_000_000 = 20_000_000_000 + // burn = 0 + // S[t+1] = 220.9M + 20_000_000_000 = 220_920_000_000_000 <= 221M cap -> OK no clamping + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::ExecutePeriod { + burn_amount: Uint128::zero(), + staked_amount: Uint128::new(220_900_000_000_000), + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap(); + + let state: SupplyStateResponse = + from_json(query(deps.as_ref(), env, QueryMsg::SupplyState {}).unwrap()).unwrap(); + + // Supply must not exceed hard cap + assert!(state.current_supply <= Uint128::new(221_000_000_000_000)); +} + +// ========================================================================= +// SPEC Acceptance Test 3: Non-negative supply +// If B[t] > S[t] + M[t], then S[t+1] = 0. +// ========================================================================= +#[test] +fn test_non_negative_supply() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + // Small supply, massive burn + let msg = InstantiateMsg { + hard_cap: Uint128::new(221_000_000_000_000), + initial_supply: Uint128::new(1_000_000), // 1 REGEN + base_regrowth_rate: Decimal::percent(2), + ecological_multiplier_enabled: false, + ecological_reference_value: Decimal::from_atomics(50u128, 0).unwrap(), + m014_phase: M014Phase::Inactive, + equilibrium_threshold: Uint128::new(1_000_000_000), + equilibrium_periods_required: 12, + }; + instantiate(deps.as_mut(), env.clone(), info.clone(), msg).unwrap(); + + // Burn way more than supply + mint + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::ExecutePeriod { + burn_amount: Uint128::new(999_999_999_999_999), + staked_amount: Uint128::zero(), + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap(); + + let state: SupplyStateResponse = + from_json(query(deps.as_ref(), env, QueryMsg::SupplyState {}).unwrap()).unwrap(); + + assert_eq!(state.current_supply, Uint128::zero()); +} + +// ========================================================================= +// SPEC Acceptance Test 4: Staking multiplier range +// 0% staked -> 1.0, 100% staked -> 2.0, 50% staked -> 1.5 +// ========================================================================= +#[test] +fn test_staking_multiplier_range() { + let (deps, env) = setup_contract(); + + // 0% staked -> multiplier = 1.0, r = 0.02 * 1.0 = 0.02 + // headroom = 21_000_000_000_000 + // M = 0.02 * 21_000_000_000_000 = 420_000_000_000 + let resp: SimulatePeriodResponse = from_json( + query( + deps.as_ref(), + env.clone(), + QueryMsg::SimulatePeriod { + burn_amount: Uint128::zero(), + staked_amount: Uint128::zero(), + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.effective_multiplier, Decimal::one()); + assert_eq!(resp.mint_amount, Uint128::new(420_000_000_000)); + + // 50% staked -> multiplier = 1.5, r = 0.02 * 1.5 = 0.03 + // M = 0.03 * 21_000_000_000_000 = 630_000_000_000 + let resp: SimulatePeriodResponse = from_json( + query( + deps.as_ref(), + env.clone(), + QueryMsg::SimulatePeriod { + burn_amount: Uint128::zero(), + staked_amount: Uint128::new(100_000_000_000_000), // 50% of 200M + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.effective_multiplier, Decimal::percent(150)); + assert_eq!(resp.mint_amount, Uint128::new(630_000_000_000)); + + // 100% staked -> multiplier = 2.0, r = 0.02 * 2.0 = 0.04 + // M = 0.04 * 21_000_000_000_000 = 840_000_000_000 + let resp: SimulatePeriodResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::SimulatePeriod { + burn_amount: Uint128::zero(), + staked_amount: Uint128::new(200_000_000_000_000), // 100% of 200M + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(resp.effective_multiplier, Decimal::percent(200)); + assert_eq!(resp.mint_amount, Uint128::new(840_000_000_000)); +} + +// ========================================================================= +// SPEC Acceptance Test 5: Near-cap deceleration +// When supply is at 99% of cap, minted amount is very small. +// ========================================================================= +#[test] +fn test_near_cap_deceleration() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + // Supply at 99% of cap + let cap = Uint128::new(221_000_000_000_000); + let supply_99pct = Uint128::new(218_790_000_000_000); // 99% of 221M + + let msg = InstantiateMsg { + hard_cap: cap, + initial_supply: supply_99pct, + base_regrowth_rate: Decimal::percent(2), + ecological_multiplier_enabled: false, + ecological_reference_value: Decimal::from_atomics(50u128, 0).unwrap(), + m014_phase: M014Phase::Inactive, + equilibrium_threshold: Uint128::new(1_000_000_000), + equilibrium_periods_required: 12, + }; + instantiate(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // headroom = 1% of 221M = 2_210_000_000_000 + // r = 0.02 * 1.0 = 0.02 (no staking) + // M = 0.02 * 2_210_000_000_000 = 44_200_000_000 + // This is much smaller than the mint at 200M supply (420B) + let resp: SimulatePeriodResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::SimulatePeriod { + burn_amount: Uint128::zero(), + staked_amount: Uint128::zero(), + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(resp.mint_amount, Uint128::new(44_200_000_000)); + // This is about 10.5% of what would be minted at 200M supply (420B) + assert!(resp.mint_amount < Uint128::new(420_000_000_000)); +} + +// ========================================================================= +// SPEC Acceptance Test 6: INACTIVE phase — only staking multiplier +// ========================================================================= +#[test] +fn test_phase_inactive_uses_staking_only() { + let (deps, env) = setup_contract(); + + // M014 Inactive: even with stability_committed, only staking matters + let resp: SimulatePeriodResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::SimulatePeriod { + burn_amount: Uint128::zero(), + staked_amount: Uint128::new(100_000_000_000_000), // 50% staked + stability_committed: Uint128::new(180_000_000_000_000), // 90% stability + delta_co2: None, + }, + ) + .unwrap(), + ) + .unwrap(); + + // Should use staking_multiplier = 1.5, NOT stability = 1.9 + assert_eq!(resp.effective_multiplier, Decimal::percent(150)); +} + +// ========================================================================= +// SPEC Acceptance Test 7: TRANSITION phase — max(staking, stability) +// ========================================================================= +#[test] +fn test_phase_transition_uses_max() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + let mut msg = default_instantiate_msg(); + msg.m014_phase = M014Phase::Transition; + instantiate(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // staking = 50% -> multiplier = 1.5 + // stability = 80% -> multiplier = 1.8 + // TRANSITION: max(1.5, 1.8) = 1.8 + let resp: SimulatePeriodResponse = from_json( + query( + deps.as_ref(), + env.clone(), + QueryMsg::SimulatePeriod { + burn_amount: Uint128::zero(), + staked_amount: Uint128::new(100_000_000_000_000), // 50% + stability_committed: Uint128::new(160_000_000_000_000), // 80% + delta_co2: None, + }, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(resp.effective_multiplier, Decimal::percent(180)); + + // Now staking > stability: staking = 90%, stability = 30% + // staking_mult = 1.9, stability_mult = 1.3 + // TRANSITION: max(1.9, 1.3) = 1.9 + let resp: SimulatePeriodResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::SimulatePeriod { + burn_amount: Uint128::zero(), + staked_amount: Uint128::new(180_000_000_000_000), // 90% + stability_committed: Uint128::new(60_000_000_000_000), // 30% + delta_co2: None, + }, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(resp.effective_multiplier, Decimal::percent(190)); +} + +// ========================================================================= +// SPEC Acceptance Test 8: ACTIVE phase — only stability multiplier +// ========================================================================= +#[test] +fn test_phase_active_uses_stability_only() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + let mut msg = default_instantiate_msg(); + msg.m014_phase = M014Phase::Active; + instantiate(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // staking = 90% -> multiplier = 1.9 (should be ignored) + // stability = 40% -> multiplier = 1.4 (should be used) + let resp: SimulatePeriodResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::SimulatePeriod { + burn_amount: Uint128::zero(), + staked_amount: Uint128::new(180_000_000_000_000), // 90% staked + stability_committed: Uint128::new(80_000_000_000_000), // 40% stability + delta_co2: None, + }, + ) + .unwrap(), + ) + .unwrap(); + + // Should use stability_multiplier = 1.4, NOT staking = 1.9 + assert_eq!(resp.effective_multiplier, Decimal::percent(140)); +} + +// ========================================================================= +// SPEC Acceptance Test 9: Ecological multiplier disabled (v0) +// ========================================================================= +#[test] +fn test_ecological_multiplier_disabled() { + let (deps, env) = setup_contract(); + + // Even with delta_co2 provided, eco_mult should be 1.0 when disabled + let resp: SimulatePeriodResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::SimulatePeriod { + burn_amount: Uint128::zero(), + staked_amount: Uint128::zero(), + stability_committed: Uint128::zero(), + delta_co2: Some(Decimal::from_atomics(25u128, 0).unwrap()), + }, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(resp.ecological_multiplier, Decimal::one()); +} + +// ========================================================================= +// SPEC Acceptance Test 10: Ecological multiplier enabled +// delta_co2 = 25 ppm, reference = 50 ppm -> eco_mult = 0.5 +// ========================================================================= +#[test] +fn test_ecological_multiplier_enabled() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + let mut msg = default_instantiate_msg(); + msg.ecological_multiplier_enabled = true; + instantiate(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // delta_co2 = 25, reference = 50 -> eco_mult = max(0, 1 - 25/50) = 0.5 + let resp: SimulatePeriodResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::SimulatePeriod { + burn_amount: Uint128::zero(), + staked_amount: Uint128::zero(), + stability_committed: Uint128::zero(), + delta_co2: Some(Decimal::from_atomics(25u128, 0).unwrap()), + }, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(resp.ecological_multiplier, Decimal::percent(50)); + + // Verify mint is halved: normal mint with eco=1.0 would be 420B + // With eco=0.5: r = 0.02 * 1.0 * 0.5 = 0.01, M = 0.01 * 21T = 210B + assert_eq!(resp.mint_amount, Uint128::new(210_000_000_000)); +} + +// ========================================================================= +// SPEC Acceptance Test 11: Ecological multiplier floor at 0 +// delta_co2 = 100 ppm, reference = 50 ppm -> eco_mult = 0 (not negative) +// ========================================================================= +#[test] +fn test_ecological_multiplier_floor() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + let mut msg = default_instantiate_msg(); + msg.ecological_multiplier_enabled = true; + instantiate(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // delta_co2 = 100, reference = 50 -> eco_mult = max(0, 1 - 100/50) = max(0, -1) = 0 + let resp: SimulatePeriodResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::SimulatePeriod { + burn_amount: Uint128::zero(), + staked_amount: Uint128::zero(), + stability_committed: Uint128::zero(), + delta_co2: Some(Decimal::from_atomics(100u128, 0).unwrap()), + }, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(resp.ecological_multiplier, Decimal::zero()); + assert_eq!(resp.mint_amount, Uint128::zero()); +} + +// ========================================================================= +// SPEC Acceptance Test 12-13: State machine TRANSITION -> DYNAMIC +// Requires first burn period complete. +// ========================================================================= +#[test] +fn test_phase_transition_to_dynamic() { + let (mut deps, env) = setup_contract(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + // Initial phase should be Transition + let state: SupplyStateResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::SupplyState {}).unwrap()).unwrap(); + assert_eq!(state.phase, SupplyPhase::Transition); + + // Execute period with zero burn -> stays in Transition + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::ExecutePeriod { + burn_amount: Uint128::zero(), + staked_amount: Uint128::zero(), + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap(); + + let state: SupplyStateResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::SupplyState {}).unwrap()).unwrap(); + assert_eq!(state.phase, SupplyPhase::Transition); + + // Execute period with non-zero burn -> transitions to Dynamic + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::ExecutePeriod { + burn_amount: Uint128::new(1_000_000), + staked_amount: Uint128::zero(), + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap(); + + let state: SupplyStateResponse = + from_json(query(deps.as_ref(), env, QueryMsg::SupplyState {}).unwrap()).unwrap(); + assert_eq!(state.phase, SupplyPhase::Dynamic); +} + +// ========================================================================= +// SPEC Acceptance Test 14: DYNAMIC -> EQUILIBRIUM +// 12 consecutive near-balance periods. +// ========================================================================= +#[test] +fn test_phase_dynamic_to_equilibrium() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + let mut msg = default_instantiate_msg(); + msg.equilibrium_threshold = Uint128::new(1_000_000_000); // 1000 REGEN + msg.equilibrium_periods_required = 3; // Use 3 for test speed + instantiate(deps.as_mut(), env.clone(), info.clone(), msg).unwrap(); + + // First: transition to Dynamic with a burn + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::ExecutePeriod { + burn_amount: Uint128::new(420_000_000_000), // ~ equal to mint + staked_amount: Uint128::zero(), + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap(); + + let state: SupplyStateResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::SupplyState {}).unwrap()).unwrap(); + assert_eq!(state.phase, SupplyPhase::Dynamic); + + // Now run 3 periods with near-equal mint and burn + // Current supply ~ 200M, headroom ~21M, r=0.02, M~420B + // Set burn close to expected mint for near-equilibrium + for _ in 0..3 { + // Query current state to compute expected mint + let sim: SimulatePeriodResponse = from_json( + query( + deps.as_ref(), + env.clone(), + QueryMsg::SimulatePeriod { + burn_amount: Uint128::zero(), + staked_amount: Uint128::zero(), + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap(), + ) + .unwrap(); + + // Burn exactly equal to expected mint -> diff = 0 < threshold + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::ExecutePeriod { + burn_amount: sim.mint_amount, + staked_amount: Uint128::zero(), + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap(); + } + + let state: SupplyStateResponse = + from_json(query(deps.as_ref(), env, QueryMsg::SupplyState {}).unwrap()).unwrap(); + assert_eq!(state.phase, SupplyPhase::Equilibrium); +} + +// ========================================================================= +// SPEC Acceptance Test 15: EQUILIBRIUM -> DYNAMIC on shock +// ========================================================================= +#[test] +fn test_phase_equilibrium_to_dynamic_on_shock() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + let mut msg = default_instantiate_msg(); + msg.equilibrium_threshold = Uint128::new(1_000_000_000); + msg.equilibrium_periods_required = 2; // Quick equilibrium for test + instantiate(deps.as_mut(), env.clone(), info.clone(), msg).unwrap(); + + // Transition to Dynamic + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::ExecutePeriod { + burn_amount: Uint128::new(420_000_000_000), + staked_amount: Uint128::zero(), + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap(); + + // Reach equilibrium with 2 balanced periods + for _ in 0..2 { + let sim: SimulatePeriodResponse = from_json( + query( + deps.as_ref(), + env.clone(), + QueryMsg::SimulatePeriod { + burn_amount: Uint128::zero(), + staked_amount: Uint128::zero(), + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap(), + ) + .unwrap(); + + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::ExecutePeriod { + burn_amount: sim.mint_amount, + staked_amount: Uint128::zero(), + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap(); + } + + let state: SupplyStateResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::SupplyState {}).unwrap()).unwrap(); + assert_eq!(state.phase, SupplyPhase::Equilibrium); + + // External shock: massive burn with no corresponding mint + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::ExecutePeriod { + burn_amount: Uint128::new(10_000_000_000_000), // 10M REGEN burn shock + staked_amount: Uint128::zero(), + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap(); + + let state: SupplyStateResponse = + from_json(query(deps.as_ref(), env, QueryMsg::SupplyState {}).unwrap()).unwrap(); + assert_eq!(state.phase, SupplyPhase::Dynamic); + assert_eq!(state.consecutive_equilibrium_periods, 0); +} + +// ========================================================================= +// SPEC Invariant 16: Cap inviolability — S[t] <= hard_cap at all times +// ========================================================================= +#[test] +fn test_cap_inviolability_invariant() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + // Start very close to cap with aggressive regrowth + let msg = InstantiateMsg { + hard_cap: Uint128::new(100_000_000), + initial_supply: Uint128::new(99_000_000), // 1M headroom + base_regrowth_rate: Decimal::percent(10), // Max rate + ecological_multiplier_enabled: false, + ecological_reference_value: Decimal::from_atomics(50u128, 0).unwrap(), + m014_phase: M014Phase::Inactive, + equilibrium_threshold: Uint128::new(1_000), + equilibrium_periods_required: 12, + }; + instantiate(deps.as_mut(), env.clone(), info.clone(), msg).unwrap(); + + // Execute with 100% staked (max multiplier 2.0), zero burn + // r = 0.10 * 2.0 = 0.20, headroom = 1M, M = 200_000 + // Even after repeated executions, supply must never exceed cap + for _ in 0..20 { + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::ExecutePeriod { + burn_amount: Uint128::zero(), + staked_amount: Uint128::new(99_000_000), + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap(); + + let state: SupplyStateResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::SupplyState {}).unwrap()) + .unwrap(); + assert!(state.current_supply <= Uint128::new(100_000_000)); + } +} + +// ========================================================================= +// SPEC Invariant 17: Non-negative supply — S[t] >= 0 at all times +// ========================================================================= +#[test] +fn test_non_negative_supply_invariant() { + let (mut deps, env) = setup_contract(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + // Burn far more than exists + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::ExecutePeriod { + burn_amount: Uint128::new(999_000_000_000_000_000), + staked_amount: Uint128::zero(), + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap(); + + let state: SupplyStateResponse = + from_json(query(deps.as_ref(), env, QueryMsg::SupplyState {}).unwrap()).unwrap(); + assert_eq!(state.current_supply, Uint128::zero()); +} + +// ========================================================================= +// SPEC Invariant 20: Parameter bound safety — r_base in [0, 0.10] +// ========================================================================= +#[test] +fn test_regrowth_rate_bound_safety() { + let (mut deps, env) = setup_contract(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + // Try to set rate to 11% -> should fail + let err = execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::UpdateRegrowthRate { + rate: Decimal::percent(11), + }, + ) + .unwrap_err(); + + match err { + ContractError::RegrowthRateExceedsBound { .. } => {} + e => panic!("Expected RegrowthRateExceedsBound, got: {:?}", e), + } + + // Exactly 10% should succeed + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::UpdateRegrowthRate { + rate: Decimal::percent(10), + }, + ) + .unwrap(); + + // 0% should succeed + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::UpdateRegrowthRate { + rate: Decimal::zero(), + }, + ) + .unwrap(); +} + +// ========================================================================= +// Admin authorization tests +// ========================================================================= +#[test] +fn test_unauthorized_execute_period() { + let (mut deps, env) = setup_contract(); + let other = message_info(&Addr::unchecked("other"), &[]); + + let err = execute( + deps.as_mut(), + env, + other, + ExecuteMsg::ExecutePeriod { + burn_amount: Uint128::zero(), + staked_amount: Uint128::zero(), + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap_err(); + + match err { + ContractError::Unauthorized {} => {} + e => panic!("Expected Unauthorized, got: {:?}", e), + } +} + +#[test] +fn test_unauthorized_update_regrowth_rate() { + let (mut deps, env) = setup_contract(); + let other = message_info(&Addr::unchecked("other"), &[]); + + let err = execute( + deps.as_mut(), + env, + other, + ExecuteMsg::UpdateRegrowthRate { + rate: Decimal::percent(5), + }, + ) + .unwrap_err(); + + match err { + ContractError::Unauthorized {} => {} + e => panic!("Expected Unauthorized, got: {:?}", e), + } +} + +#[test] +fn test_unauthorized_update_m014_phase() { + let (mut deps, env) = setup_contract(); + let other = message_info(&Addr::unchecked("other"), &[]); + + let err = execute( + deps.as_mut(), + env, + other, + ExecuteMsg::UpdateM014Phase { + phase: M014Phase::Active, + }, + ) + .unwrap_err(); + + match err { + ContractError::Unauthorized {} => {} + e => panic!("Expected Unauthorized, got: {:?}", e), + } +} + +// ========================================================================= +// Instantiation validation tests +// ========================================================================= +#[test] +fn test_instantiation_fails_zero_cap() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + let mut msg = default_instantiate_msg(); + msg.hard_cap = Uint128::zero(); + + let err = instantiate(deps.as_mut(), env, info, msg).unwrap_err(); + match err { + ContractError::ZeroCap {} => {} + e => panic!("Expected ZeroCap, got: {:?}", e), + } +} + +#[test] +fn test_instantiation_fails_supply_exceeds_cap() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + let mut msg = default_instantiate_msg(); + msg.initial_supply = Uint128::new(300_000_000_000_000); // > 221M cap + + let err = instantiate(deps.as_mut(), env, info, msg).unwrap_err(); + match err { + ContractError::SupplyExceedsCap { .. } => {} + e => panic!("Expected SupplyExceedsCap, got: {:?}", e), + } +} + +#[test] +fn test_instantiation_fails_rate_too_high() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + let mut msg = default_instantiate_msg(); + msg.base_regrowth_rate = Decimal::percent(15); // 15% > 10% max + + let err = instantiate(deps.as_mut(), env, info, msg).unwrap_err(); + match err { + ContractError::RegrowthRateExceedsBound { .. } => {} + e => panic!("Expected RegrowthRateExceedsBound, got: {:?}", e), + } +} + +// ========================================================================= +// Query: supply params +// ========================================================================= +#[test] +fn test_query_supply_params() { + let (deps, env) = setup_contract(); + + let resp: SupplyParamsResponse = + from_json(query(deps.as_ref(), env, QueryMsg::SupplyParams {}).unwrap()).unwrap(); + + assert_eq!(resp.admin, "admin"); + assert_eq!(resp.hard_cap, Uint128::new(221_000_000_000_000)); + assert_eq!(resp.base_regrowth_rate, Decimal::percent(2)); + assert!(!resp.ecological_multiplier_enabled); + assert_eq!(resp.m014_phase, M014Phase::Inactive); + assert_eq!(resp.equilibrium_periods_required, 12); +} + +// ========================================================================= +// Query: cap headroom +// ========================================================================= +#[test] +fn test_query_cap_headroom() { + let (deps, env) = setup_contract(); + + let state: SupplyStateResponse = + from_json(query(deps.as_ref(), env, QueryMsg::SupplyState {}).unwrap()).unwrap(); + + // 221M - 200M = 21M REGEN = 21_000_000_000_000 uregen + assert_eq!(state.cap_headroom, Uint128::new(21_000_000_000_000)); +} + +// ========================================================================= +// Multi-period simulation: supply converges toward cap +// ========================================================================= +#[test] +fn test_supply_converges_toward_cap() { + let (mut deps, env) = setup_contract(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + let cap = Uint128::new(221_000_000_000_000); + let mut prev_headroom = Uint128::new(21_000_000_000_000); + + // Run 10 periods with no burn -> supply should steadily approach cap + for _ in 0..10 { + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::ExecutePeriod { + burn_amount: Uint128::zero(), + staked_amount: Uint128::zero(), + stability_committed: Uint128::zero(), + delta_co2: None, + }, + ) + .unwrap(); + + let state: SupplyStateResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::SupplyState {}).unwrap()) + .unwrap(); + + // Headroom should be shrinking + assert!(state.cap_headroom < prev_headroom); + // Supply must never exceed cap + assert!(state.current_supply <= cap); + prev_headroom = state.cap_headroom; + } +} + +// ========================================================================= +// UpdateM014Phase and SetEcologicalMultiplier admin controls +// ========================================================================= +#[test] +fn test_update_m014_phase() { + let (mut deps, env) = setup_contract(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::UpdateM014Phase { + phase: M014Phase::Active, + }, + ) + .unwrap(); + + let params: SupplyParamsResponse = + from_json(query(deps.as_ref(), env, QueryMsg::SupplyParams {}).unwrap()).unwrap(); + assert_eq!(params.m014_phase, M014Phase::Active); +} + +#[test] +fn test_set_ecological_multiplier() { + let (mut deps, env) = setup_contract(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + // Enable with updated reference value + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::SetEcologicalMultiplier { + enabled: true, + reference_value: Some(Decimal::from_atomics(100u128, 0).unwrap()), + }, + ) + .unwrap(); + + let params: SupplyParamsResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::SupplyParams {}).unwrap()).unwrap(); + assert!(params.ecological_multiplier_enabled); + assert_eq!( + params.ecological_reference_value, + Decimal::from_atomics(100u128, 0).unwrap() + ); + + // Disable + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::SetEcologicalMultiplier { + enabled: false, + reference_value: None, + }, + ) + .unwrap(); + + let params: SupplyParamsResponse = + from_json(query(deps.as_ref(), env, QueryMsg::SupplyParams {}).unwrap()).unwrap(); + assert!(!params.ecological_multiplier_enabled); +} + +#[test] +fn test_set_ecological_multiplier_fails_zero_ref() { + let (mut deps, env) = setup_contract(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::SetEcologicalMultiplier { + enabled: true, + reference_value: Some(Decimal::zero()), + }, + ) + .unwrap_err(); + + match err { + ContractError::ZeroReferenceValue {} => {} + e => panic!("Expected ZeroReferenceValue, got: {:?}", e), + } +}