From 3507d97748d0016b947de5a01ad5b2039fc2178c Mon Sep 17 00:00:00 2001 From: elizabeth-art Date: Sun, 29 Mar 2026 09:50:29 +0100 Subject: [PATCH] feat: implement replay protection, secure treasury management, and event integrity --- apps/rust/property-token/src/lib.rs | 229 +++++++++++++++++++++-- apps/rust/property-token/src/msg.rs | 42 ++++- apps/rust/property-token/src/security.rs | 39 +++- apps/rust/property-token/src/state.rs | 21 ++- 4 files changed, 303 insertions(+), 28 deletions(-) diff --git a/apps/rust/property-token/src/lib.rs b/apps/rust/property-token/src/lib.rs index 1c61e0a..00d8871 100644 --- a/apps/rust/property-token/src/lib.rs +++ b/apps/rust/property-token/src/lib.rs @@ -1,4 +1,53 @@ -use crate::security::prevent_replay; +pub mod msg; +pub mod state; +pub mod security; + +use cosmwasm_std::{ + attr, entry_point, DepsMut, Env, MessageInfo, Response, StdError, StdResult, Uint128, BankMsg, Coin, +}; +use crate::msg::{Auth, BatchMsg, ExecuteMsg, PropertyMetadata, TreasuryAction}; +use crate::state::{ADMIN, AUTHORIZED_ROLES, FEE_BALANCES, METADATA, TREASURY_BALANCE}; +use crate::security::{ensure_authorized, prevent_replay}; + +#[entry_point] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> StdResult { + ADMIN.save(deps.storage, &msg.admin.unwrap_or_else(|| info.sender.to_string()))?; + TREASURY_BALANCE.save(deps.storage, &Uint128::zero())?; + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("admin", msg.admin.unwrap_or_else(|| info.sender.to_string()))) +} + +#[entry_point] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> StdResult { + match msg { + ExecuteMsg::SetMetadata { token_id, metadata, auth } => { + execute_set_metadata(deps, env, info, token_id, metadata, auth) + } + ExecuteMsg::UpdateMetadata { token_id, metadata, auth } => { + execute_update_metadata(deps, env, info, token_id, metadata, auth) + } + ExecuteMsg::Batch { msgs, auth } => execute_batch(deps, env, info, msgs, auth), + ExecuteMsg::WithdrawFees { recipient, amount, token } => { + execute_withdraw_fees(deps, env, info, recipient, amount, token) + } + ExecuteMsg::TreasuryAction { action } => execute_treasury_action(deps, env, info, action), + ExecuteMsg::UpdateConfig { new_admin, authorized_roles } => { + execute_update_config(deps, env, info, new_admin, authorized_roles) + } + } +} pub fn execute_set_metadata( mut deps: DepsMut, @@ -7,29 +56,181 @@ pub fn execute_set_metadata( token_id: String, metadata: PropertyMetadata, auth: Auth, -) -> Result { +) -> StdResult { + // 🔒 Security: Replay protection with strict sequence check prevent_replay(&mut deps, &env, &info, auth.nonce, auth.expires_at)?; - validate_metadata(&metadata)?; + // State update + METADATA.save(deps.storage, &token_id, &metadata)?; + // 🏷️ Accurate Event Emission + Ok(Response::new() + .add_attribute("action", "set_metadata") + .add_attribute("token_id", &token_id) + .add_attribute("caller", &info.sender)) +} + +pub fn execute_update_metadata( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + token_id: String, + metadata: PropertyMetadata, + auth: Auth, +) -> StdResult { + // 🔒 Security: Replay protection + prevent_replay(&mut deps, &env, &info, auth.nonce, auth.expires_at)?; + + // Ensure metadata exists + if !METADATA.has(deps.storage, &token_id) { + return Err(StdError::generic_err("Property metadata not found")); + } + + // State update METADATA.save(deps.storage, &token_id, &metadata)?; - Ok(Response::new().add_attribute("action", "set_metadata")) + // 🏷️ Accurate Event Emission + Ok(Response::new() + .add_attribute("action", "update_metadata") + .add_attribute("token_id", &token_id) + .add_attribute("caller", &info.sender)) } -pub fn execute( - deps: DepsMut, +pub fn execute_batch( + mut deps: DepsMut, env: Env, info: MessageInfo, - msg: ExecuteMsg, -) -> Result { - match msg { - ExecuteMsg::SetMetadata { token_id, metadata } => { - execute_set_metadata(deps, env, info, token_id, metadata) + msgs: Vec, + auth: Auth, +) -> StdResult { + // 🔒 Security: Replay protection for the entire batch + prevent_replay(&mut deps, &env, &info, auth.nonce, auth.expires_at)?; + + let mut response = Response::new().add_attribute("action", "execute_batch"); + + for (idx, msg) in msgs.into_iter().enumerate() { + match msg { + BatchMsg::SetMetadata { token_id, metadata } => { + METADATA.save(deps.storage, &token_id, &metadata)?; + response = response.add_attribute(format!("batch_event_{}", idx), format!("set_metadata_{}", token_id)); + } + BatchMsg::UpdateMetadata { token_id, metadata } => { + if !METADATA.has(deps.storage, &token_id) { + return Err(StdError::generic_err(format!("Property {} not found in batch", token_id))); + } + METADATA.save(deps.storage, &token_id, &metadata)?; + response = response.add_attribute(format!("batch_event_{}", idx), format!("update_metadata_{}", token_id)); + } + } + } + + Ok(response) +} + +pub fn execute_withdraw_fees( + deps: DepsMut, + _env: Env, + info: MessageInfo, + recipient: String, + amount: Uint128, + token: String, +) -> StdResult { + // 🔒 RBAC Check (#138) + ensure_authorized(&deps.as_ref(), &info)?; + + // Validate fee balance + let current_balance = FEE_BALANCES.may_load(deps.storage, &token)?.unwrap_or_default(); + if amount > current_balance { + return Err(StdError::generic_err(format!( + "InsufficientFeeBalance: Available: {}, Requested: {}", + current_balance, amount + ))); + } + + // ⚡ Internal state update BEFORE external transfer (Checks-Effects-Interactions) + let new_balance = current_balance.checked_sub(amount).map_err(|e| StdError::generic_err(e.to_string()))?; + FEE_BALANCES.save(deps.storage, &token, &new_balance)?; + + // Prepare transfer message + let bank_msg = BankMsg::Send { + to_address: recipient.clone(), + amount: vec![Coin { denom: token.clone(), amount }], + }; + + // 🏷️ Proper Audit Event Emission + Ok(Response::new() + .add_message(bank_msg) + .add_attribute("action", "withdraw_fees") + .add_attribute("recipient", recipient) + .add_attribute("amount", amount) + .add_attribute("token", token)) +} + +pub fn execute_treasury_action( + deps: DepsMut, + _env: Env, + info: MessageInfo, + action: TreasuryAction, +) -> StdResult { + // 🔒 RBAC Check (#139) + ensure_authorized(&deps.as_ref(), &info)?; + + match action { + TreasuryAction::Deposit { amount } => { + let current = TREASURY_BALANCE.load(deps.storage).unwrap_or_default(); + TREASURY_BALANCE.save(deps.storage, &(current + amount))?; + + Ok(Response::new() + .add_attribute("action", "treasury_deposit") + .add_attribute("amount", amount)) } - ExecuteMsg::UpdateMetadata { token_id, metadata } => { - execute_update_metadata(deps, env, info, token_id, metadata) + TreasuryAction::Withdraw { amount, recipient } | TreasuryAction::Transfer { amount, recipient } => { + let current = TREASURY_BALANCE.load(deps.storage).unwrap_or_default(); + + if amount > current { + return Err(StdError::generic_err("TreasuryOverdraw: Insufficient funds in treasury vault.")); + } + + // State update FIRST + TREASURY_BALANCE.save(deps.storage, &(current - amount))?; + + // Transfer logic + let msg = BankMsg::Send { + to_address: recipient.clone(), + amount: vec![Coin { denom: "stablecoin".to_string(), amount }], // Usually would be configurable + }; + + Ok(Response::new() + .add_message(msg) + .add_attribute("action", "treasury_outflow") + .add_attribute("type", "withdrawal/transfer") + .add_attribute("recipient", recipient) + .add_attribute("amount", amount)) } - ExecuteMsg::Batch { msgs } => execute_batch(deps, env, info, msgs), } +} + +pub fn execute_update_config( + deps: DepsMut, + _env: Env, + info: MessageInfo, + new_admin: Option, + authorized_roles: Option>, +) -> StdResult { + let admin = ADMIN.load(deps.storage)?; + if info.sender.as_str() != admin { + return Err(StdError::generic_err("Only the current admin can update config")); + } + + if let Some(addr) = new_admin { + ADMIN.save(deps.storage, &addr)?; + } + + if let Some(roles) = authorized_roles { + for (addr, is_auth) in roles { + AUTHORIZED_ROLES.save(deps.storage, &addr, &is_auth)?; + } + } + + Ok(Response::new().add_attribute("action", "update_config")) } \ No newline at end of file diff --git a/apps/rust/property-token/src/msg.rs b/apps/rust/property-token/src/msg.rs index 8b82c7f..5f7a10b 100644 --- a/apps/rust/property-token/src/msg.rs +++ b/apps/rust/property-token/src/msg.rs @@ -1,9 +1,17 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Uint128; + #[cw_serde] pub struct Auth { pub nonce: u64, pub expires_at: Option, } +#[cw_serde] +pub struct InstantiateMsg { + pub admin: Option, +} + #[cw_serde] pub enum ExecuteMsg { SetMetadata { @@ -20,10 +28,31 @@ pub enum ExecuteMsg { msgs: Vec, auth: Auth, }, - - Batch { - msgs: Vec, + + // #138 Fee Withdrawal Security + WithdrawFees { + recipient: String, + amount: Uint128, + token: String, + }, + + // #139 Treasury Management Logic + TreasuryAction { + action: TreasuryAction, }, + + // Role-based Access Control + UpdateConfig { + new_admin: Option, + authorized_roles: Option>, + }, +} + +#[cw_serde] +pub enum TreasuryAction { + Deposit { amount: Uint128 }, + Withdraw { amount: Uint128, recipient: String }, + Transfer { amount: Uint128, recipient: String }, } #[cw_serde] @@ -36,4 +65,11 @@ pub enum BatchMsg { token_id: String, metadata: PropertyMetadata, }, +} + +#[cw_serde] +pub struct PropertyMetadata { + pub name: String, + pub description: String, + pub image_url: Option, } \ No newline at end of file diff --git a/apps/rust/property-token/src/security.rs b/apps/rust/property-token/src/security.rs index d035454..06571e0 100644 --- a/apps/rust/property-token/src/security.rs +++ b/apps/rust/property-token/src/security.rs @@ -1,5 +1,5 @@ use cosmwasm_std::{Env, MessageInfo, StdError, StdResult}; -use crate::state::USED_NONCES; +use crate::state::NEXT_NONCE; pub fn prevent_replay( deps: &mut cosmwasm_std::DepsMut, @@ -10,20 +10,43 @@ pub fn prevent_replay( ) -> StdResult<()> { let sender = info.sender.as_str(); - // 1. Check expiry + // 1. Signature Expiration Validation (#54-2) if let Some(expiry) = expires_at { if env.block.time.seconds() > expiry { - return Err(StdError::generic_err("Message expired")); + // Provide clear descriptive error for expiration + return Err(StdError::generic_err("SignatureExpired: Meta-transaction signature has expired.")); } } - // 2. Check nonce already used - if USED_NONCES.has(deps.storage, (sender, nonce)) { - return Err(StdError::generic_err("Replay detected")); + // 2. Sequential Nonce Validation (#54-1) + let current_nonce = NEXT_NONCE.may_load(deps.storage, sender)?.unwrap_or(0); + + // Check if the received nonce is the next expected one + if nonce < current_nonce { + return Err(StdError::generic_err("ReplayDetected: Nonce already used.")); } + + if nonce > current_nonce { + return Err(StdError::generic_err("OutOfOrderTransaction: Received nonce higher than expected.")); + } + + // 3. Update Nonce + NEXT_NONCE.save(deps.storage, sender, &(current_nonce + 1))?; - // 3. Mark nonce as used - USED_NONCES.save(deps.storage, (sender, nonce), &true)?; + Ok(()) +} +pub fn ensure_authorized( + deps: &cosmwasm_std::Deps, + info: &MessageInfo, +) -> StdResult<()> { + let sender = info.sender.as_str(); + let is_authorized = crate::state::AUTHORIZED_ROLES.may_load(deps.storage, sender)?.unwrap_or(false); + let admin = crate::state::ADMIN.load(deps.storage)?; + + if sender != admin && !is_authorized { + return Err(StdError::generic_err("Unauthorized: Role-based access control restriction.")); + } + Ok(()) } \ No newline at end of file diff --git a/apps/rust/property-token/src/state.rs b/apps/rust/property-token/src/state.rs index a7bb33b..b40f2fb 100644 --- a/apps/rust/property-token/src/state.rs +++ b/apps/rust/property-token/src/state.rs @@ -1,4 +1,19 @@ -use cw_storage_plus::Map; +use cw_storage_plus::{Item, Map}; +use cosmwasm_std::Uint128; +use crate::msg::PropertyMetadata; -// (sender, nonce) -> used -pub const USED_NONCES: Map<(&str, u64), bool> = Map::new("used_nonces"); \ No newline at end of file +// Property metadata storage +pub const METADATA: Map<&str, PropertyMetadata> = Map::new("metadata"); + +// Current nonce for each user (next expected nonce) +pub const NEXT_NONCE: Map<&str, u64> = Map::new("next_nonce"); + +// Protocol fee balances per address (or per token) +pub const FEE_BALANCES: Map<&str, Uint128> = Map::new("fee_balances"); + +// Treasury balance +pub const TREASURY_BALANCE: Item = Item::new("treasury_balance"); + +// Authorized roles (admin, treasury, etc) +pub const ADMIN: Item = Item::new("admin"); +pub const AUTHORIZED_ROLES: Map<&str, bool> = Map::new("authorized_roles"); \ No newline at end of file