diff --git a/README.md b/README.md index 736b9a82..df7af3fb 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) +- [Project Status Registry](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/project-status-registry) ### :three: Complex Applications - [Constant Product AMM](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/constant-product-amm) diff --git a/project-status-registry/Cargo.toml b/project-status-registry/Cargo.toml new file mode 100644 index 00000000..d66e7dcc --- /dev/null +++ b/project-status-registry/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "project-status-registry" +version = "0.1.0" +authors = ["ryanll"] +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" } + +[dev-dependencies] +cw-multi-test = "0.16.2" diff --git a/project-status-registry/README.md b/project-status-registry/README.md new file mode 100644 index 00000000..a636accc --- /dev/null +++ b/project-status-registry/README.md @@ -0,0 +1,68 @@ +# Project Status Registry + +Project Status Registry is a small CosmWasm contract that records project milestones and their current review status. It is intended as a simple example of owner-managed records with public queries. + +## What It Teaches + +- Saving contract configuration with `Item` +- Saving multiple records with `Map` +- Restricting write actions to a validated owner +- Querying a single record or a paginated list of records +- Emitting useful response attributes from execute messages + +## Contract Flow + +1. Instantiate the contract with an optional owner. If no owner is supplied, the sender becomes the owner. +2. The owner adds project records with an id, title, status, and note. +3. The owner updates a project's status as work moves through review. +4. Anyone can query a project or list the stored projects. + +## Messages + +### Instantiate + +```rust +pub struct InstantiateMsg { + pub owner: Option, +} +``` + +### Execute + +```rust +pub enum ExecuteMsg { + AddProject { + id: String, + title: String, + status: ProjectStatus, + note: String, + }, + UpdateStatus { + id: String, + status: ProjectStatus, + note: String, + }, + TransferOwnership { + new_owner: String, + }, +} +``` + +### Query + +```rust +pub enum QueryMsg { + Config {}, + Project { id: String }, + ListProjects { + start_after: Option, + limit: Option, + }, +} +``` + +## Example Use Cases + +- Track open-source grant milestones. +- Publish a transparent status log for community projects. +- Keep lightweight on-chain evidence of who last updated a project record. diff --git a/project-status-registry/src/bin/schema.rs b/project-status-registry/src/bin/schema.rs new file mode 100644 index 00000000..e44d2781 --- /dev/null +++ b/project-status-registry/src/bin/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; + +use project_status_registry::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + execute: ExecuteMsg, + query: QueryMsg, + } +} diff --git a/project-status-registry/src/contract.rs b/project-status-registry/src/contract.rs new file mode 100644 index 00000000..9b5b96ae --- /dev/null +++ b/project-status-registry/src/contract.rs @@ -0,0 +1,348 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_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, ExecuteMsg, InstantiateMsg, ProjectListResponse, ProjectResponse, QueryMsg, +}; +use crate::state::{Config, Project, CONFIG, PROJECTS}; + +const CONTRACT_NAME: &str = "crates.io:project-status-registry"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); +const DEFAULT_LIMIT: u32 = 10; +const MAX_LIMIT: u32 = 30; + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + let owner = match msg.owner { + Some(owner) => deps.api.addr_validate(&owner)?, + None => info.sender.clone(), + }; + + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + CONFIG.save(deps.storage, &Config { owner: owner.clone() })?; + + Ok(Response::new() + .add_attribute("method", "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::AddProject { + id, + title, + status, + note, + } => execute_add_project(deps, env, info, id, title, status, note), + ExecuteMsg::UpdateStatus { id, status, note } => { + execute_update_status(deps, env, info, id, status, note) + } + ExecuteMsg::TransferOwnership { new_owner } => { + execute_transfer_ownership(deps, info, new_owner) + } + } +} + +pub fn execute_add_project( + deps: DepsMut, + env: Env, + info: MessageInfo, + id: String, + title: String, + status: crate::msg::ProjectStatus, + note: String, +) -> Result { + assert_owner(deps.as_ref(), &info)?; + + if PROJECTS.has(deps.storage, &id) { + return Err(ContractError::ProjectAlreadyExists {}); + } + + let project = Project { + id: id.clone(), + title, + status, + note, + updated_by: info.sender.clone(), + updated_at: env.block.time.seconds(), + }; + + PROJECTS.save(deps.storage, &id, &project)?; + + Ok(Response::new() + .add_attribute("action", "add_project") + .add_attribute("project_id", id) + .add_attribute("updated_by", info.sender)) +} + +pub fn execute_update_status( + deps: DepsMut, + env: Env, + info: MessageInfo, + id: String, + status: crate::msg::ProjectStatus, + note: String, +) -> Result { + assert_owner(deps.as_ref(), &info)?; + + let project = PROJECTS + .update(deps.storage, &id, |project| match project { + Some(mut project) => { + project.status = status; + project.note = note; + project.updated_by = info.sender.clone(); + project.updated_at = env.block.time.seconds(); + Ok(project) + } + None => Err(ContractError::ProjectNotFound {}), + })?; + + Ok(Response::new() + .add_attribute("action", "update_status") + .add_attribute("project_id", project.id) + .add_attribute("updated_by", info.sender)) +} + +pub fn execute_transfer_ownership( + deps: DepsMut, + info: MessageInfo, + new_owner: String, +) -> Result { + assert_owner(deps.as_ref(), &info)?; + let new_owner = deps.api.addr_validate(&new_owner)?; + + CONFIG.update(deps.storage, |mut config| -> Result<_, ContractError> { + config.owner = new_owner.clone(); + Ok(config) + })?; + + 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_binary(&query_config(deps)?), + QueryMsg::Project { id } => to_binary(&query_project(deps, id)?), + QueryMsg::ListProjects { start_after, limit } => { + to_binary(&query_projects(deps, start_after, limit)?) + } + } +} + +pub fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + Ok(ConfigResponse { + owner: config.owner.to_string(), + }) +} + +pub fn query_project(deps: Deps, id: String) -> StdResult { + let project = PROJECTS.load(deps.storage, &id)?; + Ok(project.into()) +} + +pub fn query_projects( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.as_deref().map(Bound::exclusive); + + let projects = PROJECTS + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| item.map(|(_, project)| project.into())) + .collect::>>()?; + + Ok(ProjectListResponse { projects }) +} + +fn assert_owner(deps: Deps, info: &MessageInfo) -> Result<(), ContractError> { + let config = CONFIG.load(deps.storage)?; + if info.sender != config.owner { + return Err(ContractError::Unauthorized {}); + } + Ok(()) +} + +impl From for ProjectResponse { + fn from(project: Project) -> Self { + Self { + id: project.id, + title: project.title, + status: project.status, + note: project.note, + updated_by: project.updated_by.to_string(), + updated_at: project.updated_at, + } + } +} + +#[cfg(test)] +mod tests { + use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; + use cosmwasm_std::from_binary; + + use super::*; + use crate::msg::ProjectStatus; + + #[test] + fn owner_can_add_and_query_project() { + let mut deps = mock_dependencies(); + let info = mock_info("owner", &[]); + instantiate(deps.as_mut(), mock_env(), info.clone(), InstantiateMsg { owner: None }) + .unwrap(); + + execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::AddProject { + id: "grant-1".to_string(), + title: "Grant milestone one".to_string(), + status: ProjectStatus::InProgress, + note: "Draft is ready".to_string(), + }, + ) + .unwrap(); + + let response = query( + deps.as_ref(), + mock_env(), + QueryMsg::Project { + id: "grant-1".to_string(), + }, + ) + .unwrap(); + let project: ProjectResponse = from_binary(&response).unwrap(); + + assert_eq!(project.title, "Grant milestone one"); + assert_eq!(project.status, ProjectStatus::InProgress); + assert_eq!(project.updated_by, "owner"); + } + + #[test] + fn rejects_non_owner_writes() { + let mut deps = mock_dependencies(); + instantiate( + deps.as_mut(), + mock_env(), + mock_info("owner", &[]), + InstantiateMsg { owner: None }, + ) + .unwrap(); + + let err = execute( + deps.as_mut(), + mock_env(), + mock_info("intruder", &[]), + ExecuteMsg::AddProject { + id: "grant-1".to_string(), + title: "Grant milestone one".to_string(), + status: ProjectStatus::Planned, + note: "Trying to edit".to_string(), + }, + ) + .unwrap_err(); + + assert!(matches!(err, ContractError::Unauthorized {})); + } + + #[test] + fn owner_can_update_status_and_list_projects() { + let mut deps = mock_dependencies(); + let info = mock_info("owner", &[]); + instantiate(deps.as_mut(), mock_env(), info.clone(), InstantiateMsg { owner: None }) + .unwrap(); + + for id in ["grant-1", "grant-2"] { + execute( + deps.as_mut(), + mock_env(), + info.clone(), + ExecuteMsg::AddProject { + id: id.to_string(), + title: format!("Project {id}"), + status: ProjectStatus::Planned, + note: "Queued".to_string(), + }, + ) + .unwrap(); + } + + execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UpdateStatus { + id: "grant-1".to_string(), + status: ProjectStatus::Completed, + note: "Accepted by reviewer".to_string(), + }, + ) + .unwrap(); + + let response = query( + deps.as_ref(), + mock_env(), + QueryMsg::ListProjects { + start_after: None, + limit: Some(10), + }, + ) + .unwrap(); + let projects: ProjectListResponse = from_binary(&response).unwrap(); + + assert_eq!(projects.projects.len(), 2); + assert_eq!(projects.projects[0].id, "grant-1"); + assert_eq!(projects.projects[0].status, ProjectStatus::Completed); + assert_eq!(projects.projects[0].note, "Accepted by reviewer"); + } + + #[test] + fn owner_can_transfer_ownership() { + 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::TransferOwnership { + new_owner: "new-owner".to_string(), + }, + ) + .unwrap(); + + let response = query(deps.as_ref(), mock_env(), QueryMsg::Config {}).unwrap(); + let config: ConfigResponse = from_binary(&response).unwrap(); + assert_eq!(config.owner, "new-owner"); + } +} diff --git a/project-status-registry/src/error.rs b/project-status-registry/src/error.rs new file mode 100644 index 00000000..5f053a37 --- /dev/null +++ b/project-status-registry/src/error.rs @@ -0,0 +1,17 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Project already exists")] + ProjectAlreadyExists {}, + + #[error("Project not found")] + ProjectNotFound {}, +} diff --git a/project-status-registry/src/lib.rs b/project-status-registry/src/lib.rs new file mode 100644 index 00000000..dfedc9dc --- /dev/null +++ b/project-status-registry/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/project-status-registry/src/msg.rs b/project-status-registry/src/msg.rs new file mode 100644 index 00000000..7eb7f3a6 --- /dev/null +++ b/project-status-registry/src/msg.rs @@ -0,0 +1,68 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; + +#[cw_serde] +pub struct InstantiateMsg { + pub owner: Option, +} + +#[cw_serde] +pub enum ExecuteMsg { + AddProject { + id: String, + title: String, + status: ProjectStatus, + note: String, + }, + UpdateStatus { + id: String, + status: ProjectStatus, + note: String, + }, + TransferOwnership { + new_owner: String, + }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(ConfigResponse)] + Config {}, + + #[returns(ProjectResponse)] + Project { id: String }, + + #[returns(ProjectListResponse)] + ListProjects { + start_after: Option, + limit: Option, + }, +} + +#[cw_serde] +pub enum ProjectStatus { + Planned, + InProgress, + Blocked, + Completed, +} + +#[cw_serde] +pub struct ConfigResponse { + pub owner: String, +} + +#[cw_serde] +pub struct ProjectResponse { + pub id: String, + pub title: String, + pub status: ProjectStatus, + pub note: String, + pub updated_by: String, + pub updated_at: u64, +} + +#[cw_serde] +pub struct ProjectListResponse { + pub projects: Vec, +} diff --git a/project-status-registry/src/state.rs b/project-status-registry/src/state.rs new file mode 100644 index 00000000..1983252c --- /dev/null +++ b/project-status-registry/src/state.rs @@ -0,0 +1,23 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Addr; +use cw_storage_plus::{Item, Map}; + +use crate::msg::ProjectStatus; + +#[cw_serde] +pub struct Config { + pub owner: Addr, +} + +#[cw_serde] +pub struct Project { + pub id: String, + pub title: String, + pub status: ProjectStatus, + pub note: String, + pub updated_by: Addr, + pub updated_at: u64, +} + +pub const CONFIG: Item = Item::new("config"); +pub const PROJECTS: Map<&str, Project> = Map::new("projects");