diff --git a/Cargo.lock b/Cargo.lock index d3edb699c..c04a23a82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -293,6 +293,31 @@ dependencies = [ "sha2 0.9.9", ] +[[package]] +name = "btsg-ft-factory" +version = "2.5.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw2 1.1.2", + "dao-dao-core", + "dao-interface", + "dao-proposal-single", + "dao-testing", + "dao-voting 2.5.0", + "dao-voting-token-staked", + "osmosis-std-derive", + "prost 0.12.3", + "prost-types 0.12.3", + "schemars", + "serde", + "thiserror", +] + [[package]] name = "bumpalo" version = "3.14.0" diff --git a/contracts/external/btsg-ft-factory/Cargo.toml b/contracts/external/btsg-ft-factory/Cargo.toml new file mode 100644 index 000000000..f88dfdee8 --- /dev/null +++ b/contracts/external/btsg-ft-factory/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "btsg-ft-factory" +authors = ["noah "] +description = "A CosmWasm factory contract for issuing fantokens on BitSong." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[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] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +dao-interface = { workspace = true } +osmosis-std-derive = { workspace = true } +prost = { workspace = true } +prost-types = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-multi-test = { workspace = true } +cw-utils = { workspace = true } +dao-dao-core = { workspace = true, features = ["library"] } +dao-proposal-single = { workspace = true, features = ["library"] } +dao-testing = { workspace = true } +dao-voting-token-staked = { workspace = true, features = ["library"] } +dao-voting = { workspace = true } diff --git a/contracts/external/btsg-ft-factory/README.md b/contracts/external/btsg-ft-factory/README.md new file mode 100644 index 000000000..4ccf751f5 --- /dev/null +++ b/contracts/external/btsg-ft-factory/README.md @@ -0,0 +1,11 @@ +# btsg-ft-factory + +Serves as a factory that issues new +[fantokens](https://github.com/bitsongofficial/go-bitsong/tree/main/x/fantoken) +on BitSong and returns their denom for use with the +[dao-voting-token-staked](../../voting/dao-voting-token-staked) voting module +contract. + +Instantiation and execution are permissionless. All DAOs will use the same +factory and execute `Issue` to create new fantokens through `TokenInfo::Factory` +during voting module instantiation. diff --git a/contracts/external/btsg-ft-factory/examples/schema.rs b/contracts/external/btsg-ft-factory/examples/schema.rs new file mode 100644 index 000000000..ffa4b588a --- /dev/null +++ b/contracts/external/btsg-ft-factory/examples/schema.rs @@ -0,0 +1,11 @@ +use btsg_ft_factory::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; +use cosmwasm_schema::write_api; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/external/btsg-ft-factory/schema/btsg-ft-factory.json b/contracts/external/btsg-ft-factory/schema/btsg-ft-factory.json new file mode 100644 index 000000000..66c4d8f67 --- /dev/null +++ b/contracts/external/btsg-ft-factory/schema/btsg-ft-factory.json @@ -0,0 +1,117 @@ +{ + "contract_name": "btsg-ft-factory", + "contract_version": "2.5.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "additionalProperties": false + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Issues a new fantoken.", + "type": "object", + "required": [ + "issue" + ], + "properties": { + "issue": { + "$ref": "#/definitions/NewFanToken" + } + }, + "additionalProperties": false + } + ], + "definitions": { + "InitialBalance": { + "type": "object", + "required": [ + "address", + "amount" + ], + "properties": { + "address": { + "type": "string" + }, + "amount": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + }, + "NewFanToken": { + "type": "object", + "required": [ + "initial_balances", + "max_supply", + "name", + "symbol", + "uri" + ], + "properties": { + "initial_balances": { + "description": "The initial balances to set for the token, cannot be empty.", + "type": "array", + "items": { + "$ref": "#/definitions/InitialBalance" + } + }, + "initial_dao_balance": { + "description": "Optional balance to mint for the DAO.", + "anyOf": [ + { + "$ref": "#/definitions/Uint128" + }, + { + "type": "null" + } + ] + }, + "max_supply": { + "description": "Fan token max supply.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "name": { + "description": "Fan token name.", + "type": "string" + }, + "symbol": { + "description": "Fan token symbol.", + "type": "string" + }, + "uri": { + "description": "Fan token URI.", + "type": "string" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "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.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "type": "string", + "enum": [] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "additionalProperties": false + }, + "sudo": null, + "responses": {} +} diff --git a/contracts/external/btsg-ft-factory/src/bitsong.rs b/contracts/external/btsg-ft-factory/src/bitsong.rs new file mode 100644 index 000000000..0f047e97e --- /dev/null +++ b/contracts/external/btsg-ft-factory/src/bitsong.rs @@ -0,0 +1,209 @@ +use osmosis_std_derive::CosmwasmExt; + +/// Coin defines a token with a denomination and an amount. +/// +/// NOTE: The amount field is an Int which implements the custom method +/// signatures required by gogoproto. +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + ::serde::Serialize, + ::serde::Deserialize, + schemars::JsonSchema, + CosmwasmExt, +)] +#[proto_message(type_url = "/cosmos.base.v1beta1.Coin")] +pub struct Coin { + #[prost(string, tag = "1")] + pub denom: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub amount: ::prost::alloc::string::String, +} + +// see https://github.com/bitsongofficial/go-bitsong/blob/dfa3563dccf990eac1d9dc4462c2850b9b2a21e1/proto/bitsong/fantoken/v1beta1/tx.proto + +/// MsgIssue defines a message for issuing a new fan token +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + CosmwasmExt, +)] +#[proto_message(type_url = "/bitsong.fantoken.MsgIssue")] +pub struct MsgIssue { + #[prost(string, tag = "1")] + pub symbol: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub name: ::prost::alloc::string::String, + #[prost(string, tag = "3")] + pub max_supply: ::prost::alloc::string::String, + #[prost(string, tag = "4")] + pub authority: ::prost::alloc::string::String, + #[prost(string, tag = "5")] + pub minter: ::prost::alloc::string::String, + #[prost(string, tag = "6")] + pub uri: ::prost::alloc::string::String, +} + +/// MsgIssueResponse defines the MsgIssue response type +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + CosmwasmExt, +)] +#[proto_message(type_url = "/bitsong.fantoken.MsgIssueResponse")] +pub struct MsgIssueResponse { + #[prost(string, tag = "1")] + pub denom: ::prost::alloc::string::String, +} + +/// MsgMint defines a message for minting a new fan token +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + CosmwasmExt, +)] +#[proto_message(type_url = "/bitsong.fantoken.MsgMint")] +pub struct MsgMint { + #[prost(string, tag = "1")] + pub recipient: ::prost::alloc::string::String, + #[prost(message, tag = "2")] + pub coin: ::core::option::Option, + #[prost(string, tag = "3")] + pub minter: ::prost::alloc::string::String, +} + +/// MsgMintResponse defines the MsgMint response type +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + CosmwasmExt, +)] +#[proto_message(type_url = "/bitsong.fantoken.MsgMintResponse")] +pub struct MsgMintResponse {} + +/// MsgSetMinter defines a message for changing the fan token minter address +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + CosmwasmExt, +)] +#[proto_message(type_url = "/bitsong.fantoken.MsgSetMinter")] +pub struct MsgSetMinter { + #[prost(string, tag = "1")] + pub denom: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub old_minter: ::prost::alloc::string::String, + #[prost(string, tag = "3")] + pub new_minter: ::prost::alloc::string::String, +} + +/// MsgSetMinterResponse defines the MsgSetMinter response type +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + CosmwasmExt, +)] +#[proto_message(type_url = "/bitsong.fantoken.MsgSetMinterResponse")] +pub struct MsgSetMinterResponse {} + +// MsgSetAuthority defines a message for changing the fan token minter address +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + CosmwasmExt, +)] +#[proto_message(type_url = "/bitsong.fantoken.MsgSetAuthority")] +pub struct MsgSetAuthority { + #[prost(string, tag = "1")] + pub denom: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub old_authority: ::prost::alloc::string::String, + #[prost(string, tag = "3")] + pub new_authority: ::prost::alloc::string::String, +} + +// MsgSetAuthorityResponse defines the MsgSetAuthority response type +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + CosmwasmExt, +)] +#[proto_message(type_url = "/bitsong.fantoken.MsgSetAuthorityResponse")] +pub struct MsgSetAuthorityResponse {} + +/// MsgSetUri defines a message for updating the fan token URI +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + CosmwasmExt, +)] +#[proto_message(type_url = "/bitsong.fantoken.MsgSetUri")] +pub struct MsgSetUri { + #[prost(string, tag = "1")] + pub authority: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub denom: ::prost::alloc::string::String, + #[prost(string, tag = "3")] + pub uri: ::prost::alloc::string::String, +} + +/// MsgSetUriResponse defines the MsgSetUri response type +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + CosmwasmExt, +)] +#[proto_message(type_url = "/bitsong.fantoken.MsgSetUriResponse")] +pub struct MsgSetUriResponse {} diff --git a/contracts/external/btsg-ft-factory/src/contract.rs b/contracts/external/btsg-ft-factory/src/contract.rs new file mode 100644 index 000000000..86f0806b5 --- /dev/null +++ b/contracts/external/btsg-ft-factory/src/contract.rs @@ -0,0 +1,181 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_json_binary, Addr, Binary, CosmosMsg, Deps, DepsMut, Env, MessageInfo, Reply, Response, + StdError, StdResult, SubMsg, +}; + +use cw2::set_contract_version; +use dao_interface::token::{InitialBalance, TokenFactoryCallback}; + +use crate::bitsong::{Coin, MsgIssue, MsgIssueResponse, MsgMint, MsgSetAuthority, MsgSetMinter}; +use crate::error::ContractError; +use crate::msg::{CreatingFanToken, ExecuteMsg, InstantiateMsg, MigrateMsg, NewFanToken, QueryMsg}; +use crate::state::CREATING_FAN_TOKEN; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:btsg-ft-factory"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const ISSUE_REPLY_ID: u64 = 0; + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + _msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + Ok(Response::new() + .add_attribute("method", "instantiate") + .add_attribute("creator", info.sender)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Issue(issue_info) => execute_issue(deps, env, info, issue_info), + } +} + +pub fn execute_issue( + deps: DepsMut, + env: Env, + info: MessageInfo, + token: NewFanToken, +) -> Result { + let dao: Addr = deps + .querier + .query_wasm_smart(info.sender, &dao_interface::voting::Query::Dao {})?; + + CREATING_FAN_TOKEN.save( + deps.storage, + &CreatingFanToken { + token: token.clone(), + dao: dao.clone(), + }, + )?; + + let msg = SubMsg::reply_on_success( + MsgIssue { + symbol: token.symbol, + name: token.name, + max_supply: token.max_supply.to_string(), + // this needs to be the current contract address as the authority is + // used to determine who is allowed to send this message. will be + // set to DAO in reply once token is issued. + authority: env.contract.address.to_string(), + // this needs to be the current contract address as we mint initial + // balances in the reply. will be set to DAO in reply once initial + // balances are minted. + minter: env.contract.address.to_string(), + uri: token.uri, + }, + ISSUE_REPLY_ID, + ); + + Ok(Response::default() + .add_attribute("action", "issue") + .add_submessage(msg)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(_deps: Deps, _env: Env, _msg: QueryMsg) -> StdResult { + Err(StdError::generic_err("no queries")) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(deps: DepsMut, env: Env, msg: Reply) -> Result { + match msg.id { + ISSUE_REPLY_ID => { + let MsgIssueResponse { denom } = msg.result.try_into()?; + + // must load fan token info from execution + let CreatingFanToken { token, dao } = CREATING_FAN_TOKEN.load(deps.storage)?; + + // mgs to be executed to finalize setup + let mut msgs: Vec = vec![]; + + // mint tokens for initial balances + token + .initial_balances + .iter() + .for_each(|b: &InitialBalance| { + msgs.push( + MsgMint { + recipient: b.address.clone(), + coin: Some(Coin { + amount: b.amount.to_string(), + denom: denom.clone(), + }), + minter: env.contract.address.to_string(), + } + .into(), + ); + }); + + // add initial DAO balance to initial_balances if nonzero + if let Some(initial_dao_balance) = token.initial_dao_balance { + if !initial_dao_balance.is_zero() { + msgs.push( + MsgMint { + recipient: dao.to_string(), + coin: Some(Coin { + amount: initial_dao_balance.to_string(), + denom: denom.clone(), + }), + minter: env.contract.address.to_string(), + } + .into(), + ); + } + } + + // set authority and minter to DAO + msgs.push( + MsgSetAuthority { + denom: denom.clone(), + old_authority: env.contract.address.to_string(), + new_authority: dao.to_string(), + } + .into(), + ); + msgs.push( + MsgSetMinter { + denom: denom.clone(), + old_minter: env.contract.address.to_string(), + new_minter: dao.to_string(), + } + .into(), + ); + + // create reply data for dao-voting-token-staked + let data = to_json_binary(&TokenFactoryCallback { + denom: denom.clone(), + token_contract: None, + module_instantiate_callback: None, + })?; + + // remove since we don't need it anymore + CREATING_FAN_TOKEN.remove(deps.storage); + + Ok(Response::default() + .add_messages(msgs) + .set_data(data) + .add_attribute("fantoken_denom", denom)) + } + _ => Err(ContractError::UnknownReplyID {}), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + // Set contract to version to latest + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(Response::default()) +} diff --git a/contracts/external/btsg-ft-factory/src/error.rs b/contracts/external/btsg-ft-factory/src/error.rs new file mode 100644 index 000000000..b51988fea --- /dev/null +++ b/contracts/external/btsg-ft-factory/src/error.rs @@ -0,0 +1,11 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("An unknown reply ID was received.")] + UnknownReplyID {}, +} diff --git a/contracts/external/btsg-ft-factory/src/lib.rs b/contracts/external/btsg-ft-factory/src/lib.rs new file mode 100644 index 000000000..f5c4da5bd --- /dev/null +++ b/contracts/external/btsg-ft-factory/src/lib.rs @@ -0,0 +1,14 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod bitsong; +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +mod shim; + +pub use crate::error::ContractError; + +#[cfg(test)] +mod testing; diff --git a/contracts/external/btsg-ft-factory/src/msg.rs b/contracts/external/btsg-ft-factory/src/msg.rs new file mode 100644 index 000000000..e54936860 --- /dev/null +++ b/contracts/external/btsg-ft-factory/src/msg.rs @@ -0,0 +1,43 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Addr, Uint128}; +use dao_interface::token::InitialBalance; + +#[cw_serde] +pub struct InstantiateMsg {} + +#[cw_serde] +pub enum ExecuteMsg { + /// Issues a new fantoken. + Issue(NewFanToken), +} + +#[cw_serde] +pub struct CreatingFanToken { + /// Fan token info. + pub token: NewFanToken, + /// DAO address. + pub dao: Addr, +} + +#[cw_serde] +pub struct NewFanToken { + /// Fan token symbol. + pub symbol: String, + /// Fan token name. + pub name: String, + /// Fan token max supply. + pub max_supply: Uint128, + /// Fan token URI. + pub uri: String, + /// The initial balances to set for the token, cannot be empty. + pub initial_balances: Vec, + /// Optional balance to mint for the DAO. + pub initial_dao_balance: Option, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg {} + +#[cw_serde] +pub struct MigrateMsg {} diff --git a/contracts/external/btsg-ft-factory/src/shim.rs b/contracts/external/btsg-ft-factory/src/shim.rs new file mode 100644 index 000000000..bd5d321da --- /dev/null +++ b/contracts/external/btsg-ft-factory/src/shim.rs @@ -0,0 +1,63 @@ +// depended on by osmosis_std +#[derive(Clone, PartialEq, Eq, ::prost::Message, schemars::JsonSchema)] +pub struct Any { + /// A URL/resource name that uniquely identifies the type of the serialized + /// protocol buffer message. This string must contain at least + /// one "/" character. The last segment of the URL's path must represent + /// the fully qualified name of the type (as in + /// `path/google.protobuf.Duration`). The name should be in a canonical form + /// (e.g., leading "." is not accepted). + /// + /// In practice, teams usually precompile into the binary all types that they + /// expect it to use in the context of Any. However, for URLs which use the + /// scheme `http`, `https`, or no scheme, one can optionally set up a type + /// server that maps type URLs to message definitions as follows: + /// + /// * If no scheme is provided, `https` is assumed. + /// * An HTTP GET on the URL must yield a \[google.protobuf.Type][\] + /// value in binary format, or produce an error. + /// * Applications are allowed to cache lookup results based on the + /// URL, or have them precompiled into a binary to avoid any + /// lookup. Therefore, binary compatibility needs to be preserved + /// on changes to types. (Use versioned type names to manage + /// breaking changes.) + /// + /// Note: this functionality is not currently available in the official + /// protobuf release, and it is not used for type URLs beginning with + /// type.googleapis.com. + /// + /// Schemes other than `http`, `https` (or the empty scheme) might be + /// used with implementation specific semantics. + /// + #[prost(string, tag = "1")] + pub type_url: ::prost::alloc::string::String, + /// Must be a valid serialized protocol buffer of the above specified type. + #[prost(bytes = "vec", tag = "2")] + pub value: ::prost::alloc::vec::Vec, +} + +macro_rules! impl_prost_types_exact_conversion { + ($t:ident | $($arg:ident),*) => { + impl From<$t> for prost_types::$t { + fn from(src: $t) -> Self { + prost_types::$t { + $( + $arg: src.$arg, + )* + } + } + } + + impl From for $t { + fn from(src: prost_types::$t) -> Self { + $t { + $( + $arg: src.$arg, + )* + } + } + } + }; +} + +impl_prost_types_exact_conversion! { Any | type_url, value } diff --git a/contracts/external/btsg-ft-factory/src/state.rs b/contracts/external/btsg-ft-factory/src/state.rs new file mode 100644 index 000000000..aef1663f9 --- /dev/null +++ b/contracts/external/btsg-ft-factory/src/state.rs @@ -0,0 +1,7 @@ +use cw_storage_plus::Item; + +use crate::msg::CreatingFanToken; + +/// Temporarily holds data about the fan token being created that's needed in +/// reply so we can mint initial tokens and reset the minter. +pub const CREATING_FAN_TOKEN: Item = Item::new("cft"); diff --git a/contracts/external/btsg-ft-factory/src/testing/app.rs b/contracts/external/btsg-ft-factory/src/testing/app.rs new file mode 100644 index 000000000..558e16f80 --- /dev/null +++ b/contracts/external/btsg-ft-factory/src/testing/app.rs @@ -0,0 +1,61 @@ +use std::ops::{Deref, DerefMut}; + +use crate::testing::bitsong_stargate::StargateKeeper; +use cosmwasm_std::{testing::MockApi, Empty, GovMsg, IbcMsg, IbcQuery, MemoryStorage}; +use cw_multi_test::{ + no_init, App, AppBuilder, BankKeeper, DistributionKeeper, FailingModule, StakeKeeper, + WasmKeeper, +}; +#[allow(clippy::type_complexity)] +pub struct BitsongApp( + App< + BankKeeper, + MockApi, + MemoryStorage, + FailingModule, + WasmKeeper, + StakeKeeper, + DistributionKeeper, + FailingModule, + FailingModule, + StargateKeeper, + >, +); +impl Deref for BitsongApp { + type Target = App< + BankKeeper, + MockApi, + MemoryStorage, + FailingModule, + WasmKeeper, + StakeKeeper, + DistributionKeeper, + FailingModule, + FailingModule, + StargateKeeper, + >; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for BitsongApp { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} +impl Default for BitsongApp { + fn default() -> Self { + Self::new() + } +} + +impl BitsongApp { + pub fn new() -> Self { + let app_builder = AppBuilder::default(); + let stargate = StargateKeeper {}; + let app = app_builder.with_stargate(stargate).build(no_init); + BitsongApp(app) + } +} diff --git a/contracts/external/btsg-ft-factory/src/testing/bitsong_stargate.rs b/contracts/external/btsg-ft-factory/src/testing/bitsong_stargate.rs new file mode 100644 index 000000000..024527888 --- /dev/null +++ b/contracts/external/btsg-ft-factory/src/testing/bitsong_stargate.rs @@ -0,0 +1,180 @@ +use anyhow::Error; +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{ + from_json, to_json_binary, Addr, Api, Binary, BlockInfo, Coin, Querier, Storage, Uint64, +}; +use cw_multi_test::{error::AnyResult, AppResponse, BankSudo, CosmosRouter, Stargate, SudoMsg}; +use prost::Message; + +use crate::bitsong::{ + MsgIssue, MsgIssueResponse, MsgMint, MsgMintResponse, MsgSetAuthority, MsgSetMinter, + MsgSetMinterResponse, MsgSetUri, MsgSetUriResponse, +}; + +const DENOMS_PREFIX: &str = "denoms"; +const DENOMS_COUNT_KEY: &str = "denoms_count"; + +#[cw_serde] +struct FanToken { + pub denom: String, + pub name: String, + pub symbol: String, + pub max_supply: String, + pub authority: String, + pub minter: String, + pub uri: String, +} + +pub struct StargateKeeper {} + +impl StargateKeeper {} + +impl Stargate for StargateKeeper { + fn execute( + &self, + api: &dyn Api, + storage: &mut dyn Storage, + router: &dyn CosmosRouter, + block: &BlockInfo, + sender: Addr, + type_url: String, + value: Binary, + ) -> AnyResult { + if type_url == *"/bitsong.fantoken.MsgIssue" { + let denoms_count: Uint64 = storage + .get(DENOMS_COUNT_KEY.as_bytes()) + .map_or_else(Uint64::zero, |d| from_json(d).unwrap()); + let denom = format!("fantoken{}", denoms_count.u64() + 1); + + let msg: MsgIssue = Message::decode(value.as_slice()).unwrap(); + let ft = FanToken { + denom: denom.clone(), + name: msg.name, + symbol: msg.symbol, + max_supply: msg.max_supply, + authority: msg.authority, + minter: msg.minter, + uri: msg.uri, + }; + + let key = format!("{}:{}", DENOMS_PREFIX, denom.clone()); + let serialized_ft = to_json_binary(&ft).expect("Failed to serialize FanToken"); + storage.set(key.as_bytes(), &serialized_ft); + + return Ok(AppResponse { + events: vec![], + data: Some(Binary::from(MsgIssueResponse { denom })), + }); + } + if type_url == *"/bitsong.fantoken.MsgMint" { + let msg: MsgMint = Message::decode(value.as_slice()).unwrap(); + + let coin = msg.coin.unwrap(); + + let key = format!("{}:{}", DENOMS_PREFIX, coin.denom.clone()); + let serialized_ft = storage.get(key.as_bytes()); + let fantoken: FanToken = + from_json(serialized_ft.unwrap()).expect("Failed to deserialize FanToken"); + + if sender != fantoken.minter || msg.minter != fantoken.minter { + return Err(Error::msg("Minter unauthorized")); + } + + router.sudo( + api, + storage, + block, + SudoMsg::Bank(BankSudo::Mint { + to_address: msg.recipient.clone(), + amount: vec![Coin::new(coin.amount.parse().unwrap(), coin.denom.clone())], + }), + )?; + + return Ok(AppResponse { + events: vec![], + data: Some(Binary::from(MsgMintResponse {})), + }); + } + if type_url == *"/bitsong.fantoken.MsgSetMinter" { + let msg: MsgSetMinter = Message::decode(value.as_slice()).unwrap(); + + let key = format!("{}:{}", DENOMS_PREFIX, msg.denom.clone()); + let serialized_ft = storage.get(key.as_bytes()); + let mut fantoken: FanToken = + from_json(serialized_ft.unwrap()).expect("Failed to deserialize FanToken"); + + if sender != fantoken.minter { + return Err(Error::msg("Unauthorized")); + } + + if msg.old_minter != fantoken.minter { + return Err(Error::msg("Old minter does not match")); + } + + fantoken.minter = msg.new_minter; + storage.set(key.as_bytes(), &to_json_binary(&fantoken).unwrap()); + + return Ok(AppResponse { + events: vec![], + data: Some(Binary::from(MsgSetMinterResponse {})), + }); + } + if type_url == *"/bitsong.fantoken.MsgSetAuthority" { + let msg: MsgSetAuthority = Message::decode(value.as_slice()).unwrap(); + + let key = format!("{}:{}", DENOMS_PREFIX, msg.denom.clone()); + let serialized_ft = storage.get(key.as_bytes()); + let mut fantoken: FanToken = + from_json(serialized_ft.unwrap()).expect("Failed to deserialize FanToken"); + + if sender != fantoken.authority { + return Err(Error::msg("Unauthorized")); + } + + if msg.old_authority != fantoken.authority { + return Err(Error::msg("Old authority does not match")); + } + + fantoken.authority = msg.new_authority; + storage.set(key.as_bytes(), &to_json_binary(&fantoken).unwrap()); + + return Ok(AppResponse { + events: vec![], + data: Some(Binary::from(MsgSetMinterResponse {})), + }); + } + if type_url == *"/bitsong.fantoken.MsgSetUri" { + let msg: MsgSetUri = Message::decode(value.as_slice()).unwrap(); + + let key = format!("{}:{}", DENOMS_PREFIX, msg.denom.clone()); + let serialized_ft = storage.get(key.as_bytes()); + let mut fantoken: FanToken = + from_json(serialized_ft.unwrap()).expect("Failed to deserialize FanToken"); + + if sender != fantoken.authority || msg.authority != fantoken.authority { + return Err(Error::msg("Authority unauthorized")); + } + + fantoken.uri = msg.uri; + storage.set(key.as_bytes(), &to_json_binary(&fantoken).unwrap()); + + return Ok(AppResponse { + events: vec![], + data: Some(Binary::from(MsgSetUriResponse {})), + }); + } + Ok(AppResponse::default()) + } + + fn query( + &self, + _api: &dyn Api, + _storage: &dyn Storage, + _querier: &dyn Querier, + _block: &BlockInfo, + _path: String, + data: Binary, + ) -> AnyResult { + Ok(data) + } +} diff --git a/contracts/external/btsg-ft-factory/src/testing/mod.rs b/contracts/external/btsg-ft-factory/src/testing/mod.rs new file mode 100644 index 000000000..2282ac98d --- /dev/null +++ b/contracts/external/btsg-ft-factory/src/testing/mod.rs @@ -0,0 +1,64 @@ +mod app; +mod bitsong_stargate; +mod tests; + +use app::BitsongApp; +use cosmwasm_std::{Addr, Empty}; +use cw_multi_test::{Contract, ContractWrapper, Executor}; +use dao_testing::contracts::native_staked_balances_voting_contract; + +use crate::msg::InstantiateMsg; + +/// Address used to stake stuff. +pub(crate) const STAKER: &str = "staker"; + +pub(crate) fn btsg_ft_factory_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_reply(crate::contract::reply) + .with_migrate(crate::contract::migrate); + Box::new(contract) +} + +pub(crate) struct CommonTest { + app: BitsongApp, + module_id: u64, + factory: Addr, +} + +pub(crate) fn setup_test() -> CommonTest { + let mut app = BitsongApp::new(); + let factory_id = app.store_code(btsg_ft_factory_contract()); + let module_id = app.store_code(native_staked_balances_voting_contract()); + + let factory = app + .instantiate_contract( + factory_id, + Addr::unchecked("anyone"), + &InstantiateMsg {}, + &[], + "bitsong_fantoken_factory", + None, + ) + .unwrap(); + + CommonTest { + app, + module_id, + factory, + } +} + +// Advantage to using a macro for this is that the error trace links +// to the exact line that the error occured, instead of inside of a +// function where the assertion would otherwise happen. +macro_rules! is_error { + ($x:expr => $e:tt) => { + assert!(format!("{:#}", $x.unwrap_err()).contains($e)) + }; +} + +pub(crate) use is_error; diff --git a/contracts/external/btsg-ft-factory/src/testing/tests.rs b/contracts/external/btsg-ft-factory/src/testing/tests.rs new file mode 100644 index 000000000..21cb4f58a --- /dev/null +++ b/contracts/external/btsg-ft-factory/src/testing/tests.rs @@ -0,0 +1,534 @@ +use cosmwasm_std::{ + coins, + testing::{mock_dependencies, mock_env}, + to_json_binary, Addr, Uint128, WasmMsg, +}; +use cw_multi_test::Executor; +use cw_utils::Duration; +use dao_interface::{ + state::{Admin, ModuleInstantiateInfo}, + token::InitialBalance, +}; +use dao_testing::contracts::{dao_dao_contract, proposal_single_contract}; + +use crate::{ + bitsong::{Coin, MsgMint, MsgSetUri}, + contract::{migrate, CONTRACT_NAME, CONTRACT_VERSION}, + msg::{ExecuteMsg, MigrateMsg, NewFanToken}, + testing::is_error, +}; + +use super::{setup_test, CommonTest, STAKER}; + +/// I can create a new fantoken on DAO creation. +#[test] +fn test_issue_fantoken() -> anyhow::Result<()> { + let CommonTest { + mut app, + factory, + module_id, + .. + } = setup_test(); + + let core_id = app.store_code(dao_dao_contract()); + let proposal_single_id = app.store_code(proposal_single_contract()); + + let initial_balances = vec![InitialBalance { + amount: Uint128::new(100), + address: STAKER.to_string(), + }]; + + let governance_instantiate = dao_interface::msg::InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: module_id, + msg: to_json_binary(&dao_voting_token_staked::msg::InstantiateMsg { + token_info: dao_voting_token_staked::msg::TokenInfo::Factory(to_json_binary( + &WasmMsg::Execute { + contract_addr: factory.to_string(), + msg: to_json_binary(&ExecuteMsg::Issue(NewFanToken { + symbol: "FAN".to_string(), + name: "Fantoken".to_string(), + max_supply: Uint128::new(1_000_000_000_000_000_000), + uri: "".to_string(), + initial_balances, + initial_dao_balance: Some(Uint128::new(100_000_000)), + }))?, + funds: vec![], + }, + )?), + unstaking_duration: None, + active_threshold: None, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + funds: vec![], + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: proposal_single_id, + msg: to_json_binary(&dao_proposal_single::msg::InstantiateMsg { + threshold: dao_voting::threshold::Threshold::AbsoluteCount { + threshold: Uint128::new(100), + }, + max_voting_period: Duration::Time(86400), + min_voting_period: None, + only_members_execute: true, + allow_revoting: false, + pre_propose_info: dao_voting::pre_propose::PreProposeInfo::AnyoneMayPropose {}, + close_proposal_on_execution_failure: true, + veto: None, + })?, + admin: Some(Admin::CoreModule {}), + funds: vec![], + label: "DAO DAO governance module".to_string(), + }], + initial_items: None, + }; + + let dao = app + .instantiate_contract( + core_id, + Addr::unchecked(STAKER), + &governance_instantiate, + &[], + "DAO DAO", + None, + ) + .unwrap(); + + let voting_module: Addr = app + .wrap() + .query_wasm_smart(dao, &dao_interface::msg::QueryMsg::VotingModule {}) + .unwrap(); + + let denom_res: dao_interface::voting::DenomResponse = app + .wrap() + .query_wasm_smart( + voting_module, + &dao_voting_token_staked::msg::QueryMsg::Denom {}, + ) + .unwrap(); + + // first fantoken created has the denom "fantoken1" + assert_eq!(denom_res.denom, "fantoken1"); + + Ok(()) +} + +/// I can create a new fantoken on DAO creation with initial balances. +#[test] +fn test_initial_fantoken_balances() -> anyhow::Result<()> { + let CommonTest { + mut app, + factory, + module_id, + .. + } = setup_test(); + + let core_id = app.store_code(dao_dao_contract()); + let proposal_single_id = app.store_code(proposal_single_contract()); + + let initial_balances = vec![InitialBalance { + amount: Uint128::new(100), + address: STAKER.to_string(), + }]; + + let governance_instantiate = dao_interface::msg::InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: module_id, + msg: to_json_binary(&dao_voting_token_staked::msg::InstantiateMsg { + token_info: dao_voting_token_staked::msg::TokenInfo::Factory(to_json_binary( + &WasmMsg::Execute { + contract_addr: factory.to_string(), + msg: to_json_binary(&ExecuteMsg::Issue(NewFanToken { + symbol: "FAN".to_string(), + name: "Fantoken".to_string(), + max_supply: Uint128::new(1_000_000_000_000_000_000), + uri: "".to_string(), + initial_balances, + initial_dao_balance: Some(Uint128::new(100_000_000)), + }))?, + funds: vec![], + }, + )?), + unstaking_duration: None, + active_threshold: None, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + funds: vec![], + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: proposal_single_id, + msg: to_json_binary(&dao_proposal_single::msg::InstantiateMsg { + threshold: dao_voting::threshold::Threshold::AbsoluteCount { + threshold: Uint128::new(100), + }, + max_voting_period: Duration::Time(86400), + min_voting_period: None, + only_members_execute: true, + allow_revoting: false, + pre_propose_info: dao_voting::pre_propose::PreProposeInfo::AnyoneMayPropose {}, + close_proposal_on_execution_failure: true, + veto: None, + })?, + admin: Some(Admin::CoreModule {}), + funds: vec![], + label: "DAO DAO governance module".to_string(), + }], + initial_items: None, + }; + + let dao = app + .instantiate_contract( + core_id, + Addr::unchecked(STAKER), + &governance_instantiate, + &[], + "DAO DAO", + None, + ) + .unwrap(); + + let voting_module: Addr = app + .wrap() + .query_wasm_smart(&dao, &dao_interface::msg::QueryMsg::VotingModule {}) + .unwrap(); + + let denom_res: dao_interface::voting::DenomResponse = app + .wrap() + .query_wasm_smart( + voting_module, + &dao_voting_token_staked::msg::QueryMsg::Denom {}, + ) + .unwrap(); + + // verify DAO has initial balance + let dao_balance = app.wrap().query_balance(&dao, &denom_res.denom).unwrap(); + assert_eq!(dao_balance.amount, Uint128::new(100_000_000)); + + // verify staker has initial balance + let staker_balance = app.wrap().query_balance(STAKER, &denom_res.denom).unwrap(); + assert_eq!(staker_balance.amount, Uint128::new(100)); + + Ok(()) +} + +/// The minter and authority are set to the DAO. +#[test] +fn test_fantoken_minter_and_authority_set_to_dao() -> anyhow::Result<()> { + let CommonTest { + mut app, + factory, + module_id, + .. + } = setup_test(); + + let core_id = app.store_code(dao_dao_contract()); + let proposal_single_id = app.store_code(proposal_single_contract()); + + let initial_balances = vec![InitialBalance { + amount: Uint128::new(100), + address: STAKER.to_string(), + }]; + + let governance_instantiate = dao_interface::msg::InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: module_id, + msg: to_json_binary(&dao_voting_token_staked::msg::InstantiateMsg { + token_info: dao_voting_token_staked::msg::TokenInfo::Factory(to_json_binary( + &WasmMsg::Execute { + contract_addr: factory.to_string(), + msg: to_json_binary(&ExecuteMsg::Issue(NewFanToken { + symbol: "FAN".to_string(), + name: "Fantoken".to_string(), + max_supply: Uint128::new(1_000_000_000_000_000_000), + uri: "".to_string(), + initial_balances, + initial_dao_balance: Some(Uint128::new(100_000_000)), + }))?, + funds: vec![], + }, + )?), + unstaking_duration: None, + active_threshold: None, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + funds: vec![], + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: proposal_single_id, + msg: to_json_binary(&dao_proposal_single::msg::InstantiateMsg { + threshold: dao_voting::threshold::Threshold::AbsoluteCount { + threshold: Uint128::new(100), + }, + max_voting_period: Duration::Time(86400), + min_voting_period: None, + only_members_execute: true, + allow_revoting: false, + pre_propose_info: dao_voting::pre_propose::PreProposeInfo::AnyoneMayPropose {}, + close_proposal_on_execution_failure: true, + veto: None, + })?, + admin: Some(Admin::CoreModule {}), + funds: vec![], + label: "DAO DAO governance module".to_string(), + }], + initial_items: None, + }; + + let dao = app + .instantiate_contract( + core_id, + Addr::unchecked(STAKER), + &governance_instantiate, + &[], + "DAO DAO", + None, + ) + .unwrap(); + + let voting_module: Addr = app + .wrap() + .query_wasm_smart(&dao, &dao_interface::msg::QueryMsg::VotingModule {}) + .unwrap(); + + let denom_res: dao_interface::voting::DenomResponse = app + .wrap() + .query_wasm_smart( + voting_module, + &dao_voting_token_staked::msg::QueryMsg::Denom {}, + ) + .unwrap(); + + // attempt to mint with factory that created the token, and fail + let res = app.execute( + factory.clone(), + MsgMint { + recipient: STAKER.to_string(), + coin: Some(Coin { + amount: "100".to_string(), + denom: denom_res.denom.clone(), + }), + minter: factory.to_string(), + } + .into(), + ); + is_error!(res => "Minter unauthorized"); + + // verify minter is the DAO + app.execute( + dao.clone(), + MsgMint { + recipient: STAKER.to_string(), + coin: Some(Coin { + amount: "100".to_string(), + denom: denom_res.denom.clone(), + }), + minter: dao.to_string(), + } + .into(), + ) + .unwrap(); + + // attempt to change URI with factory that created the token, and fail + let res = app.execute( + factory.clone(), + MsgSetUri { + authority: factory.to_string(), + denom: denom_res.denom.clone(), + uri: "https://example.com".to_string(), + } + .into(), + ); + is_error!(res => "Authority unauthorized"); + + // verify authority is the DAO + app.execute( + dao.clone(), + MsgSetUri { + authority: dao.to_string(), + denom: denom_res.denom.clone(), + uri: "https://example.com".to_string(), + } + .into(), + ) + .unwrap(); + + // verify staker has new balance + let staker_balance = app.wrap().query_balance(STAKER, &denom_res.denom).unwrap(); + assert_eq!(staker_balance.amount, Uint128::new(200)); + + Ok(()) +} + +/// A staker can stake fantokens. +#[test] +fn test_fantoken_can_be_staked() -> anyhow::Result<()> { + let CommonTest { + mut app, + factory, + module_id, + .. + } = setup_test(); + + let core_id = app.store_code(dao_dao_contract()); + let proposal_single_id = app.store_code(proposal_single_contract()); + + let initial_balances = vec![InitialBalance { + amount: Uint128::new(100), + address: STAKER.to_string(), + }]; + + let governance_instantiate = dao_interface::msg::InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: module_id, + msg: to_json_binary(&dao_voting_token_staked::msg::InstantiateMsg { + token_info: dao_voting_token_staked::msg::TokenInfo::Factory(to_json_binary( + &WasmMsg::Execute { + contract_addr: factory.to_string(), + msg: to_json_binary(&ExecuteMsg::Issue(NewFanToken { + symbol: "FAN".to_string(), + name: "Fantoken".to_string(), + max_supply: Uint128::new(1_000_000_000_000_000_000), + uri: "".to_string(), + initial_balances, + initial_dao_balance: Some(Uint128::new(100_000_000)), + }))?, + funds: vec![], + }, + )?), + unstaking_duration: None, + active_threshold: None, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + funds: vec![], + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: proposal_single_id, + msg: to_json_binary(&dao_proposal_single::msg::InstantiateMsg { + threshold: dao_voting::threshold::Threshold::AbsoluteCount { + threshold: Uint128::new(100), + }, + max_voting_period: Duration::Time(86400), + min_voting_period: None, + only_members_execute: true, + allow_revoting: false, + pre_propose_info: dao_voting::pre_propose::PreProposeInfo::AnyoneMayPropose {}, + close_proposal_on_execution_failure: true, + veto: None, + })?, + admin: Some(Admin::CoreModule {}), + funds: vec![], + label: "DAO DAO governance module".to_string(), + }], + initial_items: None, + }; + + let dao = app + .instantiate_contract( + core_id, + Addr::unchecked(STAKER), + &governance_instantiate, + &[], + "DAO DAO", + None, + ) + .unwrap(); + + let voting_module: Addr = app + .wrap() + .query_wasm_smart(dao, &dao_interface::msg::QueryMsg::VotingModule {}) + .unwrap(); + + let denom_res: dao_interface::voting::DenomResponse = app + .wrap() + .query_wasm_smart( + &voting_module, + &dao_voting_token_staked::msg::QueryMsg::Denom {}, + ) + .unwrap(); + + // verify staker voting power is 0 + let vp: dao_interface::voting::VotingPowerAtHeightResponse = app.wrap().query_wasm_smart( + &voting_module, + &dao_interface::voting::Query::VotingPowerAtHeight { + address: STAKER.to_string(), + height: None, + }, + )?; + assert_eq!(vp.power, Uint128::new(0)); + + // stake from staker + app.execute_contract( + Addr::unchecked(STAKER), + voting_module.clone(), + &dao_voting_token_staked::msg::ExecuteMsg::Stake {}, + &coins(100, denom_res.denom), + )?; + + // next block so voting power is updated + app.update_block(|b| b.height += 1); + + // verify staker voting power is 100 + let vp: dao_interface::voting::VotingPowerAtHeightResponse = app.wrap().query_wasm_smart( + &voting_module, + &dao_interface::voting::Query::VotingPowerAtHeight { + address: STAKER.to_string(), + height: None, + }, + )?; + assert_eq!(vp.power, Uint128::new(100)); + + Ok(()) +} + +#[test] +pub fn test_migrate_update_version() { + let mut deps = mock_dependencies(); + cw2::set_contract_version(&mut deps.storage, "my-contract", "1.0.0").unwrap(); + + migrate(deps.as_mut(), mock_env(), MigrateMsg {}).unwrap(); + let version = cw2::get_contract_version(&deps.storage).unwrap(); + assert_eq!(version.version, CONTRACT_VERSION); + assert_eq!(version.contract, CONTRACT_NAME); + + // migrate again, should do nothing + migrate(deps.as_mut(), mock_env(), MigrateMsg {}).unwrap(); + let version = cw2::get_contract_version(&deps.storage).unwrap(); + assert_eq!(version.version, CONTRACT_VERSION); + assert_eq!(version.contract, CONTRACT_NAME); +} diff --git a/contracts/voting/dao-voting-token-staked/src/contract.rs b/contracts/voting/dao-voting-token-staked/src/contract.rs index 058210f44..c7fbd5ca4 100644 --- a/contracts/voting/dao-voting-token-staked/src/contract.rs +++ b/contracts/voting/dao-voting-token-staked/src/contract.rs @@ -746,6 +746,32 @@ pub fn reply(deps: DepsMut, env: Env, msg: Reply) -> Result Result Box> { dao_voting_token_staked::contract::execute, dao_voting_token_staked::contract::instantiate, dao_voting_token_staked::contract::query, - ); + ) + .with_reply(dao_voting_token_staked::contract::reply); Box::new(contract) }