diff --git a/Cargo.lock b/Cargo.lock index 9f46eb37..c396c32f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -712,6 +712,8 @@ dependencies = [ "derivative", "mesh-apis", "mesh-bindings", + "mesh-converter", + "mesh-simple-price-feed", "schemars", "serde", "sylvia", diff --git a/contracts/consumer/converter/src/contract.rs b/contracts/consumer/converter/src/contract.rs index 3a1566db..734d39c1 100644 --- a/contracts/consumer/converter/src/contract.rs +++ b/contracts/consumer/converter/src/contract.rs @@ -1,6 +1,6 @@ use cosmwasm_std::{ ensure_eq, to_binary, Addr, BankMsg, Coin, CosmosMsg, Decimal, Deps, DepsMut, Event, IbcMsg, - Reply, Response, SubMsg, SubMsgResponse, WasmMsg, + Reply, Response, SubMsg, SubMsgResponse, Validator, WasmMsg, }; use cw2::set_contract_version; use cw_storage_plus::Item; @@ -14,7 +14,7 @@ use mesh_apis::price_feed_api; use mesh_apis::virtual_staking_api; use crate::error::ContractError; -use crate::ibc::{packet_timeout_rewards, IBC_CHANNEL}; +use crate::ibc::{add_validators_msg, packet_timeout_rewards, IBC_CHANNEL}; use crate::msg::ConfigResponse; use crate::state::Config; @@ -36,7 +36,7 @@ impl ConverterContract<'_> { pub const fn new() -> Self { Self { config: Item::new("config"), - virtual_stake: Item::new("bonded"), + virtual_stake: Item::new("virtual_stake"), } } @@ -325,4 +325,38 @@ impl ConverterApi for ConverterContract<'_> { } Ok(resp) } + + /// Valset updates. + /// + /// Sent validator set additions (entering the active validator set) to the external staking + /// contract on the Consumer via IBC. + #[msg(exec)] + fn valset_update( + &self, + ctx: ExecCtx, + additions: Vec, + ) -> Result { + let virtual_stake = self.virtual_stake.load(ctx.deps.storage)?; + ensure_eq!( + ctx.info.sender, + virtual_stake, + ContractError::Unauthorized {} + ); + + // Send over IBC to the Consumer + let channel = IBC_CHANNEL.load(ctx.deps.storage)?; + let msg = add_validators_msg(&ctx.env, channel, &additions)?; + + let event = Event::new("valset_update").add_attribute( + "additions", + additions + .iter() + .map(|v| v.address.clone()) + .collect::>() + .join(","), + ); + let resp = Response::new().add_event(event).add_message(msg); + + Ok(resp) + } } diff --git a/contracts/consumer/converter/src/ibc.rs b/contracts/consumer/converter/src/ibc.rs index f9bb6e8c..3dab04cf 100644 --- a/contracts/consumer/converter/src/ibc.rs +++ b/contracts/consumer/converter/src/ibc.rs @@ -5,7 +5,7 @@ use cosmwasm_std::{ from_slice, to_binary, DepsMut, Env, Event, Ibc3ChannelOpenResponse, IbcBasicResponse, IbcChannel, IbcChannelCloseMsg, IbcChannelConnectMsg, IbcChannelOpenMsg, IbcChannelOpenResponse, IbcMsg, IbcPacketAckMsg, IbcPacketReceiveMsg, IbcPacketTimeoutMsg, - IbcReceiveResponse, IbcTimeout, + IbcReceiveResponse, IbcTimeout, Validator, }; use cw_storage_plus::Item; @@ -114,13 +114,24 @@ pub fn ibc_channel_connect( // Send a validator sync packet to arrive with the newly established channel let validators = deps.querier.query_all_validators()?; + let msg = add_validators_msg(&env, channel, &validators)?; + + Ok(IbcBasicResponse::new().add_message(msg)) +} + +pub(crate) fn add_validators_msg( + env: &Env, + channel: IbcChannel, + validators: &[Validator], +) -> Result { let updates = validators - .into_iter() + .iter() .map(|v| AddValidator { - valoper: v.address, - // TODO: not yet available in CosmWasm APIs + valoper: v.address.clone(), + // TODO: not yet available in CosmWasm APIs. See https://github.com/CosmWasm/cosmwasm/issues/1828 pub_key: "TODO".to_string(), - // Use current height/time as start height/time (no slashing before mesh starts) + // Use current height/time as start height/time (no slashing before mesh starts). + // Warning: These will be updated as well when updating an already existing validator. start_height: env.block.height, start_time: env.block.time.seconds(), }) @@ -129,10 +140,9 @@ pub fn ibc_channel_connect( let msg = IbcMsg::SendPacket { channel_id: channel.endpoint.channel_id, data: to_binary(&packet)?, - timeout: packet_timeout_validator(&env), + timeout: packet_timeout_validator(env), }; - - Ok(IbcBasicResponse::new().add_message(msg)) + Ok(msg) } #[cfg_attr(not(feature = "library"), entry_point)] diff --git a/contracts/consumer/converter/src/multitest.rs b/contracts/consumer/converter/src/multitest.rs index ef942ac2..3ec84b11 100644 --- a/contracts/consumer/converter/src/multitest.rs +++ b/contracts/consumer/converter/src/multitest.rs @@ -1,10 +1,13 @@ mod virtual_staking_mock; -use cosmwasm_std::{coin, Addr, Decimal, Uint128}; +use cosmwasm_std::{coin, Addr, Decimal, StdError, Uint128, Validator}; use cw_multi_test::App as MtApp; use sylvia::multitest::App; use crate::contract; +use crate::contract::test_utils::ConverterApi; +use crate::error::ContractError; +use crate::error::ContractError::Unauthorized; const JUNO: &str = "ujuno"; @@ -195,3 +198,65 @@ fn ibc_stake_and_unstake() { ] ); } + +#[test] +fn valset_update_works() { + let app = App::default(); + + let owner = "sunny"; // Owner of the staking contract (i. e. the vault contract) + let admin = "theman"; + let discount = Decimal::percent(10); // 1 OSMO worth of JUNO should give 0.9 OSMO of stake + let native_per_foreign = Decimal::percent(40); // 1 JUNO is worth 0.4 OSMO + + let SetupResponse { + price_feed: _, + converter, + virtual_staking, + } = setup( + &app, + SetupArgs { + owner, + admin, + discount, + native_per_foreign, + }, + ); + + // Send a valset update + let new_validators = vec![ + Validator { + address: "validator1".to_string(), + commission: Default::default(), + max_commission: Default::default(), + max_change_rate: Default::default(), + }, + Validator { + address: "validator3".to_string(), + commission: Default::default(), + max_commission: Default::default(), + max_change_rate: Default::default(), + }, + ]; + + // Check that only the virtual staking contract can call this handler + let res = converter + .converter_api_proxy() + .valset_update(vec![]) + .call(owner); + assert_eq!(res.unwrap_err(), Unauthorized {}); + + let res = converter + .converter_api_proxy() + .valset_update(new_validators) + .call(virtual_staking.contract_addr.as_ref()); + + // This fails because of lack of IBC support in mt now. + // Cannot be tested further in this setup. + // TODO: Change this when IBC support is there in mt. + assert_eq!( + res.unwrap_err(), + ContractError::Std(StdError::NotFound { + kind: "cosmwasm_std::ibc::IbcChannel".to_string() + }) + ); +} diff --git a/contracts/consumer/virtual-staking/Cargo.toml b/contracts/consumer/virtual-staking/Cargo.toml index 3ec4d81d..de630655 100644 --- a/contracts/consumer/virtual-staking/Cargo.toml +++ b/contracts/consumer/virtual-staking/Cargo.toml @@ -20,7 +20,7 @@ mt = ["library", "sylvia/mt"] [dependencies] mesh-apis = { workspace = true } -mesh-bindings = { workspace = true } +mesh-bindings = { workspace = true } sylvia = { workspace = true } cosmwasm-schema = { workspace = true } @@ -35,10 +35,12 @@ serde = { workspace = true } thiserror = { workspace = true } [dev-dependencies] -cw-multi-test = { workspace = true } -test-case = { workspace = true } -derivative = { workspace = true } -anyhow = { workspace = true } +mesh-simple-price-feed = { workspace = true, features = ["mt"] } +mesh-converter = { workspace = true, features = ["mt"] } +cw-multi-test = { workspace = true } +test-case = { workspace = true } +derivative = { workspace = true } +anyhow = { workspace = true } [[bin]] name = "schema" diff --git a/contracts/consumer/virtual-staking/src/contract.rs b/contracts/consumer/virtual-staking/src/contract.rs index ed5f8bd7..0376f691 100644 --- a/contracts/consumer/virtual-staking/src/contract.rs +++ b/contracts/consumer/virtual-staking/src/contract.rs @@ -4,7 +4,7 @@ use std::collections::BTreeMap; use cosmwasm_std::{ coin, ensure_eq, entry_point, to_binary, Coin, CosmosMsg, CustomQuery, DepsMut, DistributionMsg, Env, Event, Reply, Response, StdResult, SubMsg, SubMsgResponse, Uint128, - WasmMsg, + Validator, WasmMsg, }; use cw2::set_contract_version; use cw_storage_plus::{Item, Map}; @@ -44,6 +44,7 @@ pub struct VirtualStakingContract<'a> { #[contract] #[error(ContractError)] #[messages(virtual_staking_api as VirtualStakingApi)] +// #[sv::override_entry_point(sudo=sudo(SudoMsg))] // Disabled because lack of custom query support impl VirtualStakingContract<'_> { pub const fn new() -> Self { Self { @@ -134,6 +135,34 @@ impl VirtualStakingContract<'_> { Ok(resp) } + /** + * This is called every time there's a change of the active validator set. + * + */ + fn handle_valset_update( + &self, + deps: DepsMut, + additions: &[Validator], + removals: &[Validator], + ) -> Result, ContractError> { + // TODO: Store/process removals (and additions) locally, so that they are filtered out from + // the `bonded` list + let _ = removals; + + // Send additions to the converter. Removals are considered non-permanent and ignored + let cfg = self.config.load(deps.storage)?; + let msg = converter_api::ExecMsg::ValsetUpdate { + additions: additions.to_vec(), + }; + let msg = WasmMsg::Execute { + contract_addr: cfg.converter.to_string(), + msg: to_binary(&msg)?, + funds: vec![], + }; + let resp = Response::new().add_message(msg); + Ok(resp) + } + #[msg(reply)] fn reply(&self, ctx: ReplyCtx, reply: Reply) -> Result { match (reply.id, reply.result.into_result()) { @@ -325,5 +354,9 @@ pub fn sudo( ) -> Result, ContractError> { match msg { SudoMsg::Rebalance {} => VirtualStakingContract::new().handle_epoch(deps, env), + SudoMsg::ValsetUpdate { + additions, + removals, + } => VirtualStakingContract::new().handle_valset_update(deps, &additions, &removals), } } diff --git a/contracts/consumer/virtual-staking/src/lib.rs b/contracts/consumer/virtual-staking/src/lib.rs index a5abdbb0..745b54f6 100644 --- a/contracts/consumer/virtual-staking/src/lib.rs +++ b/contracts/consumer/virtual-staking/src/lib.rs @@ -1,4 +1,6 @@ pub mod contract; pub mod error; pub mod msg; +#[cfg(test)] +mod multitest; pub mod state; diff --git a/contracts/consumer/virtual-staking/src/multitest.rs b/contracts/consumer/virtual-staking/src/multitest.rs new file mode 100644 index 00000000..0bfa28e3 --- /dev/null +++ b/contracts/consumer/virtual-staking/src/multitest.rs @@ -0,0 +1,167 @@ +use cosmwasm_std::{Addr, Decimal, Validator}; +use cw_multi_test::App as MtApp; +use mesh_apis::virtual_staking_api::SudoMsg; +use sylvia::multitest::App; + +use crate::contract; + +const JUNO: &str = "ujuno"; + +struct SetupArgs<'a> { + owner: &'a str, + admin: &'a str, + discount: Decimal, + native_per_foreign: Decimal, +} + +struct SetupResponse<'a> { + price_feed: + mesh_simple_price_feed::contract::multitest_utils::SimplePriceFeedContractProxy<'a, MtApp>, + converter: mesh_converter::contract::multitest_utils::ConverterContractProxy<'a, MtApp>, + virtual_staking: contract::multitest_utils::VirtualStakingContractProxy<'a, MtApp>, +} + +fn setup<'a>(app: &'a App, args: SetupArgs<'a>) -> SetupResponse<'a> { + let SetupArgs { + owner, + admin, + discount, + native_per_foreign, + } = args; + + let price_feed_code = + mesh_simple_price_feed::contract::multitest_utils::CodeId::store_code(app); + let virtual_staking_code = contract::multitest_utils::CodeId::store_code(app); + let converter_code = mesh_converter::contract::multitest_utils::CodeId::store_code(app); + + let price_feed = price_feed_code + .instantiate(native_per_foreign, None) + .with_label("Price Feed") + .call(owner) + .unwrap(); + + let converter = converter_code + .instantiate( + price_feed.contract_addr.to_string(), + discount, + JUNO.to_owned(), + virtual_staking_code.code_id(), + Some(admin.to_owned()), + ) + .with_label("Juno Converter") + .with_admin(admin) + .call(owner) + .unwrap(); + + let config = converter.config().unwrap(); + let virtual_staking_addr = Addr::unchecked(config.virtual_staking); + let virtual_staking = + contract::multitest_utils::VirtualStakingContractProxy::new(virtual_staking_addr, app); + + SetupResponse { + price_feed, + converter, + virtual_staking, + } +} + +// TODO: Redundant test. Remove it. +#[test] +fn instantiation() { + let app = App::default(); + + let owner = "sunny"; // Owner of the staking contract (i. e. the vault contract) + let admin = "theman"; + let discount = Decimal::percent(40); // 1 OSMO worth of JUNO should give 0.6 OSMO of stake + let native_per_foreign = Decimal::percent(50); // 1 JUNO is worth 0.5 OSMO + + let SetupResponse { + price_feed, + converter, + virtual_staking, + } = setup( + &app, + SetupArgs { + owner, + admin, + discount, + native_per_foreign, + }, + ); + + // check the config + let config = converter.config().unwrap(); + assert_eq!(config.price_feed, price_feed.contract_addr.to_string()); + assert_eq!(config.adjustment, Decimal::percent(60)); + assert!(!config.virtual_staking.is_empty()); + + // let's check we passed the admin here properly + let vs_info = app + .app() + .wrap() + .query_wasm_contract_info(&config.virtual_staking) + .unwrap(); + assert_eq!(vs_info.admin, Some(admin.to_string())); + + // let's query virtual staking to find the owner + let vs_config = virtual_staking.config().unwrap(); + assert_eq!(vs_config.converter, converter.contract_addr.to_string()); +} + +#[test] +#[ignore] // FIXME: Enable / finish this test once custom query support is added to sylvia +fn valset_update_sudo() { + let app = App::default(); + + let owner = "sunny"; // Owner of the staking contract (i. e. the vault contract) + let admin = "theman"; + let discount = Decimal::percent(40); // 1 OSMO worth of JUNO should give 0.6 OSMO of stake + let native_per_foreign = Decimal::percent(50); // 1 JUNO is worth 0.5 OSMO + + let SetupResponse { + price_feed: _, + converter: _, + virtual_staking, + } = setup( + &app, + SetupArgs { + owner, + admin, + discount, + native_per_foreign, + }, + ); + + // Send a valset update sudo message + let adds = vec![ + Validator { + address: "cosmosval3".to_string(), + commission: Decimal::percent(3), + max_commission: Decimal::percent(30), + max_change_rate: Default::default(), + }, + Validator { + address: "cosmosval1".to_string(), + commission: Decimal::percent(1), + max_commission: Decimal::percent(10), + max_change_rate: Default::default(), + }, + ]; + let rems = vec![Validator { + address: "cosmosval2".to_string(), + commission: Decimal::percent(2), + max_commission: Decimal::percent(20), + max_change_rate: Default::default(), + }]; + let msg = SudoMsg::ValsetUpdate { + additions: adds, + removals: rems, + }; + + let res = app + .app_mut() + .wasm_sudo(virtual_staking.contract_addr, &msg) + .unwrap(); + + println!("res: {:?}", res); +} diff --git a/packages/apis/src/converter_api.rs b/packages/apis/src/converter_api.rs index bd1c3233..fac10626 100644 --- a/packages/apis/src/converter_api.rs +++ b/packages/apis/src/converter_api.rs @@ -1,5 +1,5 @@ use cosmwasm_schema::cw_serde; -use cosmwasm_std::{Response, StdError, Uint128}; +use cosmwasm_std::{Response, StdError, Uint128, Validator}; use sylvia::types::ExecCtx; use sylvia::{interface, schemars}; @@ -26,6 +26,19 @@ pub trait ConverterApi { ctx: ExecCtx, payments: Vec, ) -> Result; + + /// Valset updates. Only additions are accepted, as removals (leaving the active validator set) + /// are non-permanent and ignored (CRDTs only support permanent removals). + /// + /// If a validator that already exists in the list is re-sent for addition, its pubkey + /// will be updated. + /// TODO: pubkeys need to be part of the Validator struct (requires CosmWasm support). + #[msg(exec)] + fn valset_update( + &self, + ctx: ExecCtx, + additions: Vec, + ) -> Result; } #[cw_serde] diff --git a/packages/apis/src/virtual_staking_api.rs b/packages/apis/src/virtual_staking_api.rs index 9473cecd..925709f9 100644 --- a/packages/apis/src/virtual_staking_api.rs +++ b/packages/apis/src/virtual_staking_api.rs @@ -1,5 +1,5 @@ use cosmwasm_schema::cw_serde; -use cosmwasm_std::{Coin, Response, StdError}; +use cosmwasm_std::{Coin, Response, StdError, Validator}; use sylvia::types::ExecCtx; use sylvia::{interface, schemars}; @@ -28,12 +28,19 @@ pub trait VirtualStakingApi { ) -> Result; } -/// SudoMsg::Rebalance{} should be called once per epoch by the sdk (in EndBlock). -/// It allows the virtual staking contract to bond or unbond any pending requests, as well -/// as to perform a rebalance if needed (over the max cap). -/// -/// It should also withdraw all pending rewards here, and send them to the converter contract. #[cw_serde] pub enum SudoMsg { + /// SudoMsg::Rebalance{} should be called once per epoch by the sdk (in EndBlock). + /// It allows the virtual staking contract to bond or unbond any pending requests, as well + /// as to perform a rebalance if needed (over the max cap). + /// + /// It should also withdraw all pending rewards here, and send them to the converter contract. Rebalance {}, + /// SudoMsg::ValsetUpdate{} should be called every time there's a validator set update: addition + /// of a new validator to the active validator set, or removal of a validator from the + /// active validator set. + ValsetUpdate { + additions: Vec, + removals: Vec, + }, }