diff --git a/README.md b/README.md index 736b9a82..c904b28a 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Athena Consulting has been awarded a grant by [Atom Accelerator DAO](https://www - [Reading and Writing From State](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/read-write-state) - [Response and Attributes](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/responses-attributes) - [Cosmwasm Math Examples](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/cosmwasm-math) +- [Address Book](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/address-book) - [Cross-Contract Instantiation](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/cross-contract-instatiation) - [Receiving CW20 Tokens](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/receiving-cw20-tokens) diff --git a/address-book/.cargo/config b/address-book/.cargo/config new file mode 100644 index 00000000..e82e5693 --- /dev/null +++ b/address-book/.cargo/config @@ -0,0 +1,3 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +wasm-debug = "build --target wasm32-unknown-unknown" diff --git a/address-book/Cargo.toml b/address-book/Cargo.toml new file mode 100644 index 00000000..a5e0ecae --- /dev/null +++ b/address-book/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "address-book" +version = "0.1.0" +authors = ["alexgduarte"] +edition = "2021" + +exclude = [ + "contract.wasm", + "hash.txt", +] + +[lib] +crate-type = ["cdylib", "rlib"] + +[profile.release] +opt-level = 3 +debug = false +rpath = false +lto = true +debug-assertions = false +codegen-units = 1 +panic = 'abort' +incremental = false +overflow-checks = true + +[features] +backtraces = ["cosmwasm-std/backtraces"] +library = [] + +[package.metadata.scripts] +optimize = """docker run --rm -v "$(pwd)":/code \ + --mount type=volume,source="$(basename "$(pwd)")_cache,target=/code/target \ + --mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \ + cosmwasm/rust-optimizer:0.12.10 +""" + +[dependencies] +cosmwasm-schema = "1.1.3" +cosmwasm-std = "1.1.3" +cw-storage-plus = "1.0.1" +cw2 = "1.0.1" +schemars = "0.8.10" +serde = { version = "1.0.145", default-features = false, features = ["derive"] } +thiserror = "1.0.31" diff --git a/address-book/README.md b/address-book/README.md new file mode 100644 index 00000000..d76fc3cc --- /dev/null +++ b/address-book/README.md @@ -0,0 +1,72 @@ +# Address Book + +This example shows how a CosmWasm contract can keep a small address book in +contract storage. + +The contract owner can: + +- Add a named contact with a validated blockchain address. +- Remove a contact by name. +- Transfer ownership to another address. + +Anyone can query: + +- The current owner. +- One contact by name. +- A paginated list of contacts. + +The example is intentionally simple. It focuses on common building blocks that +new CosmWasm developers reuse often: `Item` configuration, `Map` storage, +address validation, owner-only execute messages, and paginated range queries. + +## Messages + +Instantiate with an optional owner. If no owner is provided, the sender becomes +the owner. + +```json +{} +``` + +Add a contact: + +```json +{ + "add_contact": { + "name": "validator-one", + "address": "cosmos1...", + "note": "Primary validator address" + } +} +``` + +Remove a contact: + +```json +{ + "remove_contact": { + "name": "validator-one" + } +} +``` + +Query one contact: + +```json +{ + "contact": { + "name": "validator-one" + } +} +``` + +List contacts with optional pagination: + +```json +{ + "contacts": { + "start_after": "validator-one", + "limit": 10 + } +} +``` diff --git a/address-book/src/contract.rs b/address-book/src/contract.rs new file mode 100644 index 00000000..98016843 --- /dev/null +++ b/address-book/src/contract.rs @@ -0,0 +1,398 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Order, Response, StdResult, +}; +use cw2::set_contract_version; +use cw_storage_plus::Bound; + +use crate::error::ContractError; +use crate::msg::{ + ConfigResponse, ContactResponse, ContactsResponse, ExecuteMsg, InstantiateMsg, QueryMsg, +}; +use crate::state::{Config, Contact, CONFIG, CONTACTS}; + +const CONTRACT_NAME: &str = "crates.io:address-book"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); +const DEFAULT_LIMIT: u32 = 20; +const MAX_LIMIT: u32 = 50; +const MAX_NOTE_LENGTH: usize = 280; + +#[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)?; + + let owner = match msg.owner { + Some(owner) => deps.api.addr_validate(&owner)?, + None => info.sender.clone(), + }; + + CONFIG.save( + deps.storage, + &Config { + owner: owner.clone(), + }, + )?; + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("owner", owner)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::AddContact { + name, + address, + note, + } => execute_add_contact(deps, info, name, address, note), + ExecuteMsg::RemoveContact { name } => execute_remove_contact(deps, info, name), + ExecuteMsg::TransferOwnership { new_owner } => { + execute_transfer_ownership(deps, info, new_owner) + } + } +} + +pub fn execute_add_contact( + deps: DepsMut, + info: MessageInfo, + name: String, + address: String, + note: Option, +) -> Result { + ensure_owner(deps.as_ref(), &info)?; + let name = normalize_name(name)?; + ensure_note_length(¬e)?; + + if CONTACTS.may_load(deps.storage, name.clone())?.is_some() { + return Err(ContractError::ContactExists {}); + } + + let contact = Contact { + address: deps.api.addr_validate(&address)?, + note, + }; + CONTACTS.save(deps.storage, name.clone(), &contact)?; + + Ok(Response::new() + .add_attribute("action", "add_contact") + .add_attribute("name", name) + .add_attribute("address", contact.address)) +} + +pub fn execute_remove_contact( + deps: DepsMut, + info: MessageInfo, + name: String, +) -> Result { + ensure_owner(deps.as_ref(), &info)?; + let name = normalize_name(name)?; + + CONTACTS.remove(deps.storage, name.clone()); + + Ok(Response::new() + .add_attribute("action", "remove_contact") + .add_attribute("name", name)) +} + +pub fn execute_transfer_ownership( + deps: DepsMut, + info: MessageInfo, + new_owner: String, +) -> Result { + ensure_owner(deps.as_ref(), &info)?; + let new_owner = deps.api.addr_validate(&new_owner)?; + + CONFIG.save( + deps.storage, + &Config { + owner: new_owner.clone(), + }, + )?; + + Ok(Response::new() + .add_attribute("action", "transfer_ownership") + .add_attribute("new_owner", new_owner)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => to_json_binary(&query_config(deps)?), + QueryMsg::Contact { name } => to_json_binary(&query_contact(deps, name)?), + QueryMsg::Contacts { start_after, limit } => { + to_json_binary(&query_contacts(deps, start_after, limit)?) + } + } +} + +fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + + Ok(ConfigResponse { + owner: config.owner.to_string(), + }) +} + +fn query_contact(deps: Deps, name: String) -> StdResult { + let name = normalize_name_for_query(name); + let contact = CONTACTS.load(deps.storage, name.clone())?; + + Ok(contact_response(name, contact)) +} + +fn query_contacts( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after + .map(normalize_name_for_query) + .map(Bound::exclusive); + + let contacts = CONTACTS + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| item.map(|(name, contact)| contact_response(name, contact))) + .collect::>>()?; + + Ok(ContactsResponse { contacts }) +} + +fn ensure_owner(deps: Deps, info: &MessageInfo) -> Result<(), ContractError> { + let config = CONFIG.load(deps.storage)?; + + if info.sender != config.owner { + return Err(ContractError::Unauthorized {}); + } + + Ok(()) +} + +fn normalize_name(name: String) -> Result { + let name = normalize_name_for_query(name); + + if name.is_empty() { + return Err(ContractError::EmptyName {}); + } + + Ok(name) +} + +fn normalize_name_for_query(name: String) -> String { + name.trim().to_ascii_lowercase() +} + +fn ensure_note_length(note: &Option) -> Result<(), ContractError> { + if note.as_ref().map(|note| note.len()).unwrap_or_default() > MAX_NOTE_LENGTH { + return Err(ContractError::NoteTooLong {}); + } + + Ok(()) +} + +fn contact_response(name: String, contact: Contact) -> ContactResponse { + ContactResponse { + name, + address: contact.address.to_string(), + note: contact.note, + } +} + +#[cfg(test)] +mod tests { + use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; + use cosmwasm_std::{coins, from_json}; + + use super::*; + + #[test] + fn instantiate_uses_sender_as_default_owner() { + let mut deps = mock_dependencies(); + let info = mock_info("owner", &coins(1, "uatom")); + + instantiate( + deps.as_mut(), + mock_env(), + info, + InstantiateMsg { owner: None }, + ) + .unwrap(); + + let response: ConfigResponse = + from_json(&query(deps.as_ref(), mock_env(), QueryMsg::Config {}).unwrap()).unwrap(); + assert_eq!(response.owner, "owner"); + } + + #[test] + fn owner_can_add_and_query_contact() { + let mut deps = mock_dependencies(); + instantiate( + deps.as_mut(), + mock_env(), + mock_info("owner", &[]), + InstantiateMsg { owner: None }, + ) + .unwrap(); + + execute( + deps.as_mut(), + mock_env(), + mock_info("owner", &[]), + ExecuteMsg::AddContact { + name: "Validator One".to_string(), + address: "validator".to_string(), + note: Some("primary validator".to_string()), + }, + ) + .unwrap(); + + let response: ContactResponse = from_json( + &query( + deps.as_ref(), + mock_env(), + QueryMsg::Contact { + name: "validator one".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(response.name, "validator one"); + assert_eq!(response.address, "validator"); + assert_eq!(response.note, Some("primary validator".to_string())); + } + + #[test] + fn non_owner_cannot_add_contact() { + let mut deps = mock_dependencies(); + instantiate( + deps.as_mut(), + mock_env(), + mock_info("owner", &[]), + InstantiateMsg { owner: None }, + ) + .unwrap(); + + let error = execute( + deps.as_mut(), + mock_env(), + mock_info("visitor", &[]), + ExecuteMsg::AddContact { + name: "friend".to_string(), + address: "friend".to_string(), + note: None, + }, + ) + .unwrap_err(); + + assert_eq!(error, ContractError::Unauthorized {}); + } + + #[test] + fn contacts_query_is_paginated() { + let mut deps = mock_dependencies(); + instantiate( + deps.as_mut(), + mock_env(), + mock_info("owner", &[]), + InstantiateMsg { owner: None }, + ) + .unwrap(); + + for name in ["alice", "bob", "carol"] { + execute( + deps.as_mut(), + mock_env(), + mock_info("owner", &[]), + ExecuteMsg::AddContact { + name: name.to_string(), + address: format!("{name}-addr"), + note: None, + }, + ) + .unwrap(); + } + + let response: ContactsResponse = from_json( + &query( + deps.as_ref(), + mock_env(), + QueryMsg::Contacts { + start_after: Some("alice".to_string()), + limit: Some(2), + }, + ) + .unwrap(), + ) + .unwrap(); + + let names: Vec = response + .contacts + .into_iter() + .map(|contact| contact.name) + .collect(); + assert_eq!(names, vec!["bob".to_string(), "carol".to_string()]); + } + + #[test] + fn owner_can_remove_contact() { + let mut deps = mock_dependencies(); + instantiate( + deps.as_mut(), + mock_env(), + mock_info("owner", &[]), + InstantiateMsg { owner: None }, + ) + .unwrap(); + + execute( + deps.as_mut(), + mock_env(), + mock_info("owner", &[]), + ExecuteMsg::AddContact { + name: "friend".to_string(), + address: "friend".to_string(), + note: None, + }, + ) + .unwrap(); + execute( + deps.as_mut(), + mock_env(), + mock_info("owner", &[]), + ExecuteMsg::RemoveContact { + name: "friend".to_string(), + }, + ) + .unwrap(); + + let response: ContactsResponse = from_json( + &query( + deps.as_ref(), + mock_env(), + QueryMsg::Contacts { + start_after: None, + limit: None, + }, + ) + .unwrap(), + ) + .unwrap(); + + assert!(response.contacts.is_empty()); + } +} diff --git a/address-book/src/error.rs b/address-book/src/error.rs new file mode 100644 index 00000000..b74ac52d --- /dev/null +++ b/address-book/src/error.rs @@ -0,0 +1,20 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Contact already exists")] + ContactExists {}, + + #[error("Contact name cannot be empty")] + EmptyName {}, + + #[error("Contact note cannot be longer than 280 characters")] + NoteTooLong {}, +} diff --git a/address-book/src/lib.rs b/address-book/src/lib.rs new file mode 100644 index 00000000..e5ff7237 --- /dev/null +++ b/address-book/src/lib.rs @@ -0,0 +1,6 @@ +pub mod contract; +pub mod error; +pub mod msg; +pub mod state; + +pub use crate::error::ContractError; diff --git a/address-book/src/msg.rs b/address-book/src/msg.rs new file mode 100644 index 00000000..a9a3d08f --- /dev/null +++ b/address-book/src/msg.rs @@ -0,0 +1,52 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; + +#[cw_serde] +pub struct InstantiateMsg { + pub owner: Option, +} + +#[cw_serde] +pub enum ExecuteMsg { + AddContact { + name: String, + address: String, + note: Option, + }, + RemoveContact { + name: String, + }, + TransferOwnership { + new_owner: String, + }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(ConfigResponse)] + Config {}, + #[returns(ContactResponse)] + Contact { name: String }, + #[returns(ContactsResponse)] + Contacts { + start_after: Option, + limit: Option, + }, +} + +#[cw_serde] +pub struct ConfigResponse { + pub owner: String, +} + +#[cw_serde] +pub struct ContactResponse { + pub name: String, + pub address: String, + pub note: Option, +} + +#[cw_serde] +pub struct ContactsResponse { + pub contacts: Vec, +} diff --git a/address-book/src/state.rs b/address-book/src/state.rs new file mode 100644 index 00000000..0a999fd5 --- /dev/null +++ b/address-book/src/state.rs @@ -0,0 +1,17 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Addr; +use cw_storage_plus::{Item, Map}; + +#[cw_serde] +pub struct Config { + pub owner: Addr, +} + +#[cw_serde] +pub struct Contact { + pub address: Addr, + pub note: Option, +} + +pub const CONFIG: Item = Item::new("config"); +pub const CONTACTS: Map = Map::new("contacts");