From cb91645790623ce37e359a1c61ff5da57170bcfd Mon Sep 17 00:00:00 2001 From: alexgduarte <24414784+alexgduarte@users.noreply.github.com> Date: Thu, 21 May 2026 10:29:25 +0100 Subject: [PATCH] Add inventory tracker contract example --- README.md | 1 + inventory-tracker/Cargo.toml | 43 ++ inventory-tracker/README.md | 119 +++++ inventory-tracker/src/bin/schema.rs | 10 + inventory-tracker/src/contract.rs | 758 ++++++++++++++++++++++++++++ inventory-tracker/src/error.rs | 23 + inventory-tracker/src/lib.rs | 6 + inventory-tracker/src/msg.rs | 104 ++++ inventory-tracker/src/state.rs | 27 + 9 files changed, 1091 insertions(+) create mode 100644 inventory-tracker/Cargo.toml create mode 100644 inventory-tracker/README.md create mode 100644 inventory-tracker/src/bin/schema.rs create mode 100644 inventory-tracker/src/contract.rs create mode 100644 inventory-tracker/src/error.rs create mode 100644 inventory-tracker/src/lib.rs create mode 100644 inventory-tracker/src/msg.rs create mode 100644 inventory-tracker/src/state.rs diff --git a/README.md b/README.md index 736b9a82..f0dec38a 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) +- [Inventory Tracker](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/inventory-tracker) ### :three: Complex Applications - [Constant Product AMM](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/constant-product-amm) diff --git a/inventory-tracker/Cargo.toml b/inventory-tracker/Cargo.toml new file mode 100644 index 00000000..d1431328 --- /dev/null +++ b/inventory-tracker/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "inventory-tracker" +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/inventory-tracker/README.md b/inventory-tracker/README.md new file mode 100644 index 00000000..5f3b352e --- /dev/null +++ b/inventory-tracker/README.md @@ -0,0 +1,119 @@ +# Inventory Tracker Contract + +This example shows a simple per-wallet inventory tracker. + +Each wallet can add inventory items, increase or decrease quantities, update item metadata, delete records, and page through its own inventory. The contract owner controls the maximum number of records each wallet can keep. + +## Features + +- Independent inventory for each address. +- Sequential item IDs per user. +- SKU, item name, optional note, and quantity fields. +- Increase, decrease, and set quantity flows. +- Safe underflow checks when decreasing quantity. +- Owner-managed maximum item limit per user. +- Paginated item listing and item count queries. + +## Instantiate + +```json +{ + "max_items_per_user": 100 +} +``` + +## Execute + +Add an item: + +```json +{ + "add_item": { + "sku": "HW-WALLET-01", + "name": "Hardware Wallet", + "quantity": "2", + "note": "Cold storage devices" + } +} +``` + +Increase quantity: + +```json +{ + "increase_quantity": { + "item_id": 1, + "amount": "3" + } +} +``` + +Decrease quantity: + +```json +{ + "decrease_quantity": { + "item_id": 1, + "amount": "1" + } +} +``` + +Update item metadata: + +```json +{ + "update_item": { + "item_id": 1, + "sku": "HW-WALLET-02", + "name": "Hardware Wallet Pro", + "note": null + } +} +``` + +Set quantity directly: + +```json +{ + "set_quantity": { + "item_id": 1, + "quantity": "10" + } +} +``` + +Delete an item: + +```json +{ + "delete_item": { + "item_id": 1 + } +} +``` + +## Query + +List a user's inventory: + +```json +{ + "user_items": { + "owner": "cosmos1owner...", + "start_after": null, + "limit": 10 + } +} +``` + +Query one item: + +```json +{ + "item": { + "owner": "cosmos1owner...", + "item_id": 1 + } +} +``` diff --git a/inventory-tracker/src/bin/schema.rs b/inventory-tracker/src/bin/schema.rs new file mode 100644 index 00000000..fe0bca14 --- /dev/null +++ b/inventory-tracker/src/bin/schema.rs @@ -0,0 +1,10 @@ +use cosmwasm_schema::write_api; +use inventory_tracker::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + execute: ExecuteMsg, + query: QueryMsg, + } +} diff --git a/inventory-tracker/src/contract.rs b/inventory-tracker/src/contract.rs new file mode 100644 index 00000000..464fa0e0 --- /dev/null +++ b/inventory-tracker/src/contract.rs @@ -0,0 +1,758 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Order, Response, StdResult, Uint128, +}; +use cw2::set_contract_version; +use cw_storage_plus::Bound; + +use crate::error::ContractError; +use crate::msg::{ + ConfigResponse, ExecuteMsg, InstantiateMsg, InventoryItemResponse, ItemCountResponse, + ItemResponse, MigrateMsg, QueryMsg, UserItemsResponse, +}; +use crate::state::{Config, InventoryItem, CONFIG, USER_ITEMS, USER_ITEM_COUNT, USER_NEXT_ID}; + +const CONTRACT_NAME: &str = "crates.io:inventory-tracker"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); +const DEFAULT_MAX_ITEMS_PER_USER: u32 = 100; +const MAX_ITEMS_PER_USER: u32 = 1_000; +const MAX_QUERY_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 max_items_per_user = msg.max_items_per_user.unwrap_or(DEFAULT_MAX_ITEMS_PER_USER); + validate_max_items(max_items_per_user)?; + + CONFIG.save( + deps.storage, + &Config { + owner: info.sender.clone(), + max_items_per_user, + }, + )?; + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("owner", info.sender) + .add_attribute("max_items_per_user", max_items_per_user.to_string())) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::AddItem { + sku, + name, + quantity, + note, + } => add_item(deps, env, info, sku, name, quantity, note), + ExecuteMsg::UpdateItem { + item_id, + sku, + name, + note, + } => update_item(deps, env, info, item_id, sku, name, note), + ExecuteMsg::SetQuantity { item_id, quantity } => { + set_quantity(deps, env, info, item_id, quantity) + } + ExecuteMsg::IncreaseQuantity { item_id, amount } => { + increase_quantity(deps, env, info, item_id, amount) + } + ExecuteMsg::DecreaseQuantity { item_id, amount } => { + decrease_quantity(deps, env, info, item_id, amount) + } + ExecuteMsg::DeleteItem { item_id } => delete_item(deps, info, item_id), + ExecuteMsg::UpdateConfig { max_items_per_user } => { + update_config(deps, info, max_items_per_user) + } + ExecuteMsg::TransferOwnership { new_owner } => transfer_ownership(deps, info, new_owner), + } +} + +pub fn add_item( + deps: DepsMut, + env: Env, + info: MessageInfo, + sku: String, + name: String, + quantity: Uint128, + note: Option, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let count = USER_ITEM_COUNT + .may_load(deps.storage, &info.sender)? + .unwrap_or_default(); + + if count >= config.max_items_per_user { + return Err(ContractError::ItemLimitReached {}); + } + + validate_nonzero("quantity", quantity)?; + let sku = validate_sku(sku)?; + let name = validate_text("name", name, 3, 80)?; + let note = validate_optional_text("note", note, 1, 160)?; + + let item_id = USER_NEXT_ID + .may_load(deps.storage, &info.sender)? + .unwrap_or(1); + let item = InventoryItem { + item_id, + owner: info.sender.clone(), + sku, + name, + quantity, + note, + created_at_height: env.block.height, + updated_at_height: env.block.height, + }; + + USER_ITEMS.save(deps.storage, (&info.sender, item_id), &item)?; + USER_NEXT_ID.save(deps.storage, &info.sender, &(item_id + 1))?; + USER_ITEM_COUNT.save(deps.storage, &info.sender, &(count + 1))?; + + Ok(Response::new() + .add_attribute("action", "add_item") + .add_attribute("owner", info.sender) + .add_attribute("item_id", item_id.to_string()) + .add_attribute("quantity", quantity.to_string())) +} + +pub fn update_item( + deps: DepsMut, + env: Env, + info: MessageInfo, + item_id: u64, + sku: String, + name: String, + note: Option, +) -> Result { + let sku = validate_sku(sku)?; + let name = validate_text("name", name, 3, 80)?; + let note = validate_optional_text("note", note, 1, 160)?; + + USER_ITEMS.update( + deps.storage, + (&info.sender, item_id), + |item| -> Result { + let mut item = item.ok_or(ContractError::ItemNotFound {})?; + item.sku = sku; + item.name = name; + item.note = note; + item.updated_at_height = env.block.height; + Ok(item) + }, + )?; + + Ok(Response::new() + .add_attribute("action", "update_item") + .add_attribute("owner", info.sender) + .add_attribute("item_id", item_id.to_string())) +} + +pub fn set_quantity( + deps: DepsMut, + env: Env, + info: MessageInfo, + item_id: u64, + quantity: Uint128, +) -> Result { + USER_ITEMS.update( + deps.storage, + (&info.sender, item_id), + |item| -> Result { + let mut item = item.ok_or(ContractError::ItemNotFound {})?; + item.quantity = quantity; + item.updated_at_height = env.block.height; + Ok(item) + }, + )?; + + Ok(Response::new() + .add_attribute("action", "set_quantity") + .add_attribute("owner", info.sender) + .add_attribute("item_id", item_id.to_string()) + .add_attribute("quantity", quantity.to_string())) +} + +pub fn increase_quantity( + deps: DepsMut, + env: Env, + info: MessageInfo, + item_id: u64, + amount: Uint128, +) -> Result { + validate_nonzero("amount", amount)?; + + let quantity = USER_ITEMS.update( + deps.storage, + (&info.sender, item_id), + |item| -> Result { + let mut item = item.ok_or(ContractError::ItemNotFound {})?; + item.quantity += amount; + item.updated_at_height = env.block.height; + Ok(item) + }, + )?; + + Ok(Response::new() + .add_attribute("action", "increase_quantity") + .add_attribute("owner", info.sender) + .add_attribute("item_id", item_id.to_string()) + .add_attribute("quantity", quantity.quantity.to_string())) +} + +pub fn decrease_quantity( + deps: DepsMut, + env: Env, + info: MessageInfo, + item_id: u64, + amount: Uint128, +) -> Result { + validate_nonzero("amount", amount)?; + + let quantity = USER_ITEMS.update( + deps.storage, + (&info.sender, item_id), + |item| -> Result { + let mut item = item.ok_or(ContractError::ItemNotFound {})?; + item.quantity = item + .quantity + .checked_sub(amount) + .map_err(|_| ContractError::InsufficientQuantity {})?; + item.updated_at_height = env.block.height; + Ok(item) + }, + )?; + + Ok(Response::new() + .add_attribute("action", "decrease_quantity") + .add_attribute("owner", info.sender) + .add_attribute("item_id", item_id.to_string()) + .add_attribute("quantity", quantity.quantity.to_string())) +} + +pub fn delete_item( + deps: DepsMut, + info: MessageInfo, + item_id: u64, +) -> Result { + if !USER_ITEMS.has(deps.storage, (&info.sender, item_id)) { + return Err(ContractError::ItemNotFound {}); + } + + USER_ITEMS.remove(deps.storage, (&info.sender, item_id)); + let count = USER_ITEM_COUNT + .may_load(deps.storage, &info.sender)? + .unwrap_or_default() + .saturating_sub(1); + USER_ITEM_COUNT.save(deps.storage, &info.sender, &count)?; + + Ok(Response::new() + .add_attribute("action", "delete_item") + .add_attribute("owner", info.sender) + .add_attribute("item_id", item_id.to_string())) +} + +pub fn update_config( + deps: DepsMut, + info: MessageInfo, + max_items_per_user: u32, +) -> Result { + assert_owner(deps.as_ref(), &info.sender)?; + validate_max_items(max_items_per_user)?; + + CONFIG.update( + deps.storage, + |mut config| -> Result { + config.max_items_per_user = max_items_per_user; + Ok(config) + }, + )?; + + Ok(Response::new() + .add_attribute("action", "update_config") + .add_attribute("max_items_per_user", max_items_per_user.to_string())) +} + +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) + }, + )?; + + 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::Item { owner, item_id } => to_json_binary(&query_item(deps, owner, item_id)?), + QueryMsg::UserItems { + owner, + start_after, + limit, + } => to_json_binary(&query_user_items(deps, owner, start_after, limit)?), + QueryMsg::ItemCount { owner } => to_json_binary(&query_item_count(deps, owner)?), + } +} + +#[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(), + max_items_per_user: config.max_items_per_user, + }) +} + +fn query_item(deps: Deps, owner: String, item_id: u64) -> StdResult { + let owner = deps.api.addr_validate(&owner)?; + let item = USER_ITEMS.load(deps.storage, (&owner, item_id))?; + Ok(ItemResponse { + item: item_to_response(item), + }) +} + +fn query_user_items( + deps: Deps, + owner: String, + start_after: Option, + limit: Option, +) -> StdResult { + let owner = deps.api.addr_validate(&owner)?; + let limit = limit.unwrap_or(20).min(MAX_QUERY_LIMIT) as usize; + let start = start_after.map(Bound::exclusive); + let items = USER_ITEMS + .prefix(&owner) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| item.map(|(_, inventory_item)| item_to_response(inventory_item))) + .collect::>>()?; + + Ok(UserItemsResponse { + owner: owner.to_string(), + items, + }) +} + +fn query_item_count(deps: Deps, owner: String) -> StdResult { + let owner = deps.api.addr_validate(&owner)?; + let count = USER_ITEM_COUNT + .may_load(deps.storage, &owner)? + .unwrap_or_default(); + + Ok(ItemCountResponse { + owner: owner.to_string(), + count, + }) +} + +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 item_to_response(item: InventoryItem) -> InventoryItemResponse { + InventoryItemResponse { + item_id: item.item_id, + owner: item.owner.to_string(), + sku: item.sku, + name: item.name, + quantity: item.quantity, + note: item.note, + created_at_height: item.created_at_height, + updated_at_height: item.updated_at_height, + } +} + +fn validate_max_items(value: u32) -> Result<(), ContractError> { + if value == 0 || value > MAX_ITEMS_PER_USER { + return Err(ContractError::InvalidInput { + reason: format!("max_items_per_user must be between 1 and {MAX_ITEMS_PER_USER}"), + }); + } + Ok(()) +} + +fn validate_nonzero(field: &str, value: Uint128) -> Result<(), ContractError> { + if value.is_zero() { + return Err(ContractError::InvalidInput { + reason: format!("{field} must be greater than zero"), + }); + } + Ok(()) +} + +fn validate_sku(value: String) -> Result { + let value = validate_text("sku", value, 2, 32)?; + if !value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + { + return Err(ContractError::InvalidInput { + reason: "sku can only contain ASCII letters, numbers, hyphens, or underscores" + .to_string(), + }); + } + Ok(value) +} + +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) +} + +fn validate_optional_text( + field: &str, + value: Option, + min_len: usize, + max_len: usize, +) -> Result, ContractError> { + value + .map(|value| validate_text(field, value, min_len, max_len)) + .transpose() +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::{ + from_json, + testing::{mock_dependencies, mock_env, mock_info}, + }; + + fn instantiate_contract(deps: DepsMut) { + instantiate( + deps, + mock_env(), + mock_info("admin", &[]), + InstantiateMsg { + max_items_per_user: Some(2), + }, + ) + .unwrap(); + } + + fn add_sample_item(deps: DepsMut, sender: &str, sku: &str, quantity: u128) { + execute( + deps, + mock_env(), + mock_info(sender, &[]), + ExecuteMsg::AddItem { + sku: sku.to_string(), + name: "Hardware Wallet".to_string(), + quantity: Uint128::new(quantity), + note: Some("Cold storage".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, "admin"); + assert_eq!(config.max_items_per_user, 2); + } + + #[test] + fn user_adds_and_lists_items() { + let mut deps = mock_dependencies(); + instantiate_contract(deps.as_mut()); + add_sample_item(deps.as_mut(), "alice", "HW-1", 2); + add_sample_item(deps.as_mut(), "alice", "CABLE-1", 5); + + let res = query( + deps.as_ref(), + mock_env(), + QueryMsg::UserItems { + owner: "alice".to_string(), + start_after: None, + limit: None, + }, + ) + .unwrap(); + let items: UserItemsResponse = from_json(&res).unwrap(); + + assert_eq!(items.owner, "alice"); + assert_eq!(items.items.len(), 2); + assert_eq!(items.items[0].item_id, 1); + assert_eq!(items.items[1].sku, "CABLE-1"); + } + + #[test] + fn user_updates_metadata_and_quantity() { + let mut deps = mock_dependencies(); + instantiate_contract(deps.as_mut()); + add_sample_item(deps.as_mut(), "alice", "HW-1", 2); + + execute( + deps.as_mut(), + mock_env(), + mock_info("alice", &[]), + ExecuteMsg::UpdateItem { + item_id: 1, + sku: "HW-PRO".to_string(), + name: "Hardware Wallet Pro".to_string(), + note: None, + }, + ) + .unwrap(); + + execute( + deps.as_mut(), + mock_env(), + mock_info("alice", &[]), + ExecuteMsg::IncreaseQuantity { + item_id: 1, + amount: Uint128::new(3), + }, + ) + .unwrap(); + + execute( + deps.as_mut(), + mock_env(), + mock_info("alice", &[]), + ExecuteMsg::DecreaseQuantity { + item_id: 1, + amount: Uint128::new(1), + }, + ) + .unwrap(); + + let res = query( + deps.as_ref(), + mock_env(), + QueryMsg::Item { + owner: "alice".to_string(), + item_id: 1, + }, + ) + .unwrap(); + let item: ItemResponse = from_json(&res).unwrap(); + + assert_eq!(item.item.sku, "HW-PRO"); + assert_eq!(item.item.name, "Hardware Wallet Pro"); + assert_eq!(item.item.quantity, Uint128::new(4)); + assert_eq!(item.item.note, None); + } + + #[test] + fn decrease_cannot_underflow() { + let mut deps = mock_dependencies(); + instantiate_contract(deps.as_mut()); + add_sample_item(deps.as_mut(), "alice", "HW-1", 2); + + let err = execute( + deps.as_mut(), + mock_env(), + mock_info("alice", &[]), + ExecuteMsg::DecreaseQuantity { + item_id: 1, + amount: Uint128::new(3), + }, + ) + .unwrap_err(); + + assert_eq!(err, ContractError::InsufficientQuantity {}); + } + + #[test] + fn users_cannot_modify_each_others_items() { + let mut deps = mock_dependencies(); + instantiate_contract(deps.as_mut()); + add_sample_item(deps.as_mut(), "alice", "HW-1", 2); + + let err = execute( + deps.as_mut(), + mock_env(), + mock_info("bob", &[]), + ExecuteMsg::SetQuantity { + item_id: 1, + quantity: Uint128::new(10), + }, + ) + .unwrap_err(); + + assert_eq!(err, ContractError::ItemNotFound {}); + } + + #[test] + fn item_limit_and_validation_are_enforced() { + let mut deps = mock_dependencies(); + instantiate_contract(deps.as_mut()); + add_sample_item(deps.as_mut(), "alice", "HW-1", 2); + add_sample_item(deps.as_mut(), "alice", "CABLE-1", 5); + + let limit_err = execute( + deps.as_mut(), + mock_env(), + mock_info("alice", &[]), + ExecuteMsg::AddItem { + sku: "THIRD".to_string(), + name: "Third Item".to_string(), + quantity: Uint128::new(1), + note: None, + }, + ) + .unwrap_err(); + assert_eq!(limit_err, ContractError::ItemLimitReached {}); + + let sku_err = execute( + deps.as_mut(), + mock_env(), + mock_info("bob", &[]), + ExecuteMsg::AddItem { + sku: "bad sku".to_string(), + name: "Invalid Sku".to_string(), + quantity: Uint128::new(1), + note: None, + }, + ) + .unwrap_err(); + assert_eq!( + sku_err, + ContractError::InvalidInput { + reason: "sku can only contain ASCII letters, numbers, hyphens, or underscores" + .to_string() + } + ); + + let zero_err = execute( + deps.as_mut(), + mock_env(), + mock_info("bob", &[]), + ExecuteMsg::AddItem { + sku: "ZERO".to_string(), + name: "Zero Quantity".to_string(), + quantity: Uint128::zero(), + note: None, + }, + ) + .unwrap_err(); + assert_eq!( + zero_err, + ContractError::InvalidInput { + reason: "quantity must be greater than zero".to_string() + } + ); + } + + #[test] + fn delete_updates_count() { + let mut deps = mock_dependencies(); + instantiate_contract(deps.as_mut()); + add_sample_item(deps.as_mut(), "alice", "HW-1", 2); + + execute( + deps.as_mut(), + mock_env(), + mock_info("alice", &[]), + ExecuteMsg::DeleteItem { item_id: 1 }, + ) + .unwrap(); + + let res = query( + deps.as_ref(), + mock_env(), + QueryMsg::ItemCount { + owner: "alice".to_string(), + }, + ) + .unwrap(); + let count: ItemCountResponse = from_json(&res).unwrap(); + + assert_eq!(count.count, 0); + } + + #[test] + fn owner_updates_config_and_transfers_ownership() { + let mut deps = mock_dependencies(); + instantiate_contract(deps.as_mut()); + + let unauthorized = execute( + deps.as_mut(), + mock_env(), + mock_info("alice", &[]), + ExecuteMsg::UpdateConfig { + max_items_per_user: 3, + }, + ) + .unwrap_err(); + assert_eq!(unauthorized, ContractError::Unauthorized {}); + + execute( + deps.as_mut(), + mock_env(), + mock_info("admin", &[]), + ExecuteMsg::UpdateConfig { + max_items_per_user: 3, + }, + ) + .unwrap(); + + execute( + deps.as_mut(), + mock_env(), + mock_info("admin", &[]), + ExecuteMsg::TransferOwnership { + new_owner: "new_admin".to_string(), + }, + ) + .unwrap(); + + let res = query(deps.as_ref(), mock_env(), QueryMsg::Config {}).unwrap(); + let config: ConfigResponse = from_json(&res).unwrap(); + assert_eq!(config.owner, "new_admin"); + assert_eq!(config.max_items_per_user, 3); + } +} diff --git a/inventory-tracker/src/error.rs b/inventory-tracker/src/error.rs new file mode 100644 index 00000000..0491f873 --- /dev/null +++ b/inventory-tracker/src/error.rs @@ -0,0 +1,23 @@ +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("Inventory item not found")] + ItemNotFound {}, + + #[error("Inventory item limit reached")] + ItemLimitReached {}, + + #[error("Insufficient quantity")] + InsufficientQuantity {}, +} diff --git a/inventory-tracker/src/lib.rs b/inventory-tracker/src/lib.rs new file mode 100644 index 00000000..dfedc9dc --- /dev/null +++ b/inventory-tracker/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/inventory-tracker/src/msg.rs b/inventory-tracker/src/msg.rs new file mode 100644 index 00000000..31ba1fa6 --- /dev/null +++ b/inventory-tracker/src/msg.rs @@ -0,0 +1,104 @@ +use cosmwasm_schema::QueryResponses; +use cosmwasm_std::Uint128; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct InstantiateMsg { + pub max_items_per_user: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ExecuteMsg { + AddItem { + sku: String, + name: String, + quantity: Uint128, + note: Option, + }, + UpdateItem { + item_id: u64, + sku: String, + name: String, + note: Option, + }, + SetQuantity { + item_id: u64, + quantity: Uint128, + }, + IncreaseQuantity { + item_id: u64, + amount: Uint128, + }, + DecreaseQuantity { + item_id: u64, + amount: Uint128, + }, + DeleteItem { + item_id: u64, + }, + UpdateConfig { + max_items_per_user: u32, + }, + TransferOwnership { + new_owner: String, + }, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, QueryResponses)] +#[serde(rename_all = "snake_case")] +pub enum QueryMsg { + #[returns(ConfigResponse)] + Config {}, + #[returns(ItemResponse)] + Item { owner: String, item_id: u64 }, + #[returns(UserItemsResponse)] + UserItems { + owner: String, + start_after: Option, + limit: Option, + }, + #[returns(ItemCountResponse)] + ItemCount { owner: 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 max_items_per_user: u32, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct ItemResponse { + pub item: InventoryItemResponse, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct UserItemsResponse { + pub owner: String, + pub items: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct ItemCountResponse { + pub owner: String, + pub count: u32, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct InventoryItemResponse { + pub item_id: u64, + pub owner: String, + pub sku: String, + pub name: String, + pub quantity: Uint128, + pub note: Option, + pub created_at_height: u64, + pub updated_at_height: u64, +} diff --git a/inventory-tracker/src/state.rs b/inventory-tracker/src/state.rs new file mode 100644 index 00000000..c54162bc --- /dev/null +++ b/inventory-tracker/src/state.rs @@ -0,0 +1,27 @@ +use cosmwasm_std::{Addr, Uint128}; +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 max_items_per_user: u32, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct InventoryItem { + pub item_id: u64, + pub owner: Addr, + pub sku: String, + pub name: String, + pub quantity: Uint128, + pub note: Option, + pub created_at_height: u64, + pub updated_at_height: u64, +} + +pub const CONFIG: Item = Item::new("config"); +pub const USER_NEXT_ID: Map<&Addr, u64> = Map::new("user_next_id"); +pub const USER_ITEM_COUNT: Map<&Addr, u32> = Map::new("user_item_count"); +pub const USER_ITEMS: Map<(&Addr, u64), InventoryItem> = Map::new("user_items");