From 7d5798801c19b5aac64313180387814ff62e32bc Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:21:39 -0700 Subject: [PATCH 1/3] feat: M013 CosmWasm fee-router contract scaffold with unit tests Implement the CosmWasm contract scaffold for M013 Value-Based Fee Routing, translating the mechanism spec and JS reference implementation into Rust. Contract structure: - msg.rs: InstantiateMsg, ExecuteMsg (CollectFee, UpdateFeeRate, UpdateDistribution), QueryMsg (FeeConfig, PoolBalances, CalculateFee) with typed responses - state.rs: FeeConfig (rates as Decimal, distribution shares, min_fee) and PoolBalances (burn_pool, validator_fund, community_pool, agent_infra) - error.rs: InvalidFeeRate, ShareSumNotUnity, Unauthorized, ZeroValue, RateExceedsCap - contract.rs: Entry points with fee calculation matching the JS reference impl integer rounding strategy (floor for 3 pools, remainder to validator) Fee calculation: fee = max(value * rate, min_fee) Distribution: burn/community/agent use floor(), validator gets remainder (Fee Conservation invariant: sum of pools == fee collected) 13 unit tests covering: - Valid/invalid instantiation (share sum unity) - All 4 tx type fee calculations matching test vectors - Fee distribution (100M -> 30M burn, 40M validator, 25M community, 5M agent) - Fee conservation across all 5 test vector transactions (total 132M uregen) - Rate cap enforcement (max 10%) - Share update validation - Unauthorized access rejection - Zero value rejection Co-Authored-By: Claude Opus 4.6 (1M context) --- contracts/fee-router/.cargo/config.toml | 2 + contracts/fee-router/Cargo.toml | 24 ++ contracts/fee-router/src/contract.rs | 332 ++++++++++++++++++ contracts/fee-router/src/error.rs | 23 ++ contracts/fee-router/src/lib.rs | 7 + contracts/fee-router/src/msg.rs | 119 +++++++ contracts/fee-router/src/state.rs | 50 +++ contracts/fee-router/src/tests.rs | 442 ++++++++++++++++++++++++ 8 files changed, 999 insertions(+) create mode 100644 contracts/fee-router/.cargo/config.toml create mode 100644 contracts/fee-router/Cargo.toml create mode 100644 contracts/fee-router/src/contract.rs create mode 100644 contracts/fee-router/src/error.rs create mode 100644 contracts/fee-router/src/lib.rs create mode 100644 contracts/fee-router/src/msg.rs create mode 100644 contracts/fee-router/src/state.rs create mode 100644 contracts/fee-router/src/tests.rs diff --git a/contracts/fee-router/.cargo/config.toml b/contracts/fee-router/.cargo/config.toml new file mode 100644 index 0000000..946af0f --- /dev/null +++ b/contracts/fee-router/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" diff --git a/contracts/fee-router/Cargo.toml b/contracts/fee-router/Cargo.toml new file mode 100644 index 0000000..96cfbef --- /dev/null +++ b/contracts/fee-router/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "fee-router" +version = "0.1.0" +edition = "2021" +description = "M013 Value-Based Fee Routing 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/fee-router/src/contract.rs b/contracts/fee-router/src/contract.rs new file mode 100644 index 0000000..aa14814 --- /dev/null +++ b/contracts/fee-router/src/contract.rs @@ -0,0 +1,332 @@ +use cosmwasm_std::{ + entry_point, to_json_binary, Binary, Decimal, Deps, DepsMut, Env, MessageInfo, Response, + StdResult, Uint128, +}; + +use crate::error::ContractError; +use crate::msg::{ + CalculateFeeResponse, ExecuteMsg, FeeConfigResponse, InstantiateMsg, PoolBalancesResponse, + QueryMsg, TxType, +}; +use crate::state::{FeeConfig, PoolBalances, FEE_CONFIG, POOL_BALANCES}; + +// --------------------------------------------------------------------------- +// Instantiate +// --------------------------------------------------------------------------- + +#[entry_point] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + // Validate all rates are within [0, 0.10] + let max_rate = Decimal::raw(100_000_000_000_000_000); // 0.1 + validate_rate(msg.issuance_rate, max_rate)?; + validate_rate(msg.transfer_rate, max_rate)?; + validate_rate(msg.retirement_rate, max_rate)?; + validate_rate(msg.trade_rate, max_rate)?; + + // Validate distribution shares sum to 1.0 + validate_shares( + msg.burn_share, + msg.validator_share, + msg.community_share, + msg.agent_share, + )?; + + let config = FeeConfig { + admin: info.sender.clone(), + issuance_rate: msg.issuance_rate, + transfer_rate: msg.transfer_rate, + retirement_rate: msg.retirement_rate, + trade_rate: msg.trade_rate, + burn_share: msg.burn_share, + validator_share: msg.validator_share, + community_share: msg.community_share, + agent_share: msg.agent_share, + min_fee: msg.min_fee, + }; + FEE_CONFIG.save(deps.storage, &config)?; + + let pools = PoolBalances { + burn_pool: Uint128::zero(), + validator_fund: Uint128::zero(), + community_pool: Uint128::zero(), + agent_infra: Uint128::zero(), + }; + POOL_BALANCES.save(deps.storage, &pools)?; + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("admin", info.sender)) +} + +// --------------------------------------------------------------------------- +// Execute +// --------------------------------------------------------------------------- + +#[entry_point] +pub fn execute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::CollectFee { tx_type, value } => execute_collect_fee(deps, tx_type, value), + ExecuteMsg::UpdateFeeRate { tx_type, rate } => { + execute_update_fee_rate(deps, info, tx_type, rate) + } + ExecuteMsg::UpdateDistribution { + burn_share, + validator_share, + community_share, + agent_share, + } => execute_update_distribution( + deps, + info, + burn_share, + validator_share, + community_share, + agent_share, + ), + } +} + +fn execute_collect_fee( + deps: DepsMut, + tx_type: TxType, + value: Uint128, +) -> Result { + if value.is_zero() { + return Err(ContractError::ZeroValue {}); + } + + let config = FEE_CONFIG.load(deps.storage)?; + + let (fee_amount, min_fee_applied) = calculate_fee_amount(&config, &tx_type, value); + let (burn, validator, community, agent) = distribute_fee(&config, fee_amount); + + // Update pool balances + POOL_BALANCES.update(deps.storage, |mut pools| -> StdResult { + pools.burn_pool += burn; + pools.validator_fund += validator; + pools.community_pool += community; + pools.agent_infra += agent; + Ok(pools) + })?; + + Ok(Response::new() + .add_attribute("action", "collect_fee") + .add_attribute("tx_type", format!("{:?}", tx_type)) + .add_attribute("value", value) + .add_attribute("fee_amount", fee_amount) + .add_attribute("min_fee_applied", min_fee_applied.to_string()) + .add_attribute("burn", burn) + .add_attribute("validator", validator) + .add_attribute("community", community) + .add_attribute("agent", agent)) +} + +fn execute_update_fee_rate( + deps: DepsMut, + info: MessageInfo, + tx_type: TxType, + rate: Decimal, +) -> Result { + let mut config = FEE_CONFIG.load(deps.storage)?; + + // Admin-only + if info.sender != config.admin { + return Err(ContractError::Unauthorized {}); + } + + // Validate rate within [0, 0.10] + let max_rate = Decimal::raw(100_000_000_000_000_000); // 0.1 + validate_rate(rate, max_rate)?; + + match tx_type { + TxType::CreditIssuance => config.issuance_rate = rate, + TxType::CreditTransfer => config.transfer_rate = rate, + TxType::CreditRetirement => config.retirement_rate = rate, + TxType::MarketplaceTrade => config.trade_rate = rate, + } + + FEE_CONFIG.save(deps.storage, &config)?; + + Ok(Response::new() + .add_attribute("action", "update_fee_rate") + .add_attribute("tx_type", format!("{:?}", tx_type)) + .add_attribute("rate", rate.to_string())) +} + +fn execute_update_distribution( + deps: DepsMut, + info: MessageInfo, + burn_share: Decimal, + validator_share: Decimal, + community_share: Decimal, + agent_share: Decimal, +) -> Result { + let mut config = FEE_CONFIG.load(deps.storage)?; + + // Admin-only + if info.sender != config.admin { + return Err(ContractError::Unauthorized {}); + } + + // Validate shares sum to 1.0 + validate_shares(burn_share, validator_share, community_share, agent_share)?; + + config.burn_share = burn_share; + config.validator_share = validator_share; + config.community_share = community_share; + config.agent_share = agent_share; + + FEE_CONFIG.save(deps.storage, &config)?; + + Ok(Response::new() + .add_attribute("action", "update_distribution") + .add_attribute("burn_share", burn_share.to_string()) + .add_attribute("validator_share", validator_share.to_string()) + .add_attribute("community_share", community_share.to_string()) + .add_attribute("agent_share", agent_share.to_string())) +} + +// --------------------------------------------------------------------------- +// Query +// --------------------------------------------------------------------------- + +#[entry_point] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::FeeConfig {} => to_json_binary(&query_fee_config(deps)?), + QueryMsg::PoolBalances {} => to_json_binary(&query_pool_balances(deps)?), + QueryMsg::CalculateFee { tx_type, value } => { + to_json_binary(&query_calculate_fee(deps, tx_type, value)?) + } + } +} + +fn query_fee_config(deps: Deps) -> StdResult { + let config = FEE_CONFIG.load(deps.storage)?; + Ok(FeeConfigResponse { + admin: config.admin.to_string(), + issuance_rate: config.issuance_rate, + transfer_rate: config.transfer_rate, + retirement_rate: config.retirement_rate, + trade_rate: config.trade_rate, + burn_share: config.burn_share, + validator_share: config.validator_share, + community_share: config.community_share, + agent_share: config.agent_share, + min_fee: config.min_fee, + }) +} + +fn query_pool_balances(deps: Deps) -> StdResult { + let pools = POOL_BALANCES.load(deps.storage)?; + Ok(PoolBalancesResponse { + burn_pool: pools.burn_pool, + validator_fund: pools.validator_fund, + community_pool: pools.community_pool, + agent_infra: pools.agent_infra, + }) +} + +fn query_calculate_fee( + deps: Deps, + tx_type: TxType, + value: Uint128, +) -> StdResult { + let config = FEE_CONFIG.load(deps.storage)?; + let (fee_amount, min_fee_applied) = calculate_fee_amount(&config, &tx_type, value); + let (burn, validator, community, agent) = distribute_fee(&config, fee_amount); + + Ok(CalculateFeeResponse { + fee_amount, + min_fee_applied, + burn_amount: burn, + validator_amount: validator, + community_amount: community, + agent_amount: agent, + }) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Get the fee rate for a given transaction type. +fn get_rate(config: &FeeConfig, tx_type: &TxType) -> Decimal { + match tx_type { + TxType::CreditIssuance => config.issuance_rate, + TxType::CreditTransfer => config.transfer_rate, + TxType::CreditRetirement => config.retirement_rate, + TxType::MarketplaceTrade => config.trade_rate, + } +} + +/// Calculate the fee amount for a transaction. +/// +/// fee_amount = max(value * rate, min_fee) +/// +/// Uses Decimal multiplication which truncates (floor) by default, +/// matching the JS reference implementation's Math.floor behavior. +fn calculate_fee_amount( + config: &FeeConfig, + tx_type: &TxType, + value: Uint128, +) -> (Uint128, bool) { + let rate = get_rate(config, tx_type); + let raw_fee = value.mul_floor(rate); // floor(value * rate) + let min_fee_applied = raw_fee < config.min_fee; + let fee_amount = if min_fee_applied { + config.min_fee + } else { + raw_fee + }; + (fee_amount, min_fee_applied) +} + +/// Distribute a fee amount across the four pools. +/// +/// Three pools (burn, community, agent) use floor division; +/// the validator fund receives the remainder to preserve the +/// Fee Conservation invariant (fee_amount == sum of all distributions). +fn distribute_fee(config: &FeeConfig, fee_amount: Uint128) -> (Uint128, Uint128, Uint128, Uint128) { + let burn = fee_amount.mul_floor(config.burn_share); + let community = fee_amount.mul_floor(config.community_share); + let agent = fee_amount.mul_floor(config.agent_share); + let validator = fee_amount - burn - community - agent; + + (burn, validator, community, agent) +} + +/// Validate that a fee rate is within [0, max_rate]. +fn validate_rate(rate: Decimal, max_rate: Decimal) -> Result<(), ContractError> { + if rate > max_rate { + return Err(ContractError::RateExceedsCap { + rate: rate.to_string(), + }); + } + Ok(()) +} + +/// Validate that distribution shares sum to exactly 1.0. +fn validate_shares( + burn_share: Decimal, + validator_share: Decimal, + community_share: Decimal, + agent_share: Decimal, +) -> Result<(), ContractError> { + let sum = burn_share + validator_share + community_share + agent_share; + if sum != Decimal::one() { + return Err(ContractError::ShareSumNotUnity { + sum: sum.to_string(), + }); + } + Ok(()) +} diff --git a/contracts/fee-router/src/error.rs b/contracts/fee-router/src/error.rs new file mode 100644 index 0000000..cd8049c --- /dev/null +++ b/contracts/fee-router/src/error.rs @@ -0,0 +1,23 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Invalid fee rate: rate {rate} exceeds cap of 0.10 (10%)")] + InvalidFeeRate { rate: String }, + + #[error("Distribution shares must sum to 1.0, got {sum}")] + ShareSumNotUnity { sum: String }, + + #[error("Unauthorized: only admin can perform this action")] + Unauthorized {}, + + #[error("Transaction value must be greater than zero")] + ZeroValue {}, + + #[error("Fee rate {rate} exceeds maximum cap of 0.10 (10%)")] + RateExceedsCap { rate: String }, +} diff --git a/contracts/fee-router/src/lib.rs b/contracts/fee-router/src/lib.rs new file mode 100644 index 0000000..f0e2fd3 --- /dev/null +++ b/contracts/fee-router/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/fee-router/src/msg.rs b/contracts/fee-router/src/msg.rs new file mode 100644 index 0000000..be609f9 --- /dev/null +++ b/contracts/fee-router/src/msg.rs @@ -0,0 +1,119 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Decimal, Uint128}; + +/// Transaction types for ecological credit operations. +#[cw_serde] +pub enum TxType { + /// Credit issuance (MsgCreateBatch) — default rate 2% + CreditIssuance, + /// Credit transfer (MsgSend) — default rate 0.1% + CreditTransfer, + /// Credit retirement (MsgRetire) — default rate 0.5% + CreditRetirement, + /// Marketplace trade (MsgBuySellOrder) — default rate 1% + MarketplaceTrade, +} + +/// Instantiate the fee router with initial configuration. +/// +/// All fee rates must be in [0, 0.10] (max 10%). +/// Distribution shares must sum to exactly 1.0. +#[cw_serde] +pub struct InstantiateMsg { + /// Fee rate for credit issuance transactions + pub issuance_rate: Decimal, + /// Fee rate for credit transfer transactions + pub transfer_rate: Decimal, + /// Fee rate for credit retirement transactions + pub retirement_rate: Decimal, + /// Fee rate for marketplace trade transactions + pub trade_rate: Decimal, + /// Share of fees routed to burn pool (e.g., 0.30 = 30%) + pub burn_share: Decimal, + /// Share of fees routed to validator fund (e.g., 0.40 = 40%) + pub validator_share: Decimal, + /// Share of fees routed to community pool (e.g., 0.25 = 25%) + pub community_share: Decimal, + /// Share of fees routed to agent infrastructure fund (e.g., 0.05 = 5%) + pub agent_share: Decimal, + /// Minimum fee floor in uregen + pub min_fee: Uint128, +} + +/// Execute messages for the fee router contract. +#[cw_serde] +pub enum ExecuteMsg { + /// Collect a fee for a credit transaction. + /// Calculates fee = max(value * rate, min_fee) and distributes to pools. + CollectFee { + tx_type: TxType, + value: Uint128, + }, + /// Update the fee rate for a specific transaction type (admin only). + /// Rate must be in [0, 0.10]. + UpdateFeeRate { + tx_type: TxType, + rate: Decimal, + }, + /// Update the distribution shares (admin only). + /// Shares must sum to exactly 1.0. + UpdateDistribution { + burn_share: Decimal, + validator_share: Decimal, + community_share: Decimal, + agent_share: Decimal, + }, +} + +/// Query messages for the fee router contract. +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Returns the current fee configuration. + #[returns(FeeConfigResponse)] + FeeConfig {}, + /// Returns current pool balances. + #[returns(PoolBalancesResponse)] + PoolBalances {}, + /// Calculate fee for a transaction without executing (dry run). + #[returns(CalculateFeeResponse)] + CalculateFee { + tx_type: TxType, + value: Uint128, + }, +} + +/// Response for FeeConfig query. +#[cw_serde] +pub struct FeeConfigResponse { + pub admin: String, + pub issuance_rate: Decimal, + pub transfer_rate: Decimal, + pub retirement_rate: Decimal, + pub trade_rate: Decimal, + pub burn_share: Decimal, + pub validator_share: Decimal, + pub community_share: Decimal, + pub agent_share: Decimal, + pub min_fee: Uint128, +} + +/// Response for PoolBalances query. +#[cw_serde] +pub struct PoolBalancesResponse { + pub burn_pool: Uint128, + pub validator_fund: Uint128, + pub community_pool: Uint128, + pub agent_infra: Uint128, +} + +/// Response for CalculateFee query. +#[cw_serde] +pub struct CalculateFeeResponse { + pub fee_amount: Uint128, + pub min_fee_applied: bool, + pub burn_amount: Uint128, + pub validator_amount: Uint128, + pub community_amount: Uint128, + pub agent_amount: Uint128, +} diff --git a/contracts/fee-router/src/state.rs b/contracts/fee-router/src/state.rs new file mode 100644 index 0000000..b2a92fd --- /dev/null +++ b/contracts/fee-router/src/state.rs @@ -0,0 +1,50 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Decimal, Uint128}; +use cw_storage_plus::Item; + +/// Fee configuration: rates per transaction type, distribution shares, and minimum fee. +/// +/// Rates are stored as Decimal values in [0, 0.10] (max 10%). +/// Distribution shares must sum to exactly 1.0 (Share Sum Unity invariant). +#[cw_serde] +pub struct FeeConfig { + /// Admin address (governance module) + pub admin: Addr, + /// Fee rate for credit issuance transactions + pub issuance_rate: Decimal, + /// Fee rate for credit transfer transactions + pub transfer_rate: Decimal, + /// Fee rate for credit retirement transactions + pub retirement_rate: Decimal, + /// Fee rate for marketplace trade transactions + pub trade_rate: Decimal, + /// Share of fees routed to the burn pool + pub burn_share: Decimal, + /// Share of fees routed to the validator fund + pub validator_share: Decimal, + /// Share of fees routed to the community pool + pub community_share: Decimal, + /// Share of fees routed to the agent infrastructure fund + pub agent_share: Decimal, + /// Minimum fee floor in uregen (1 REGEN = 1,000,000 uregen) + pub min_fee: Uint128, +} + +/// Pool balances tracking accumulated fee distributions. +/// +/// All values are in uregen. The Fee Conservation invariant guarantees +/// that total fees collected == burn_pool + validator_fund + community_pool + agent_infra. +#[cw_serde] +pub struct PoolBalances { + /// Accumulated burn pool balance (tokens queued for burn via M012) + pub burn_pool: Uint128, + /// Accumulated validator fund balance (distributed via M014) + pub validator_fund: Uint128, + /// Accumulated community pool balance (governance-directed spending) + pub community_pool: Uint128, + /// Accumulated agent infrastructure fund balance + pub agent_infra: Uint128, +} + +pub const FEE_CONFIG: Item = Item::new("fee_config"); +pub const POOL_BALANCES: Item = Item::new("pool_balances"); diff --git a/contracts/fee-router/src/tests.rs b/contracts/fee-router/src/tests.rs new file mode 100644 index 0000000..0f2e662 --- /dev/null +++ b/contracts/fee-router/src/tests.rs @@ -0,0 +1,442 @@ +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::{ + CalculateFeeResponse, ExecuteMsg, FeeConfigResponse, InstantiateMsg, PoolBalancesResponse, + QueryMsg, TxType, +}; + +/// Helper to build the default v0 Model A InstantiateMsg. +fn default_instantiate_msg() -> InstantiateMsg { + InstantiateMsg { + issuance_rate: Decimal::percent(2), // 0.02 + transfer_rate: Decimal::permille(1), // 0.001 + retirement_rate: Decimal::permille(5), // 0.005 + trade_rate: Decimal::percent(1), // 0.01 + burn_share: Decimal::percent(30), // 0.30 + validator_share: Decimal::percent(40), // 0.40 + community_share: Decimal::percent(25), // 0.25 + agent_share: Decimal::percent(5), // 0.05 + min_fee: Uint128::new(1_000_000), // 1 REGEN = 1,000,000 uregen + } +} + +// ========================================================================= +// Test 1: Instantiation with valid params +// ========================================================================= +#[test] +fn test_instantiation_valid() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + let msg = default_instantiate_msg(); + let res = instantiate(deps.as_mut(), env.clone(), info, msg).unwrap(); + assert_eq!(res.attributes.len(), 2); + assert_eq!(res.attributes[0].value, "instantiate"); + assert_eq!(res.attributes[1].value, "admin"); + + // Verify config was stored correctly + let config_resp: FeeConfigResponse = + from_json(query(deps.as_ref(), env.clone(), QueryMsg::FeeConfig {}).unwrap()).unwrap(); + assert_eq!(config_resp.admin, "admin"); + assert_eq!(config_resp.issuance_rate, Decimal::percent(2)); + assert_eq!(config_resp.transfer_rate, Decimal::permille(1)); + assert_eq!(config_resp.retirement_rate, Decimal::permille(5)); + assert_eq!(config_resp.trade_rate, Decimal::percent(1)); + assert_eq!(config_resp.min_fee, Uint128::new(1_000_000)); + + // Verify pools initialized to zero + let pools_resp: PoolBalancesResponse = + from_json(query(deps.as_ref(), env, QueryMsg::PoolBalances {}).unwrap()).unwrap(); + assert_eq!(pools_resp.burn_pool, Uint128::zero()); + assert_eq!(pools_resp.validator_fund, Uint128::zero()); + assert_eq!(pools_resp.community_pool, Uint128::zero()); + assert_eq!(pools_resp.agent_infra, Uint128::zero()); +} + +// ========================================================================= +// Test 2: Instantiation fails when shares don't sum to 1.0 +// ========================================================================= +#[test] +fn test_instantiation_fails_bad_shares() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + + let mut msg = default_instantiate_msg(); + msg.agent_share = Decimal::percent(10); // sum = 0.30 + 0.40 + 0.25 + 0.10 = 1.05 + + let err = instantiate(deps.as_mut(), env, info, msg).unwrap_err(); + match err { + ContractError::ShareSumNotUnity { .. } => {} + e => panic!("Expected ShareSumNotUnity, got: {:?}", e), + } +} + +// ========================================================================= +// Test 3: Fee calculation matches M013 test vectors +// ========================================================================= + +/// Test vector 1: Credit issuance of 5,000,000,000 uregen at 2% = 100,000,000 uregen fee +#[test] +fn test_fee_calc_credit_issuance() { + 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(); + + let resp: CalculateFeeResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::CalculateFee { + tx_type: TxType::CreditIssuance, + value: Uint128::new(5_000_000_000), + }, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(resp.fee_amount, Uint128::new(100_000_000)); + assert!(!resp.min_fee_applied); +} + +/// Test vector 2: Credit transfer of 100,000,000 uregen at 0.1% -> 100,000 < min_fee -> clamped to 1,000,000 +#[test] +fn test_fee_calc_credit_transfer_min_fee() { + 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(); + + let resp: CalculateFeeResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::CalculateFee { + tx_type: TxType::CreditTransfer, + value: Uint128::new(100_000_000), + }, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(resp.fee_amount, Uint128::new(1_000_000)); + assert!(resp.min_fee_applied); +} + +/// Test vector 3: Credit retirement of 1,000,000,000 uregen at 0.5% = 5,000,000 uregen fee +#[test] +fn test_fee_calc_credit_retirement() { + 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(); + + let resp: CalculateFeeResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::CalculateFee { + tx_type: TxType::CreditRetirement, + value: Uint128::new(1_000_000_000), + }, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(resp.fee_amount, Uint128::new(5_000_000)); + assert!(!resp.min_fee_applied); +} + +/// Test vector 4: Marketplace trade of 2,500,000,000 uregen at 1% = 25,000,000 uregen fee +#[test] +fn test_fee_calc_marketplace_trade() { + 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(); + + let resp: CalculateFeeResponse = from_json( + query( + deps.as_ref(), + env, + QueryMsg::CalculateFee { + tx_type: TxType::MarketplaceTrade, + value: Uint128::new(2_500_000_000), + }, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(resp.fee_amount, Uint128::new(25_000_000)); + assert!(!resp.min_fee_applied); +} + +// ========================================================================= +// Test 4: Fee distribution — 100M fee -> burn 30M, validator 40M, community 25M, agent 5M +// ========================================================================= +#[test] +fn test_fee_distribution() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + instantiate(deps.as_mut(), env.clone(), info.clone(), default_instantiate_msg()).unwrap(); + + // Collect fee: issuance of 5B uregen -> fee = 100M uregen + execute( + deps.as_mut(), + env.clone(), + info, + ExecuteMsg::CollectFee { + tx_type: TxType::CreditIssuance, + value: Uint128::new(5_000_000_000), + }, + ) + .unwrap(); + + let pools_resp: PoolBalancesResponse = + from_json(query(deps.as_ref(), env, QueryMsg::PoolBalances {}).unwrap()).unwrap(); + + assert_eq!(pools_resp.burn_pool, Uint128::new(30_000_000)); + assert_eq!(pools_resp.validator_fund, Uint128::new(40_000_000)); + assert_eq!(pools_resp.community_pool, Uint128::new(25_000_000)); + assert_eq!(pools_resp.agent_infra, Uint128::new(5_000_000)); +} + +// ========================================================================= +// Test 5: Fee conservation — sum of pools == fee collected +// ========================================================================= +#[test] +fn test_fee_conservation() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + instantiate(deps.as_mut(), env.clone(), info.clone(), default_instantiate_msg()).unwrap(); + + // Collect fees for all four tx types from test vectors + let test_cases = vec![ + (TxType::CreditIssuance, 5_000_000_000u128, 100_000_000u128), + (TxType::CreditTransfer, 100_000_000, 1_000_000), // min_fee applied + (TxType::CreditRetirement, 1_000_000_000, 5_000_000), + (TxType::MarketplaceTrade, 2_500_000_000, 25_000_000), + (TxType::CreditTransfer, 500_000, 1_000_000), // min_fee applied + ]; + + let mut total_expected_fees = Uint128::zero(); + for (tx_type, value, expected_fee) in test_cases { + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::CollectFee { + tx_type, + value: Uint128::new(value), + }, + ) + .unwrap(); + total_expected_fees += Uint128::new(expected_fee); + } + + let pools_resp: PoolBalancesResponse = + from_json(query(deps.as_ref(), env, QueryMsg::PoolBalances {}).unwrap()).unwrap(); + + let pool_sum = pools_resp.burn_pool + + pools_resp.validator_fund + + pools_resp.community_pool + + pools_resp.agent_infra; + + // Fee Conservation invariant: sum of pools == total fees collected + assert_eq!(pool_sum, total_expected_fees); + + // Additionally verify the expected totals from the test vector KPIs: + // total_fees = 132,000,000 + assert_eq!(total_expected_fees, Uint128::new(132_000_000)); + // distribution_by_pool: burn 39,600,000 / validator 52,800,000 / community 33,000,000 / agent 6,600,000 + assert_eq!(pools_resp.burn_pool, Uint128::new(39_600_000)); + assert_eq!(pools_resp.validator_fund, Uint128::new(52_800_000)); + assert_eq!(pools_resp.community_pool, Uint128::new(33_000_000)); + assert_eq!(pools_resp.agent_infra, Uint128::new(6_600_000)); +} + +// ========================================================================= +// Test 6: Rate update rejected above 10% +// ========================================================================= +#[test] +fn test_rate_update_rejected_above_cap() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + instantiate(deps.as_mut(), env.clone(), info.clone(), default_instantiate_msg()).unwrap(); + + // Try to set issuance rate to 11% (exceeds 10% cap) + let err = execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::UpdateFeeRate { + tx_type: TxType::CreditIssuance, + rate: Decimal::percent(11), + }, + ) + .unwrap_err(); + + match err { + ContractError::RateExceedsCap { .. } => {} + e => panic!("Expected RateExceedsCap, got: {:?}", e), + } + + // Exactly 10% should succeed + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::UpdateFeeRate { + tx_type: TxType::CreditIssuance, + rate: Decimal::percent(10), + }, + ) + .unwrap(); +} + +// ========================================================================= +// Test 7: Share update rejected when sum != 1.0 +// ========================================================================= +#[test] +fn test_share_update_rejected_bad_sum() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + instantiate(deps.as_mut(), env.clone(), info.clone(), default_instantiate_msg()).unwrap(); + + // Shares sum to 0.95 (not 1.0) + let err = execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::UpdateDistribution { + burn_share: Decimal::percent(25), + validator_share: Decimal::percent(40), + community_share: Decimal::percent(25), + agent_share: Decimal::percent(5), + }, + ) + .unwrap_err(); + + match err { + ContractError::ShareSumNotUnity { .. } => {} + e => panic!("Expected ShareSumNotUnity, got: {:?}", e), + } + + // Valid shares (sum = 1.0) should succeed + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::UpdateDistribution { + burn_share: Decimal::percent(20), + validator_share: Decimal::percent(45), + community_share: Decimal::percent(30), + agent_share: Decimal::percent(5), + }, + ) + .unwrap(); +} + +// ========================================================================= +// Additional: Unauthorized tests +// ========================================================================= +#[test] +fn test_unauthorized_update_fee_rate() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let admin_info = message_info(&Addr::unchecked("admin"), &[]); + let other_info = message_info(&Addr::unchecked("other"), &[]); + instantiate( + deps.as_mut(), + env.clone(), + admin_info, + default_instantiate_msg(), + ) + .unwrap(); + + let err = execute( + deps.as_mut(), + env, + other_info, + ExecuteMsg::UpdateFeeRate { + tx_type: TxType::CreditIssuance, + rate: Decimal::percent(3), + }, + ) + .unwrap_err(); + + match err { + ContractError::Unauthorized {} => {} + e => panic!("Expected Unauthorized, got: {:?}", e), + } +} + +#[test] +fn test_unauthorized_update_distribution() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let admin_info = message_info(&Addr::unchecked("admin"), &[]); + let other_info = message_info(&Addr::unchecked("other"), &[]); + instantiate( + deps.as_mut(), + env.clone(), + admin_info, + default_instantiate_msg(), + ) + .unwrap(); + + let err = execute( + deps.as_mut(), + env, + other_info, + ExecuteMsg::UpdateDistribution { + burn_share: Decimal::percent(30), + validator_share: Decimal::percent(40), + community_share: Decimal::percent(25), + agent_share: Decimal::percent(5), + }, + ) + .unwrap_err(); + + match err { + ContractError::Unauthorized {} => {} + e => panic!("Expected Unauthorized, got: {:?}", e), + } +} + +#[test] +fn test_collect_fee_zero_value_rejected() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = message_info(&Addr::unchecked("admin"), &[]); + instantiate(deps.as_mut(), env.clone(), info.clone(), default_instantiate_msg()).unwrap(); + + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::CollectFee { + tx_type: TxType::CreditIssuance, + value: Uint128::zero(), + }, + ) + .unwrap_err(); + + match err { + ContractError::ZeroValue {} => {} + e => panic!("Expected ZeroValue, got: {:?}", e), + } +} From e77cadf191f3ca44e400b4c6856c42ca4efa3cd8 Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:52:58 -0700 Subject: [PATCH 2/3] fix: remove unused InvalidFeeRate variant, document pool isolation semantics - Remove dead `InvalidFeeRate` error variant (only `RateExceedsCap` is used) - Add doc comment on PoolBalances explaining write-only pool design and how downstream mechanisms (M012/M014/M015) handle distribution Co-Authored-By: Claude Opus 4.6 (1M context) --- contracts/fee-router/src/error.rs | 3 --- contracts/fee-router/src/state.rs | 6 ++++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/contracts/fee-router/src/error.rs b/contracts/fee-router/src/error.rs index cd8049c..e91fd83 100644 --- a/contracts/fee-router/src/error.rs +++ b/contracts/fee-router/src/error.rs @@ -6,9 +6,6 @@ pub enum ContractError { #[error("{0}")] Std(#[from] StdError), - #[error("Invalid fee rate: rate {rate} exceeds cap of 0.10 (10%)")] - InvalidFeeRate { rate: String }, - #[error("Distribution shares must sum to 1.0, got {sum}")] ShareSumNotUnity { sum: String }, diff --git a/contracts/fee-router/src/state.rs b/contracts/fee-router/src/state.rs index b2a92fd..9094f16 100644 --- a/contracts/fee-router/src/state.rs +++ b/contracts/fee-router/src/state.rs @@ -34,6 +34,12 @@ pub struct FeeConfig { /// /// All values are in uregen. The Fee Conservation invariant guarantees /// that total fees collected == burn_pool + validator_fund + community_pool + agent_infra. +/// +/// Pools are write-only in this contract: `CollectFee` adds to pools but no +/// execute message withdraws from them. Distribution to downstream mechanisms +/// (M012 burn, M014 validator compensation, M015 contributor rewards) happens +/// via cross-contract calls from those modules, preserving Pool Isolation +/// (SPEC security invariant #5). #[cw_serde] pub struct PoolBalances { /// Accumulated burn pool balance (tokens queued for burn via M012) From 9398923a6e7cd96633e1ecc44b0375aeb79f6c43 Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:21:04 -0700 Subject: [PATCH 3/3] fix: extract MAX_FEE_RATE constant to eliminate duplicate magic number The 0.1 (10%) max rate was defined as a local variable in both instantiate() and execute_update_fee_rate(). Extracted to a module-level constant for single-source-of-truth and clarity. Addresses gemini-code-assist review feedback on PR #65. Co-Authored-By: Claude Opus 4.6 (1M context) --- contracts/fee-router/src/contract.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/contracts/fee-router/src/contract.rs b/contracts/fee-router/src/contract.rs index aa14814..a13451f 100644 --- a/contracts/fee-router/src/contract.rs +++ b/contracts/fee-router/src/contract.rs @@ -10,6 +10,10 @@ use crate::msg::{ }; use crate::state::{FeeConfig, PoolBalances, FEE_CONFIG, POOL_BALANCES}; +/// Maximum allowed fee rate: 10%% (0.1). +/// Decimal stores 18 decimal places, so 0.1 = 100_000_000_000_000_000. +const MAX_FEE_RATE: Decimal = Decimal::raw(100_000_000_000_000_000); + // --------------------------------------------------------------------------- // Instantiate // --------------------------------------------------------------------------- @@ -21,12 +25,11 @@ pub fn instantiate( info: MessageInfo, msg: InstantiateMsg, ) -> Result { - // Validate all rates are within [0, 0.10] - let max_rate = Decimal::raw(100_000_000_000_000_000); // 0.1 - validate_rate(msg.issuance_rate, max_rate)?; - validate_rate(msg.transfer_rate, max_rate)?; - validate_rate(msg.retirement_rate, max_rate)?; - validate_rate(msg.trade_rate, max_rate)?; + // Validate all rates are within [0, MAX_FEE_RATE] + validate_rate(msg.issuance_rate, MAX_FEE_RATE)?; + validate_rate(msg.transfer_rate, MAX_FEE_RATE)?; + validate_rate(msg.retirement_rate, MAX_FEE_RATE)?; + validate_rate(msg.trade_rate, MAX_FEE_RATE)?; // Validate distribution shares sum to 1.0 validate_shares( @@ -143,9 +146,8 @@ fn execute_update_fee_rate( return Err(ContractError::Unauthorized {}); } - // Validate rate within [0, 0.10] - let max_rate = Decimal::raw(100_000_000_000_000_000); // 0.1 - validate_rate(rate, max_rate)?; + // Validate rate within [0, MAX_FEE_RATE] + validate_rate(rate, MAX_FEE_RATE)?; match tx_type { TxType::CreditIssuance => config.issuance_rate = rate,