diff --git a/src/pages/tutorial/cw-contract/_meta.json b/src/pages/tutorial/cw-contract/_meta.json index adff6584..853fd7af 100644 --- a/src/pages/tutorial/cw-contract/_meta.json +++ b/src/pages/tutorial/cw-contract/_meta.json @@ -5,5 +5,6 @@ "query": "Creating a query", "testing-query": "Testing the query", "multitest-introduction": "Multitest introduction", - "state": "Storing state" + "state": "Storing state", + "execution": "Execution messages" } diff --git a/src/pages/tutorial/cw-contract/execution.mdx b/src/pages/tutorial/cw-contract/execution.mdx new file mode 100644 index 00000000..82290747 --- /dev/null +++ b/src/pages/tutorial/cw-contract/execution.mdx @@ -0,0 +1,595 @@ +import { Tabs } from "nextra/components"; + +# Execution messages + +We went through instantiate and query messages. It is finally time to introduce the last basic entry +point - the execute messages. It is similar to what we have done so far and it should be just +revisiting our knowledge. I encourage you to try implementing what I am describing here on your own +as an exercise - without checking out the source code. + +The idea of the contract will be easy - every contract admin would be eligible to call two execute +messages: + +- `AddMembers` message would allow the admin to add another address to the admin's list +- `Leave` would allow and admin to remove himself from the list + +Not too complicated. Let's get coding. Start with defining messages: + +```rust {9-13} filename="src/msg.rs" +use cosmwasm_std::Addr; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +pub struct InstantiateMsg { + pub admins: Vec, +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +pub enum ExecuteMsg { + AddMembers { admins: Vec }, + Leave {}, +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +pub struct GreetResp { + pub message: String, +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +pub struct AdminsListResp { + pub admins: Vec, +} + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +pub enum QueryMsg { + Greet {}, + AdminsList {}, +} +``` + +And implement execute handling: + + + + +```rust filename="src/contract.rs" +use crate::error::ContractError; +use crate::msg::{ExecuteMsg, GreetResp, InstantiateMsg, QueryMsg}; +use crate::state::ADMINS; +use cosmwasm_std::{to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; +use cw_storey::CwStorage; + +// ... + +pub fn execute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> StdResult { + use ExecuteMsg::*; + + match msg { + AddMembers { admins } => exec::add_members(deps, info, admins), + Leave {} => exec::leave(deps, info), + } +} + +mod exec { + use super::*; + + pub fn add_members( + deps: DepsMut, + info: MessageInfo, + admins: Vec, + ) -> StdResult { + let mut cw_storage = CwStorage(deps.storage); + + // Consider proper error handling instead of `unwrap`. + let mut curr_admins = ADMINS.access(&cw_storage).get()?.unwrap(); + if !curr_admins.contains(&info.sender) { + return Err(StdError::generic_err("Unauthorised access")); + } + + let admins: StdResult> = admins + .into_iter() + .map(|addr| deps.api.addr_validate(&addr)) + .collect(); + + curr_admins.append(&mut admins?); + ADMINS.access(&mut cw_storage).set(&curr_admins)?; + + Ok(Response::new()) + } + + pub fn leave(deps: DepsMut, info: MessageInfo) -> StdResult { + let mut cw_storage = CwStorage(deps.storage); + + // Consider proper error handling instead of `unwrap`. + let curr_admins = ADMINS.access(&cw_storage).get()?.unwrap(); + + let admins: Vec<_> = curr_admins + .into_iter() + .filter(|admin| *admin != info.sender) + .collect(); + + ADMINS.access(&mut cw_storage).set(&admins)?; + + Ok(Response::new()) + } +} + +// ... +``` + + + + +```rust filename="src/contract.rs" +use crate::msg::{ExecuteMsg, GreetResp, InstantiateMsg, QueryMsg}; +use crate::state::ADMINS; +use cosmwasm_std::{ + to_json_binary, Binary, Deps, DepsMut, Empty, Env, MessageInfo, Response, StdResult, +}; + +// ... + +pub fn execute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> StdResult { + use ExecuteMsg::*; + + match msg { + AddMembers { admins } => exec::add_members(deps, info, admins), + Leave {} => exec::leave(deps, info), + } +} + +mod exec { + use cosmwasm_std::StdError; + + use super::*; + + pub fn add_members( + deps: DepsMut, + info: MessageInfo, + admins: Vec, + ) -> StdResult { + let mut curr_admins = ADMINS.load(deps.storage)?; + if !curr_admins.contains(&info.sender) { + return Err(StdError::generic_err("Unauthorised access")); + } + + let admins: StdResult> = admins + .into_iter() + .map(|addr| deps.api.addr_validate(&addr)) + .collect(); + + curr_admins.append(&mut admins?); + ADMINS.save(deps.storage, &curr_admins)?; + + Ok(Response::new()) + } + + pub fn leave(deps: DepsMut, info: MessageInfo) -> StdResult { + ADMINS.update(deps.storage, move |admins| -> StdResult<_> { + let admins = admins + .into_iter() + .filter(|admin| *admin != info.sender) + .collect(); + Ok(admins) + })?; + + Ok(Response::new()) + } +} + +// ... +``` + + + + +The entry point itself also has to be created in `src/lib.rs`: + +```rust filename="src/lib.rs" +use cosmwasm_std::{entry_point, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; +use msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +mod contract; +mod msg; +mod state; + +#[entry_point] +pub fn instantiate( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> StdResult { + contract::instantiate(deps, env, info, msg) +} + +#[entry_point] +pub fn execute(deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg) -> StdResult { + contract::execute(deps, env, info, msg) +} + +#[entry_point] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + contract::query(deps, env, msg) +} +``` + +There are a couple of new things, but nothing significant. First is how do I reach the message +sender to verify he is an admin or remove him from the list - I used the `info.sender` field of +[`MessageInfo`](https://docs.rs/cosmwasm-std/latest/cosmwasm_std/struct.MessageInfo.html), which is +how it looks like - the member. As the message is always sent from the proper address, the `sender` +is already of the `Addr` type - no need to validate it. Another new thing is the +[`update`](https://docs.rs/cw-storage-plus/latest/cw_storage_plus/struct.Item.html#method.update) +function on an `Item` - it makes a read and update of an entity potentially more efficient. It is +possible to do it by reading admins first, then updating and storing the result. + +You probably noticed that when working with `Item`, we always assume something is there. But nothing +forces us to initialize the `ADMINS` value on instantiation! So what happens there? Well, both +`load` and `update` functions would return an error. But there is a +[`may_load`](https://docs.rs/cw-storage-plus/latest/cw_storage_plus/struct.Item.html#method.may_load) +function, which returns `StdResult>` - it would return `Ok(None)` in case of empty +storage. There is even a possibility to remove an existing item from storage with the +[`remove`](https://docs.rs/cw-storage-plus/latest/cw_storage_plus/struct.Item.html#method.remove) +function. + +One thing to improve is error handling. While validating the sender to be admin, we are returning +some arbitrary string as an error. We can do better. + +## Error handling + +In our contract, we now have an error situation when a user tries to execute `AddMembers` not being +an admin himself. There is no proper error case in +[`StdError`](https://docs.rs/cosmwasm-std/latest/cosmwasm_std/enum.StdError.html) to report this +situation, so we have to return a generic error with a message. It is not the best approach. + +For error reporting, we encourage using +[`thiserror`](https://crates.io/crates/thiserror/2.0.3/dependencies) crate. Start with updating your +dependencies: + +```toml {13} filename="Cargo.toml" +[package] +name = "contract" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +cosmwasm-std = { version = "2.1.4", features = ["staking"] } +serde = { version = "1.0.214", default-features = false, features = ["derive"] } +cw-storage-plus = "2.0.0" +thiserror = "2.0.3" + +[dev-dependencies] +cw-multi-test = "2.2.0" +``` + +Now we define an error. + +```rust filename="src/error.rs" +use cosmwasm_std::{Addr, StdError}; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + StdError(#[from] StdError), + #[error("{sender} is not contract admin")] + Unauthorized { sender: Addr }, +} +``` + +We also need to add the new module to `src/lib.rs`: + +```rust {5} filename="src/lib.rs" +use cosmwasm_std::{entry_point, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; +use msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +mod contract; +mod error; +mod msg; +mod state; + +#[entry_point] +pub fn instantiate( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> StdResult { + contract::instantiate(deps, env, info, msg) +} + +#[entry_point] +pub fn execute(deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg) -> StdResult { + contract::execute(deps, env, info, msg) +} + +#[entry_point] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + contract::query(deps, env, msg) +} +``` + +Using [`thiserror`](https://crates.io/crates/thiserror) we define errors like a simple enum, and the +crate ensures that the type implements +[`std::error::Error`](https://doc.rust-lang.org/std/error/trait.Error.html) trait. A very nice +feature of this crate is the inline definition of +[`Display`](https://doc.rust-lang.org/std/fmt/trait.Display.html) trait by an `#[error]` attribute. +Also, another helpful thing is the `#[from]` attribute, which automatically generates proper +[`From`](https://doc.rust-lang.org/std/convert/trait.From.html) implementation, so it is easy to use +`?` operator with `thiserror` types. + +Now update the execute endpoint to use our new error type. + + + + +```rust {14, 30, 36-38, 52} filename="src/contract.rs" +use crate::error::ContractError; +use crate::msg::{ExecuteMsg, GreetResp, InstantiateMsg, QueryMsg}; +use crate::state::ADMINS; +use cosmwasm_std::{to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; +use cw_storey::CwStorage; + +// ... + +pub fn execute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + use ExecuteMsg::*; + + match msg { + AddMembers { admins } => exec::add_members(deps, info, admins), + Leave {} => exec::leave(deps, info), + } +} + +mod exec { + use super::*; + + pub fn add_members( + deps: DepsMut, + info: MessageInfo, + admins: Vec, + ) -> Result { + let mut cw_storage = CwStorage(deps.storage); + + // Consider proper error handling instead of `unwrap`. + let mut curr_admins = ADMINS.access(&cw_storage).get()?.unwrap(); + if !curr_admins.contains(&info.sender) { + return Err(ContractError::Unauthorized { + sender: info.sender, + }); + } + + let admins: StdResult> = admins + .into_iter() + .map(|addr| deps.api.addr_validate(&addr)) + .collect(); + + curr_admins.append(&mut admins?); + ADMINS.access(&mut cw_storage).set(&curr_admins)?; + + Ok(Response::new()) + } + + pub fn leave(deps: DepsMut, info: MessageInfo) -> Result { + let mut cw_storage = CwStorage(deps.storage); + + // Consider proper error handling instead of `unwrap`. + let curr_admins = ADMINS.access(&cw_storage).get()?.unwrap(); + + let admins: Vec<_> = curr_admins + .into_iter() + .filter(|admin| *admin != info.sender) + .collect(); + + ADMINS.access(&mut cw_storage).set(&admins)?; + + Ok(Response::new()) + } +} +``` + + + + +```rust {13, 29, 32-34, 48} filename="src/contract.rs" +use crate::error::ContractError; +use crate::msg::{AdminsListResp, ExecuteMsg, GreetResp, InstantiateMsg, QueryMsg}; +use crate::state::ADMINS; +use cosmwasm_std::{to_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; + +// ... + +pub fn execute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + use ExecuteMsg::*; + + match msg { + AddMembers { admins } => exec::add_members(deps, info, admins), + Leave {} => exec::leave(deps, info), + } +} + +mod exec { + use super::*; + + pub fn add_members( + deps: DepsMut, + info: MessageInfo, + admins: Vec, + ) -> Result { + let mut curr_admins = ADMINS.load(deps.storage)?; + if !curr_admins.contains(&info.sender) { + return Err(ContractError::Unauthorized { + sender: info.sender, + }); + } + + let admins: StdResult> = admins + .into_iter() + .map(|addr| deps.api.addr_validate(&addr)) + .collect(); + + curr_admins.append(&mut admins?); + ADMINS.save(deps.storage, &curr_admins)?; + + Ok(Response::new()) + } + + pub fn leave(deps: DepsMut, info: MessageInfo) -> Result { + ADMINS.update(deps.storage, move |admins| -> StdResult<_> { + let admins = admins + .into_iter() + .filter(|admin| *admin != info.sender) + .collect(); + Ok(admins) + })?; + + Ok(Response::new()) + } +} + +// ... +``` + + + + +The entry point return type also has to be updated: + +```rust {2, 26} filename="src/lib.rs" +use cosmwasm_std::{entry_point, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; +use error::ContractError; +use msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +mod contract; +mod error; +mod msg; +mod state; + +#[entry_point] +pub fn instantiate( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> StdResult { + contract::instantiate(deps, env, info, msg) +} + +#[entry_point] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + contract::execute(deps, env, info, msg) +} + +#[entry_point] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + contract::query(deps, env, msg) +} +``` + +## Custom error and MultiTest + +Using proper custom error type has one nice upside - MultiTest is maintaining error type using the +[`anyhow`](https://crates.io/crates/anyhow) crate. It is a sibling of `thiserror`, designed to +implement type-erased errors in a way that allows getting the original error back. + +Let's write a test that verifies that a non-admin cannot add himself to a list: + +```rust filename="src/contract.rs" +// ... + +#[cfg(test)] +mod tests { + use cosmwasm_std::Addr; + use cw_multi_test::{App, ContractWrapper, Executor, IntoAddr}; + + use crate::msg::AdminsListResp; + + use super::*; + + // ... + + #[test] + fn unauthorized() { + let mut app = App::default(); + + let code = ContractWrapper::new(execute, instantiate, query); + let code_id = app.store_code(Box::new(code)); + let owner = "owner".into_addr(); + + let addr = app + .instantiate_contract( + code_id, + owner.clone(), + &InstantiateMsg { admins: vec![] }, + &[], + "Contract", + None, + ) + .unwrap(); + + let err = app + .execute_contract( + owner.clone(), + addr, + &ExecuteMsg::AddMembers { + admins: vec!["user".to_owned()], + }, + &[], + ) + .unwrap_err(); + + assert_eq!( + ContractError::Unauthorized { sender: owner }, + err.downcast().unwrap() + ); + } +} +``` + +Executing a contract is very similar to any other call - we use an +[`execute_contract`](https://docs.rs/cw-multi-test/latest/cw_multi_test/trait.Executor.html#method.execute_contract) +function. As the execution may fail, we get an error type out of this call, but instead of calling +`unwrap` to extract a value out of it, we expect an error to occur - this is the purpose of the +[`unwrap_err`](https://doc.rust-lang.org/std/result/enum.Result.html#method.unwrap_err) call. Now, +as we have an error value, we can check if it matches what we expected with an `assert_eq!`. There +is a slight complication - the error returned from `execute_contract` is an +[`anyhow::Error`](https://docs.rs/anyhow/latest/anyhow/struct.Error.html) error, but we expect it to +be a `ContractError`. Fortunately, as I said before, `anyhow` errors can recover their original type +using the [`downcast`](https://docs.rs/anyhow/latest/anyhow/struct.Error.html#method.downcast) +function. The `unwrap` right after it is needed because downcasting may fail. The reason is that +`downcast` doesn't magically know the type kept in the underlying error. It deduces it by some +context - here, it knows we expect it to be a `ContractError`, because of being compared to it - +type elision miracles. But if the underlying error would not be a `ContractError`, then `unwrap` +would panic. + +We just created a simple failure test for execution, but it is not enough to claim the contract is +production-ready. All reasonable ok-cases should be covered for that. I encourage you to create some +tests and experiment with them as an exercise after this chapter. diff --git a/src/pages/tutorial/cw-contract/state.mdx b/src/pages/tutorial/cw-contract/state.mdx index c79ebd9c..034724c8 100644 --- a/src/pages/tutorial/cw-contract/state.mdx +++ b/src/pages/tutorial/cw-contract/state.mdx @@ -539,13 +539,27 @@ pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { // ... -pub fn admins_list(deps: Deps) -> StdResult { - let cw_storage = CwStorage(deps.storage); - let admins = ADMINS.access(&cw_storage).get()?; - let resp = AdminsListResp { - admins: admins.unwrap_or_default(), - }; - Ok(resp) +mod query { + use crate::msg::AdminsListResp; + + use super::*; + + pub fn greet() -> StdResult { + let resp = GreetResp { + message: "Hello World".to_owned(), + }; + + Ok(resp) + } + + pub fn admins_list(deps: Deps) -> StdResult { + let cw_storage = CwStorage(deps.storage); + let admins = ADMINS.access(&cw_storage).get()?; + let resp = AdminsListResp { + admins: admins.unwrap_or_default(), + }; + Ok(resp) + } } ``` @@ -568,10 +582,24 @@ pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { // ... -pub fn admins_list(deps: Deps) -> StdResult { - let admins = ADMINS.load(deps.storage)?; - let resp = AdminsListResp { admins }; - Ok(resp) +mod query { + use crate::msg::AdminsListResp; + + use super::*; + + pub fn greet() -> StdResult { + let resp = GreetResp { + message: "Hello World".to_owned(), + }; + + Ok(resp) + } + + pub fn admins_list(deps: Deps) -> StdResult { + let admins = ADMINS.load(deps.storage)?; + let resp = AdminsListResp { admins }; + Ok(resp) + } } ```