From 5c1d8a7f25a0d5c2c801ebb04e2021fbae1df69e Mon Sep 17 00:00:00 2001 From: alexgduarte <24414784+alexgduarte@users.noreply.github.com> Date: Thu, 21 May 2026 09:38:24 +0100 Subject: [PATCH] Add achievement badges contract example --- README.md | 1 + achievement-badges/Cargo.toml | 43 ++ achievement-badges/README.md | 85 +++ achievement-badges/src/bin/schema.rs | 10 + achievement-badges/src/contract.rs | 757 +++++++++++++++++++++++++++ achievement-badges/src/error.rs | 35 ++ achievement-badges/src/lib.rs | 6 + achievement-badges/src/msg.rs | 125 +++++ achievement-badges/src/state.rs | 36 ++ 9 files changed, 1098 insertions(+) create mode 100644 achievement-badges/Cargo.toml create mode 100644 achievement-badges/README.md create mode 100644 achievement-badges/src/bin/schema.rs create mode 100644 achievement-badges/src/contract.rs create mode 100644 achievement-badges/src/error.rs create mode 100644 achievement-badges/src/lib.rs create mode 100644 achievement-badges/src/msg.rs create mode 100644 achievement-badges/src/state.rs diff --git a/README.md b/README.md index 736b9a82..bd318769 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) +- [Achievement Badges](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/achievement-badges) ### :three: Complex Applications - [Constant Product AMM](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/constant-product-amm) diff --git a/achievement-badges/Cargo.toml b/achievement-badges/Cargo.toml new file mode 100644 index 00000000..cf7cf550 --- /dev/null +++ b/achievement-badges/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "achievement-badges" +version = "0.1.0" +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/achievement-badges/README.md b/achievement-badges/README.md new file mode 100644 index 00000000..7fe88943 --- /dev/null +++ b/achievement-badges/README.md @@ -0,0 +1,85 @@ +# Achievement Badges Contract + +This example shows a simple on-chain achievement badge registry. + +The contract owner creates badge definitions and manages issuer accounts. The owner or an approved issuer can award a badge to an address. Awarded badges can be queried by badge id, holder address, or holder-and-badge pair. + +## Features + +- Owner-managed badge definitions. +- Optional issuer accounts for awarding badges. +- One award per holder per badge. +- Per-holder badge limit. +- Badge archiving to stop future awards without deleting history. +- Queries for config, badge definitions, holder badges, and badge ownership. + +## Instantiate + +```json +{ + "name": "Learning Badges", + "description": "Badges for completed tutorials", + "issuers": ["cosmos1issuer..."], + "max_badges_per_holder": 25 +} +``` +## Execute + +Create a badge: + +```json +{ + "create_badge": { + "badge_id": "first-contract", + "title": "First Contract", + "description": "Completed a first CosmWasm contract" + } +} +``` + +Award a badge: + +```json +{ + "award_badge": { + "badge_id": "first-contract", + "recipient": "cosmos1recipient...", + "note": "Completed the hello-world lesson" + } +} +``` + +Archive a badge: + +```json +{ + "archive_badge": { + "badge_id": "first-contract" + } +} +``` + +## Query + +Query one holder's badges: + +```json +{ + "holder_badges": { + "holder": "cosmos1recipient...", + "start_after": null, + "limit": 10 + } +} +``` + +Check whether a holder has a badge: + +```json +{ + "has_badge": { + "holder": "cosmos1recipient...", + "badge_id": "first-contract" + } +} +``` diff --git a/achievement-badges/src/bin/schema.rs b/achievement-badges/src/bin/schema.rs new file mode 100644 index 00000000..05f19443 --- /dev/null +++ b/achievement-badges/src/bin/schema.rs @@ -0,0 +1,10 @@ +use achievement_badges::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use cosmwasm_schema::write_api; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + execute: ExecuteMsg, + query: QueryMsg, + } +} diff --git a/achievement-badges/src/contract.rs b/achievement-badges/src/contract.rs new file mode 100644 index 00000000..e6051095 --- /dev/null +++ b/achievement-badges/src/contract.rs @@ -0,0 +1,757 @@ +#[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::{ + AwardInfo, BadgeInfo, BadgeResponse, BadgesResponse, ConfigResponse, ExecuteMsg, + HasBadgeResponse, HolderBadgesResponse, InstantiateMsg, IsIssuerResponse, MigrateMsg, QueryMsg, +}; +use crate::state::{Award, Badge, Config, AWARDS, BADGES, CONFIG, HOLDER_COUNTS, ISSUERS}; + +const CONTRACT_NAME: &str = "crates.io:achievement-badges"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); +const DEFAULT_MAX_BADGES_PER_HOLDER: u32 = 25; +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)?; + + let name = validate_text("name", msg.name, 3, 64)?; + let description = msg + .description + .map(|value| validate_text("description", value, 1, 160)) + .transpose()?; + let max_badges_per_holder = msg + .max_badges_per_holder + .unwrap_or(DEFAULT_MAX_BADGES_PER_HOLDER); + + if max_badges_per_holder == 0 { + return Err(ContractError::InvalidInput { + reason: "max_badges_per_holder must be greater than zero".to_string(), + }); + } + + let config = Config { + owner: info.sender.clone(), + name, + description, + max_badges_per_holder, + }; + + CONFIG.save(deps.storage, &config)?; + ISSUERS.save(deps.storage, &info.sender, &true)?; + + for issuer in msg.issuers { + let issuer = deps.api.addr_validate(&issuer)?; + ISSUERS.save(deps.storage, &issuer, &true)?; + } + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("owner", info.sender)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::CreateBadge { + badge_id, + title, + description, + } => create_badge(deps, info, badge_id, title, description), + ExecuteMsg::ArchiveBadge { badge_id } => archive_badge(deps, info, badge_id), + ExecuteMsg::AddIssuer { issuer } => add_issuer(deps, info, issuer), + ExecuteMsg::RemoveIssuer { issuer } => remove_issuer(deps, info, issuer), + ExecuteMsg::AwardBadge { + badge_id, + recipient, + note, + } => award_badge(deps, env, info, badge_id, recipient, note), + ExecuteMsg::RevokeBadge { badge_id, holder } => revoke_badge(deps, info, badge_id, holder), + ExecuteMsg::TransferOwnership { new_owner } => transfer_ownership(deps, info, new_owner), + } +} + +pub fn create_badge( + deps: DepsMut, + info: MessageInfo, + badge_id: String, + title: String, + description: String, +) -> Result { + assert_owner(deps.as_ref(), &info.sender)?; + + let badge_id = validate_id(badge_id)?; + let title = validate_text("title", title, 3, 64)?; + let description = validate_text("description", description, 3, 200)?; + + if BADGES.has(deps.storage, badge_id.as_str()) { + return Err(ContractError::BadgeExists {}); + } + + let badge = Badge { + badge_id: badge_id.clone(), + title, + description, + creator: info.sender.clone(), + archived: false, + }; + + BADGES.save(deps.storage, badge_id.as_str(), &badge)?; + + Ok(Response::new() + .add_attribute("action", "create_badge") + .add_attribute("badge_id", badge_id) + .add_attribute("creator", info.sender)) +} + +pub fn archive_badge( + deps: DepsMut, + info: MessageInfo, + badge_id: String, +) -> Result { + assert_owner(deps.as_ref(), &info.sender)?; + let badge_id = validate_id(badge_id)?; + + BADGES.update( + deps.storage, + badge_id.as_str(), + |badge| -> Result { + let mut badge = badge.ok_or(ContractError::BadgeNotFound {})?; + badge.archived = true; + Ok(badge) + }, + )?; + + Ok(Response::new() + .add_attribute("action", "archive_badge") + .add_attribute("badge_id", badge_id)) +} + +pub fn add_issuer( + deps: DepsMut, + info: MessageInfo, + issuer: String, +) -> Result { + assert_owner(deps.as_ref(), &info.sender)?; + let issuer = deps.api.addr_validate(&issuer)?; + ISSUERS.save(deps.storage, &issuer, &true)?; + + Ok(Response::new() + .add_attribute("action", "add_issuer") + .add_attribute("issuer", issuer)) +} + +pub fn remove_issuer( + deps: DepsMut, + info: MessageInfo, + issuer: String, +) -> Result { + assert_owner(deps.as_ref(), &info.sender)?; + let issuer = deps.api.addr_validate(&issuer)?; + let config = CONFIG.load(deps.storage)?; + + if issuer == config.owner { + return Err(ContractError::CannotRemoveOwnerIssuer {}); + } + + ISSUERS.remove(deps.storage, &issuer); + + Ok(Response::new() + .add_attribute("action", "remove_issuer") + .add_attribute("issuer", issuer)) +} + +pub fn award_badge( + deps: DepsMut, + env: Env, + info: MessageInfo, + badge_id: String, + recipient: String, + note: Option, +) -> Result { + assert_issuer(deps.as_ref(), &info.sender)?; + + let badge_id = validate_id(badge_id)?; + let recipient = deps.api.addr_validate(&recipient)?; + let note = note + .map(|value| validate_text("note", value, 1, 120)) + .transpose()?; + + let config = CONFIG.load(deps.storage)?; + let badge = BADGES + .may_load(deps.storage, badge_id.as_str())? + .ok_or(ContractError::BadgeNotFound {})?; + + if badge.archived { + return Err(ContractError::BadgeArchived {}); + } + + if AWARDS.has(deps.storage, (&recipient, badge_id.as_str())) { + return Err(ContractError::AwardExists {}); + } + + let count = HOLDER_COUNTS + .may_load(deps.storage, &recipient)? + .unwrap_or_default(); + + if count >= config.max_badges_per_holder { + return Err(ContractError::LimitReached {}); + } + + let award = Award { + badge_id: badge_id.clone(), + holder: recipient.clone(), + awarded_by: info.sender.clone(), + awarded_at_height: env.block.height, + note, + }; + + AWARDS.save(deps.storage, (&recipient, badge_id.as_str()), &award)?; + HOLDER_COUNTS.save(deps.storage, &recipient, &(count + 1))?; + + Ok(Response::new() + .add_attribute("action", "award_badge") + .add_attribute("badge_id", badge_id) + .add_attribute("holder", recipient) + .add_attribute("awarded_by", info.sender)) +} + +pub fn revoke_badge( + deps: DepsMut, + info: MessageInfo, + badge_id: String, + holder: String, +) -> Result { + let badge_id = validate_id(badge_id)?; + let holder = deps.api.addr_validate(&holder)?; + let config = CONFIG.load(deps.storage)?; + let award = AWARDS + .may_load(deps.storage, (&holder, badge_id.as_str()))? + .ok_or(ContractError::AwardNotFound {})?; + + if info.sender != config.owner && info.sender != award.awarded_by { + return Err(ContractError::Unauthorized {}); + } + + AWARDS.remove(deps.storage, (&holder, badge_id.as_str())); + let count = HOLDER_COUNTS + .may_load(deps.storage, &holder)? + .unwrap_or_default() + .saturating_sub(1); + HOLDER_COUNTS.save(deps.storage, &holder, &count)?; + + Ok(Response::new() + .add_attribute("action", "revoke_badge") + .add_attribute("badge_id", badge_id) + .add_attribute("holder", holder)) +} + +pub fn transfer_ownership( + deps: DepsMut, + info: MessageInfo, + new_owner: String, +) -> Result { + assert_owner(deps.as_ref(), &info.sender)?; + let new_owner = deps.api.addr_validate(&new_owner)?; + + CONFIG.update( + deps.storage, + |mut config| -> Result { + config.owner = new_owner.clone(); + Ok(config) + }, + )?; + ISSUERS.save(deps.storage, &new_owner, &true)?; + + 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::IsIssuer { address } => to_json_binary(&query_is_issuer(deps, address)?), + QueryMsg::Badge { badge_id } => to_json_binary(&query_badge(deps, badge_id)?), + QueryMsg::Badges { start_after, limit } => { + to_json_binary(&query_badges(deps, start_after, limit)?) + } + QueryMsg::HolderBadges { + holder, + start_after, + limit, + } => to_json_binary(&query_holder_badges(deps, holder, start_after, limit)?), + QueryMsg::HasBadge { holder, badge_id } => { + to_json_binary(&query_has_badge(deps, holder, badge_id)?) + } + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(_deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + Ok(Response::new().add_attribute("action", "migrate")) +} + +fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + Ok(ConfigResponse { + owner: config.owner.to_string(), + name: config.name, + description: config.description, + max_badges_per_holder: config.max_badges_per_holder, + }) +} + +fn query_is_issuer(deps: Deps, address: String) -> StdResult { + let address = deps.api.addr_validate(&address)?; + let is_issuer = ISSUERS + .may_load(deps.storage, &address)? + .unwrap_or_default(); + + Ok(IsIssuerResponse { + address: address.to_string(), + is_issuer, + }) +} + +fn query_badge(deps: Deps, badge_id: String) -> StdResult { + let badge_id = validate_id_std(badge_id)?; + let badge = BADGES.load(deps.storage, badge_id.as_str())?; + Ok(BadgeResponse { + badge: badge_to_info(badge), + }) +} + +fn query_badges( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(20).min(MAX_LIMIT) as usize; + let start = start_after.as_deref().map(Bound::exclusive); + let badges = BADGES + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| item.map(|(_, badge)| badge_to_info(badge))) + .collect::>>()?; + + Ok(BadgesResponse { badges }) +} + +fn query_holder_badges( + deps: Deps, + holder: String, + start_after: Option, + limit: Option, +) -> StdResult { + let holder = deps.api.addr_validate(&holder)?; + let limit = limit.unwrap_or(20).min(MAX_LIMIT) as usize; + let start = start_after.as_deref().map(Bound::exclusive); + let awards = AWARDS + .prefix(&holder) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| item.map(|(_, award)| award_to_info(award))) + .collect::>>()?; + + Ok(HolderBadgesResponse { + holder: holder.to_string(), + awards, + }) +} + +fn query_has_badge(deps: Deps, holder: String, badge_id: String) -> StdResult { + let holder = deps.api.addr_validate(&holder)?; + let badge_id = validate_id_std(badge_id)?; + let award = AWARDS + .may_load(deps.storage, (&holder, badge_id.as_str()))? + .map(award_to_info); + + Ok(HasBadgeResponse { + has_badge: award.is_some(), + award, + }) +} + +fn assert_owner(deps: Deps, sender: &cosmwasm_std::Addr) -> Result<(), ContractError> { + let config = CONFIG.load(deps.storage)?; + if config.owner != *sender { + return Err(ContractError::Unauthorized {}); + } + Ok(()) +} + +fn assert_issuer(deps: Deps, sender: &cosmwasm_std::Addr) -> Result<(), ContractError> { + let is_issuer = ISSUERS.may_load(deps.storage, sender)?.unwrap_or_default(); + + if !is_issuer { + return Err(ContractError::Unauthorized {}); + } + + Ok(()) +} + +fn badge_to_info(badge: Badge) -> BadgeInfo { + BadgeInfo { + badge_id: badge.badge_id, + title: badge.title, + description: badge.description, + creator: badge.creator.to_string(), + archived: badge.archived, + } +} + +fn award_to_info(award: Award) -> AwardInfo { + AwardInfo { + badge_id: award.badge_id, + holder: award.holder.to_string(), + awarded_by: award.awarded_by.to_string(), + awarded_at_height: award.awarded_at_height, + note: award.note, + } +} + +fn validate_id(value: String) -> Result { + let value = value.trim().to_ascii_lowercase(); + + if value.len() < 3 || value.len() > 48 { + return Err(ContractError::InvalidInput { + reason: "badge_id must be 3 to 48 characters".to_string(), + }); + } + + if !value + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') + { + return Err(ContractError::InvalidInput { + reason: "badge_id may only contain lowercase letters, digits, and hyphens".to_string(), + }); + } + + Ok(value) +} + +fn validate_id_std(value: String) -> StdResult { + validate_id(value).map_err(|err| cosmwasm_std::StdError::generic_err(err.to_string())) +} + +fn validate_text( + field: &str, + value: String, + min_len: usize, + max_len: usize, +) -> Result { + let value = value.trim().to_string(); + + if value.len() < min_len || value.len() > max_len { + return Err(ContractError::InvalidInput { + reason: format!("{field} must be {min_len} to {max_len} characters"), + }); + } + + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::{ + from_json, + testing::{mock_dependencies, mock_env, mock_info}, + }; + + fn instantiate_contract(deps: DepsMut) { + let msg = InstantiateMsg { + name: "Learning Badges".to_string(), + description: Some("Badge examples".to_string()), + issuers: vec!["issuer".to_string()], + max_badges_per_holder: Some(2), + }; + instantiate(deps, mock_env(), mock_info("owner", &[]), msg).unwrap(); + } + + fn create_first_badge(deps: DepsMut) { + execute( + deps, + mock_env(), + mock_info("owner", &[]), + ExecuteMsg::CreateBadge { + badge_id: "first-contract".to_string(), + title: "First Contract".to_string(), + description: "Completed a first contract".to_string(), + }, + ) + .unwrap(); + } + + #[test] + fn instantiate_and_query_config() { + let mut deps = mock_dependencies(); + instantiate_contract(deps.as_mut()); + + let res = query(deps.as_ref(), mock_env(), QueryMsg::Config {}).unwrap(); + let config: ConfigResponse = from_json(&res).unwrap(); + + assert_eq!(config.owner, "owner"); + assert_eq!(config.name, "Learning Badges"); + assert_eq!(config.max_badges_per_holder, 2); + } + + #[test] + fn owner_creates_badge_and_lists_it() { + let mut deps = mock_dependencies(); + instantiate_contract(deps.as_mut()); + create_first_badge(deps.as_mut()); + + let res = query( + deps.as_ref(), + mock_env(), + QueryMsg::Badges { + start_after: None, + limit: None, + }, + ) + .unwrap(); + let badges: BadgesResponse = from_json(&res).unwrap(); + + assert_eq!(badges.badges.len(), 1); + assert_eq!(badges.badges[0].badge_id, "first-contract"); + assert!(!badges.badges[0].archived); + } + + #[test] + fn issuer_awards_badge_and_holder_can_query_it() { + let mut deps = mock_dependencies(); + instantiate_contract(deps.as_mut()); + create_first_badge(deps.as_mut()); + + execute( + deps.as_mut(), + mock_env(), + mock_info("issuer", &[]), + ExecuteMsg::AwardBadge { + badge_id: "first-contract".to_string(), + recipient: "learner".to_string(), + note: Some("Finished lesson one".to_string()), + }, + ) + .unwrap(); + + let res = query( + deps.as_ref(), + mock_env(), + QueryMsg::HolderBadges { + holder: "learner".to_string(), + start_after: None, + limit: None, + }, + ) + .unwrap(); + let holder_badges: HolderBadgesResponse = from_json(&res).unwrap(); + + assert_eq!(holder_badges.holder, "learner"); + assert_eq!(holder_badges.awards.len(), 1); + assert_eq!(holder_badges.awards[0].awarded_by, "issuer"); + + let res = query( + deps.as_ref(), + mock_env(), + QueryMsg::HasBadge { + holder: "learner".to_string(), + badge_id: "first-contract".to_string(), + }, + ) + .unwrap(); + let has_badge: HasBadgeResponse = from_json(&res).unwrap(); + + assert!(has_badge.has_badge); + } + + #[test] + fn unauthorized_sender_cannot_create_or_award() { + let mut deps = mock_dependencies(); + instantiate_contract(deps.as_mut()); + create_first_badge(deps.as_mut()); + + let create_err = execute( + deps.as_mut(), + mock_env(), + mock_info("intruder", &[]), + ExecuteMsg::CreateBadge { + badge_id: "second-contract".to_string(), + title: "Second Contract".to_string(), + description: "Another badge".to_string(), + }, + ) + .unwrap_err(); + assert_eq!(create_err, ContractError::Unauthorized {}); + + let award_err = execute( + deps.as_mut(), + mock_env(), + mock_info("intruder", &[]), + ExecuteMsg::AwardBadge { + badge_id: "first-contract".to_string(), + recipient: "learner".to_string(), + note: None, + }, + ) + .unwrap_err(); + assert_eq!(award_err, ContractError::Unauthorized {}); + } + + #[test] + fn duplicate_awards_and_holder_limits_are_rejected() { + let mut deps = mock_dependencies(); + instantiate_contract(deps.as_mut()); + create_first_badge(deps.as_mut()); + + execute( + deps.as_mut(), + mock_env(), + mock_info("owner", &[]), + ExecuteMsg::CreateBadge { + badge_id: "second-contract".to_string(), + title: "Second Contract".to_string(), + description: "Completed a second contract".to_string(), + }, + ) + .unwrap(); + execute( + deps.as_mut(), + mock_env(), + mock_info("owner", &[]), + ExecuteMsg::CreateBadge { + badge_id: "third-contract".to_string(), + title: "Third Contract".to_string(), + description: "Completed a third contract".to_string(), + }, + ) + .unwrap(); + + for badge_id in ["first-contract", "second-contract"] { + execute( + deps.as_mut(), + mock_env(), + mock_info("issuer", &[]), + ExecuteMsg::AwardBadge { + badge_id: badge_id.to_string(), + recipient: "learner".to_string(), + note: None, + }, + ) + .unwrap(); + } + + let duplicate_err = execute( + deps.as_mut(), + mock_env(), + mock_info("issuer", &[]), + ExecuteMsg::AwardBadge { + badge_id: "first-contract".to_string(), + recipient: "learner".to_string(), + note: None, + }, + ) + .unwrap_err(); + assert_eq!(duplicate_err, ContractError::AwardExists {}); + + let limit_err = execute( + deps.as_mut(), + mock_env(), + mock_info("issuer", &[]), + ExecuteMsg::AwardBadge { + badge_id: "third-contract".to_string(), + recipient: "learner".to_string(), + note: None, + }, + ) + .unwrap_err(); + assert_eq!(limit_err, ContractError::LimitReached {}); + } + + #[test] + fn owner_archives_and_award_issuer_can_revoke() { + let mut deps = mock_dependencies(); + instantiate_contract(deps.as_mut()); + create_first_badge(deps.as_mut()); + + execute( + deps.as_mut(), + mock_env(), + mock_info("issuer", &[]), + ExecuteMsg::AwardBadge { + badge_id: "first-contract".to_string(), + recipient: "learner".to_string(), + note: None, + }, + ) + .unwrap(); + + execute( + deps.as_mut(), + mock_env(), + mock_info("issuer", &[]), + ExecuteMsg::RevokeBadge { + badge_id: "first-contract".to_string(), + holder: "learner".to_string(), + }, + ) + .unwrap(); + + let res = query( + deps.as_ref(), + mock_env(), + QueryMsg::HasBadge { + holder: "learner".to_string(), + badge_id: "first-contract".to_string(), + }, + ) + .unwrap(); + let has_badge: HasBadgeResponse = from_json(&res).unwrap(); + assert!(!has_badge.has_badge); + + execute( + deps.as_mut(), + mock_env(), + mock_info("owner", &[]), + ExecuteMsg::ArchiveBadge { + badge_id: "first-contract".to_string(), + }, + ) + .unwrap(); + + let archived_err = execute( + deps.as_mut(), + mock_env(), + mock_info("issuer", &[]), + ExecuteMsg::AwardBadge { + badge_id: "first-contract".to_string(), + recipient: "learner".to_string(), + note: None, + }, + ) + .unwrap_err(); + + assert_eq!(archived_err, ContractError::BadgeArchived {}); + } +} diff --git a/achievement-badges/src/error.rs b/achievement-badges/src/error.rs new file mode 100644 index 00000000..3e8dbdff --- /dev/null +++ b/achievement-badges/src/error.rs @@ -0,0 +1,35 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Invalid input: {reason}")] + InvalidInput { reason: String }, + + #[error("Badge already exists")] + BadgeExists {}, + + #[error("Badge not found")] + BadgeNotFound {}, + + #[error("Badge is archived")] + BadgeArchived {}, + + #[error("Award already exists")] + AwardExists {}, + + #[error("Award not found")] + AwardNotFound {}, + + #[error("Badge limit reached for holder")] + LimitReached {}, + + #[error("The owner must remain an issuer")] + CannotRemoveOwnerIssuer {}, +} diff --git a/achievement-badges/src/lib.rs b/achievement-badges/src/lib.rs new file mode 100644 index 00000000..dfedc9dc --- /dev/null +++ b/achievement-badges/src/lib.rs @@ -0,0 +1,6 @@ +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +pub use crate::error::ContractError; diff --git a/achievement-badges/src/msg.rs b/achievement-badges/src/msg.rs new file mode 100644 index 00000000..4d79cbe9 --- /dev/null +++ b/achievement-badges/src/msg.rs @@ -0,0 +1,125 @@ +use cosmwasm_schema::QueryResponses; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct InstantiateMsg { + pub name: String, + pub description: Option, + pub issuers: Vec, + pub max_badges_per_holder: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ExecuteMsg { + CreateBadge { + badge_id: String, + title: String, + description: String, + }, + ArchiveBadge { + badge_id: String, + }, + AddIssuer { + issuer: String, + }, + RemoveIssuer { + issuer: String, + }, + AwardBadge { + badge_id: String, + recipient: String, + note: Option, + }, + RevokeBadge { + badge_id: String, + holder: String, + }, + TransferOwnership { + new_owner: String, + }, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, QueryResponses)] +#[serde(rename_all = "snake_case")] +pub enum QueryMsg { + #[returns(ConfigResponse)] + Config {}, + #[returns(IsIssuerResponse)] + IsIssuer { address: String }, + #[returns(BadgeResponse)] + Badge { badge_id: String }, + #[returns(BadgesResponse)] + Badges { + start_after: Option, + limit: Option, + }, + #[returns(HolderBadgesResponse)] + HolderBadges { + holder: String, + start_after: Option, + limit: Option, + }, + #[returns(HasBadgeResponse)] + HasBadge { holder: String, badge_id: String }, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct MigrateMsg {} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct ConfigResponse { + pub owner: String, + pub name: String, + pub description: Option, + pub max_badges_per_holder: u32, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct IsIssuerResponse { + pub address: String, + pub is_issuer: bool, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct BadgeResponse { + pub badge: BadgeInfo, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct BadgesResponse { + pub badges: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct HolderBadgesResponse { + pub holder: String, + pub awards: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct HasBadgeResponse { + pub has_badge: bool, + pub award: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct BadgeInfo { + pub badge_id: String, + pub title: String, + pub description: String, + pub creator: String, + pub archived: bool, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct AwardInfo { + pub badge_id: String, + pub holder: String, + pub awarded_by: String, + pub awarded_at_height: u64, + pub note: Option, +} diff --git a/achievement-badges/src/state.rs b/achievement-badges/src/state.rs new file mode 100644 index 00000000..38479b9a --- /dev/null +++ b/achievement-badges/src/state.rs @@ -0,0 +1,36 @@ +use cosmwasm_std::Addr; +use cw_storage_plus::{Item, Map}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Config { + pub owner: Addr, + pub name: String, + pub description: Option, + pub max_badges_per_holder: u32, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Badge { + pub badge_id: String, + pub title: String, + pub description: String, + pub creator: Addr, + pub archived: bool, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Award { + pub badge_id: String, + pub holder: Addr, + pub awarded_by: Addr, + pub awarded_at_height: u64, + pub note: Option, +} + +pub const CONFIG: Item = Item::new("config"); +pub const BADGES: Map<&str, Badge> = Map::new("badges"); +pub const ISSUERS: Map<&Addr, bool> = Map::new("issuers"); +pub const AWARDS: Map<(&Addr, &str), Award> = Map::new("awards"); +pub const HOLDER_COUNTS: Map<&Addr, u32> = Map::new("holder_counts");