diff --git a/packages/contracts/mirror/service-manager/src/entry.rs b/packages/contracts/mirror/service-manager/src/entry.rs index f594e73..e3f1757 100644 --- a/packages/contracts/mirror/service-manager/src/entry.rs +++ b/packages/contracts/mirror/service-manager/src/entry.rs @@ -229,14 +229,6 @@ fn validate_quorum( .may_load_at_height(deps.storage, reference_block)? .ok_or_else(|| StdError::msg("quorum denominator missing at reference_block"))?; - // Calculate threshold weight: (total_weight * numerator) / denominator - let threshold_weight = - total_weight.full_mul(numerator) / cosmwasm_std::Uint512::from(denominator); - // Convert threshold_weight from Uint512 to Uint256 (safely) - let threshold_weight = threshold_weight - .try_into() - .unwrap_or(cosmwasm_std::Uint256::MAX); - // Avoid 0 weight ever passing this check if total_weight.is_zero() { return Ok(WavsValidateResult::Err( @@ -244,8 +236,14 @@ fn validate_quorum( )); } - // Check if signed_weight >= threshold_weight - if signed_weight < threshold_weight { + // Quorum check via cross multiplication to avoid floor-rounding the + // threshold. Integer division would allow under-quorum signatures for + // non-divisible totals, e.g. 1 of 2 passing a 2/3 threshold. + let lhs = cosmwasm_std::Uint512::from(signed_weight) * cosmwasm_std::Uint512::from(denominator); + let rhs = cosmwasm_std::Uint512::from(total_weight) * cosmwasm_std::Uint512::from(numerator); + if lhs < rhs { + let threshold = total_weight.full_mul(numerator) / cosmwasm_std::Uint512::from(denominator); + let threshold_weight = threshold.try_into().unwrap_or(cosmwasm_std::Uint256::MAX); return Ok(WavsValidateResult::Err( WavsValidateError::InsufficientQuorum { signer_weight: signed_weight, diff --git a/packages/contracts/mirror/stake-registry/src/entry.rs b/packages/contracts/mirror/stake-registry/src/entry.rs index 416a1b6..e55e67c 100644 --- a/packages/contracts/mirror/stake-registry/src/entry.rs +++ b/packages/contracts/mirror/stake-registry/src/entry.rs @@ -50,7 +50,7 @@ pub fn instantiate( } } OWNER.save(deps.storage, &info.sender)?; - TOTAL_WEIGHT.save(deps.storage, &Uint256::zero())?; + TOTAL_WEIGHT.save(deps.storage, &Uint256::zero(), env.block.height)?; Ok(Response::new() .add_attribute("method", "instantiate") @@ -223,7 +223,7 @@ fn set_operator_details_at( .map_err(|e| ContractError::Std(StdError::msg(format!("total weight underflow: {e}"))))? .checked_add(weight) .map_err(|e| ContractError::Std(StdError::msg(format!("total weight overflow: {e}"))))?; - TOTAL_WEIGHT.save(deps.storage, &new_total_weight)?; + TOTAL_WEIGHT.save(deps.storage, &new_total_weight, snapshot_height)?; // Update signing key mappings let current_signing_key = @@ -277,7 +277,10 @@ fn query_validate_signature( envelope: WavsEnvelope, signature_data: WavsSignatureData, ) -> StdResult { - let total_weight = TOTAL_WEIGHT.load(deps.storage)?; + let reference_block = signature_data.reference_block as u64; + let total_weight = TOTAL_WEIGHT + .may_load_at_height(deps.storage, reference_block)? + .unwrap_or_default(); let mut voting_power_signed = Uint256::zero(); // Basic sanity checks to avoid panics and invalid data @@ -318,11 +321,7 @@ fn query_validate_signature( // operator set. let signing_key_str = signing_key.to_string(); let operator = SIGNING_KEY_TO_OPERATOR - .may_load_at_height( - deps.storage, - signing_key_str.clone(), - signature_data.reference_block as u64, - )? + .may_load_at_height(deps.storage, signing_key_str.clone(), reference_block)? .ok_or_else(|| { StdError::msg(format!( "Signer not registered at reference_block: {signing_key}" @@ -344,11 +343,7 @@ fn query_validate_signature( // post-snapshot weight to a historical envelope. let operator_key = operator.to_string(); let operator_weight = OPERATOR_WEIGHTS - .may_load_at_height( - deps.storage, - operator_key, - signature_data.reference_block as u64, - )? + .may_load_at_height(deps.storage, operator_key, reference_block)? .unwrap_or_default(); if operator_weight.is_zero() { return Err(StdError::msg(format!( diff --git a/packages/contracts/mirror/stake-registry/src/state.rs b/packages/contracts/mirror/stake-registry/src/state.rs index 0f6ad00..7bef4d3 100644 --- a/packages/contracts/mirror/stake-registry/src/state.rs +++ b/packages/contracts/mirror/stake-registry/src/state.rs @@ -1,6 +1,6 @@ use cosmwasm_schema::cw_serde; use cosmwasm_std::{Addr, Uint256}; -use cw_storage_plus::{Item, Map, SnapshotMap, Strategy}; +use cw_storage_plus::{Item, Map, SnapshotItem, SnapshotMap, Strategy}; use layer_climb_address::EvmAddr; // Contract configuration @@ -28,8 +28,13 @@ pub const SIGNING_KEY_TO_OPERATOR: SnapshotMap = SnapshotMap::n ); pub const OPERATOR_REGISTERED: Map = Map::new("operator_registered"); -// Weight tracking -pub const TOTAL_WEIGHT: Item = Item::new("total_weight"); +// Weight tracking with historical checkpoints for validating historical signature sets. +pub const TOTAL_WEIGHT: SnapshotItem = SnapshotItem::new( + "total_weight", + "total_weight__checkpoints", + "total_weight__changelog", + Strategy::EveryBlock, +); #[cw_serde] pub struct Config { diff --git a/packages/tests/off-chain/tests/contracts_mirror.rs b/packages/tests/off-chain/tests/contracts_mirror.rs index 3beebd1..482f20a 100644 --- a/packages/tests/off-chain/tests/contracts_mirror.rs +++ b/packages/tests/off-chain/tests/contracts_mirror.rs @@ -1,5 +1,7 @@ #![recursion_limit = "512"] +use cosmwasm_std::{HexBinary, Uint256}; +use layer_climb_address::EvmAddr; use off_chain_tests::client::{ mirror::MirrorTestClient, trigger::SimpleTriggerTestClient, ContractTestClient, }; @@ -183,3 +185,66 @@ async fn mirror_service_manager_validate_query_is_result() { WavsValidateResult::Err(WavsValidateError::InvalidSignature(_)) )); } + +#[tokio::test] +async fn mirror_validate_signature_uses_snapshot_total_weight_after_later_weight_changes() { + tracing_tests_init(); + + let client = ContractTestClient::new("admin"); + let app = client.app(); + let mirror_client = MirrorTestClient::new(client); + + let (signing_key, signing_addr) = mirror_stake_registry::create_signing_key_and_address(); + let operator = EvmAddr::new([0x21; 20]); + + app.borrow_mut().update_block(|block| block.height += 1); + mirror_client + .stake_registry_executor + .set_operator_details( + operator.clone(), + signing_addr.clone(), + Uint256::from(100u128), + ) + .await + .unwrap(); + // cw-storage-plus snapshots are queried at the height after the checkpoint. + let reference_block = (app.borrow().block_info().height + 1) as u32; + + let message = b"snapshot total weight regression"; + let digest = mirror_stake_registry::create_eip191_hash(message); + let signature = mirror_stake_registry::sign_message_hash(&signing_key, digest.as_slice()); + + app.borrow_mut().update_block(|block| block.height += 2); + mirror_client + .stake_registry_executor + .set_operator_details( + EvmAddr::new([0x22; 20]), + EvmAddr::new([0x23; 20]), + Uint256::from(900u128), + ) + .await + .unwrap(); + + app.borrow_mut().update_block(|block| block.height += 1); + mirror_client + .stake_registry_executor + .set_operator_details(operator, signing_addr.clone(), Uint256::from(1u128)) + .await + .unwrap(); + + let result = mirror_client + .stake_registry_querier + .validate_signature( + WavsEnvelope::new_raw(message.to_vec()), + WavsSignatureData { + signers: vec![signing_addr], + signatures: vec![HexBinary::from(signature)], + reference_block, + }, + ) + .await + .unwrap(); + + assert_eq!(result.total_voting_power, Uint256::from(100u128)); + assert_eq!(result.voting_power_signed, Uint256::from(100u128)); +} diff --git a/packages/tests/shared/src/mirror_stake_registry.rs b/packages/tests/shared/src/mirror_stake_registry.rs index 2890c9d..d32fbdf 100644 --- a/packages/tests/shared/src/mirror_stake_registry.rs +++ b/packages/tests/shared/src/mirror_stake_registry.rs @@ -11,11 +11,11 @@ use layer_climb_address::EvmAddr; use rand::thread_rng; use wavs_types::contracts::cosmwasm::service_handler::{WavsEnvelope, WavsSignatureData}; -fn create_eip191_hash(message: &[u8]) -> B256 { +pub fn create_eip191_hash(message: &[u8]) -> B256 { eip191_hash_message(alloy_keccak256(message)) } -fn create_signing_key_and_address() -> (SigningKey, EvmAddr) { +pub fn create_signing_key_and_address() -> (SigningKey, EvmAddr) { let signing_key = SigningKey::random(&mut thread_rng()); let eth_address = derive_eth_address_from_signing_key(&signing_key); (signing_key, eth_address) @@ -34,7 +34,7 @@ fn derive_eth_address_from_signing_key(signing_key: &SigningKey) -> EvmAddr { EvmAddr::new(addr_bytes) } -fn sign_message_hash(signing_key: &SigningKey, message_hash: &[u8]) -> Vec { +pub fn sign_message_hash(signing_key: &SigningKey, message_hash: &[u8]) -> Vec { let signature: Signature = signing_key.sign_prehash(message_hash).unwrap(); let (r, s) = signature.split_bytes();