diff --git a/Cargo.lock b/Cargo.lock index 820b4003..43b9306c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,9 +15,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.79" +version = "1.0.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" +checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1" [[package]] name = "astroport" @@ -899,6 +899,52 @@ dependencies = [ "thiserror", ] +[[package]] +name = "lido-proposal-votes-poc" +version = "1.0.0" +dependencies = [ + "cosmos-sdk-proto", + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-ownable", + "cw-storage-plus 1.2.0", + "cw2 1.1.2", + "lido-helpers", + "lido-staking-base", + "neutron-sdk", + "prost", + "prost-types", + "schemars", + "serde", + "serde-json-wasm 1.0.1", + "tendermint-proto", + "thiserror", +] + +[[package]] +name = "lido-provider-proposals-poc" +version = "1.0.0" +dependencies = [ + "cosmos-sdk-proto", + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-ownable", + "cw-storage-plus 1.2.0", + "cw2 1.1.2", + "lido-helpers", + "lido-staking-base", + "neutron-sdk", + "prost", + "prost-types", + "schemars", + "serde", + "serde-json-wasm 1.0.1", + "tendermint-proto", + "thiserror", +] + [[package]] name = "lido-pump" version = "1.0.0" @@ -1082,7 +1128,7 @@ dependencies = [ [[package]] name = "neutron-sdk" version = "0.8.0" -source = "git+https://github.com/neutron-org/neutron-sdk?branch=feat/LIDO-68-query-siginig-info#e753df9a24cb0001dfb125ef6f67595da01eee64" +source = "git+https://github.com/neutron-org/neutron-sdk?branch=feat/proposal-votes#5e6a807571ec547715c06591c18a91620ad45840" dependencies = [ "bech32", "cosmos-sdk-proto", @@ -1145,7 +1191,7 @@ checksum = "fa59f025cde9c698fcb4fcb3533db4621795374065bee908215263488f2d2a1d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.50", ] [[package]] @@ -1199,7 +1245,7 @@ dependencies = [ "itertools 0.11.0", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.50", ] [[package]] @@ -1273,9 +1319,9 @@ checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" [[package]] name = "ryu" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" +checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" [[package]] name = "schemars" @@ -1317,15 +1363,15 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97ed7a9823b74f99c7742f5336af7be5ecd3eeafcb1507d1fa93347b1d589b0" +checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" [[package]] name = "serde" -version = "1.0.196" +version = "1.0.197" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" +checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" dependencies = [ "serde_derive", ] @@ -1359,13 +1405,13 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.196" +version = "1.0.197" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" +checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.50", ] [[package]] @@ -1381,9 +1427,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.113" +version = "1.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79" +checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" dependencies = [ "itoa", "ryu", @@ -1469,7 +1515,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.48", + "syn 2.0.50", ] [[package]] @@ -1500,9 +1546,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.48" +version = "2.0.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" +checksum = "74f1bdc9872430ce9b75da68329d1c1746faf50ffac5f19e02b71e37ff881ffb" dependencies = [ "proc-macro2", "quote", @@ -1544,7 +1590,7 @@ checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.50", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f276bb3c..fef5aa73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,8 @@ members = [ "contracts/token", "contracts/validators-set", "contracts/rewards-manager", + "contracts/proposal-votes-poc", + "contracts/provider-proposals-poc", "contracts/strategy", "contracts/pump", "contracts/hook-tester", @@ -28,7 +30,7 @@ cosmwasm-std = { version = "1.5.2", default-features = false, features = [ "stargate", "cosmwasm_1_2", ] } -neutron-sdk = { package = "neutron-sdk", git = "https://github.com/neutron-org/neutron-sdk", branch = "feat/LIDO-68-query-siginig-info" } +neutron-sdk = { package = "neutron-sdk", git = "https://github.com/neutron-org/neutron-sdk", branch = "feat/proposal-votes" } cosmos-sdk-proto = { version = "0.20.0", default-features = false } cw-ownable = { version = "0.5.1", default-features = false } diff --git a/contracts/proposal-votes-poc/.cargo/config b/contracts/proposal-votes-poc/.cargo/config new file mode 100644 index 00000000..f010c4c0 --- /dev/null +++ b/contracts/proposal-votes-poc/.cargo/config @@ -0,0 +1,2 @@ +[alias] +schema = "run --bin lido-proposal-votes-poc-schema" diff --git a/contracts/proposal-votes-poc/Cargo.toml b/contracts/proposal-votes-poc/Cargo.toml new file mode 100644 index 00000000..51e50013 --- /dev/null +++ b/contracts/proposal-votes-poc/Cargo.toml @@ -0,0 +1,46 @@ +[package] +authors = ["Albert Andrejev "] +description = "Contract to control proposals voting process" +edition = "2021" +name = "lido-proposal-votes-poc" +version = "1.0.0" + +exclude = [ + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "contract.wasm", + "hash.txt", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmos-sdk-proto = { workspace = true } +prost = { workspace = true } +prost-types = { workspace = true } +tendermint-proto = { workspace = true } +thiserror = { workspace = true } + +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw2 = { workspace = true } +cw-storage-plus = { workspace = true } +cw-ownable = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +serde-json-wasm = { workspace = true } + +neutron-sdk = { workspace = true } +lido-staking-base = { workspace = true } +lido-helpers = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } diff --git a/contracts/proposal-votes-poc/README.md b/contracts/proposal-votes-poc/README.md new file mode 100644 index 00000000..e010398a --- /dev/null +++ b/contracts/proposal-votes-poc/README.md @@ -0,0 +1,3 @@ +# LIDO Proposal votes contract + +** This is POC contract. ** \ No newline at end of file diff --git a/contracts/proposal-votes-poc/src/bin/lido-proposal-votes-poc-schema.rs b/contracts/proposal-votes-poc/src/bin/lido-proposal-votes-poc-schema.rs new file mode 100644 index 00000000..f7108264 --- /dev/null +++ b/contracts/proposal-votes-poc/src/bin/lido-proposal-votes-poc-schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use lido_staking_base::msg::proposal_votes::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg + } +} diff --git a/contracts/proposal-votes-poc/src/contract.rs b/contracts/proposal-votes-poc/src/contract.rs new file mode 100644 index 00000000..442a57f9 --- /dev/null +++ b/contracts/proposal-votes-poc/src/contract.rs @@ -0,0 +1,383 @@ +use std::collections::HashSet; + +use cosmwasm_std::{ + attr, ensure_eq, entry_point, to_json_binary, Attribute, CosmosMsg, Deps, Reply, SubMsg, + WasmMsg, +}; +use cosmwasm_std::{Binary, DepsMut, Env, MessageInfo, Response, StdResult}; +use cw2::set_contract_version; +use lido_helpers::answer::response; +use lido_helpers::query_id::get_query_id; +use lido_staking_base::msg::proposal_votes::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; +use lido_staking_base::msg::provider_proposals::ExecuteMsg as ProviderProposalsExecuteMsg; +use lido_staking_base::state::proposal_votes::{ + Config, ConfigOptional, Metrics, ACTIVE_PROPOSALS, CONFIG, PROPOSALS_VOTES_REMOVE_REPLY_ID, + PROPOSALS_VOTES_REPLY_ID, QUERY_ID, VOTERS, +}; +use neutron_sdk::bindings::msg::NeutronMsg; +use neutron_sdk::bindings::query::{NeutronQuery, QueryRegisteredQueryResultResponse}; +use neutron_sdk::interchain_queries::queries::get_raw_interchain_query_result; +use neutron_sdk::interchain_queries::types::KVReconstruct; +use neutron_sdk::interchain_queries::v045::register_queries::{ + new_register_gov_proposal_votes_query_msg, update_register_gov_proposal_votes_query_msg, +}; +use neutron_sdk::interchain_queries::v045::types::GovernmentProposalVotes; +use neutron_sdk::sudo::msg::SudoMsg; + +use crate::error::{ContractError, ContractResult}; + +const CONTRACT_NAME: &str = concat!("crates.io:lido-staking__", env!("CARGO_PKG_NAME")); + +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> ContractResult> { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + let core = deps.api.addr_validate(&msg.core_address)?; + let provider_proposals = deps.api.addr_validate(&msg.provider_proposals_address)?; + + cw_ownable::initialize_owner(deps.storage, deps.api, Some(core.as_ref()))?; + + let config = &Config { + connection_id: msg.connection_id.clone(), + port_id: msg.port_id.clone(), + update_period: msg.update_period, + core_address: msg.core_address.to_string(), + provider_proposals_address: provider_proposals.to_string(), + }; + + CONFIG.save(deps.storage, config)?; + + Ok(response( + "instantiate", + CONTRACT_NAME, + [ + attr("connection_id", msg.connection_id), + attr("port_id", msg.port_id), + attr("update_period", msg.update_period.to_string()), + attr("core_address", msg.core_address), + attr("provider_proposals_address", msg.provider_proposals_address), + ], + )) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => query_config(deps), + QueryMsg::Metrics {} => query_metrics(deps), + } +} + +fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + to_json_binary(&config) +} + +fn query_metrics(deps: Deps) -> StdResult { + let voters = VOTERS.may_load(deps.storage)?.unwrap_or_default(); + + to_json_binary(&Metrics { + total_voters: voters.len() as u64, + }) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> ContractResult> { + match msg { + ExecuteMsg::UpdateConfig { new_config } => execute_update_config(deps, info, new_config), + ExecuteMsg::UpdateActiveProposals { active_proposals } => { + execute_update_active_proposals(deps, info, active_proposals) + } + ExecuteMsg::UpdateVotersList { voters } => execute_update_voters_list(deps, info, voters), + } +} + +fn execute_update_config( + deps: DepsMut, + info: MessageInfo, + new_config: ConfigOptional, +) -> ContractResult> { + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + let mut config = CONFIG.load(deps.storage)?; + + let mut attrs: Vec = Vec::new(); + if let Some(core_address) = new_config.core_address { + let core_address = deps.api.addr_validate(&core_address)?; + config.core_address = core_address.to_string(); + attrs.push(attr("core_address", core_address)) + } + + if let Some(provider_proposals_address) = new_config.provider_proposals_address { + let provider_proposals_address = deps.api.addr_validate(&provider_proposals_address)?; + config.provider_proposals_address = provider_proposals_address.to_string(); + attrs.push(attr( + "provider_proposals_address", + provider_proposals_address, + )) + } + + if let Some(connection_id) = new_config.connection_id { + config.connection_id = connection_id.clone(); + attrs.push(attr("connection_id", connection_id)) + } + + if let Some(port_id) = new_config.port_id { + config.port_id = port_id.clone(); + attrs.push(attr("port_id", port_id)) + } + + if let Some(update_period) = new_config.update_period { + config.update_period = update_period; + attrs.push(attr("update_period", update_period.to_string())) + } + + CONFIG.save(deps.storage, &config)?; + + Ok(response("config_update", CONTRACT_NAME, attrs)) +} + +fn execute_update_voters_list( + deps: DepsMut, + info: MessageInfo, + voters: Vec, +) -> ContractResult> { + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + VOTERS.save(deps.storage, &voters)?; + + Ok(response( + "config_update", + CONTRACT_NAME, + [attr("total_count", voters.len().to_string())], + )) +} + +fn execute_update_active_proposals( + deps: DepsMut, + info: MessageInfo, + active_proposals: Vec, +) -> ContractResult> { + let config = CONFIG.load(deps.storage)?; + + ensure_eq!( + config.provider_proposals_address, + info.sender, + ContractError::Unauthorized {} + ); + + let query_id = QUERY_ID.may_load(deps.storage)?; + + if active_proposals.is_empty() && query_id.is_none() { + return Ok(Response::default()); + } + + if active_proposals.is_empty() && query_id.is_some() { + if let Some(query_id) = query_id { + return remove_votes_interchain_query(query_id); + } + } + + process_new_data(deps, &config, query_id, active_proposals) +} + +fn process_new_data( + deps: DepsMut, + config: &Config, + query_id: Option, + active_proposals: Vec, +) -> ContractResult> { + let voters = VOTERS.may_load(deps.storage)?; + + let mut sub_msgs: Vec> = Vec::new(); + let mut attrs: Vec = Vec::new(); + + if let Some(voters) = voters { + attrs.push(attr("total_proposals", active_proposals.len().to_string())); + attrs.push(attr("total_voters", voters.len().to_string())); + + if !active_proposals.is_empty() && query_id.is_none() { + ACTIVE_PROPOSALS.save(deps.storage, &active_proposals)?; + + sub_msgs.push(register_votes_interchain_query( + config, + active_proposals.to_owned(), + voters.to_owned(), + )?); + } + + let old_active_proposals = ACTIVE_PROPOSALS.may_load(deps.storage)?.unwrap_or_default(); + + let active_proposals_set: HashSet<_> = active_proposals.clone().into_iter().collect(); + let old_active_proposals_set: HashSet<_> = old_active_proposals.into_iter().collect(); + + let new_proposals: HashSet<_> = active_proposals_set + .difference(&old_active_proposals_set) + .cloned() + .collect(); + let proposals_to_remove: HashSet<_> = old_active_proposals_set + .difference(&active_proposals_set) + .cloned() + .collect(); + + if !new_proposals.is_empty() || !proposals_to_remove.is_empty() { + if let Some(query_id) = query_id { + ACTIVE_PROPOSALS.save(deps.storage, &active_proposals)?; + + sub_msgs.push(update_votes_interchain_query( + query_id, + active_proposals, + voters, + )?); + } + } + } + + Ok(response("update_votes_interchain_query", CONTRACT_NAME, attrs).add_submessages(sub_msgs)) +} + +fn update_votes_interchain_query( + query_id: u64, + active_proposals: Vec, + voters: Vec, +) -> ContractResult> { + let msg = update_register_gov_proposal_votes_query_msg( + query_id, + active_proposals.to_owned(), + voters.to_owned(), + None, + None, + )?; + + Ok(SubMsg::reply_on_success(msg, PROPOSALS_VOTES_REPLY_ID)) +} + +fn register_votes_interchain_query( + config: &Config, + active_proposals: Vec, + voters: Vec, +) -> ContractResult> { + let msg = new_register_gov_proposal_votes_query_msg( + config.connection_id.to_string(), + active_proposals, + voters, + config.update_period, + )?; + + Ok(SubMsg::reply_on_success(msg, PROPOSALS_VOTES_REPLY_ID)) +} + +fn remove_votes_interchain_query(query_id: u64) -> ContractResult> { + let msg = NeutronMsg::remove_interchain_query(query_id); + let sub_msg = SubMsg::reply_on_success(msg, PROPOSALS_VOTES_REMOVE_REPLY_ID); + + Ok(response( + "remove_votes_interchain_query", + CONTRACT_NAME, + [attr("query_id", query_id.to_string())], + ) + .add_submessage(sub_msg)) +} + +#[entry_point] +pub fn sudo( + deps: DepsMut, + env: Env, + msg: SudoMsg, +) -> ContractResult> { + deps.api.debug(&format!( + "WASMDEBUG: sudo call: {:?}, block: {:?}", + msg, env.block + )); + match msg { + SudoMsg::KVQueryResult { query_id } => sudo_kv_query_result(deps, env, query_id), + _ => Ok(Response::default()), + } +} + +pub fn sudo_kv_query_result( + deps: DepsMut, + _env: Env, + query_id: u64, +) -> ContractResult> { + deps.api.debug(&format!( + "WASMDEBUG: sudo_kv_query_result call: {query_id:?}", + )); + + let votes_query_id = QUERY_ID.may_load(deps.storage)?; + + let interchain_query_result = get_raw_interchain_query_result(deps.as_ref(), query_id)?; + + if Some(query_id) == votes_query_id { + return sudo_proposal_votes(deps, interchain_query_result); + } + + Ok(Response::default()) +} + +fn sudo_proposal_votes( + deps: DepsMut, + interchain_query_result: QueryRegisteredQueryResultResponse, +) -> ContractResult> { + let data: GovernmentProposalVotes = + KVReconstruct::reconstruct(&interchain_query_result.result.kv_results)?; + + deps.api + .debug(&format!("WASMDEBUG: sudo_proposal_votes data: {data:?}",)); + + let config = CONFIG.load(deps.storage)?; + + let msg: CosmosMsg = CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: config.provider_proposals_address, + msg: to_json_binary(&ProviderProposalsExecuteMsg::UpdateProposalVotes { + votes: data.proposal_votes, + })?, + funds: vec![], + }); + + Ok(Response::new().add_message(msg)) +} + +#[entry_point] +pub fn reply(deps: DepsMut, env: Env, msg: Reply) -> ContractResult { + deps.api + .debug(format!("WASMDEBUG: reply msg: {msg:?}").as_str()); + + match msg.id { + PROPOSALS_VOTES_REPLY_ID => proposals_votes_reply(deps, env, msg), + PROPOSALS_VOTES_REMOVE_REPLY_ID => proposals_votes_remove_reply(deps, env, msg), + id => Err(ContractError::UnknownReplyId { id }), + } +} + +fn proposals_votes_reply(deps: DepsMut, _env: Env, msg: Reply) -> ContractResult { + let query_id = get_query_id(msg.result)?; + + QUERY_ID.save(deps.storage, &query_id)?; + + Ok(Response::new()) +} + +fn proposals_votes_remove_reply(deps: DepsMut, _env: Env, _msg: Reply) -> ContractResult { + QUERY_ID.remove(deps.storage); + + Ok(Response::new()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> StdResult { + deps.api.debug("WASMDEBUG: migrate"); + Ok(Response::default()) +} diff --git a/contracts/proposal-votes-poc/src/error.rs b/contracts/proposal-votes-poc/src/error.rs new file mode 100644 index 00000000..716e6d79 --- /dev/null +++ b/contracts/proposal-votes-poc/src/error.rs @@ -0,0 +1,24 @@ +use cosmwasm_std::StdError; +use cw_ownable::OwnershipError; +use neutron_sdk::NeutronError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("{0}")] + NeutronError(#[from] NeutronError), + + #[error("{0}")] + OwnershipError(#[from] OwnershipError), + + #[error("unauthorized")] + Unauthorized, + + #[error("unknown reply id: {id}")] + UnknownReplyId { id: u64 }, +} + +pub type ContractResult = Result; diff --git a/contracts/proposal-votes-poc/src/lib.rs b/contracts/proposal-votes-poc/src/lib.rs new file mode 100644 index 00000000..eacc6df2 --- /dev/null +++ b/contracts/proposal-votes-poc/src/lib.rs @@ -0,0 +1,5 @@ +pub mod contract; +pub mod error; + +#[cfg(test)] +mod tests; diff --git a/contracts/proposal-votes-poc/src/tests.rs b/contracts/proposal-votes-poc/src/tests.rs new file mode 100644 index 00000000..0059ceaf --- /dev/null +++ b/contracts/proposal-votes-poc/src/tests.rs @@ -0,0 +1,336 @@ +use cosmwasm_std::{ + attr, + testing::{mock_env, mock_info, MockApi, MockQuerier, MockStorage}, + to_json_binary, Addr, Event, OwnedDeps, Querier, +}; +use neutron_sdk::bindings::query::NeutronQuery; +use std::marker::PhantomData; + +fn mock_dependencies() -> OwnedDeps { + OwnedDeps { + storage: MockStorage::default(), + api: MockApi::default(), + querier: Q::default(), + custom_query_type: PhantomData, + } +} + +#[test] +fn instantiate() { + let mut deps = mock_dependencies::(); + let response = crate::contract::instantiate( + deps.as_mut(), + mock_env(), + mock_info("admin", &[]), + lido_staking_base::msg::proposal_votes::InstantiateMsg { + connection_id: "connection-0".to_string(), + port_id: "transfer".to_string(), + update_period: 100, + core_address: "core".to_string(), + provider_proposals_address: "provider_proposals".to_string(), + }, + ) + .unwrap(); + + let config = lido_staking_base::state::proposal_votes::CONFIG + .load(deps.as_ref().storage) + .unwrap(); + assert_eq!( + config, + lido_staking_base::state::proposal_votes::Config { + connection_id: "connection-0".to_string(), + port_id: "transfer".to_string(), + update_period: 100, + core_address: "core".to_string(), + provider_proposals_address: "provider_proposals".to_string(), + } + ); + + assert_eq!(response.messages.len(), 0); + assert_eq!( + response.events, + vec![ + Event::new("crates.io:lido-staking__lido-proposal-votes-poc-instantiate") + .add_attributes([ + attr("connection_id", "connection-0"), + attr("port_id", "transfer"), + attr("update_period", "100"), + attr("core_address", "core"), + attr("provider_proposals_address", "provider_proposals") + ]) + ] + ); + assert!(response.attributes.is_empty()); +} + +#[test] +fn query_config() { + let mut deps = mock_dependencies::(); + lido_staking_base::state::proposal_votes::CONFIG + .save( + deps.as_mut().storage, + &lido_staking_base::state::proposal_votes::Config { + connection_id: "connection-0".to_string(), + port_id: "transfer".to_string(), + update_period: 100, + core_address: "core".to_string(), + provider_proposals_address: "provider_proposals".to_string(), + }, + ) + .unwrap(); + + let response = crate::contract::query( + deps.as_ref(), + mock_env(), + lido_staking_base::msg::proposal_votes::QueryMsg::Config {}, + ) + .unwrap(); + assert_eq!( + response, + to_json_binary(&lido_staking_base::state::proposal_votes::Config { + connection_id: "connection-0".to_string(), + port_id: "transfer".to_string(), + update_period: 100, + core_address: "core".to_string(), + provider_proposals_address: "provider_proposals".to_string() + }) + .unwrap() + ); +} + +#[test] +fn update_config_wrong_owner() { + let mut deps = mock_dependencies::(); + + lido_staking_base::state::proposal_votes::CONFIG + .save( + deps.as_mut().storage, + &lido_staking_base::state::proposal_votes::Config { + connection_id: "connection-0".to_string(), + port_id: "transfer".to_string(), + update_period: 100, + core_address: "core".to_string(), + provider_proposals_address: "provider_proposals".to_string(), + }, + ) + .unwrap(); + + let error = crate::contract::execute( + deps.as_mut(), + mock_env(), + mock_info("core1", &[]), + lido_staking_base::msg::proposal_votes::ExecuteMsg::UpdateConfig { + new_config: lido_staking_base::state::proposal_votes::ConfigOptional { + connection_id: Some("connection-0".to_string()), + port_id: Some("transfer".to_string()), + update_period: Some(100), + core_address: Some("core".to_string()), + provider_proposals_address: Some("provider_proposals".to_string()), + }, + }, + ) + .unwrap_err(); + assert_eq!( + error, + crate::error::ContractError::OwnershipError(cw_ownable::OwnershipError::Std( + cosmwasm_std::StdError::NotFound { + kind: "type: cw_ownable::Ownership; key: [6F, 77, 6E, 65, 72, 73, 68, 69, 70]".to_string() + } + )) + ); +} + +#[test] +fn update_config_ok() { + let mut deps = mock_dependencies::(); + + let deps_mut = deps.as_mut(); + + let _result = cw_ownable::initialize_owner( + deps_mut.storage, + deps_mut.api, + Some(Addr::unchecked("core").as_ref()), + ); + + lido_staking_base::state::proposal_votes::CONFIG + .save( + deps.as_mut().storage, + &lido_staking_base::state::proposal_votes::Config { + connection_id: "connection-0".to_string(), + port_id: "transfer".to_string(), + update_period: 100, + core_address: "core".to_string(), + provider_proposals_address: "provider_proposals".to_string(), + }, + ) + .unwrap(); + + let _response = crate::contract::execute( + deps.as_mut(), + mock_env(), + mock_info("core", &[]), + lido_staking_base::msg::proposal_votes::ExecuteMsg::UpdateConfig { + new_config: lido_staking_base::state::proposal_votes::ConfigOptional { + connection_id: Some("connection-1".to_string()), + port_id: Some("transfer1".to_string()), + update_period: Some(200), + core_address: Some("core1".to_string()), + provider_proposals_address: Some("provider_proposals_1".to_string()), + }, + }, + ) + .unwrap(); + + let config = crate::contract::query( + deps.as_ref(), + mock_env(), + lido_staking_base::msg::proposal_votes::QueryMsg::Config {}, + ) + .unwrap(); + + assert_eq!( + config, + to_json_binary(&lido_staking_base::state::proposal_votes::Config { + connection_id: "connection-1".to_string(), + port_id: "transfer1".to_string(), + update_period: 200, + core_address: "core1".to_string(), + provider_proposals_address: "provider_proposals_1".to_string() + }) + .unwrap() + ); +} + +#[test] +fn update_voters_list_wrong_owner() { + let mut deps = mock_dependencies::(); + + let error = crate::contract::execute( + deps.as_mut(), + mock_env(), + mock_info("core1", &[]), + lido_staking_base::msg::proposal_votes::ExecuteMsg::UpdateVotersList { + voters: vec!["voter1".to_string(), "voter2".to_string()], + }, + ) + .unwrap_err(); + assert_eq!( + error, + crate::error::ContractError::OwnershipError(cw_ownable::OwnershipError::Std( + cosmwasm_std::StdError::NotFound { + kind: "type: cw_ownable::Ownership; key: [6F, 77, 6E, 65, 72, 73, 68, 69, 70]".to_string() + } + )) + ); +} + +#[test] +fn update_voters_list_ok() { + let mut deps = mock_dependencies::(); + + let deps_mut = deps.as_mut(); + + let _result = cw_ownable::initialize_owner( + deps_mut.storage, + deps_mut.api, + Some(Addr::unchecked("core").as_ref()), + ); + + let response = crate::contract::execute( + deps.as_mut(), + mock_env(), + mock_info("core", &[]), + lido_staking_base::msg::proposal_votes::ExecuteMsg::UpdateVotersList { + voters: vec!["voter1".to_string(), "voter2".to_string()], + }, + ) + .unwrap(); + assert_eq!(response.messages.len(), 0); + + let voters = lido_staking_base::state::proposal_votes::VOTERS + .load(deps.as_mut().storage) + .unwrap(); + + assert_eq!(voters, vec!["voter1".to_string(), "voter2".to_string()]); +} + +#[test] +fn update_active_proposals_wrong_owner() { + let mut deps = mock_dependencies::(); + + lido_staking_base::state::proposal_votes::CONFIG + .save( + deps.as_mut().storage, + &lido_staking_base::state::proposal_votes::Config { + connection_id: "connection-0".to_string(), + port_id: "transfer".to_string(), + update_period: 100, + core_address: "core".to_string(), + provider_proposals_address: "provider_proposals".to_string(), + }, + ) + .unwrap(); + + let error = crate::contract::execute( + deps.as_mut(), + mock_env(), + mock_info("wrong_provider_proposals_address", &[]), + lido_staking_base::msg::proposal_votes::ExecuteMsg::UpdateActiveProposals { + active_proposals: vec![1], + }, + ) + .unwrap_err(); + + assert_eq!(error, crate::error::ContractError::Unauthorized); +} + +#[test] +fn update_active_proposals_ok() { + let mut deps = mock_dependencies::(); + + lido_staking_base::state::proposal_votes::CONFIG + .save( + deps.as_mut().storage, + &lido_staking_base::state::proposal_votes::Config { + connection_id: "connection-0".to_string(), + port_id: "transfer".to_string(), + update_period: 100, + core_address: "core".to_string(), + provider_proposals_address: "provider_proposals".to_string(), + }, + ) + .unwrap(); + + lido_staking_base::state::proposal_votes::QUERY_ID + .save(deps.as_mut().storage, &1) + .unwrap(); + + lido_staking_base::state::proposal_votes::VOTERS + .save( + deps.as_mut().storage, + &vec![ + "neutron1x69dz0c0emw8m2c6kp5v6c08kgjxmu30f4a8w5".to_string(), + "neutron10h9stc5v6ntgeygf5xf945njqq5h32r54rf7kf".to_string(), + ], + ) + .unwrap(); + + let _response = crate::contract::execute( + deps.as_mut(), + mock_env(), + mock_info("provider_proposals", &[]), + lido_staking_base::msg::proposal_votes::ExecuteMsg::UpdateActiveProposals { + active_proposals: vec![1, 2], + }, + ) + .unwrap(); + + let active_proposals = lido_staking_base::state::proposal_votes::ACTIVE_PROPOSALS + .may_load(deps.as_mut().storage) + .unwrap() + .unwrap(); + + assert_eq!(active_proposals, vec![1, 2]); +} + +// TODO: Add more tests diff --git a/contracts/provider-proposals-poc/.cargo/config b/contracts/provider-proposals-poc/.cargo/config new file mode 100644 index 00000000..7d0d943f --- /dev/null +++ b/contracts/provider-proposals-poc/.cargo/config @@ -0,0 +1,2 @@ +[alias] +schema = "run --bin lido-provider-proposals-poc-schema" diff --git a/contracts/provider-proposals-poc/Cargo.toml b/contracts/provider-proposals-poc/Cargo.toml new file mode 100644 index 00000000..85a6fb27 --- /dev/null +++ b/contracts/provider-proposals-poc/Cargo.toml @@ -0,0 +1,46 @@ +[package] +authors = ["Albert Andrejev "] +description = "Contract to control provider proposals and collect voting information" +edition = "2021" +name = "lido-provider-proposals-poc" +version = "1.0.0" + +exclude = [ + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "contract.wasm", + "hash.txt", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmos-sdk-proto = { workspace = true } +prost = { workspace = true } +prost-types = { workspace = true } +tendermint-proto = { workspace = true } +thiserror = { workspace = true } + +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw2 = { workspace = true } +cw-storage-plus = { workspace = true } +cw-ownable = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +serde-json-wasm = { workspace = true } + +neutron-sdk = { workspace = true } +lido-staking-base = { workspace = true } +lido-helpers = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } diff --git a/contracts/provider-proposals-poc/README.md b/contracts/provider-proposals-poc/README.md new file mode 100644 index 00000000..48adbd1d --- /dev/null +++ b/contracts/provider-proposals-poc/README.md @@ -0,0 +1,11 @@ +# LIDO Provider chain proposal and votes processing (POC) + +** This is POC contract. ** + +There is at least one problem with the current implementation of the proposals queue (see the `sudo_proposals_query` function). It does not support the deletion of proposals in the case of insufficient deposits. Given that this is a POC (Proof of Concept) contract, it was decided to leave the queue processing as is. + +However, in the production version of the contract, it will be required to process the proposals queue more carefully and take into account the potential large number of underdeposited proposals. + +One of the possible solutions is to make the window for querying proposals dynamic, so it keeps increasing until it reaches an empty proposal (if we are querying non-existent proposals via the query relayer, it returns an empty proposal with null-valued fields). After that, we need to monitor the current list of proposals for changes (if a proposal becomes null, it means that this proposal was removed due to insufficient deposit, and we can remove this proposal from the queue). This implies that we need to store the previous queue content to compare it with the current results. + +Also, we need to define minimum and maximum window lengths, as well as the ability to manually control different aspects of the contract, to fix possible problems during an attack involving a large number of proposal creations. \ No newline at end of file diff --git a/contracts/provider-proposals-poc/src/bin/lido-provider-proposals-poc-schema.rs b/contracts/provider-proposals-poc/src/bin/lido-provider-proposals-poc-schema.rs new file mode 100644 index 00000000..3b680b79 --- /dev/null +++ b/contracts/provider-proposals-poc/src/bin/lido-provider-proposals-poc-schema.rs @@ -0,0 +1,13 @@ +use cosmwasm_schema::write_api; +use lido_staking_base::msg::provider_proposals::{ + ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg, +}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg + } +} diff --git a/contracts/provider-proposals-poc/src/contract.rs b/contracts/provider-proposals-poc/src/contract.rs new file mode 100644 index 00000000..8239efce --- /dev/null +++ b/contracts/provider-proposals-poc/src/contract.rs @@ -0,0 +1,439 @@ +use std::collections::HashMap; + +use cosmos_sdk_proto::cosmos::gov::v1beta1::ProposalStatus; +use cosmwasm_std::{ + attr, ensure_eq, entry_point, to_json_binary, Attribute, Binary, CosmosMsg, Decimal, Deps, + DepsMut, Env, MessageInfo, Order, Reply, Response, StdResult, SubMsg, Uint128, WasmMsg, +}; + +use lido_helpers::answer::response; +use lido_helpers::query_id::get_query_id; +use lido_staking_base::msg::proposal_votes::ExecuteMsg as ProposalVotesExecuteMsg; +use lido_staking_base::msg::provider_proposals::{ + ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg, +}; +use lido_staking_base::msg::validatorset::ExecuteMsg as ValidatorSetExecuteMsg; +use lido_staking_base::state::provider_proposals::{ + Config, ConfigOptional, Metrics, ProposalInfo, CONFIG, PROPOSALS, PROPOSALS_REPLY_ID, + PROPOSALS_VOTES, QUERY_ID, +}; +use neutron_sdk::bindings::msg::NeutronMsg; +use neutron_sdk::bindings::query::{NeutronQuery, QueryRegisteredQueryResultResponse}; +use neutron_sdk::interchain_queries::queries::get_raw_interchain_query_result; +use neutron_sdk::interchain_queries::types::KVReconstruct; +use neutron_sdk::interchain_queries::v045::register_queries::{ + new_register_gov_proposal_query_msg, update_register_gov_proposal_query_msg, +}; +use neutron_sdk::interchain_queries::v045::types::{GovernmentProposal, Proposal, ProposalVote}; +use neutron_sdk::sudo::msg::SudoMsg; + +use crate::error::{ContractError, ContractResult}; + +const CONTRACT_NAME: &str = concat!("crates.io:lido-staking__", env!("CARGO_PKG_NAME")); + +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> ContractResult> { + cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + let core = deps.api.addr_validate(&msg.core_address)?; + let validators_set = deps.api.addr_validate(&msg.validators_set_address)?; + + cw_ownable::initialize_owner(deps.storage, deps.api, Some(core.as_ref()))?; + + let config = &Config { + connection_id: msg.connection_id.to_string(), + port_id: msg.port_id.clone(), + update_period: msg.update_period, + core_address: msg.core_address.to_string(), + proposal_votes_address: None, + validators_set_address: validators_set.to_string(), + init_proposal: msg.init_proposal, + proposals_prefetch: msg.proposals_prefetch, + veto_spam_threshold: msg.veto_spam_threshold, + }; + + CONFIG.save(deps.storage, config)?; + + let initial_proposals: Vec = + (msg.init_proposal..msg.init_proposal + msg.proposals_prefetch).collect(); + + let reg_msg = new_register_gov_proposal_query_msg( + msg.connection_id.to_string(), + initial_proposals.clone(), + msg.update_period, + )?; + + let sub_msg = SubMsg::reply_on_success(reg_msg, PROPOSALS_REPLY_ID); + + Ok(response( + "instantiate", + CONTRACT_NAME, + [ + attr("connection_id", msg.connection_id), + attr("port_id", msg.port_id), + attr("update_period", msg.update_period.to_string()), + attr("core_address", msg.core_address), + attr("validators_set_address", msg.validators_set_address), + attr("init_proposal", msg.init_proposal.to_string()), + attr("proposals_prefetch", msg.proposals_prefetch.to_string()), + attr("veto_spam_threshold", msg.veto_spam_threshold.to_string()), + ], + ) + .add_submessage(sub_msg)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => query_config(deps), + QueryMsg::GetProposal { proposal_id } => query_proposal(deps, proposal_id), + QueryMsg::GetProposals {} => query_proposals(deps), + QueryMsg::Metrics {} => query_metrics(deps), + } +} + +fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + to_json_binary(&config) +} + +fn query_metrics(deps: Deps) -> StdResult { + let keys = PROPOSALS.keys(deps.storage, None, None, Order::Ascending); + let max_key = keys.fold(0u64, |max, current| { + let current_key = current.unwrap_or_default(); + if current_key > max { + current_key + } else { + max + } + }); + + to_json_binary(&Metrics { + last_proposal: max_key, + }) +} + +fn query_proposal(deps: Deps, proposal_id: u64) -> StdResult { + let config = CONFIG.load(deps.storage)?; + let proposal: Proposal = PROPOSALS.load(deps.storage, proposal_id)?; + let votes = PROPOSALS_VOTES + .may_load(deps.storage, proposal_id) + .ok() + .unwrap_or_default(); + to_json_binary(&ProposalInfo { + proposal: proposal.clone(), + votes, + is_spam: is_spam_proposal(&proposal, config.veto_spam_threshold), + }) +} + +fn query_proposals(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + let proposals: StdResult> = PROPOSALS + .range_raw(deps.storage, None, None, Order::Ascending) + .map(|item| { + item.map(|(_key, value)| { + let votes = PROPOSALS_VOTES + .may_load(deps.storage, value.proposal_id) + .ok() + .unwrap_or_default(); + + ProposalInfo { + proposal: value.clone(), + votes, + is_spam: is_spam_proposal(&value, config.veto_spam_threshold), + } + }) + }) + .collect(); + + to_json_binary(&proposals?) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> ContractResult> { + match msg { + ExecuteMsg::UpdateConfig { new_config } => execute_update_config(deps, info, new_config), + ExecuteMsg::UpdateProposalVotes { votes } => execute_update_votes(deps, info, votes), + } +} + +fn execute_update_config( + deps: DepsMut, + info: MessageInfo, + new_config: ConfigOptional, +) -> ContractResult> { + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + let mut config = CONFIG.load(deps.storage)?; + + let mut msgs: Vec> = Vec::new(); + + let mut attrs: Vec = Vec::new(); + if let Some(core_address) = new_config.core_address { + let core_address = deps.api.addr_validate(&core_address)?; + config.core_address = core_address.to_string(); + attrs.push(attr("core_address", core_address)) + } + + if let Some(proposal_votes_address) = new_config.proposal_votes_address { + let proposal_votes_address = deps.api.addr_validate(&proposal_votes_address)?; + config.proposal_votes_address = Some(proposal_votes_address.to_string()); + + let keys = PROPOSALS.keys(deps.storage, None, None, Order::Ascending); + let proposal_ids = keys + .map(|key| key.unwrap_or_default()) + .filter(|id| *id != 0) + .collect::>(); + + msgs.push(update_voting_proposals_msg( + proposal_votes_address.to_string(), + proposal_ids, + )?); + + attrs.push(attr("proposal_votes_address", proposal_votes_address)) + } + + if let Some(validators_set_address) = new_config.validators_set_address { + let validators_set_address = deps.api.addr_validate(&validators_set_address)?; + config.validators_set_address = validators_set_address.to_string(); + attrs.push(attr("validators_set_address", validators_set_address)) + } + + if let Some(connection_id) = new_config.connection_id { + config.connection_id = connection_id.clone(); + attrs.push(attr("connection_id", connection_id)) + } + + if let Some(port_id) = new_config.port_id { + config.port_id = port_id.clone(); + attrs.push(attr("port_id", port_id)) + } + + if let Some(update_period) = new_config.update_period { + config.update_period = update_period; + attrs.push(attr("update_period", update_period.to_string())) + } + + if let Some(proposals_prefetch) = new_config.proposals_prefetch { + config.proposals_prefetch = proposals_prefetch; + attrs.push(attr("proposals_prefetch", proposals_prefetch.to_string())) + } + + if let Some(veto_spam_threshold) = new_config.veto_spam_threshold { + config.veto_spam_threshold = veto_spam_threshold; + attrs.push(attr("veto_spam_threshold", veto_spam_threshold.to_string())) + } + + CONFIG.save(deps.storage, &config)?; + + Ok(response("config_update", CONTRACT_NAME, attrs).add_messages(msgs)) +} + +pub fn execute_update_votes( + deps: DepsMut, + info: MessageInfo, + votes: Vec, +) -> ContractResult> { + let config = CONFIG.load(deps.storage)?; + + ensure_eq!( + config.proposal_votes_address, + Some(info.sender.to_string()), + ContractError::Unauthorized {} + ); + + let mut votes_map: HashMap> = HashMap::new(); + + for vote in votes.clone() { + votes_map.entry(vote.proposal_id).or_default().push(vote); + } + + for (proposal_id, votes) in votes_map.iter() { + PROPOSALS_VOTES.save(deps.storage, *proposal_id, votes)?; + } + + Ok(response( + "config_update", + CONTRACT_NAME, + [attr("total_count", votes.len().to_string())], + )) +} + +#[entry_point] +pub fn sudo( + deps: DepsMut, + env: Env, + msg: SudoMsg, +) -> ContractResult> { + deps.api.debug(&format!( + "WASMDEBUG: sudo call: {:?}, block: {:?}", + msg, env.block + )); + match msg { + SudoMsg::KVQueryResult { query_id } => sudo_kv_query_result(deps, env, query_id), + _ => Ok(Response::default()), + } +} + +pub fn sudo_kv_query_result( + deps: DepsMut, + _env: Env, + query_id: u64, +) -> ContractResult> { + deps.api.debug(&format!( + "WASMDEBUG: sudo_kv_query_result call: {query_id:?}", + )); + + let proposals_query_id = QUERY_ID.may_load(deps.storage)?; + + let interchain_query_result = get_raw_interchain_query_result(deps.as_ref(), query_id)?; + + if Some(query_id) == proposals_query_id { + return sudo_proposals_query(deps, interchain_query_result); + } + + Ok(Response::default()) +} + +fn sudo_proposals_query( + deps: DepsMut, + interchain_query_result: QueryRegisteredQueryResultResponse, +) -> ContractResult> { + let data: GovernmentProposal = + KVReconstruct::reconstruct(&interchain_query_result.result.kv_results)?; + + let mut msgs: Vec> = Vec::new(); + match data.proposals.first() { + Some(first_proposal) => { + if is_proposal_finished(first_proposal) { + let query_id = QUERY_ID.may_load(deps.storage)?; + let config = CONFIG.load(deps.storage)?; + if let Some(query_id) = query_id { + let new_proposals: Vec = (first_proposal.proposal_id + ..first_proposal.proposal_id + config.proposals_prefetch) + .collect(); + + let reg_msg = CosmosMsg::Custom(update_register_gov_proposal_query_msg( + query_id, + new_proposals.to_owned(), + None, + None, + )?); + + msgs.push(reg_msg); + + let votes = PROPOSALS_VOTES + .may_load(deps.storage, first_proposal.proposal_id) + .ok() + .unwrap_or_default(); + + let update_msg = CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: config.validators_set_address, + msg: to_json_binary(&ValidatorSetExecuteMsg::UpdateValidatorsVoting { + proposal: ProposalInfo { + proposal: first_proposal.clone(), + votes, + is_spam: is_spam_proposal( + first_proposal, + config.veto_spam_threshold, + ), + }, + })?, + funds: vec![], + }); + + msgs.push(update_msg); + + if let Some(proposal_votes_address) = config.proposal_votes_address { + msgs.push(update_voting_proposals_msg( + proposal_votes_address, + new_proposals, + )?); + } + } + } + } + None => deps.api.debug("WASMDEBUG: first_proposal is None"), + } + + for proposal in data.proposals { + if proposal.status != ProposalStatus::Unspecified as i32 { + PROPOSALS.save(deps.storage, proposal.proposal_id, &proposal)?; + } + } + + Ok(Response::new().add_messages(msgs)) +} + +fn is_proposal_finished(proposal: &Proposal) -> bool { + proposal.status == ProposalStatus::Passed as i32 + || proposal.status == ProposalStatus::Rejected as i32 + || proposal.status == ProposalStatus::Failed as i32 +} + +fn is_spam_proposal(proposal: &Proposal, veto_spam_threshold: Decimal) -> bool { + if let Some(final_tally_result) = &proposal.final_tally_result { + let total_votes = final_tally_result.yes + + final_tally_result.no + + final_tally_result.abstain + + final_tally_result.no_with_veto; + + if total_votes == Uint128::zero() { + return false; + } + + return Decimal::from_ratio(final_tally_result.no_with_veto, total_votes) + > veto_spam_threshold; + } + + false +} + +fn update_voting_proposals_msg( + proposal_votes_address: String, + active_proposals: Vec, +) -> ContractResult> { + Ok(CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: proposal_votes_address, + msg: to_json_binary(&ProposalVotesExecuteMsg::UpdateActiveProposals { active_proposals })?, + funds: vec![], + })) +} + +#[entry_point] +pub fn reply(deps: DepsMut, env: Env, msg: Reply) -> ContractResult { + deps.api + .debug(format!("WASMDEBUG: reply msg: {msg:?}").as_str()); + + match msg.id { + PROPOSALS_REPLY_ID => proposals_votes_reply(deps, env, msg), + id => Err(ContractError::UnknownReplyId { id }), + } +} + +fn proposals_votes_reply(deps: DepsMut, _env: Env, msg: Reply) -> ContractResult { + let query_id = get_query_id(msg.result)?; + + QUERY_ID.save(deps.storage, &query_id)?; + + Ok(Response::new()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> StdResult { + deps.api.debug("WASMDEBUG: migrate"); + Ok(Response::default()) +} diff --git a/contracts/provider-proposals-poc/src/error.rs b/contracts/provider-proposals-poc/src/error.rs new file mode 100644 index 00000000..716e6d79 --- /dev/null +++ b/contracts/provider-proposals-poc/src/error.rs @@ -0,0 +1,24 @@ +use cosmwasm_std::StdError; +use cw_ownable::OwnershipError; +use neutron_sdk::NeutronError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("{0}")] + NeutronError(#[from] NeutronError), + + #[error("{0}")] + OwnershipError(#[from] OwnershipError), + + #[error("unauthorized")] + Unauthorized, + + #[error("unknown reply id: {id}")] + UnknownReplyId { id: u64 }, +} + +pub type ContractResult = Result; diff --git a/contracts/provider-proposals-poc/src/lib.rs b/contracts/provider-proposals-poc/src/lib.rs new file mode 100644 index 00000000..eacc6df2 --- /dev/null +++ b/contracts/provider-proposals-poc/src/lib.rs @@ -0,0 +1,5 @@ +pub mod contract; +pub mod error; + +#[cfg(test)] +mod tests; diff --git a/contracts/provider-proposals-poc/src/tests.rs b/contracts/provider-proposals-poc/src/tests.rs new file mode 100644 index 00000000..0c079a8c --- /dev/null +++ b/contracts/provider-proposals-poc/src/tests.rs @@ -0,0 +1,466 @@ +use cosmwasm_std::{ + attr, + testing::{mock_env, mock_info, MockApi, MockQuerier, MockStorage}, + to_json_binary, Addr, Binary, Decimal, Event, OwnedDeps, Querier, SubMsg, +}; +use neutron_sdk::bindings::{msg::NeutronMsg, query::NeutronQuery, types::KVKey}; +use std::marker::PhantomData; + +fn mock_dependencies() -> OwnedDeps { + OwnedDeps { + storage: MockStorage::default(), + api: MockApi::default(), + querier: Q::default(), + custom_query_type: PhantomData, + } +} + +#[test] +fn instantiate() { + let mut deps = mock_dependencies::(); + let response = crate::contract::instantiate( + deps.as_mut(), + mock_env(), + mock_info("admin", &[]), + lido_staking_base::msg::provider_proposals::InstantiateMsg { + connection_id: "connection-0".to_string(), + port_id: "transfer".to_string(), + update_period: 100, + core_address: "core".to_string(), + validators_set_address: "validators_set".to_string(), + init_proposal: 1, + proposals_prefetch: 5, + veto_spam_threshold: Decimal::from_atomics(1u64, 2).unwrap(), + }, + ) + .unwrap(); + + let config = lido_staking_base::state::provider_proposals::CONFIG + .load(deps.as_ref().storage) + .unwrap(); + assert_eq!( + config, + lido_staking_base::state::provider_proposals::Config { + connection_id: "connection-0".to_string(), + port_id: "transfer".to_string(), + update_period: 100, + core_address: "core".to_string(), + proposal_votes_address: None, + validators_set_address: "validators_set".to_string(), + init_proposal: 1, + proposals_prefetch: 5, + veto_spam_threshold: Decimal::from_atomics(1u64, 2).unwrap(), + } + ); + + assert_eq!(response.messages.len(), 1); + let sub_msg = SubMsg::reply_on_success( + NeutronMsg::RegisterInterchainQuery { + query_type: "kv".to_string(), + keys: vec![ + KVKey { + path: "gov".to_string(), + key: Binary(vec![0, 0, 0, 0, 0, 0, 0, 0, 1]), + }, + KVKey { + path: "gov".to_string(), + key: Binary(vec![0, 0, 0, 0, 0, 0, 0, 0, 2]), + }, + KVKey { + path: "gov".to_string(), + key: Binary(vec![0, 0, 0, 0, 0, 0, 0, 0, 3]), + }, + KVKey { + path: "gov".to_string(), + key: Binary(vec![0, 0, 0, 0, 0, 0, 0, 0, 4]), + }, + KVKey { + path: "gov".to_string(), + key: Binary(vec![0, 0, 0, 0, 0, 0, 0, 0, 5]), + }, + ], + transactions_filter: "".to_string(), + connection_id: "connection-0".to_string(), + update_period: 100, + }, + 1, + ); + assert_eq!(response.messages, vec![sub_msg]); + + assert_eq!( + response.events, + vec![ + Event::new("crates.io:lido-staking__lido-provider-proposals-poc-instantiate") + .add_attributes([ + attr("connection_id", "connection-0"), + attr("port_id", "transfer"), + attr("update_period", "100"), + attr("core_address", "core"), + attr("validators_set_address", "validators_set"), + attr("init_proposal", "1"), + attr("proposals_prefetch", "5"), + attr("veto_spam_threshold", "0.01") + ]) + ] + ); + assert!(response.attributes.is_empty()); +} + +#[test] +fn query_config() { + let mut deps: OwnedDeps = + mock_dependencies::(); + lido_staking_base::state::provider_proposals::CONFIG + .save( + deps.as_mut().storage, + &lido_staking_base::state::provider_proposals::Config { + connection_id: "connection-0".to_string(), + port_id: "transfer".to_string(), + update_period: 100, + core_address: "core".to_string(), + proposal_votes_address: Some("proposal_votes".to_string()), + validators_set_address: "validators_set".to_string(), + init_proposal: 1, + proposals_prefetch: 5, + veto_spam_threshold: Decimal::from_atomics(1u64, 2).unwrap(), + }, + ) + .unwrap(); + + let response = crate::contract::query( + deps.as_ref(), + mock_env(), + lido_staking_base::msg::provider_proposals::QueryMsg::Config {}, + ) + .unwrap(); + assert_eq!( + response, + to_json_binary(&lido_staking_base::state::provider_proposals::Config { + connection_id: "connection-0".to_string(), + port_id: "transfer".to_string(), + update_period: 100, + core_address: "core".to_string(), + proposal_votes_address: Some("proposal_votes".to_string()), + validators_set_address: "validators_set".to_string(), + init_proposal: 1, + proposals_prefetch: 5, + veto_spam_threshold: Decimal::from_atomics(1u64, 2).unwrap(), + }) + .unwrap() + ); +} + +#[test] +fn update_config_wrong_owner() { + let mut deps = mock_dependencies::(); + + lido_staking_base::state::provider_proposals::CONFIG + .save( + deps.as_mut().storage, + &lido_staking_base::state::provider_proposals::Config { + connection_id: "connection-0".to_string(), + port_id: "transfer".to_string(), + update_period: 100, + core_address: "core".to_string(), + proposal_votes_address: Some("proposal_votes".to_string()), + validators_set_address: "validators_set".to_string(), + init_proposal: 1, + proposals_prefetch: 5, + veto_spam_threshold: Decimal::from_atomics(1u64, 2).unwrap(), + }, + ) + .unwrap(); + + let error = crate::contract::execute( + deps.as_mut(), + mock_env(), + mock_info("core1", &[]), + lido_staking_base::msg::provider_proposals::ExecuteMsg::UpdateConfig { + new_config: lido_staking_base::state::provider_proposals::ConfigOptional { + connection_id: Some("connection-0".to_string()), + port_id: Some("transfer".to_string()), + update_period: Some(100), + core_address: Some("core".to_string()), + proposal_votes_address: Some("proposal_votes".to_string()), + validators_set_address: Some("validators_set".to_string()), + init_proposal: None, + proposals_prefetch: Some(5), + veto_spam_threshold: Some(Decimal::from_atomics(1u64, 2).unwrap()), + }, + }, + ) + .unwrap_err(); + assert_eq!( + error, + crate::error::ContractError::OwnershipError(cw_ownable::OwnershipError::Std( + cosmwasm_std::StdError::NotFound { + kind: "type: cw_ownable::Ownership; key: [6F, 77, 6E, 65, 72, 73, 68, 69, 70]".to_string() + } + )) + ); +} + +#[test] +fn update_config_ok() { + let mut deps = mock_dependencies::(); + + let deps_mut = deps.as_mut(); + + let _result = cw_ownable::initialize_owner( + deps_mut.storage, + deps_mut.api, + Some(Addr::unchecked("core").as_ref()), + ); + + lido_staking_base::state::provider_proposals::CONFIG + .save( + deps.as_mut().storage, + &lido_staking_base::state::provider_proposals::Config { + connection_id: "connection-0".to_string(), + port_id: "transfer".to_string(), + update_period: 100, + core_address: "core".to_string(), + proposal_votes_address: Some("proposal_votes".to_string()), + validators_set_address: "validators_set".to_string(), + init_proposal: 1, + proposals_prefetch: 5, + veto_spam_threshold: Decimal::from_atomics(1u64, 2).unwrap(), + }, + ) + .unwrap(); + + let response = crate::contract::execute( + deps.as_mut(), + mock_env(), + mock_info("core", &[]), + lido_staking_base::msg::provider_proposals::ExecuteMsg::UpdateConfig { + new_config: lido_staking_base::state::provider_proposals::ConfigOptional { + connection_id: Some("connection-1".to_string()), + port_id: Some("transfer1".to_string()), + update_period: Some(200), + core_address: Some("core1".to_string()), + proposal_votes_address: Some("proposal_votes_1".to_string()), + validators_set_address: Some("validators_set_1".to_string()), + proposals_prefetch: Some(7), + init_proposal: None, + veto_spam_threshold: Some(Decimal::from_atomics(3u64, 2).unwrap()), + }, + }, + ) + .unwrap(); + assert_eq!(response.messages.len(), 1); + + let config = crate::contract::query( + deps.as_ref(), + mock_env(), + lido_staking_base::msg::provider_proposals::QueryMsg::Config {}, + ) + .unwrap(); + assert_eq!( + config, + to_json_binary(&lido_staking_base::state::provider_proposals::Config { + connection_id: "connection-1".to_string(), + port_id: "transfer1".to_string(), + update_period: 200, + core_address: "core1".to_string(), + proposal_votes_address: Some("proposal_votes_1".to_string()), + validators_set_address: "validators_set_1".to_string(), + init_proposal: 1, + proposals_prefetch: 7, + veto_spam_threshold: Decimal::from_atomics(3u64, 2).unwrap(), + }) + .unwrap() + ); +} + +#[test] +fn update_votes_wrong_sender_address() { + let mut deps = mock_dependencies::(); + + lido_staking_base::state::provider_proposals::CONFIG + .save( + deps.as_mut().storage, + &lido_staking_base::state::provider_proposals::Config { + connection_id: "connection-0".to_string(), + port_id: "transfer".to_string(), + update_period: 100, + core_address: "core".to_string(), + proposal_votes_address: Some("proposal_votes".to_string()), + validators_set_address: "validators_set".to_string(), + init_proposal: 1, + proposals_prefetch: 5, + veto_spam_threshold: Decimal::from_atomics(1u64, 2).unwrap(), + }, + ) + .unwrap(); + + let error = crate::contract::execute( + deps.as_mut(), + mock_env(), + mock_info("proposal_votes_1", &[]), + lido_staking_base::msg::provider_proposals::ExecuteMsg::UpdateProposalVotes { + votes: vec![neutron_sdk::interchain_queries::v045::types::ProposalVote { + proposal_id: 1, + voter: "voter".to_string(), + options: vec![], + }], + }, + ) + .unwrap_err(); + + assert_eq!(error, crate::error::ContractError::Unauthorized); +} + +#[test] +fn update_votes_ok() { + let mut deps = mock_dependencies::(); + + lido_staking_base::state::provider_proposals::CONFIG + .save( + deps.as_mut().storage, + &lido_staking_base::state::provider_proposals::Config { + connection_id: "connection-0".to_string(), + port_id: "transfer".to_string(), + update_period: 100, + core_address: "core".to_string(), + proposal_votes_address: Some("proposal_votes".to_string()), + validators_set_address: "validators_set".to_string(), + init_proposal: 1, + proposals_prefetch: 5, + veto_spam_threshold: Decimal::from_atomics(1u64, 2).unwrap(), + }, + ) + .unwrap(); + + let response = crate::contract::execute( + deps.as_mut(), + mock_env(), + mock_info("proposal_votes", &[]), + lido_staking_base::msg::provider_proposals::ExecuteMsg::UpdateProposalVotes { + votes: vec![neutron_sdk::interchain_queries::v045::types::ProposalVote { + proposal_id: 1, + voter: "voter".to_string(), + options: vec![], + }], + }, + ) + .unwrap(); + assert_eq!(response.messages.len(), 0); + + let validator = crate::contract::query( + deps.as_ref(), + mock_env(), + lido_staking_base::msg::provider_proposals::QueryMsg::GetProposals {}, + ) + .unwrap(); + + assert_eq!( + validator, + to_json_binary(&Vec::< + lido_staking_base::state::provider_proposals::ProposalInfo, + >::new()) + .unwrap() + ); +} + +#[test] +fn update_votes_with_data() { + let mut deps = mock_dependencies::(); + + lido_staking_base::state::provider_proposals::CONFIG + .save( + deps.as_mut().storage, + &lido_staking_base::state::provider_proposals::Config { + connection_id: "connection-0".to_string(), + port_id: "transfer".to_string(), + update_period: 100, + core_address: "core".to_string(), + proposal_votes_address: Some("proposal_votes".to_string()), + validators_set_address: "validators_set".to_string(), + init_proposal: 1, + proposals_prefetch: 5, + veto_spam_threshold: Decimal::from_atomics(1u64, 2).unwrap(), + }, + ) + .unwrap(); + + lido_staking_base::state::provider_proposals::PROPOSALS + .save( + deps.as_mut().storage, + 1u64, + &neutron_sdk::interchain_queries::v045::types::Proposal { + proposal_id: 1, + proposal_type: Some("proposal_type".to_string()), + total_deposit: vec![], + status: 1, + submit_time: None, + deposit_end_time: None, + voting_start_time: None, + voting_end_time: None, + final_tally_result: None, + }, + ) + .unwrap(); + + let response = crate::contract::execute( + deps.as_mut(), + mock_env(), + mock_info("proposal_votes", &[]), + lido_staking_base::msg::provider_proposals::ExecuteMsg::UpdateProposalVotes { + votes: vec![neutron_sdk::interchain_queries::v045::types::ProposalVote { + proposal_id: 1, + voter: "voter".to_string(), + options: vec![ + neutron_sdk::interchain_queries::v045::types::WeightedVoteOption { + option: 1, + weight: "100".to_string(), + }, + ], + }], + }, + ) + .unwrap(); + assert_eq!(response.messages.len(), 0); + + let validator = crate::contract::query( + deps.as_ref(), + mock_env(), + lido_staking_base::msg::provider_proposals::QueryMsg::GetProposals {}, + ) + .unwrap(); + + assert_eq!( + validator, + to_json_binary(&vec![ + lido_staking_base::state::provider_proposals::ProposalInfo { + proposal: neutron_sdk::interchain_queries::v045::types::Proposal { + proposal_id: 1, + proposal_type: Some("proposal_type".to_string()), + total_deposit: vec![], + status: 1, + submit_time: None, + deposit_end_time: None, + voting_start_time: None, + voting_end_time: None, + final_tally_result: None, + }, + votes: Some(vec![ + neutron_sdk::interchain_queries::v045::types::ProposalVote { + proposal_id: 1, + voter: "voter".to_string(), + options: vec![ + neutron_sdk::interchain_queries::v045::types::WeightedVoteOption { + option: 1, + weight: "100".to_string(), + } + ], + } + ]), + is_spam: false, + } + ]) + .unwrap() + ); +} + +// TODO: Add more tests diff --git a/contracts/strategy/src/tests.rs b/contracts/strategy/src/tests.rs index cf800d9c..be2e8433 100644 --- a/contracts/strategy/src/tests.rs +++ b/contracts/strategy/src/tests.rs @@ -145,6 +145,9 @@ fn validator_set_query(_deps: Deps, _env: Env, msg: ValidatorSetQueryMsg) -> Std uptime: Decimal::zero(), tombstone: false, jailed_number: None, + init_proposal: None, + total_passed_proposals: 0, + total_voted_proposals: 0, }; validators.push(validator); } diff --git a/contracts/validators-set/src/contract.rs b/contracts/validators-set/src/contract.rs index 894eb9c7..525c3126 100644 --- a/contracts/validators-set/src/contract.rs +++ b/contracts/validators-set/src/contract.rs @@ -1,4 +1,4 @@ -use cosmwasm_std::{attr, ensure_eq, entry_point, to_json_binary, Addr, Deps, Order}; +use cosmwasm_std::{attr, ensure_eq, entry_point, to_json_binary, Addr, Attribute, Deps, Order}; use cosmwasm_std::{Binary, DepsMut, Env, MessageInfo, Response, StdResult}; use cw2::set_contract_version; use lido_helpers::answer::response; @@ -6,6 +6,7 @@ use lido_staking_base::error::validatorset::{ContractError, ContractResult}; use lido_staking_base::msg::validatorset::{ ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg, ValidatorData, ValidatorInfoUpdate, }; +use lido_staking_base::state::provider_proposals::ProposalInfo; use lido_staking_base::state::validatorset::{ Config, ConfigOptional, ValidatorInfo, CONFIG, VALIDATORS_SET, }; @@ -33,6 +34,7 @@ pub fn instantiate( let config = &Config { owner: owner.clone(), stats_contract: stats_contract.clone(), + provider_proposals_contract: None, }; CONFIG.save(deps.storage, config)?; @@ -40,7 +42,7 @@ pub fn instantiate( Ok(response( "instantiate", CONTRACT_NAME, - [attr("core", owner), attr("stats_contract", stats_contract)], + [attr("owner", owner), attr("stats_contract", stats_contract)], )) } @@ -59,9 +61,9 @@ fn query_config(deps: Deps, _env: Env) -> ContractResult { } fn query_validator(deps: Deps, valoper: Addr) -> ContractResult { - let validators = VALIDATORS_SET.may_load(deps.storage, valoper.to_string())?; + let validator = VALIDATORS_SET.may_load(deps.storage, valoper.to_string())?; - Ok(to_json_binary(&validators)?) + Ok(to_json_binary(&validator)?) } fn query_validators(deps: Deps) -> ContractResult { @@ -88,9 +90,12 @@ pub fn execute( ExecuteMsg::UpdateValidator { validator } => { execute_update_validator(deps, info, validator) } - ExecuteMsg::UpdateValidatorInfo { validators } => { + ExecuteMsg::UpdateValidatorsInfo { validators } => { execute_update_validators_info(deps, info, validators) } + ExecuteMsg::UpdateValidatorsVoting { proposal } => { + execute_update_validators_voting(deps, info, proposal) + } } } @@ -103,29 +108,32 @@ fn execute_update_config( let mut state = CONFIG.load(deps.storage)?; + let mut attrs: Vec = Vec::new(); + if let Some(owner) = new_config.owner { if owner != state.owner { - state.owner = owner; + state.owner = owner.clone(); cw_ownable::initialize_owner(deps.storage, deps.api, Some(state.owner.as_ref()))?; } + attrs.push(attr("owner", owner.to_string())) } if let Some(stats_contract) = new_config.stats_contract { - if stats_contract != state.stats_contract { - state.stats_contract = stats_contract; - } + state.stats_contract = stats_contract.clone(); + attrs.push(attr("stats_contract", stats_contract)) + } + + if new_config.provider_proposals_contract.is_some() { + state.provider_proposals_contract = new_config.provider_proposals_contract.clone(); + attrs.push(attr( + "provider_proposals_contract", + new_config.provider_proposals_contract.unwrap().to_string(), + )) } CONFIG.save(deps.storage, &state)?; - Ok(response( - "update_config", - CONTRACT_NAME, - [ - attr("core", state.owner), - attr("stats_contract", state.stats_contract), - ], - )) + Ok(response("update_config", CONTRACT_NAME, Vec::::new()).add_attributes(attrs)) } fn execute_update_validator( @@ -150,6 +158,9 @@ fn execute_update_validator( uptime: Default::default(), tombstone: false, jailed_number: None, + init_proposal: None, + total_passed_proposals: 0, + total_voted_proposals: 0, }, )?; @@ -191,6 +202,9 @@ fn execute_update_validators( uptime: Default::default(), tombstone: false, jailed_number: None, + init_proposal: None, + total_passed_proposals: 0, + total_voted_proposals: 0, }, )?; } @@ -254,6 +268,62 @@ fn execute_update_validators_info( )) } +fn execute_update_validators_voting( + deps: DepsMut, + info: MessageInfo, + proposal: ProposalInfo, +) -> ContractResult> { + let config = CONFIG.load(deps.storage)?; + + ensure_eq!( + config.provider_proposals_contract, + Some(info.sender), + ContractError::Unauthorized {} + ); + + if proposal.is_spam { + return Ok(response( + "update_validators_info", + CONTRACT_NAME, + [attr( + "spam_proposal", + proposal.proposal.proposal_id.to_string(), + )], + )); + } + + if let Some(votes) = proposal.votes { + for vote in votes { + let validator = VALIDATORS_SET.may_load(deps.storage, vote.voter.to_string())?; + + if let Some(validator) = validator { + let mut validator = validator; + + if validator.init_proposal.is_none() { + validator.init_proposal = Some(proposal.proposal.proposal_id); + } + + if !vote.options.is_empty() { + validator.total_voted_proposals += 1; + } + + validator.total_passed_proposals += 1; + + VALIDATORS_SET.save(deps.storage, validator.valoper_address.clone(), &validator)?; + } + } + } + + Ok(response( + "execute_update_validators_voting", + CONTRACT_NAME, + [attr( + "proposal_id", + proposal.proposal.proposal_id.to_string(), + )], + )) +} + #[cfg_attr(not(feature = "library"), entry_point)] pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> StdResult { deps.api.debug("WASMDEBUG: migrate"); diff --git a/contracts/validators-set/src/tests.rs b/contracts/validators-set/src/tests.rs index 974eae50..aa3ddde4 100644 --- a/contracts/validators-set/src/tests.rs +++ b/contracts/validators-set/src/tests.rs @@ -14,7 +14,7 @@ fn instantiate() { mock_env(), mock_info("admin", &[]), lido_staking_base::msg::validatorset::InstantiateMsg { - owner: "core".to_string(), + owner: "owner".to_string(), stats_contract: "stats_contract".to_string(), }, ) @@ -26,8 +26,9 @@ fn instantiate() { assert_eq!( config, lido_staking_base::state::validatorset::Config { - owner: Addr::unchecked("core"), + owner: Addr::unchecked("owner"), stats_contract: Addr::unchecked("stats_contract"), + provider_proposals_contract: None, } ); @@ -36,7 +37,7 @@ fn instantiate() { response.events, vec![ Event::new("crates.io:lido-staking__lido-validators-set-instantiate").add_attributes([ - attr("core", "core"), + attr("owner", "owner"), attr("stats_contract", "stats_contract") ]) ] @@ -53,6 +54,7 @@ fn query_config() { &lido_staking_base::state::validatorset::Config { owner: Addr::unchecked("core"), stats_contract: Addr::unchecked("stats_contract"), + provider_proposals_contract: Some(Addr::unchecked("provider_proposals_contract")), }, ) .unwrap(); @@ -67,7 +69,8 @@ fn query_config() { response, to_json_binary(&lido_staking_base::state::validatorset::Config { owner: Addr::unchecked("core"), - stats_contract: Addr::unchecked("stats_contract") + stats_contract: Addr::unchecked("stats_contract"), + provider_proposals_contract: Some(Addr::unchecked("provider_proposals_contract")) }) .unwrap() ); @@ -83,6 +86,7 @@ fn update_config_wrong_owner() { &lido_staking_base::state::validatorset::Config { owner: Addr::unchecked("core"), stats_contract: Addr::unchecked("stats_contract"), + provider_proposals_contract: Some(Addr::unchecked("provider_proposals_contract")), }, ) .unwrap(); @@ -95,6 +99,7 @@ fn update_config_wrong_owner() { new_config: ConfigOptional { owner: Some(Addr::unchecked("owner1")), stats_contract: Some(Addr::unchecked("stats_contract1")), + provider_proposals_contract: Some(Addr::unchecked("provider_proposals_contract1")), }, }, ) @@ -127,6 +132,7 @@ fn update_config_ok() { &lido_staking_base::state::validatorset::Config { owner: Addr::unchecked("core"), stats_contract: Addr::unchecked("stats_contract"), + provider_proposals_contract: Some(Addr::unchecked("provider_proposals_contract")), }, ) .unwrap(); @@ -136,9 +142,10 @@ fn update_config_ok() { mock_env(), mock_info("core", &[]), lido_staking_base::msg::validatorset::ExecuteMsg::UpdateConfig { - new_config: lido_staking_base::state::validatorset::ConfigOptional { + new_config: ConfigOptional { owner: Some(Addr::unchecked("owner1")), stats_contract: Some(Addr::unchecked("stats_contract1")), + provider_proposals_contract: Some(Addr::unchecked("provider_proposals_contract1")), }, }, ) @@ -155,7 +162,8 @@ fn update_config_ok() { config, to_json_binary(&lido_staking_base::state::validatorset::Config { owner: Addr::unchecked("owner1"), - stats_contract: Addr::unchecked("stats_contract1") + stats_contract: Addr::unchecked("stats_contract1"), + provider_proposals_contract: Some(Addr::unchecked("provider_proposals_contract1")) }) .unwrap() ); @@ -233,6 +241,9 @@ fn update_validator_ok() { uptime: Decimal::zero(), tombstone: false, jailed_number: None, + init_proposal: None, + total_passed_proposals: 0, + total_voted_proposals: 0, }) .unwrap() ); @@ -315,6 +326,9 @@ fn update_validators_ok() { uptime: Decimal::zero(), tombstone: false, jailed_number: None, + init_proposal: None, + total_passed_proposals: 0, + total_voted_proposals: 0, }, lido_staking_base::state::validatorset::ValidatorInfo { valoper_address: "valoper_address2".to_string(), @@ -326,6 +340,9 @@ fn update_validators_ok() { uptime: Decimal::zero(), tombstone: false, jailed_number: None, + init_proposal: None, + total_passed_proposals: 0, + total_voted_proposals: 0, } ]) .unwrap() @@ -350,6 +367,7 @@ fn update_validator_info_wrong_sender() { &lido_staking_base::state::validatorset::Config { owner: Addr::unchecked("core"), stats_contract: Addr::unchecked("stats_contract"), + provider_proposals_contract: Some(Addr::unchecked("provider_proposals_contract")), }, ) .unwrap(); @@ -371,7 +389,7 @@ fn update_validator_info_wrong_sender() { deps.as_mut(), mock_env(), mock_info("stats_contract1", &[]), - lido_staking_base::msg::validatorset::ExecuteMsg::UpdateValidatorInfo { + lido_staking_base::msg::validatorset::ExecuteMsg::UpdateValidatorsInfo { validators: vec![lido_staking_base::msg::validatorset::ValidatorInfoUpdate { valoper_address: "valoper_address".to_string(), last_processed_remote_height: None, @@ -409,6 +427,7 @@ fn update_validator_info_ok() { &lido_staking_base::state::validatorset::Config { owner: Addr::unchecked("core"), stats_contract: Addr::unchecked("stats_contract"), + provider_proposals_contract: Some(Addr::unchecked("provider_proposals_contract")), }, ) .unwrap(); @@ -431,7 +450,7 @@ fn update_validator_info_ok() { deps.as_mut(), mock_env(), mock_info("stats_contract", &[]), - lido_staking_base::msg::validatorset::ExecuteMsg::UpdateValidatorInfo { + lido_staking_base::msg::validatorset::ExecuteMsg::UpdateValidatorsInfo { validators: vec![lido_staking_base::msg::validatorset::ValidatorInfoUpdate { valoper_address: "valoper_address".to_string(), last_processed_remote_height: Some(1234), @@ -468,6 +487,9 @@ fn update_validator_info_ok() { uptime: Decimal::one(), tombstone: true, jailed_number: Some(5678), + init_proposal: None, + total_passed_proposals: 0, + total_voted_proposals: 0, }) .unwrap() ); diff --git a/integration_tests/package.json b/integration_tests/package.json index c29d4baa..91f95a44 100644 --- a/integration_tests/package.json +++ b/integration_tests/package.json @@ -6,6 +6,8 @@ "scripts": { "test": "vitest --run", "test:poc-stargate": "vitest --run poc-stargate --bail 1", + "test:poc-provider-proposals": "vitest --run poc-provider-proposals.test --bail 1", + "test:poc-proposal-votes": "vitest --run poc-proposal-votes.test --bail 1", "test:core": "vitest --run core.test.ts --bail 1", "test:core:fsm": "vitest --run core.fsm --bail 1", "test:pump": "vitest --run pump --bail 1", @@ -52,4 +54,4 @@ }, "description": "Lido on Cosmos integration test", "repository": "git@github.com:hadronlabs-org/lionco-contracts.git" -} \ No newline at end of file +} diff --git a/integration_tests/src/generated/contractLib/index.ts b/integration_tests/src/generated/contractLib/index.ts index 87f14d60..2d3f3288 100644 --- a/integration_tests/src/generated/contractLib/index.ts +++ b/integration_tests/src/generated/contractLib/index.ts @@ -16,35 +16,41 @@ export const LidoFactory = _4; import * as _5 from './lidoHookTester'; export const LidoHookTester = _5; -import * as _6 from './lidoPump'; -export const LidoPump = _6; +import * as _6 from './lidoProposalVotesPoc'; +export const LidoProposalVotesPoc = _6; -import * as _7 from './lidoPuppeteerAuthz'; -export const LidoPuppeteerAuthz = _7; +import * as _7 from './lidoProviderProposalsPoc'; +export const LidoProviderProposalsPoc = _7; -import * as _8 from './lidoPuppeteer'; -export const LidoPuppeteer = _8; +import * as _8 from './lidoPump'; +export const LidoPump = _8; -import * as _9 from './lidoRewardsManager'; -export const LidoRewardsManager = _9; +import * as _9 from './lidoPuppeteerAuthz'; +export const LidoPuppeteerAuthz = _9; -import * as _10 from './lidoStargatePoc'; -export const LidoStargatePoc = _10; +import * as _10 from './lidoPuppeteer'; +export const LidoPuppeteer = _10; -import * as _11 from './lidoStrategy'; -export const LidoStrategy = _11; +import * as _11 from './lidoRewardsManager'; +export const LidoRewardsManager = _11; -import * as _12 from './lidoToken'; -export const LidoToken = _12; +import * as _12 from './lidoStargatePoc'; +export const LidoStargatePoc = _12; -import * as _13 from './lidoValidatorsSet'; -export const LidoValidatorsSet = _13; +import * as _13 from './lidoStrategy'; +export const LidoStrategy = _13; -import * as _14 from './lidoValidatorsStats'; -export const LidoValidatorsStats = _14; +import * as _14 from './lidoToken'; +export const LidoToken = _14; -import * as _15 from './lidoWithdrawalManager'; -export const LidoWithdrawalManager = _15; +import * as _15 from './lidoValidatorsSet'; +export const LidoValidatorsSet = _15; -import * as _16 from './lidoWithdrawalVoucher'; -export const LidoWithdrawalVoucher = _16; +import * as _16 from './lidoValidatorsStats'; +export const LidoValidatorsStats = _16; + +import * as _17 from './lidoWithdrawalManager'; +export const LidoWithdrawalManager = _17; + +import * as _18 from './lidoWithdrawalVoucher'; +export const LidoWithdrawalVoucher = _18; diff --git a/integration_tests/src/generated/contractLib/lidoFactory.ts b/integration_tests/src/generated/contractLib/lidoFactory.ts index d53bee08..3cf65f99 100644 --- a/integration_tests/src/generated/contractLib/lidoFactory.ts +++ b/integration_tests/src/generated/contractLib/lidoFactory.ts @@ -155,6 +155,7 @@ export interface ConfigOptional { } export interface ConfigOptional2 { owner?: Addr | null; + provider_proposals_contract?: Addr | null; stats_contract?: Addr | null; } export interface FeesMsg { diff --git a/integration_tests/src/generated/contractLib/lidoProposalVotes.ts b/integration_tests/src/generated/contractLib/lidoProposalVotes.ts new file mode 100644 index 00000000..4b910669 --- /dev/null +++ b/integration_tests/src/generated/contractLib/lidoProposalVotes.ts @@ -0,0 +1,89 @@ +import { CosmWasmClient, SigningCosmWasmClient, ExecuteResult, InstantiateResult } from "@cosmjs/cosmwasm-stargate"; +import { StdFee } from "@cosmjs/amino"; +import { Coin } from "@cosmjs/amino"; +export interface InstantiateMsg { + connection_id: string; + core_address: string; + port_id: string; + provider_proposals_address: string; + update_period: number; +} +export interface LidoProposalVotesSchema { + responses: Config | Metrics; + execute: UpdateConfigArgs | UpdateActiveProposalsArgs | UpdateVotersListArgs; + [k: string]: unknown; +} +export interface Config { + connection_id: string; + core_address: string; + port_id: string; + provider_proposals_address: string; + update_period: number; +} +export interface Metrics { + total_voters: number; +} +export interface UpdateConfigArgs { + connection_id?: string | null; + core_address?: string | null; + port_id?: string | null; + provider_proposals_address?: string | null; + update_period?: number | null; +} +export interface UpdateActiveProposalsArgs { + active_proposals: number[]; +} +export interface UpdateVotersListArgs { + voters: string[]; +} + + +function isSigningCosmWasmClient( + client: CosmWasmClient | SigningCosmWasmClient +): client is SigningCosmWasmClient { + return 'execute' in client; +} + +export class Client { + private readonly client: CosmWasmClient | SigningCosmWasmClient; + contractAddress: string; + constructor(client: CosmWasmClient | SigningCosmWasmClient, contractAddress: string) { + this.client = client; + this.contractAddress = contractAddress; + } + mustBeSigningClient() { + return new Error("This client is not a SigningCosmWasmClient"); + } + static async instantiate( + client: SigningCosmWasmClient, + sender: string, + codeId: number, + initMsg: InstantiateMsg, + label: string, + initCoins?: readonly Coin[], + fees?: StdFee | 'auto' | number, + ): Promise { + const res = await client.instantiate(sender, codeId, initMsg, label, fees, { + ...(initCoins && initCoins.length && { funds: initCoins }), + }); + return res; + } + queryConfig = async(): Promise => { + return this.client.queryContractSmart(this.contractAddress, { config: {} }); + } + queryMetrics = async(): Promise => { + return this.client.queryContractSmart(this.contractAddress, { metrics: {} }); + } + updateConfig = async(sender:string, args: UpdateConfigArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { + if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } + return this.client.execute(sender, this.contractAddress, { update_config: args }, fee || "auto", memo, funds); + } + updateActiveProposals = async(sender:string, args: UpdateActiveProposalsArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { + if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } + return this.client.execute(sender, this.contractAddress, { update_active_proposals: args }, fee || "auto", memo, funds); + } + updateVotersList = async(sender:string, args: UpdateVotersListArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { + if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } + return this.client.execute(sender, this.contractAddress, { update_voters_list: args }, fee || "auto", memo, funds); + } +} diff --git a/integration_tests/src/generated/contractLib/lidoProposalVotesPoc.ts b/integration_tests/src/generated/contractLib/lidoProposalVotesPoc.ts new file mode 100644 index 00000000..94de78e2 --- /dev/null +++ b/integration_tests/src/generated/contractLib/lidoProposalVotesPoc.ts @@ -0,0 +1,92 @@ +import { CosmWasmClient, SigningCosmWasmClient, ExecuteResult, InstantiateResult } from "@cosmjs/cosmwasm-stargate"; +import { StdFee } from "@cosmjs/amino"; +import { Coin } from "@cosmjs/amino"; +export interface InstantiateMsg { + connection_id: string; + core_address: string; + port_id: string; + provider_proposals_address: string; + update_period: number; +} +export interface LidoProposalVotesPocSchema { + responses: Config | Metrics; + execute: UpdateConfigArgs | UpdateActiveProposalsArgs | UpdateVotersListArgs; + [k: string]: unknown; +} +export interface Config { + connection_id: string; + core_address: string; + port_id: string; + provider_proposals_address: string; + update_period: number; +} +export interface Metrics { + total_voters: number; +} +export interface UpdateConfigArgs { + new_config: ConfigOptional; +} +export interface ConfigOptional { + connection_id?: string | null; + core_address?: string | null; + port_id?: string | null; + provider_proposals_address?: string | null; + update_period?: number | null; +} +export interface UpdateActiveProposalsArgs { + active_proposals: number[]; +} +export interface UpdateVotersListArgs { + voters: string[]; +} + + +function isSigningCosmWasmClient( + client: CosmWasmClient | SigningCosmWasmClient +): client is SigningCosmWasmClient { + return 'execute' in client; +} + +export class Client { + private readonly client: CosmWasmClient | SigningCosmWasmClient; + contractAddress: string; + constructor(client: CosmWasmClient | SigningCosmWasmClient, contractAddress: string) { + this.client = client; + this.contractAddress = contractAddress; + } + mustBeSigningClient() { + return new Error("This client is not a SigningCosmWasmClient"); + } + static async instantiate( + client: SigningCosmWasmClient, + sender: string, + codeId: number, + initMsg: InstantiateMsg, + label: string, + initCoins?: readonly Coin[], + fees?: StdFee | 'auto' | number, + ): Promise { + const res = await client.instantiate(sender, codeId, initMsg, label, fees, { + ...(initCoins && initCoins.length && { funds: initCoins }), + }); + return res; + } + queryConfig = async(): Promise => { + return this.client.queryContractSmart(this.contractAddress, { config: {} }); + } + queryMetrics = async(): Promise => { + return this.client.queryContractSmart(this.contractAddress, { metrics: {} }); + } + updateConfig = async(sender:string, args: UpdateConfigArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { + if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } + return this.client.execute(sender, this.contractAddress, { update_config: args }, fee || "auto", memo, funds); + } + updateActiveProposals = async(sender:string, args: UpdateActiveProposalsArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { + if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } + return this.client.execute(sender, this.contractAddress, { update_active_proposals: args }, fee || "auto", memo, funds); + } + updateVotersList = async(sender:string, args: UpdateVotersListArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { + if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } + return this.client.execute(sender, this.contractAddress, { update_voters_list: args }, fee || "auto", memo, funds); + } +} diff --git a/integration_tests/src/generated/contractLib/lidoProviderProposals.ts b/integration_tests/src/generated/contractLib/lidoProviderProposals.ts new file mode 100644 index 00000000..b6f93307 --- /dev/null +++ b/integration_tests/src/generated/contractLib/lidoProviderProposals.ts @@ -0,0 +1,191 @@ +import { CosmWasmClient, SigningCosmWasmClient, ExecuteResult, InstantiateResult } from "@cosmjs/cosmwasm-stargate"; +import { StdFee } from "@cosmjs/amino"; +/** + * A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0 + * + * The greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18) + */ +export type Decimal = string; + +export interface InstantiateMsg { + connection_id: string; + core_address: string; + init_proposal: number; + port_id: string; + proposals_prefetch: number; + update_period: number; + validators_set_address: string; + veto_spam_threshold: Decimal; +} +/** + * A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0 + * + * The greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18) + */ +export type Decimal = string; +/** + * A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq. + * + * # Examples + * + * Use `from` to create instances of this and `u128` to get the value out: + * + * ``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123); + * + * let b = Uint128::from(42u64); assert_eq!(b.u128(), 42); + * + * let c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ``` + */ +export type Uint128 = string; +export type ArrayOfProposalInfo = ProposalInfo1[]; + +export interface LidoProviderProposalsSchema { + responses: Config | ProposalInfo | ArrayOfProposalInfo | Metrics; + query: GetProposalArgs; + execute: UpdateConfigArgs | UpdateProposalVotesArgs; + [k: string]: unknown; +} +export interface Config { + connection_id: string; + core_address: string; + init_proposal: number; + port_id: string; + proposal_votes_address?: string | null; + proposals_prefetch: number; + update_period: number; + validators_set_address: string; + veto_spam_threshold: Decimal; +} +export interface ProposalInfo { + is_spam: boolean; + proposal: Proposal; + votes?: ProposalVote[] | null; +} +/** + * Proposal defines the core field members of a governance proposal. + */ +export interface Proposal { + deposit_end_time?: number | null; + final_tally_result?: TallyResult | null; + proposal_id: number; + proposal_type?: string | null; + status: number; + submit_time?: number | null; + total_deposit: Coin[]; + voting_end_time?: number | null; + voting_start_time?: number | null; + [k: string]: unknown; +} +/** + * TallyResult defines a standard tally for a governance proposal. + */ +export interface TallyResult { + abstain: Uint128; + no: Uint128; + no_with_veto: Uint128; + yes: Uint128; + [k: string]: unknown; +} +export interface Coin { + amount: Uint128; + denom: string; + [k: string]: unknown; +} +/** + * Proposal vote defines the core field members of a governance proposal votes. + */ +export interface ProposalVote { + options: WeightedVoteOption[]; + proposal_id: number; + voter: string; + [k: string]: unknown; +} +/** + * Proposal vote option defines the members of a governance proposal vote option. + */ +export interface WeightedVoteOption { + option: number; + weight: string; + [k: string]: unknown; +} +export interface ProposalInfo1 { + is_spam: boolean; + proposal: Proposal; + votes?: ProposalVote[] | null; +} +export interface Metrics { + last_proposal: number; +} +export interface GetProposalArgs { + proposal_id: number; +} +export interface UpdateConfigArgs { + new_config: ConfigOptional; +} +export interface ConfigOptional { + connection_id?: string | null; + core_address?: string | null; + init_proposal?: number | null; + port_id?: string | null; + proposal_votes_address?: string | null; + proposals_prefetch?: number | null; + update_period?: number | null; + validators_set_address?: string | null; + veto_spam_threshold?: Decimal | null; +} +export interface UpdateProposalVotesArgs { + votes: ProposalVote[]; +} + + +function isSigningCosmWasmClient( + client: CosmWasmClient | SigningCosmWasmClient +): client is SigningCosmWasmClient { + return 'execute' in client; +} + +export class Client { + private readonly client: CosmWasmClient | SigningCosmWasmClient; + contractAddress: string; + constructor(client: CosmWasmClient | SigningCosmWasmClient, contractAddress: string) { + this.client = client; + this.contractAddress = contractAddress; + } + mustBeSigningClient() { + return new Error("This client is not a SigningCosmWasmClient"); + } + static async instantiate( + client: SigningCosmWasmClient, + sender: string, + codeId: number, + initMsg: InstantiateMsg, + label: string, + initCoins?: readonly Coin[], + fees?: StdFee | 'auto' | number, + ): Promise { + const res = await client.instantiate(sender, codeId, initMsg, label, fees, { + ...(initCoins && initCoins.length && { funds: initCoins }), + }); + return res; + } + queryConfig = async(): Promise => { + return this.client.queryContractSmart(this.contractAddress, { config: {} }); + } + queryGetProposal = async(args: GetProposalArgs): Promise => { + return this.client.queryContractSmart(this.contractAddress, { get_proposal: args }); + } + queryGetProposals = async(): Promise => { + return this.client.queryContractSmart(this.contractAddress, { get_proposals: {} }); + } + queryMetrics = async(): Promise => { + return this.client.queryContractSmart(this.contractAddress, { metrics: {} }); + } + updateConfig = async(sender:string, args: UpdateConfigArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { + if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } + return this.client.execute(sender, this.contractAddress, { update_config: args }, fee || "auto", memo, funds); + } + updateProposalVotes = async(sender:string, args: UpdateProposalVotesArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { + if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } + return this.client.execute(sender, this.contractAddress, { update_proposal_votes: args }, fee || "auto", memo, funds); + } +} diff --git a/integration_tests/src/generated/contractLib/lidoProviderProposalsPoc.ts b/integration_tests/src/generated/contractLib/lidoProviderProposalsPoc.ts new file mode 100644 index 00000000..5af2a4e1 --- /dev/null +++ b/integration_tests/src/generated/contractLib/lidoProviderProposalsPoc.ts @@ -0,0 +1,191 @@ +import { CosmWasmClient, SigningCosmWasmClient, ExecuteResult, InstantiateResult } from "@cosmjs/cosmwasm-stargate"; +import { StdFee } from "@cosmjs/amino"; +/** + * A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0 + * + * The greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18) + */ +export type Decimal = string; + +export interface InstantiateMsg { + connection_id: string; + core_address: string; + init_proposal: number; + port_id: string; + proposals_prefetch: number; + update_period: number; + validators_set_address: string; + veto_spam_threshold: Decimal; +} +/** + * A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0 + * + * The greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18) + */ +export type Decimal = string; +/** + * A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq. + * + * # Examples + * + * Use `from` to create instances of this and `u128` to get the value out: + * + * ``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123); + * + * let b = Uint128::from(42u64); assert_eq!(b.u128(), 42); + * + * let c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ``` + */ +export type Uint128 = string; +export type ArrayOfProposalInfo = ProposalInfo1[]; + +export interface LidoProviderProposalsPocSchema { + responses: Config | ProposalInfo | ArrayOfProposalInfo | Metrics; + query: GetProposalArgs; + execute: UpdateConfigArgs | UpdateProposalVotesArgs; + [k: string]: unknown; +} +export interface Config { + connection_id: string; + core_address: string; + init_proposal: number; + port_id: string; + proposal_votes_address?: string | null; + proposals_prefetch: number; + update_period: number; + validators_set_address: string; + veto_spam_threshold: Decimal; +} +export interface ProposalInfo { + is_spam: boolean; + proposal: Proposal; + votes?: ProposalVote[] | null; +} +/** + * Proposal defines the core field members of a governance proposal. + */ +export interface Proposal { + deposit_end_time?: number | null; + final_tally_result?: TallyResult | null; + proposal_id: number; + proposal_type?: string | null; + status: number; + submit_time?: number | null; + total_deposit: Coin[]; + voting_end_time?: number | null; + voting_start_time?: number | null; + [k: string]: unknown; +} +/** + * TallyResult defines a standard tally for a governance proposal. + */ +export interface TallyResult { + abstain: Uint128; + no: Uint128; + no_with_veto: Uint128; + yes: Uint128; + [k: string]: unknown; +} +export interface Coin { + amount: Uint128; + denom: string; + [k: string]: unknown; +} +/** + * Proposal vote defines the core field members of a governance proposal votes. + */ +export interface ProposalVote { + options: WeightedVoteOption[]; + proposal_id: number; + voter: string; + [k: string]: unknown; +} +/** + * Proposal vote option defines the members of a governance proposal vote option. + */ +export interface WeightedVoteOption { + option: number; + weight: string; + [k: string]: unknown; +} +export interface ProposalInfo1 { + is_spam: boolean; + proposal: Proposal; + votes?: ProposalVote[] | null; +} +export interface Metrics { + last_proposal: number; +} +export interface GetProposalArgs { + proposal_id: number; +} +export interface UpdateConfigArgs { + new_config: ConfigOptional; +} +export interface ConfigOptional { + connection_id?: string | null; + core_address?: string | null; + init_proposal?: number | null; + port_id?: string | null; + proposal_votes_address?: string | null; + proposals_prefetch?: number | null; + update_period?: number | null; + validators_set_address?: string | null; + veto_spam_threshold?: Decimal | null; +} +export interface UpdateProposalVotesArgs { + votes: ProposalVote[]; +} + + +function isSigningCosmWasmClient( + client: CosmWasmClient | SigningCosmWasmClient +): client is SigningCosmWasmClient { + return 'execute' in client; +} + +export class Client { + private readonly client: CosmWasmClient | SigningCosmWasmClient; + contractAddress: string; + constructor(client: CosmWasmClient | SigningCosmWasmClient, contractAddress: string) { + this.client = client; + this.contractAddress = contractAddress; + } + mustBeSigningClient() { + return new Error("This client is not a SigningCosmWasmClient"); + } + static async instantiate( + client: SigningCosmWasmClient, + sender: string, + codeId: number, + initMsg: InstantiateMsg, + label: string, + initCoins?: readonly Coin[], + fees?: StdFee | 'auto' | number, + ): Promise { + const res = await client.instantiate(sender, codeId, initMsg, label, fees, { + ...(initCoins && initCoins.length && { funds: initCoins }), + }); + return res; + } + queryConfig = async(): Promise => { + return this.client.queryContractSmart(this.contractAddress, { config: {} }); + } + queryGetProposal = async(args: GetProposalArgs): Promise => { + return this.client.queryContractSmart(this.contractAddress, { get_proposal: args }); + } + queryGetProposals = async(): Promise => { + return this.client.queryContractSmart(this.contractAddress, { get_proposals: {} }); + } + queryMetrics = async(): Promise => { + return this.client.queryContractSmart(this.contractAddress, { metrics: {} }); + } + updateConfig = async(sender:string, args: UpdateConfigArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { + if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } + return this.client.execute(sender, this.contractAddress, { update_config: args }, fee || "auto", memo, funds); + } + updateProposalVotes = async(sender:string, args: UpdateProposalVotesArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { + if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } + return this.client.execute(sender, this.contractAddress, { update_proposal_votes: args }, fee || "auto", memo, funds); + } +} diff --git a/integration_tests/src/generated/contractLib/lidoValidatorsSet.ts b/integration_tests/src/generated/contractLib/lidoValidatorsSet.ts index 67a6d190..c13f7edc 100644 --- a/integration_tests/src/generated/contractLib/lidoValidatorsSet.ts +++ b/integration_tests/src/generated/contractLib/lidoValidatorsSet.ts @@ -1,6 +1,5 @@ import { CosmWasmClient, SigningCosmWasmClient, ExecuteResult, InstantiateResult } from "@cosmjs/cosmwasm-stargate"; import { StdFee } from "@cosmjs/amino"; -import { Coin } from "@cosmjs/amino"; export interface InstantiateMsg { owner: string; stats_contract: string; @@ -22,35 +21,61 @@ export type Addr = string; */ export type Decimal = string; export type ArrayOfValidatorInfo = ValidatorInfo1[]; +/** + * A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq. + * + * # Examples + * + * Use `from` to create instances of this and `u128` to get the value out: + * + * ``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123); + * + * let b = Uint128::from(42u64); assert_eq!(b.u128(), 42); + * + * let c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ``` + */ +export type Uint128 = string; export interface LidoValidatorsSetSchema { responses: Config | ValidatorInfo | ArrayOfValidatorInfo; query: ValidatorArgs; - execute: UpdateConfigArgs | UpdateValidatorsArgs | UpdateValidatorArgs | UpdateValidatorInfoArgs; + execute: + | UpdateConfigArgs + | UpdateValidatorsArgs + | UpdateValidatorArgs + | UpdateValidatorsInfoArgs + | UpdateValidatorsVotingArgs; [k: string]: unknown; } export interface Config { owner: Addr; + provider_proposals_contract?: Addr | null; stats_contract: Addr; } export interface ValidatorInfo { + init_proposal?: number | null; jailed_number?: number | null; last_commission_in_range?: number | null; last_processed_local_height?: number | null; last_processed_remote_height?: number | null; last_validated_height?: number | null; tombstone: boolean; + total_passed_proposals: number; + total_voted_proposals: number; uptime: Decimal; valoper_address: string; weight: number; } export interface ValidatorInfo1 { + init_proposal?: number | null; jailed_number?: number | null; last_commission_in_range?: number | null; last_processed_local_height?: number | null; last_processed_remote_height?: number | null; last_validated_height?: number | null; tombstone: boolean; + total_passed_proposals: number; + total_voted_proposals: number; uptime: Decimal; valoper_address: string; weight: number; @@ -63,6 +88,7 @@ export interface UpdateConfigArgs { } export interface ConfigOptional { owner?: Addr | null; + provider_proposals_contract?: Addr | null; stats_contract?: Addr | null; } export interface UpdateValidatorsArgs { @@ -75,7 +101,7 @@ export interface ValidatorData { export interface UpdateValidatorArgs { validator: ValidatorData; } -export interface UpdateValidatorInfoArgs { +export interface UpdateValidatorsInfoArgs { validators: ValidatorInfoUpdate[]; } export interface ValidatorInfoUpdate { @@ -88,6 +114,61 @@ export interface ValidatorInfoUpdate { uptime: Decimal; valoper_address: string; } +export interface UpdateValidatorsVotingArgs { + proposal: ProposalInfo; +} +export interface ProposalInfo { + is_spam: boolean; + proposal: Proposal; + votes?: ProposalVote[] | null; +} +/** + * Proposal defines the core field members of a governance proposal. + */ +export interface Proposal { + deposit_end_time?: number | null; + final_tally_result?: TallyResult | null; + proposal_id: number; + proposal_type?: string | null; + status: number; + submit_time?: number | null; + total_deposit: Coin[]; + voting_end_time?: number | null; + voting_start_time?: number | null; + [k: string]: unknown; +} +/** + * TallyResult defines a standard tally for a governance proposal. + */ +export interface TallyResult { + abstain: Uint128; + no: Uint128; + no_with_veto: Uint128; + yes: Uint128; + [k: string]: unknown; +} +export interface Coin { + amount: Uint128; + denom: string; + [k: string]: unknown; +} +/** + * Proposal vote defines the core field members of a governance proposal votes. + */ +export interface ProposalVote { + options: WeightedVoteOption[]; + proposal_id: number; + voter: string; + [k: string]: unknown; +} +/** + * Proposal vote option defines the members of a governance proposal vote option. + */ +export interface WeightedVoteOption { + option: number; + weight: string; + [k: string]: unknown; +} function isSigningCosmWasmClient( @@ -141,8 +222,12 @@ export class Client { if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } return this.client.execute(sender, this.contractAddress, { update_validator: args }, fee || "auto", memo, funds); } - updateValidatorInfo = async(sender:string, args: UpdateValidatorInfoArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { + updateValidatorsInfo = async(sender:string, args: UpdateValidatorsInfoArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { + if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } + return this.client.execute(sender, this.contractAddress, { update_validators_info: args }, fee || "auto", memo, funds); + } + updateValidatorsVoting = async(sender:string, args: UpdateValidatorsVotingArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } - return this.client.execute(sender, this.contractAddress, { update_validator_info: args }, fee || "auto", memo, funds); + return this.client.execute(sender, this.contractAddress, { update_validators_voting: args }, fee || "auto", memo, funds); } } diff --git a/integration_tests/src/testcases/poc-proposal-votes.test.ts b/integration_tests/src/testcases/poc-proposal-votes.test.ts new file mode 100644 index 00000000..41204c1e --- /dev/null +++ b/integration_tests/src/testcases/poc-proposal-votes.test.ts @@ -0,0 +1,326 @@ +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { + LidoProviderProposalsPoc, + LidoProposalVotesPoc, +} from '../generated/contractLib'; +import { + QueryClient, + StakingExtension, + BankExtension, + setupStakingExtension, + setupBankExtension, + IndexedTx, +} from '@cosmjs/stargate'; +import { join } from 'path'; +import { Tendermint34Client } from '@cosmjs/tendermint-rpc'; +import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate'; +import { Client as NeutronClient } from '@neutron-org/client-ts'; +import { AccountData, DirectSecp256k1HdWallet } from '@cosmjs/proto-signing'; +import { GasPrice } from '@cosmjs/stargate'; +import { setupPark } from '../testSuite'; +import fs from 'fs'; +import { stringToPath } from '@cosmjs/crypto'; +import Cosmopark from '@neutron-org/cosmopark'; +import { waitFor } from '../helpers/waitFor'; +import { ProposalInfo1 } from '../generated/contractLib/lidoProviderProposals'; + +const LidoProviderProposalsClass = LidoProviderProposalsPoc.Client; +const LidoProposalVotesClass = LidoProposalVotesPoc.Client; + +describe('POC Proposal Votes', () => { + const context: { + park?: Cosmopark; + propsContractAddress?: string; + votesContractAddress?: string; + wallet?: DirectSecp256k1HdWallet; + gaiaWallet?: DirectSecp256k1HdWallet; + propsContractClient?: InstanceType; + votesContractClient?: InstanceType; + account?: AccountData; + client?: SigningCosmWasmClient; + gaiaClient?: SigningCosmWasmClient; + gaiaUserAddress?: string; + gaiaQueryClient?: QueryClient & StakingExtension & BankExtension; + neutronClient?: InstanceType; + neutronUserAddress?: string; + validatorAddress?: string; + secondValidatorAddress?: string; + } = {}; + + beforeAll(async () => { + context.park = await setupPark( + 'providerprops', + ['neutron', 'gaia'], + true, + true, + ); + context.wallet = await DirectSecp256k1HdWallet.fromMnemonic( + context.park.config.wallets.demowallet1.mnemonic, + { + prefix: 'neutron', + }, + ); + + context.gaiaWallet = await DirectSecp256k1HdWallet.fromMnemonic( + context.park.config.wallets.demowallet1.mnemonic, + { + prefix: 'cosmos', + }, + ); + context.account = (await context.wallet.getAccounts())[0]; + context.neutronClient = new NeutronClient({ + apiURL: `http://127.0.0.1:${context.park.ports.neutron.rest}`, + rpcURL: `127.0.0.1:${context.park.ports.neutron.rpc}`, + prefix: 'neutron', + }); + + context.client = await SigningCosmWasmClient.connectWithSigner( + `http://127.0.0.1:${context.park.ports.neutron.rpc}`, + context.wallet, + { + gasPrice: GasPrice.fromString('0.025untrn'), + }, + ); + context.gaiaClient = await SigningCosmWasmClient.connectWithSigner( + `http://127.0.0.1:${context.park.ports.gaia.rpc}`, + context.gaiaWallet, + { + gasPrice: GasPrice.fromString('0.025stake'), + }, + ); + const tmClient = await Tendermint34Client.connect( + `http://127.0.0.1:${context.park.ports.gaia.rpc}`, + ); + context.gaiaQueryClient = QueryClient.withExtensions( + tmClient, + setupStakingExtension, + setupBankExtension, + ); + }); + + afterAll(async () => { + await context.park.stop(); + }); + + it('instantiate', async () => { + const { client, account } = context; + const propsRes = await client.upload( + account.address, + fs.readFileSync( + join(__dirname, '../../../artifacts/lido_provider_proposals_poc.wasm'), + ), + 1.5, + ); + expect(propsRes.codeId).toBeGreaterThan(0); + + const instantiatePropsRes = + await LidoProviderProposalsPoc.Client.instantiate( + client, + account.address, + propsRes.codeId, + { + connection_id: 'connection-0', + port_id: 'transfer', + update_period: 10, + core_address: account.address, + validators_set_address: account.address, + init_proposal: 1, + proposals_prefetch: 10, + veto_spam_threshold: '0.5', + }, + 'label', + [ + { + amount: '10000000', + denom: 'untrn', + }, + ], + 'auto', + ); + expect(instantiatePropsRes.contractAddress).toHaveLength(66); + context.propsContractAddress = instantiatePropsRes.contractAddress; + context.propsContractClient = new LidoProviderProposalsPoc.Client( + client, + context.propsContractAddress, + ); + + const votesRes = await client.upload( + account.address, + fs.readFileSync( + join(__dirname, '../../../artifacts/lido_proposal_votes_poc.wasm'), + ), + 1.5, + ); + expect(votesRes.codeId).toBeGreaterThan(0); + + const instantiateVotesRes = await LidoProposalVotesPoc.Client.instantiate( + client, + account.address, + votesRes.codeId, + { + connection_id: 'connection-0', + port_id: 'transfer', + update_period: 10, + core_address: account.address, + provider_proposals_address: context.propsContractAddress, + }, + 'label', + [ + { + amount: '10000000', + denom: 'untrn', + }, + ], + 'auto', + ); + expect(instantiateVotesRes.contractAddress).toHaveLength(66); + context.votesContractAddress = instantiateVotesRes.contractAddress; + context.votesContractClient = new LidoProposalVotesPoc.Client( + client, + context.votesContractAddress, + ); + + context.gaiaUserAddress = ( + await context.gaiaWallet.getAccounts() + )[0].address; + context.neutronUserAddress = ( + await context.wallet.getAccounts() + )[0].address; + + const res = await context.votesContractClient.updateVotersList( + account.address, + { + voters: [context.gaiaUserAddress], + }, + 1.5, + ); + expect(res.transactionHash).toBeTruthy(); + }); + + it('delegate tokens on gaia side and create text proposal', async () => { + const wallet = await DirectSecp256k1HdWallet.fromMnemonic( + context.park.config.master_mnemonic, + { + prefix: 'cosmosvaloper', + hdPaths: [stringToPath("m/44'/118'/1'/0/0") as any], + }, + ); + context.validatorAddress = (await wallet.getAccounts())[0].address; + let res = await context.park.executeInNetwork( + 'gaia', + `gaiad tx staking delegate ${context.validatorAddress} 1000000stake --from ${context.gaiaUserAddress} --yes --chain-id testgaia --home=/opt --keyring-backend=test --output json`, + ); + expect(res.exitCode).toBe(0); + let out = JSON.parse(res.out); + expect(out.code).toBe(0); + expect(out.txhash).toHaveLength(64); + let tx: IndexedTx | null = null; + await waitFor(async () => { + tx = await context.gaiaClient.getTx(out.txhash); + return tx !== null; + }); + expect(tx.height).toBeGreaterThan(0); + expect(tx.code).toBe(0); + + res = await context.park.executeInNetwork( + 'gaia', + `gaiad tx gov submit-proposal --type text --title test --description test --from ${context.gaiaUserAddress} --deposit 10000000stake --yes --chain-id testgaia --home=/opt --keyring-backend=test --output json`, + ); + expect(res.exitCode).toBe(0); + out = JSON.parse(res.out); + + expect(out.code).toBe(0); + expect(out.txhash).toHaveLength(64); + tx = null; + await waitFor(async () => { + tx = await context.gaiaClient.getTx(out.txhash); + return tx !== null; + }); + expect(tx.height).toBeGreaterThan(0); + expect(tx.code).toBe(0); + + res = await context.park.executeInNetwork( + 'gaia', + `gaiad tx gov vote 1 yes --from ${context.gaiaUserAddress} --yes --chain-id testgaia --home=/opt --keyring-backend=test --output json`, + ); + expect(res.exitCode).toBe(0); + out = JSON.parse(res.out); + + expect(out.code).toBe(0); + expect(out.txhash).toHaveLength(64); + tx = null; + await waitFor(async () => { + tx = await context.gaiaClient.getTx(out.txhash); + return tx !== null; + }); + + expect(tx.height).toBeGreaterThan(0); + expect(tx.code).toBe(0); + + await waitFor(async () => { + const proposals = await context.propsContractClient.queryGetProposals(); + + return proposals.length > 0; + }, 60000); + + const resUpdate = await context.propsContractClient.updateConfig( + context.account.address, + { + new_config: { + proposal_votes_address: context.votesContractAddress, + }, + }, + 1.5, + ); + expect(resUpdate.transactionHash).toBeTruthy(); + }); + + it('query gaiad relayed proposals', async () => { + let proposals: ProposalInfo1[]; + + await waitFor(async () => { + proposals = await context.propsContractClient.queryGetProposals(); + + return proposals.length > 0 && proposals[0].votes !== null; + }, 60000); + + expect(proposals).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + proposal: expect.objectContaining({ + proposal_id: 1, + proposal_type: '/cosmos.gov.v1beta1.TextProposal', + status: 2, + submit_time: expect.any(Number), + deposit_end_time: expect.any(Number), + voting_start_time: expect.any(Number), + voting_end_time: expect.any(Number), + }), + votes: expect.arrayContaining([ + expect.objectContaining({ + proposal_id: 1, + voter: context.gaiaUserAddress, + options: [ + { + option: 1, + weight: '1000000000000000000', + }, + ], + }), + ]), + is_spam: false, + }), + ]), + ); + + expect(proposals.length).toEqual(1); + }); + + it('query contract metrics', async () => { + const metrics = await context.votesContractClient.queryMetrics(); + + expect(metrics).toEqual({ + total_voters: 1, + }); + }); +}); diff --git a/integration_tests/src/testcases/poc-provider-proposals.test.ts b/integration_tests/src/testcases/poc-provider-proposals.test.ts new file mode 100644 index 00000000..eb67df77 --- /dev/null +++ b/integration_tests/src/testcases/poc-provider-proposals.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { LidoProviderProposalsPoc } from '../generated/contractLib'; +import { + QueryClient, + StakingExtension, + BankExtension, + setupStakingExtension, + setupBankExtension, + IndexedTx, +} from '@cosmjs/stargate'; +import { join } from 'path'; +import { Tendermint34Client } from '@cosmjs/tendermint-rpc'; +import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate'; +import { Client as NeutronClient } from '@neutron-org/client-ts'; +import { AccountData, DirectSecp256k1HdWallet } from '@cosmjs/proto-signing'; +import { GasPrice } from '@cosmjs/stargate'; +import { setupPark } from '../testSuite'; +import fs from 'fs'; +import { stringToPath } from '@cosmjs/crypto'; +import Cosmopark from '@neutron-org/cosmopark'; +import { waitFor } from '../helpers/waitFor'; +import { ProposalInfo1 } from '../generated/contractLib/lidoProviderProposals'; + +const LidoProviderProposalsClass = LidoProviderProposalsPoc.Client; + +describe('POC Provider Proposals', () => { + const context: { + park?: Cosmopark; + contractAddress?: string; + wallet?: DirectSecp256k1HdWallet; + gaiaWallet?: DirectSecp256k1HdWallet; + contractClient?: InstanceType; + account?: AccountData; + client?: SigningCosmWasmClient; + gaiaClient?: SigningCosmWasmClient; + gaiaUserAddress?: string; + gaiaQueryClient?: QueryClient & StakingExtension & BankExtension; + neutronClient?: InstanceType; + neutronUserAddress?: string; + validatorAddress?: string; + secondValidatorAddress?: string; + } = {}; + + beforeAll(async () => { + context.park = await setupPark( + 'providerprops', + ['neutron', 'gaia'], + true, + true, + ); + context.wallet = await DirectSecp256k1HdWallet.fromMnemonic( + context.park.config.wallets.demowallet1.mnemonic, + { + prefix: 'neutron', + }, + ); + + context.gaiaWallet = await DirectSecp256k1HdWallet.fromMnemonic( + context.park.config.wallets.demowallet1.mnemonic, + { + prefix: 'cosmos', + }, + ); + context.account = (await context.wallet.getAccounts())[0]; + context.neutronClient = new NeutronClient({ + apiURL: `http://127.0.0.1:${context.park.ports.neutron.rest}`, + rpcURL: `127.0.0.1:${context.park.ports.neutron.rpc}`, + prefix: 'neutron', + }); + + context.client = await SigningCosmWasmClient.connectWithSigner( + `http://127.0.0.1:${context.park.ports.neutron.rpc}`, + context.wallet, + { + gasPrice: GasPrice.fromString('0.025untrn'), + }, + ); + context.gaiaClient = await SigningCosmWasmClient.connectWithSigner( + `http://127.0.0.1:${context.park.ports.gaia.rpc}`, + context.gaiaWallet, + { + gasPrice: GasPrice.fromString('0.025stake'), + }, + ); + const tmClient = await Tendermint34Client.connect( + `http://127.0.0.1:${context.park.ports.gaia.rpc}`, + ); + context.gaiaQueryClient = QueryClient.withExtensions( + tmClient, + setupStakingExtension, + setupBankExtension, + ); + }); + + afterAll(async () => { + await context.park.stop(); + }); + + it('instantiate', async () => { + const { client, account } = context; + const res = await client.upload( + account.address, + fs.readFileSync( + join(__dirname, '../../../artifacts/lido_provider_proposals_poc.wasm'), + ), + 1.5, + ); + expect(res.codeId).toBeGreaterThan(0); + + const instantiateRes = await LidoProviderProposalsPoc.Client.instantiate( + client, + account.address, + res.codeId, + { + connection_id: 'connection-0', + port_id: 'transfer', + update_period: 10, + core_address: account.address, + validators_set_address: account.address, + init_proposal: 1, + proposals_prefetch: 10, + veto_spam_threshold: '0.5', + }, + 'label', + [ + { + amount: '10000000', + denom: 'untrn', + }, + ], + 'auto', + ); + expect(instantiateRes.contractAddress).toHaveLength(66); + context.contractAddress = instantiateRes.contractAddress; + context.contractClient = new LidoProviderProposalsPoc.Client( + client, + context.contractAddress, + ); + + context.gaiaUserAddress = ( + await context.gaiaWallet.getAccounts() + )[0].address; + context.neutronUserAddress = ( + await context.wallet.getAccounts() + )[0].address; + }); + + it('delegate tokens on gaia side and create text proposal', async () => { + const wallet = await DirectSecp256k1HdWallet.fromMnemonic( + context.park.config.master_mnemonic, + { + prefix: 'cosmosvaloper', + hdPaths: [stringToPath("m/44'/118'/1'/0/0") as any], + }, + ); + context.validatorAddress = (await wallet.getAccounts())[0].address; + let res = await context.park.executeInNetwork( + 'gaia', + `gaiad tx staking delegate ${context.validatorAddress} 1000000stake --from ${context.gaiaUserAddress} --yes --chain-id testgaia --home=/opt --keyring-backend=test --output json`, + ); + expect(res.exitCode).toBe(0); + let out = JSON.parse(res.out); + expect(out.code).toBe(0); + expect(out.txhash).toHaveLength(64); + let tx: IndexedTx | null = null; + await waitFor(async () => { + tx = await context.gaiaClient.getTx(out.txhash); + return tx !== null; + }); + expect(tx.height).toBeGreaterThan(0); + expect(tx.code).toBe(0); + + res = await context.park.executeInNetwork( + 'gaia', + `gaiad tx gov submit-proposal --type text --title test --description test --from ${context.gaiaUserAddress} --deposit 1000000stake --yes --chain-id testgaia --home=/opt --keyring-backend=test --output json`, + ); + expect(res.exitCode).toBe(0); + out = JSON.parse(res.out); + + expect(out.code).toBe(0); + expect(out.txhash).toHaveLength(64); + tx = null; + await waitFor(async () => { + tx = await context.gaiaClient.getTx(out.txhash); + return tx !== null; + }); + expect(tx.height).toBeGreaterThan(0); + expect(tx.code).toBe(0); + }); + + it('query gaiad relayed proposals', async () => { + let proposals: ProposalInfo1[]; + + await waitFor(async () => { + proposals = await context.contractClient.queryGetProposals(); + + return proposals.length > 0; + }, 60000); + + expect(proposals).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + proposal: expect.objectContaining({ + proposal_id: 1, + proposal_type: '/cosmos.gov.v1beta1.TextProposal', + status: 1, + submit_time: expect.any(Number), + deposit_end_time: expect.any(Number), + voting_start_time: expect.any(Number), + voting_end_time: expect.any(Number), + }), + votes: null, + is_spam: false, + }), + ]), + ); + + expect(proposals.length).toEqual(1); + }); + + it('query contract metrics', async () => { + const metrics = await context.contractClient.queryMetrics(); + + expect(metrics).toEqual({ + last_proposal: 1, + }); + }); +}); diff --git a/integration_tests/src/testcases/validator-set.test.ts b/integration_tests/src/testcases/validator-set.test.ts index 1ad81c7d..e7c0402c 100644 --- a/integration_tests/src/testcases/validator-set.test.ts +++ b/integration_tests/src/testcases/validator-set.test.ts @@ -111,6 +111,9 @@ describe('Validator set', () => { uptime: '0', tombstone: false, jailed_number: null, + init_proposal: null, + total_passed_proposals: 0, + total_voted_proposals: 0, }, ]), ); @@ -150,6 +153,9 @@ describe('Validator set', () => { uptime: '0', tombstone: false, jailed_number: null, + init_proposal: null, + total_passed_proposals: 0, + total_voted_proposals: 0, }, { valoper_address: 'valoper3', @@ -161,6 +167,9 @@ describe('Validator set', () => { uptime: '0', tombstone: false, jailed_number: null, + init_proposal: null, + total_passed_proposals: 0, + total_voted_proposals: 0, }, ]), ); @@ -168,7 +177,7 @@ describe('Validator set', () => { it('Update validator info', async () => { const { contractClient, account } = context; - const res = await contractClient.updateValidatorInfo( + const res = await contractClient.updateValidatorsInfo( account.address, { validators: [ @@ -208,6 +217,9 @@ describe('Validator set', () => { uptime: '0.5', tombstone: true, jailed_number: 1, + init_proposal: null, + total_passed_proposals: 0, + total_voted_proposals: 0, }, { valoper_address: 'valoper3', @@ -219,6 +231,9 @@ describe('Validator set', () => { uptime: '0.96', tombstone: false, jailed_number: 3, + init_proposal: null, + total_passed_proposals: 0, + total_voted_proposals: 0, }, ]), ); diff --git a/packages/base/src/msg/mod.rs b/packages/base/src/msg/mod.rs index e76c2c0f..bf0be5b6 100644 --- a/packages/base/src/msg/mod.rs +++ b/packages/base/src/msg/mod.rs @@ -2,6 +2,8 @@ pub mod astroport_exchange_handler; pub mod core; pub mod distribution; pub mod hook_tester; +pub mod proposal_votes; +pub mod provider_proposals; pub mod pump; pub mod puppeteer; pub mod reward_handler; diff --git a/packages/base/src/msg/proposal_votes.rs b/packages/base/src/msg/proposal_votes.rs new file mode 100644 index 00000000..29226d67 --- /dev/null +++ b/packages/base/src/msg/proposal_votes.rs @@ -0,0 +1,31 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; + +use crate::state::proposal_votes::{Config, ConfigOptional, Metrics}; + +#[cw_serde] +pub struct InstantiateMsg { + pub connection_id: String, + pub port_id: String, + pub update_period: u64, + pub core_address: String, + pub provider_proposals_address: String, +} + +#[cw_serde] +pub enum ExecuteMsg { + UpdateConfig { new_config: ConfigOptional }, + UpdateActiveProposals { active_proposals: Vec }, + UpdateVotersList { voters: Vec }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(Config)] + Config {}, + #[returns(Metrics)] + Metrics {}, +} + +#[cw_serde] +pub struct MigrateMsg {} diff --git a/packages/base/src/msg/provider_proposals.rs b/packages/base/src/msg/provider_proposals.rs new file mode 100644 index 00000000..1ddf0646 --- /dev/null +++ b/packages/base/src/msg/provider_proposals.rs @@ -0,0 +1,39 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Decimal; +use neutron_sdk::interchain_queries::v045::types::ProposalVote; + +use crate::state::provider_proposals::{Config, ConfigOptional, Metrics, ProposalInfo}; + +#[cw_serde] +pub struct InstantiateMsg { + pub connection_id: String, + pub port_id: String, + pub update_period: u64, + pub core_address: String, + pub validators_set_address: String, + pub init_proposal: u64, + pub proposals_prefetch: u64, + pub veto_spam_threshold: Decimal, +} + +#[cw_serde] +pub enum ExecuteMsg { + UpdateConfig { new_config: ConfigOptional }, + UpdateProposalVotes { votes: Vec }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(Config)] + Config {}, + #[returns(ProposalInfo)] + GetProposal { proposal_id: u64 }, + #[returns(Vec)] + GetProposals {}, + #[returns(Metrics)] + Metrics {}, +} + +#[cw_serde] +pub struct MigrateMsg {} diff --git a/packages/base/src/msg/validatorset.rs b/packages/base/src/msg/validatorset.rs index 25d750e5..4d4f7b7e 100644 --- a/packages/base/src/msg/validatorset.rs +++ b/packages/base/src/msg/validatorset.rs @@ -1,7 +1,9 @@ -use crate::state::validatorset::ConfigOptional; +use crate::state::validatorset::{Config, ConfigOptional, ValidatorInfo}; use cosmwasm_schema::{cw_serde, QueryResponses}; use cosmwasm_std::{Addr, Decimal}; +use crate::state::provider_proposals::ProposalInfo; + #[cw_serde] pub struct InstantiateMsg { pub owner: String, @@ -37,19 +39,22 @@ pub enum ExecuteMsg { UpdateValidator { validator: ValidatorData, }, - UpdateValidatorInfo { + UpdateValidatorsInfo { validators: Vec, }, + UpdateValidatorsVoting { + proposal: ProposalInfo, + }, } #[cw_serde] #[derive(QueryResponses)] pub enum QueryMsg { - #[returns(crate::state::validatorset::Config)] + #[returns(Config)] Config {}, - #[returns(crate::state::validatorset::ValidatorInfo)] + #[returns(ValidatorInfo)] Validator { valoper: Addr }, - #[returns(Vec)] + #[returns(Vec)] Validators {}, } diff --git a/packages/base/src/state/mod.rs b/packages/base/src/state/mod.rs index f5043824..ceea0675 100644 --- a/packages/base/src/state/mod.rs +++ b/packages/base/src/state/mod.rs @@ -1,6 +1,8 @@ pub mod astroport_exchange_handler; pub mod core; pub mod hook_tester; +pub mod proposal_votes; +pub mod provider_proposals; pub mod pump; pub mod puppeteer; pub mod rewards_manager; diff --git a/packages/base/src/state/proposal_votes.rs b/packages/base/src/state/proposal_votes.rs new file mode 100644 index 00000000..12e0f669 --- /dev/null +++ b/packages/base/src/state/proposal_votes.rs @@ -0,0 +1,28 @@ +use cosmwasm_schema::cw_serde; + +use cw_storage_plus::Item; +use optfield::optfield; + +#[optfield(pub ConfigOptional, attrs)] +#[cw_serde] +pub struct Config { + pub connection_id: String, + pub port_id: String, + pub update_period: u64, + pub core_address: String, + pub provider_proposals_address: String, +} + +#[cw_serde] +pub struct Metrics { + pub total_voters: u64, +} + +pub const PROPOSALS_VOTES_REPLY_ID: u64 = 1; +pub const PROPOSALS_VOTES_REMOVE_REPLY_ID: u64 = 2; + +pub const QUERY_ID: Item = Item::new("query_id"); + +pub const CONFIG: Item = Item::new("config"); +pub const ACTIVE_PROPOSALS: Item> = Item::new("active_proposals"); +pub const VOTERS: Item> = Item::new("voters"); diff --git a/packages/base/src/state/provider_proposals.rs b/packages/base/src/state/provider_proposals.rs new file mode 100644 index 00000000..68d943b8 --- /dev/null +++ b/packages/base/src/state/provider_proposals.rs @@ -0,0 +1,41 @@ +use cosmwasm_schema::cw_serde; + +use cosmwasm_std::Decimal; +use cw_storage_plus::{Item, Map}; +use neutron_sdk::interchain_queries::v045::types::{Proposal, ProposalVote}; +use optfield::optfield; + +#[cw_serde] +pub struct ProposalInfo { + pub proposal: Proposal, + pub votes: Option>, + pub is_spam: bool, +} + +#[optfield(pub ConfigOptional, attrs)] +#[cw_serde] +pub struct Config { + pub connection_id: String, + pub port_id: String, + pub update_period: u64, + pub core_address: String, + pub proposal_votes_address: Option, + pub validators_set_address: String, + pub init_proposal: u64, + pub proposals_prefetch: u64, + pub veto_spam_threshold: Decimal, +} + +#[cw_serde] +pub struct Metrics { + pub last_proposal: u64, +} + +pub const PROPOSALS_REPLY_ID: u64 = 1; + +pub const QUERY_ID: Item = Item::new("query_id"); + +pub const CONFIG: Item = Item::new("config"); +pub const ACTIVE_PROPOSALS: Item> = Item::new("active_proposals"); +pub const PROPOSALS: Map = Map::new("proposals"); +pub const PROPOSALS_VOTES: Map> = Map::new("proposals_votes"); diff --git a/packages/base/src/state/validatorset.rs b/packages/base/src/state/validatorset.rs index ec5bcd7e..712b7cdb 100644 --- a/packages/base/src/state/validatorset.rs +++ b/packages/base/src/state/validatorset.rs @@ -8,6 +8,7 @@ use optfield::optfield; pub struct Config { pub owner: Addr, pub stats_contract: Addr, + pub provider_proposals_contract: Option, } #[cw_serde] @@ -21,6 +22,9 @@ pub struct ValidatorInfo { pub uptime: Decimal, pub tombstone: bool, pub jailed_number: Option, + pub init_proposal: Option, + pub total_passed_proposals: u64, + pub total_voted_proposals: u64, } pub const CONFIG: Item = Item::new("config"); diff --git a/packages/helpers/src/reply.rs b/packages/helpers/src/reply.rs new file mode 100644 index 00000000..bcfab52d --- /dev/null +++ b/packages/helpers/src/reply.rs @@ -0,0 +1,16 @@ +use cosmwasm_std::{StdError, StdResult, SubMsgResult}; +use neutron_sdk::bindings::msg::MsgRegisterInterchainQueryResponse; + +pub fn get_query_id(msg_result: SubMsgResult) -> StdResult { + let res: MsgRegisterInterchainQueryResponse = serde_json_wasm::from_slice( + msg_result + .into_result() + .map_err(StdError::generic_err)? + .data + .ok_or_else(|| StdError::generic_err("no result"))? + .as_slice(), + ) + .map_err(|e| StdError::generic_err(format!("failed to parse response: {e:?}")))?; + + Ok(res.id) +}