From 65d530340e6839ab49d1d751b19a6b55772a527a Mon Sep 17 00:00:00 2001 From: alexgduarte <24414784+alexgduarte@users.noreply.github.com> Date: Thu, 21 May 2026 08:52:42 +0100 Subject: [PATCH] Add profile card contract example --- README.md | 1 + profile-card/Cargo.toml | 44 ++++ profile-card/README.md | 59 ++++++ profile-card/src/bin/schema.rs | 10 + profile-card/src/contract.rs | 353 +++++++++++++++++++++++++++++++++ profile-card/src/error.rs | 20 ++ profile-card/src/lib.rs | 4 + profile-card/src/msg.rs | 44 ++++ profile-card/src/state.rs | 13 ++ 9 files changed, 548 insertions(+) create mode 100644 profile-card/Cargo.toml create mode 100644 profile-card/README.md create mode 100644 profile-card/src/bin/schema.rs create mode 100644 profile-card/src/contract.rs create mode 100644 profile-card/src/error.rs create mode 100644 profile-card/src/lib.rs create mode 100644 profile-card/src/msg.rs create mode 100644 profile-card/src/state.rs diff --git a/README.md b/README.md index 736b9a82..6fb5e99a 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Athena Consulting has been awarded a grant by [Atom Accelerator DAO](https://www - [TimeLock Contract](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/timelock) - [Crowdfunding Contract](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/crowdfunding) - [Token Vault](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/token-vault) +- [Profile Card](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/profile-card) ### :three: Complex Applications - [Constant Product AMM](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/constant-product-amm) diff --git a/profile-card/Cargo.toml b/profile-card/Cargo.toml new file mode 100644 index 00000000..74d4749a --- /dev/null +++ b/profile-card/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "profile-card" +version = "0.1.0" +authors = ["gachouchani1999 "] +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 = { version = "1.0.31" } diff --git a/profile-card/README.md b/profile-card/README.md new file mode 100644 index 00000000..9e5a2b63 --- /dev/null +++ b/profile-card/README.md @@ -0,0 +1,59 @@ +# Profile Card + +This contract stores a small public profile for each wallet address. It is a +simple example of owner-controlled state: every sender can create, update, query, +list, or clear only their own profile card. + +The example demonstrates: + +- validating execute message fields before saving state +- using `Map` to store one record per address +- removing state with `Map::remove` +- paginating query results with `start_after` and `limit` + +## Instantiate + +The contract does not need any setup data. + +```rust +pub struct InstantiateMsg {} +``` + +## Execute Messages + +`SetProfile` saves a profile for the message sender. Empty optional fields are +stored as `None`, and the display name must not be empty. + +```rust +SetProfile { + display_name: "Alice".to_string(), + bio: Some("CosmWasm builder".to_string()), + website: Some("https://example.com".to_string()), +} +``` + +`ClearProfile` removes the sender's profile. + +```rust +ClearProfile {} +``` + +## Query Messages + +`GetProfile` returns a single profile by address. + +```rust +GetProfile { + address: "alice".to_string(), +} +``` + +`ListProfiles` returns stored profiles in address order. The query accepts an +optional `start_after` cursor and a bounded `limit`. + +```rust +ListProfiles { + start_after: None, + limit: Some(10), +} +``` diff --git a/profile-card/src/bin/schema.rs b/profile-card/src/bin/schema.rs new file mode 100644 index 00000000..cdae450e --- /dev/null +++ b/profile-card/src/bin/schema.rs @@ -0,0 +1,10 @@ +use cosmwasm_schema::write_api; +use profile_card::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + execute: ExecuteMsg, + query: QueryMsg, + } +} diff --git a/profile-card/src/contract.rs b/profile-card/src/contract.rs new file mode 100644 index 00000000..e827af55 --- /dev/null +++ b/profile-card/src/contract.rs @@ -0,0 +1,353 @@ +#[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::{ + ExecuteMsg, GetProfileResponse, InstantiateMsg, ListProfilesResponse, ProfileResponse, QueryMsg, +}; +use crate::state::{Profile, PROFILES}; + +const CONTRACT_NAME: &str = "crates.io:profile-card"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); +const MAX_DISPLAY_NAME_LENGTH: usize = 64; +const MAX_BIO_LENGTH: usize = 280; +const MAX_WEBSITE_LENGTH: usize = 128; +const DEFAULT_LIMIT: u32 = 10; +const MAX_LIMIT: u32 = 50; + +#[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")) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::SetProfile { + display_name, + bio, + website, + } => execute::set_profile(deps, info, display_name, bio, website), + ExecuteMsg::ClearProfile {} => execute::clear_profile(deps, info), + } +} + +pub mod execute { + use super::*; + + pub fn set_profile( + deps: DepsMut, + info: MessageInfo, + display_name: String, + bio: Option, + website: Option, + ) -> Result { + let profile = Profile { + owner: info.sender.clone(), + display_name: validate_display_name(display_name)?, + bio: validate_optional_field(bio, MAX_BIO_LENGTH, ContractError::BioTooLong {})?, + website: validate_optional_field( + website, + MAX_WEBSITE_LENGTH, + ContractError::WebsiteTooLong {}, + )?, + }; + + PROFILES.save(deps.storage, &info.sender, &profile)?; + + Ok(Response::new() + .add_attribute("action", "set_profile") + .add_attribute("owner", info.sender)) + } + + pub fn clear_profile(deps: DepsMut, info: MessageInfo) -> Result { + PROFILES.remove(deps.storage, &info.sender); + + Ok(Response::new() + .add_attribute("action", "clear_profile") + .add_attribute("owner", info.sender)) + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::GetProfile { address } => to_json_binary(&query::profile(deps, address)?), + QueryMsg::ListProfiles { start_after, limit } => { + to_json_binary(&query::profiles(deps, start_after, limit)?) + } + } +} + +pub mod query { + use super::*; + + pub fn profile(deps: Deps, address: String) -> StdResult { + let address = deps.api.addr_validate(&address)?; + let profile = PROFILES + .may_load(deps.storage, &address)? + .map(profile_to_response); + + Ok(GetProfileResponse { profile }) + } + + pub fn profiles( + deps: Deps, + start_after: Option, + limit: Option, + ) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start_after = start_after + .map(|address| deps.api.addr_validate(&address)) + .transpose()?; + let start = start_after.as_ref().map(Bound::exclusive); + + let profiles = PROFILES + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| item.map(|(_, profile)| profile_to_response(profile))) + .collect::>>()?; + + Ok(ListProfilesResponse { profiles }) + } +} + +fn validate_display_name(display_name: String) -> Result { + let display_name = display_name.trim().to_string(); + + if display_name.is_empty() { + return Err(ContractError::EmptyDisplayName {}); + } + + if display_name.chars().count() > MAX_DISPLAY_NAME_LENGTH { + return Err(ContractError::DisplayNameTooLong {}); + } + + Ok(display_name) +} + +fn validate_optional_field( + value: Option, + max_length: usize, + error: ContractError, +) -> Result, ContractError> { + value + .map(|value| { + let value = value.trim().to_string(); + + if value.is_empty() { + return Ok(None); + } + + if value.chars().count() > max_length { + return Err(error); + } + + Ok(Some(value)) + }) + .transpose() + .map(Option::flatten) +} + +fn profile_to_response(profile: Profile) -> ProfileResponse { + ProfileResponse { + owner: profile.owner.into_string(), + display_name: profile.display_name, + bio: profile.bio, + website: profile.website, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::from_json; + use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; + + #[test] + fn proper_initialization() { + let mut deps = mock_dependencies(); + let info = mock_info("creator", &[]); + + let res = instantiate(deps.as_mut(), mock_env(), info, InstantiateMsg {}).unwrap(); + + assert_eq!(0, res.messages.len()); + assert_eq!("instantiate", res.attributes[0].value); + } + + #[test] + fn set_and_query_profile() { + let mut deps = mock_dependencies(); + instantiate( + deps.as_mut(), + mock_env(), + mock_info("creator", &[]), + InstantiateMsg {}, + ) + .unwrap(); + + let msg = ExecuteMsg::SetProfile { + display_name: " Alice ".to_string(), + bio: Some(" CosmWasm builder ".to_string()), + website: Some(" https://example.com ".to_string()), + }; + let res = execute(deps.as_mut(), mock_env(), mock_info("alice", &[]), msg).unwrap(); + + assert_eq!("set_profile", res.attributes[0].value); + + let res = query( + deps.as_ref(), + mock_env(), + QueryMsg::GetProfile { + address: "alice".to_string(), + }, + ) + .unwrap(); + let response: GetProfileResponse = from_json(&res).unwrap(); + let profile = response.profile.unwrap(); + + assert_eq!("alice", profile.owner); + assert_eq!("Alice", profile.display_name); + assert_eq!(Some("CosmWasm builder".to_string()), profile.bio); + assert_eq!(Some("https://example.com".to_string()), profile.website); + } + + #[test] + fn clear_profile_removes_sender_profile() { + let mut deps = mock_dependencies(); + instantiate( + deps.as_mut(), + mock_env(), + mock_info("creator", &[]), + InstantiateMsg {}, + ) + .unwrap(); + + execute( + deps.as_mut(), + mock_env(), + mock_info("alice", &[]), + ExecuteMsg::SetProfile { + display_name: "Alice".to_string(), + bio: None, + website: None, + }, + ) + .unwrap(); + execute( + deps.as_mut(), + mock_env(), + mock_info("alice", &[]), + ExecuteMsg::ClearProfile {}, + ) + .unwrap(); + + let res = query( + deps.as_ref(), + mock_env(), + QueryMsg::GetProfile { + address: "alice".to_string(), + }, + ) + .unwrap(); + let response: GetProfileResponse = from_json(&res).unwrap(); + + assert!(response.profile.is_none()); + } + + #[test] + fn validates_profile_fields() { + let mut deps = mock_dependencies(); + instantiate( + deps.as_mut(), + mock_env(), + mock_info("creator", &[]), + InstantiateMsg {}, + ) + .unwrap(); + + let empty_name = execute( + deps.as_mut(), + mock_env(), + mock_info("alice", &[]), + ExecuteMsg::SetProfile { + display_name: " ".to_string(), + bio: None, + website: None, + }, + ); + assert!(matches!( + empty_name, + Err(ContractError::EmptyDisplayName {}) + )); + + let long_bio = execute( + deps.as_mut(), + mock_env(), + mock_info("alice", &[]), + ExecuteMsg::SetProfile { + display_name: "Alice".to_string(), + bio: Some("a".repeat(MAX_BIO_LENGTH + 1)), + website: None, + }, + ); + assert!(matches!(long_bio, Err(ContractError::BioTooLong {}))); + } + + #[test] + fn list_profiles_uses_pagination() { + let mut deps = mock_dependencies(); + instantiate( + deps.as_mut(), + mock_env(), + mock_info("creator", &[]), + InstantiateMsg {}, + ) + .unwrap(); + + for owner in ["alice", "bob", "carol"] { + execute( + deps.as_mut(), + mock_env(), + mock_info(owner, &[]), + ExecuteMsg::SetProfile { + display_name: owner.to_string(), + bio: None, + website: None, + }, + ) + .unwrap(); + } + + let res = query( + deps.as_ref(), + mock_env(), + QueryMsg::ListProfiles { + start_after: Some("alice".to_string()), + limit: Some(1), + }, + ) + .unwrap(); + let response: ListProfilesResponse = from_json(&res).unwrap(); + + assert_eq!(1, response.profiles.len()); + assert_eq!("bob", response.profiles[0].owner); + } +} diff --git a/profile-card/src/error.rs b/profile-card/src/error.rs new file mode 100644 index 00000000..628f6697 --- /dev/null +++ b/profile-card/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("display name cannot be empty")] + EmptyDisplayName {}, + + #[error("display name is too long")] + DisplayNameTooLong {}, + + #[error("bio is too long")] + BioTooLong {}, + + #[error("website is too long")] + WebsiteTooLong {}, +} diff --git a/profile-card/src/lib.rs b/profile-card/src/lib.rs new file mode 100644 index 00000000..a5abdbb0 --- /dev/null +++ b/profile-card/src/lib.rs @@ -0,0 +1,4 @@ +pub mod contract; +pub mod error; +pub mod msg; +pub mod state; diff --git a/profile-card/src/msg.rs b/profile-card/src/msg.rs new file mode 100644 index 00000000..308a315c --- /dev/null +++ b/profile-card/src/msg.rs @@ -0,0 +1,44 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; + +#[cw_serde] +pub struct InstantiateMsg {} + +#[cw_serde] +pub enum ExecuteMsg { + SetProfile { + display_name: String, + bio: Option, + website: Option, + }, + ClearProfile {}, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(GetProfileResponse)] + GetProfile { address: String }, + #[returns(ListProfilesResponse)] + ListProfiles { + start_after: Option, + limit: Option, + }, +} + +#[cw_serde] +pub struct ProfileResponse { + pub owner: String, + pub display_name: String, + pub bio: Option, + pub website: Option, +} + +#[cw_serde] +pub struct GetProfileResponse { + pub profile: Option, +} + +#[cw_serde] +pub struct ListProfilesResponse { + pub profiles: Vec, +} diff --git a/profile-card/src/state.rs b/profile-card/src/state.rs new file mode 100644 index 00000000..0f44685a --- /dev/null +++ b/profile-card/src/state.rs @@ -0,0 +1,13 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Addr; +use cw_storage_plus::Map; + +#[cw_serde] +pub struct Profile { + pub owner: Addr, + pub display_name: String, + pub bio: Option, + pub website: Option, +} + +pub const PROFILES: Map<&Addr, Profile> = Map::new("profiles");