diff --git a/contracts/voting/dao-voting-token-staked/src/contract.rs b/contracts/voting/dao-voting-token-staked/src/contract.rs index b9d692348..7ffe5263e 100644 --- a/contracts/voting/dao-voting-token-staked/src/contract.rs +++ b/contracts/voting/dao-voting-token-staked/src/contract.rs @@ -15,7 +15,8 @@ use cw_utils::{maybe_addr, must_pay, parse_reply_instantiate_data, Duration}; use dao_hooks::stake::{stake_hook_msgs, unstake_hook_msgs}; use dao_interface::state::ModuleInstantiateCallback; use dao_interface::voting::{ - IsActiveResponse, TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, + IsActiveResponse, LimitAtHeightResponse, TotalPowerAtHeightResponse, + VotingPowerAtHeightResponse, }; use dao_voting::{ duration::validate_duration, @@ -31,8 +32,8 @@ use crate::msg::{ ListStakersResponse, MigrateMsg, NewTokenInfo, QueryMsg, StakerBalanceResponse, TokenInfo, }; use crate::state::{ - Config, ACTIVE_THRESHOLD, CLAIMS, CONFIG, DAO, DENOM, HOOKS, MAX_CLAIMS, STAKED_BALANCES, - STAKED_TOTAL, TOKEN_INSTANTIATION_INFO, TOKEN_ISSUER_CONTRACT, + Config, ACTIVE_THRESHOLD, CLAIMS, CONFIG, DAO, DENOM, HOOKS, LIMITS, MAX_CLAIMS, + STAKED_BALANCES, STAKED_TOTAL, TOKEN_INSTANTIATION_INFO, TOKEN_ISSUER_CONTRACT, }; pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-voting-token-staked"; @@ -142,6 +143,9 @@ pub fn execute( } ExecuteMsg::AddHook { addr } => execute_add_hook(deps, env, info, addr), ExecuteMsg::RemoveHook { addr } => execute_remove_hook(deps, env, info, addr), + ExecuteMsg::UpdateLimit { addr, limit } => { + execute_update_limit(deps, env, info, addr, limit) + } } } @@ -358,6 +362,37 @@ pub fn execute_remove_hook( .add_attribute("hook", addr)) } +pub fn execute_update_limit( + deps: DepsMut, + env: Env, + info: MessageInfo, + addr: String, + limit: Option, +) -> Result { + let dao = DAO.load(deps.storage)?; + if info.sender != dao { + return Err(ContractError::Unauthorized {}); + } + + let addr = deps.api.addr_validate(&addr)?; + + if let Some(limit) = limit { + LIMITS.save(deps.storage, &addr, &limit, env.block.height)?; + } else { + LIMITS.remove(deps.storage, &addr, env.block.height)?; + } + + Ok(Response::new() + .add_attribute("action", "update_limit") + .add_attribute("addr", addr.to_string()) + .add_attribute( + "limit", + limit + .as_ref() + .map_or("None".to_owned(), ToString::to_string), + )) +} + #[cfg_attr(not(feature = "library"), entry_point)] pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { match msg { @@ -380,10 +415,26 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { QueryMsg::IsActive {} => query_is_active(deps), QueryMsg::ActiveThreshold {} => query_active_threshold(deps), QueryMsg::GetHooks {} => to_binary(&query_hooks(deps)?), + QueryMsg::LimitAtHeight { address, height } => { + to_binary(&query_limit_at_height(deps, env, address, height)?) + } QueryMsg::TokenContract {} => to_binary(&TOKEN_ISSUER_CONTRACT.load(deps.storage)?), } } +pub fn query_limit_at_height( + deps: Deps, + env: Env, + address: String, + height: Option, +) -> StdResult { + let height = height.unwrap_or(env.block.height); + let address = deps.api.addr_validate(&address)?; + let limit = LIMITS.may_load_at_height(deps.storage, &address, height)?; + + Ok(LimitAtHeightResponse { limit, height }) +} + pub fn query_voting_power_at_height( deps: Deps, env: Env, @@ -392,9 +443,16 @@ pub fn query_voting_power_at_height( ) -> StdResult { let height = height.unwrap_or(env.block.height); let address = deps.api.addr_validate(&address)?; - let power = STAKED_BALANCES + let mut power = STAKED_BALANCES .may_load_at_height(deps.storage, &address, height)? .unwrap_or_default(); + + // Apply limit + let limit = LIMITS.may_load_at_height(deps.storage, &address, height)?; + if let Some(limit) = limit { + power = Uint128::min(power, limit); + } + Ok(VotingPowerAtHeightResponse { power, height }) } @@ -404,9 +462,24 @@ pub fn query_total_power_at_height( height: Option, ) -> StdResult { let height = height.unwrap_or(env.block.height); - let power = STAKED_TOTAL + let mut power = STAKED_TOTAL .may_load_at_height(deps.storage, height)? .unwrap_or_default(); + + // Adjust power according to limits + for entry in LIMITS.range(deps.storage, None, None, cosmwasm_std::Order::Ascending) { + let user_limit = entry?; + + let user_power = STAKED_BALANCES + .may_load_at_height(deps.storage, &user_limit.0, height)? + .unwrap_or_default(); + + if user_power > user_limit.1 { + let reduced_power = user_power.checked_sub(user_limit.1)?; + power = power.checked_sub(reduced_power)?; + } + } + Ok(TotalPowerAtHeightResponse { power, height }) } diff --git a/contracts/voting/dao-voting-token-staked/src/error.rs b/contracts/voting/dao-voting-token-staked/src/error.rs index 27bea213a..b60d17dca 100644 --- a/contracts/voting/dao-voting-token-staked/src/error.rs +++ b/contracts/voting/dao-voting-token-staked/src/error.rs @@ -1,4 +1,4 @@ -use cosmwasm_std::StdError; +use cosmwasm_std::{StdError, Uint128}; use cw_utils::{ParseReplyError, PaymentError}; use dao_voting::threshold::ActiveThresholdError; use thiserror::Error; @@ -43,4 +43,7 @@ pub enum ContractError { #[error("Amount being unstaked must be non-zero")] ZeroUnstake {}, + + #[error("Limit cannot be exceeded")] + LimitExceeded { limit: Uint128 }, } diff --git a/contracts/voting/dao-voting-token-staked/src/msg.rs b/contracts/voting/dao-voting-token-staked/src/msg.rs index e08123680..cf95fee7b 100644 --- a/contracts/voting/dao-voting-token-staked/src/msg.rs +++ b/contracts/voting/dao-voting-token-staked/src/msg.rs @@ -2,7 +2,10 @@ use cosmwasm_schema::{cw_serde, QueryResponses}; use cosmwasm_std::Uint128; use cw_tokenfactory_issuer::msg::DenomUnit; use cw_utils::Duration; -use dao_dao_macros::{active_query, token_query, voting_module_query}; +use dao_dao_macros::{ + active_query, limitable_voting_module, limitable_voting_module_query, token_query, + voting_module_query, +}; use dao_voting::threshold::{ActiveThreshold, ActiveThresholdResponse}; #[cw_serde] @@ -67,6 +70,7 @@ pub struct InstantiateMsg { pub active_threshold: Option, } +#[limitable_voting_module] #[cw_serde] pub enum ExecuteMsg { /// Stakes tokens with the contract to get voting power in the DAO @@ -89,6 +93,7 @@ pub enum ExecuteMsg { RemoveHook { addr: String }, } +#[limitable_voting_module_query] #[active_query] #[voting_module_query] #[token_query] diff --git a/contracts/voting/dao-voting-token-staked/src/state.rs b/contracts/voting/dao-voting-token-staked/src/state.rs index 6fb8bc4a0..3ba2bb41e 100644 --- a/contracts/voting/dao-voting-token-staked/src/state.rs +++ b/contracts/voting/dao-voting-token-staked/src/state.rs @@ -38,6 +38,14 @@ pub const STAKED_TOTAL: SnapshotItem = SnapshotItem::new( Strategy::EveryBlock, ); +/// Keeps track of voting power limits by address +pub const LIMITS: SnapshotMap<&Addr, Uint128> = SnapshotMap::new( + "limits", + "limits__checkpoints", + "limits__changelog", + Strategy::EveryBlock, +); + /// The maximum number of claims that may be outstanding. pub const MAX_CLAIMS: u64 = 100; diff --git a/contracts/voting/dao-voting-token-staked/src/tests/multitest/tests.rs b/contracts/voting/dao-voting-token-staked/src/tests/multitest/tests.rs index ab56d26f1..f8e36f5db 100644 --- a/contracts/voting/dao-voting-token-staked/src/tests/multitest/tests.rs +++ b/contracts/voting/dao-voting-token-staked/src/tests/multitest/tests.rs @@ -12,7 +12,8 @@ use cw_multi_test::{ }; use cw_utils::Duration; use dao_interface::voting::{ - InfoResponse, IsActiveResponse, TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, + InfoResponse, IsActiveResponse, LimitAtHeightResponse, TotalPowerAtHeightResponse, + VotingPowerAtHeightResponse, }; use dao_voting::threshold::{ActiveThreshold, ActiveThresholdResponse}; @@ -161,7 +162,7 @@ fn update_config( } fn get_voting_power_at_height( - app: &mut App, + app: &App, staking_addr: Addr, address: String, height: Option, @@ -175,7 +176,7 @@ fn get_voting_power_at_height( } fn get_total_power_at_height( - app: &mut App, + app: &App, staking_addr: Addr, height: Option, ) -> TotalPowerAtHeightResponse { @@ -184,19 +185,19 @@ fn get_total_power_at_height( .unwrap() } -fn get_config(app: &mut App, staking_addr: Addr) -> Config { +fn get_config(app: &App, staking_addr: Addr) -> Config { app.wrap() .query_wasm_smart(staking_addr, &QueryMsg::GetConfig {}) .unwrap() } -fn get_claims(app: &mut App, staking_addr: Addr, address: String) -> ClaimsResponse { +fn get_claims(app: &App, staking_addr: Addr, address: String) -> ClaimsResponse { app.wrap() .query_wasm_smart(staking_addr, &QueryMsg::Claims { address }) .unwrap() } -fn get_balance(app: &mut App, address: &str, denom: &str) -> Uint128 { +fn get_balance(app: &App, address: &str, denom: &str) -> Uint128 { app.wrap().query_balance(address, denom).unwrap().amount } @@ -426,7 +427,7 @@ fn test_unstake() { unstake_tokens(&mut app, addr.clone(), ADDR1, 75).unwrap(); // Query claims - let claims = get_claims(&mut app, addr.clone(), ADDR1.to_string()); + let claims = get_claims(&app, addr.clone(), ADDR1.to_string()); assert_eq!(claims.claims.len(), 1); app.update_block(next_block); @@ -434,7 +435,7 @@ fn test_unstake() { unstake_tokens(&mut app, addr.clone(), ADDR1, 25).unwrap(); // Query claims - let claims = get_claims(&mut app, addr, ADDR1.to_string()); + let claims = get_claims(&app, addr, ADDR1.to_string()); assert_eq!(claims.claims.len(), 2); } @@ -464,14 +465,14 @@ fn test_unstake_no_unstaking_duration() { app.update_block(next_block); - let balance = get_balance(&mut app, ADDR1, DENOM); + let balance = get_balance(&app, ADDR1, DENOM); // 10000 (initial bal) - 100 (staked) + 75 (unstaked) = 9975 assert_eq!(balance, Uint128::new(9975)); // Unstake the rest unstake_tokens(&mut app, addr, ADDR1, 25).unwrap(); - let balance = get_balance(&mut app, ADDR1, DENOM); + let balance = get_balance(&app, ADDR1, DENOM); // 10000 (initial bal) - 100 (staked) + 75 (unstaked 1) + 25 (unstaked 2) = 10000 assert_eq!(balance, Uint128::new(10000)) } @@ -559,7 +560,7 @@ fn test_claim() { claim(&mut app, addr.clone(), ADDR1).unwrap(); // Query balance - let balance = get_balance(&mut app, ADDR1, DENOM); + let balance = get_balance(&app, ADDR1, DENOM); // 10000 (initial bal) - 100 (staked) + 75 (unstaked) = 9975 assert_eq!(balance, Uint128::new(9975)); @@ -574,7 +575,7 @@ fn test_claim() { claim(&mut app, addr, ADDR1).unwrap(); // Query balance - let balance = get_balance(&mut app, ADDR1, DENOM); + let balance = get_balance(&app, ADDR1, DENOM); // 10000 (initial bal) - 100 (staked) + 75 (unstaked 1) + 25 (unstaked 2) = 10000 assert_eq!(balance, Uint128::new(10000)); } @@ -621,7 +622,7 @@ fn test_update_config_as_owner() { // Swap owner and manager, change duration update_config(&mut app, addr.clone(), DAO_ADDR, Some(Duration::Height(10))).unwrap(); - let config = get_config(&mut app, addr); + let config = get_config(&app, addr); assert_eq!( Config { unstaking_duration: Some(Duration::Height(10)), @@ -713,7 +714,7 @@ fn test_query_claims() { }, ); - let claims = get_claims(&mut app, addr.clone(), ADDR1.to_string()); + let claims = get_claims(&app, addr.clone(), ADDR1.to_string()); assert_eq!(claims.claims.len(), 0); // Stake some tokens @@ -724,13 +725,13 @@ fn test_query_claims() { unstake_tokens(&mut app, addr.clone(), ADDR1, 25).unwrap(); app.update_block(next_block); - let claims = get_claims(&mut app, addr.clone(), ADDR1.to_string()); + let claims = get_claims(&app, addr.clone(), ADDR1.to_string()); assert_eq!(claims.claims.len(), 1); unstake_tokens(&mut app, addr.clone(), ADDR1, 25).unwrap(); app.update_block(next_block); - let claims = get_claims(&mut app, addr, ADDR1.to_string()); + let claims = get_claims(&app, addr, ADDR1.to_string()); assert_eq!(claims.claims.len(), 2); } @@ -751,7 +752,7 @@ fn test_query_get_config() { }, ); - let config = get_config(&mut app, addr); + let config = get_config(&app, addr); assert_eq!( config, Config { @@ -778,11 +779,11 @@ fn test_voting_power_queries() { ); // Total power is 0 - let resp = get_total_power_at_height(&mut app, addr.clone(), None); + let resp = get_total_power_at_height(&app, addr.clone(), None); assert!(resp.power.is_zero()); // ADDR1 has no power, none staked - let resp = get_voting_power_at_height(&mut app, addr.clone(), ADDR1.to_string(), None); + let resp = get_voting_power_at_height(&app, addr.clone(), ADDR1.to_string(), None); assert!(resp.power.is_zero()); // ADDR1 stakes @@ -790,15 +791,15 @@ fn test_voting_power_queries() { app.update_block(next_block); // Total power is 100 - let resp = get_total_power_at_height(&mut app, addr.clone(), None); + let resp = get_total_power_at_height(&app, addr.clone(), None); assert_eq!(resp.power, Uint128::new(100)); // ADDR1 has 100 power - let resp = get_voting_power_at_height(&mut app, addr.clone(), ADDR1.to_string(), None); + let resp = get_voting_power_at_height(&app, addr.clone(), ADDR1.to_string(), None); assert_eq!(resp.power, Uint128::new(100)); // ADDR2 still has 0 power - let resp = get_voting_power_at_height(&mut app, addr.clone(), ADDR2.to_string(), None); + let resp = get_voting_power_at_height(&app, addr.clone(), ADDR2.to_string(), None); assert!(resp.power.is_zero()); // ADDR2 stakes @@ -808,30 +809,28 @@ fn test_voting_power_queries() { // Query the previous height, total 100, ADDR1 100, ADDR2 0 // Total power is 100 - let resp = get_total_power_at_height(&mut app, addr.clone(), Some(prev_height)); + let resp = get_total_power_at_height(&app, addr.clone(), Some(prev_height)); assert_eq!(resp.power, Uint128::new(100)); // ADDR1 has 100 power - let resp = - get_voting_power_at_height(&mut app, addr.clone(), ADDR1.to_string(), Some(prev_height)); + let resp = get_voting_power_at_height(&app, addr.clone(), ADDR1.to_string(), Some(prev_height)); assert_eq!(resp.power, Uint128::new(100)); // ADDR2 still has 0 power - let resp = - get_voting_power_at_height(&mut app, addr.clone(), ADDR2.to_string(), Some(prev_height)); + let resp = get_voting_power_at_height(&app, addr.clone(), ADDR2.to_string(), Some(prev_height)); assert!(resp.power.is_zero()); // For current height, total 150, ADDR1 100, ADDR2 50 // Total power is 150 - let resp = get_total_power_at_height(&mut app, addr.clone(), None); + let resp = get_total_power_at_height(&app, addr.clone(), None); assert_eq!(resp.power, Uint128::new(150)); // ADDR1 has 100 power - let resp = get_voting_power_at_height(&mut app, addr.clone(), ADDR1.to_string(), None); + let resp = get_voting_power_at_height(&app, addr.clone(), ADDR1.to_string(), None); assert_eq!(resp.power, Uint128::new(100)); // ADDR2 now has 50 power - let resp = get_voting_power_at_height(&mut app, addr.clone(), ADDR2.to_string(), None); + let resp = get_voting_power_at_height(&app, addr.clone(), ADDR2.to_string(), None); assert_eq!(resp.power, Uint128::new(50)); // ADDR1 unstakes half @@ -841,30 +840,28 @@ fn test_voting_power_queries() { // Query the previous height, total 150, ADDR1 100, ADDR2 50 // Total power is 100 - let resp = get_total_power_at_height(&mut app, addr.clone(), Some(prev_height)); + let resp = get_total_power_at_height(&app, addr.clone(), Some(prev_height)); assert_eq!(resp.power, Uint128::new(150)); // ADDR1 has 100 power - let resp = - get_voting_power_at_height(&mut app, addr.clone(), ADDR1.to_string(), Some(prev_height)); + let resp = get_voting_power_at_height(&app, addr.clone(), ADDR1.to_string(), Some(prev_height)); assert_eq!(resp.power, Uint128::new(100)); // ADDR2 still has 0 power - let resp = - get_voting_power_at_height(&mut app, addr.clone(), ADDR2.to_string(), Some(prev_height)); + let resp = get_voting_power_at_height(&app, addr.clone(), ADDR2.to_string(), Some(prev_height)); assert_eq!(resp.power, Uint128::new(50)); // For current height, total 100, ADDR1 50, ADDR2 50 // Total power is 100 - let resp = get_total_power_at_height(&mut app, addr.clone(), None); + let resp = get_total_power_at_height(&app, addr.clone(), None); assert_eq!(resp.power, Uint128::new(100)); // ADDR1 has 50 power - let resp = get_voting_power_at_height(&mut app, addr.clone(), ADDR1.to_string(), None); + let resp = get_voting_power_at_height(&app, addr.clone(), ADDR1.to_string(), None); assert_eq!(resp.power, Uint128::new(50)); // ADDR2 now has 50 power - let resp = get_voting_power_at_height(&mut app, addr, ADDR2.to_string(), None); + let resp = get_voting_power_at_height(&app, addr, ADDR2.to_string(), None); assert_eq!(resp.power, Uint128::new(50)); } @@ -1365,3 +1362,133 @@ pub fn test_migrate_update_version() { assert_eq!(version.version, CONTRACT_VERSION); assert_eq!(version.contract, CONTRACT_NAME); } + +#[test] +pub fn test_limit() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(20), + }), + }, + ); + + let staked = Uint128::from(5000u128); + let limit = Uint128::from(100u128); + + // Non-owner cannot update limits + let res = app.execute_contract( + Addr::unchecked("random"), + addr.clone(), + &ExecuteMsg::UpdateLimit { + addr: ADDR1.to_string(), + limit: Some(Uint128::from(100u128)), + }, + &[], + ); + assert!(res.is_err()); + + // Owner can update limits + app.execute_contract( + Addr::unchecked(DAO_ADDR), + addr.clone(), + &ExecuteMsg::UpdateLimit { + addr: ADDR1.to_string(), + limit: Some(limit.clone()), + }, + &[], + ) + .unwrap(); + app.update_block(next_block); + + // Assert that the limit was set + let limit_response: LimitAtHeightResponse = app + .wrap() + .query_wasm_smart( + addr.clone(), + &QueryMsg::LimitAtHeight { + address: ADDR1.to_string(), + height: None, + }, + ) + .unwrap(); + assert_eq!(limit_response.limit, Some(limit.clone())); + + // Stake 5000 tokens which is over the limit of 100 + stake_tokens(&mut app, addr.clone(), ADDR1, staked.u128(), DENOM).unwrap(); + app.update_block(next_block); + + // Query voting power + let voting_power_response: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: ADDR1.to_string(), + height: None, + }, + ) + .unwrap(); + assert_eq!(voting_power_response.power, limit.clone()); + + // Query total power + let total_power_response: TotalPowerAtHeightResponse = app + .wrap() + .query_wasm_smart(addr.clone(), &QueryMsg::TotalPowerAtHeight { height: None }) + .unwrap(); + assert_eq!(total_power_response.power, limit.clone()); + + // Owner can remove limit + app.execute_contract( + Addr::unchecked(DAO_ADDR), + addr.clone(), + &ExecuteMsg::UpdateLimit { + addr: ADDR1.to_string(), + limit: None, + }, + &[], + ) + .unwrap(); + app.update_block(next_block); + + // Assert that the limit was removed + let limit_response: LimitAtHeightResponse = app + .wrap() + .query_wasm_smart( + addr.clone(), + &QueryMsg::LimitAtHeight { + address: ADDR1.to_string(), + height: None, + }, + ) + .unwrap(); + assert_eq!(limit_response.limit, None); + + // Query voting power without limit + let voting_power_response: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: ADDR1.to_string(), + height: None, + }, + ) + .unwrap(); + assert_eq!(voting_power_response.power, staked.clone()); + + // Query total power without limit + let total_power_response: TotalPowerAtHeightResponse = app + .wrap() + .query_wasm_smart(addr.clone(), &QueryMsg::TotalPowerAtHeight { height: None }) + .unwrap(); + assert_eq!(total_power_response.power, staked.clone()); +} diff --git a/packages/dao-dao-macros/src/lib.rs b/packages/dao-dao-macros/src/lib.rs index 8b2dddda9..7fa5e1033 100644 --- a/packages/dao-dao-macros/src/lib.rs +++ b/packages/dao-dao-macros/src/lib.rs @@ -397,3 +397,43 @@ pub fn limit_variant_count(metadata: TokenStream, input: TokenStream) -> TokenSt } .into() } + +/// Limits the voting power for an address +#[proc_macro_attribute] +pub fn limitable_voting_module(metadata: TokenStream, input: TokenStream) -> TokenStream { + merge_variants( + metadata, + input, + quote! { + enum Right { + UpdateLimit { + addr: ::std::string::String, + limit: ::std::option::Option<::cosmwasm_std::Uint128> + } + } + } + .into(), + ) +} + +/// Allows querying voting module limits +#[proc_macro_attribute] +pub fn limitable_voting_module_query(metadata: TokenStream, input: TokenStream) -> TokenStream { + let l = dao_interface_path("voting::LimitAtHeightResponse"); + + merge_variants( + metadata, + input, + quote! { + enum Right { + /// Returns the voting power limit for an address + #[returns(#l)] + LimitAtHeight { + address: ::std::string::String, + height: ::std::option::Option<::std::primitive::u64> + } + } + } + .into(), + ) +} diff --git a/packages/dao-interface/src/voting.rs b/packages/dao-interface/src/voting.rs index f2df11a44..694d196e8 100644 --- a/packages/dao-interface/src/voting.rs +++ b/packages/dao-interface/src/voting.rs @@ -42,6 +42,12 @@ pub struct TotalPowerAtHeightResponse { pub height: u64, } +#[cw_serde] +pub struct LimitAtHeightResponse { + pub limit: Option, + pub height: u64, +} + #[cw_serde] pub struct InfoResponse { pub info: ContractVersion,