diff --git a/.circleci/config.yml b/.circleci/config.yml index 45dc4412d..27c652e3a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,11 +3,14 @@ workflows: version: 2 test: jobs: + - contract_cw1155_base + - contract_cw1155_royalties - contract_cw721_base - contract_cw721_expiration - contract_cw721_fixed_price - contract_cw721_receiver_tester - package_cw721 + - package_cw1155 - lint - wasm-build deploy: @@ -189,6 +192,115 @@ jobs: - target key: cargocache-v2-cw721:1.64.0-{{ checksum "~/project/Cargo.lock" }} + contract_cw1155_base: + docker: + - image: rust:1.78.0 + working_directory: ~/project/contracts/cw1155-base + steps: + - checkout: + path: ~/project + - run: + name: Version information + command: rustc --version; cargo --version; rustup --version + - restore_cache: + keys: + - cargocache-cw1155-base-rust:1.78.0-{{ checksum "~/project/Cargo.lock" }} + - run: + name: Unit Tests + environment: + RUST_BACKTRACE: 1 + command: cargo unit-test --locked + - run: + name: Build and run schema generator + command: cargo schema --locked + - run: + name: Ensure checked-in schemas are up-to-date + command: | + CHANGES_IN_REPO=$(git status --porcelain) + if [[ -n "$CHANGES_IN_REPO" ]]; then + echo "Repository is dirty. Showing 'git status' and 'git --no-pager diff' for debugging now:" + git status && git --no-pager diff + exit 1 + fi + - save_cache: + paths: + - /usr/local/cargo/registry + - target + key: cargocache-cw1155-base-rust:1.78.0-{{ checksum "~/project/Cargo.lock" }} + + contract_cw1155_royalties: + docker: + - image: rust:1.78.0 + working_directory: ~/project/contracts/cw1155-royalties + steps: + - checkout: + path: ~/project + - run: + name: Version information + command: rustc --version; cargo --version; rustup --version + - restore_cache: + keys: + - cargocache-cw1155-royalties-rust:1.78.0-{{ checksum "~/project/Cargo.lock" }} + - run: + name: Unit Tests + environment: + RUST_BACKTRACE: 1 + command: cargo unit-test --locked + - run: + name: Build and run schema generator + command: cargo schema --locked + - run: + name: Ensure checked-in schemas are up-to-date + command: | + CHANGES_IN_REPO=$(git status --porcelain) + if [[ -n "$CHANGES_IN_REPO" ]]; then + echo "Repository is dirty. Showing 'git status' and 'git --no-pager diff' for debugging now:" + git status && git --no-pager diff + exit 1 + fi + - save_cache: + paths: + - /usr/local/cargo/registry + - target + key: cargocache-cw1155-royalties-rust:1.78.0-{{ checksum "~/project/Cargo.lock" }} + + package_cw1155: + docker: + - image: rust:1.78.0 + working_directory: ~/project/packages/cw1155 + steps: + - checkout: + path: ~/project + - run: + name: Version information + command: rustc --version; cargo --version; rustup --version; rustup target list --installed + - restore_cache: + keys: + - cargocache-v2-cw1155:1.78.0-{{ checksum "~/project/Cargo.lock" }} + - run: + name: Build library for native target + command: cargo build --locked + - run: + name: Run unit tests + command: cargo test --locked + - run: + name: Build and run schema generator + command: cargo schema --locked + - run: + name: Ensure schemas are up-to-date + command: | + CHANGES_IN_REPO=$(git status --porcelain) + if [[ -n "$CHANGES_IN_REPO" ]]; then + echo "Repository is dirty. Showing 'git status' and 'git --no-pager diff' for debugging now:" + git status && git --no-pager diff + exit 1 + fi + - save_cache: + paths: + - /usr/local/cargo/registry + - target + key: cargocache-v2-cw1155:1.78.0-{{ checksum "~/project/Cargo.lock" }} + lint: docker: - image: rust:1.78.0 diff --git a/.gitignore b/.gitignore index ad53e6f59..9424941ec 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,5 @@ artifacts/ # code coverage tarpaulin-report.* -# raw json schema files -**/raw/*.json \ No newline at end of file +# json schema files +**/schema/**/*.json diff --git a/Cargo.lock b/Cargo.lock index 67e7caa32..e202c8715 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -385,6 +385,54 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cw1155" +version = "0.19.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-ownable 0.6.0", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw2 1.1.2", + "cw721 0.19.0", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw1155-base" +version = "0.19.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-ownable 0.6.0", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw1155", + "cw2 1.1.2", + "cw721 0.19.0", + "cw721-base 0.19.0", + "schemars", + "serde", +] + +[[package]] +name = "cw1155-royalties" +version = "0.19.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw1155", + "cw1155-base", + "cw2 1.1.2", + "cw2981-royalties", + "schemars", + "serde", + "thiserror", +] + [[package]] name = "cw2" version = "0.15.1" diff --git a/Cargo.toml b/Cargo.toml index 0348044fd..6933de4bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,8 @@ cw721-base-016 = { git = "https://github.com/CosmWasm/cw-nfts", tag = "v0.16.0" cw721-metadata-onchain-016 = { git = "https://github.com/CosmWasm/cw-nfts", tag = "v0.16.0", package = "cw721-metadata-onchain" } # needed for testing legacy migration cw721-base-017 = { git = "https://github.com/CosmWasm/cw-nfts", tag = "v0.17.0", package = "cw721-base" } # needed for testing legacy migration cw721-base-018 = { git = "https://github.com/CosmWasm/cw-nfts", tag = "v0.18.0", package = "cw721-base" } # needed for testing legacy migration +cw1155 = { path = "./packages/cw1155", version = "*" } +cw1155-base = { path = "./contracts/cw1155-base", version = "*" } cw-multi-test = { version = "^0.20", features = ["cosmwasm_1_2"] } cw-ownable = { git = "https://github.com/public-awesome/cw-plus-plus.git", rev = "28c1a09bfc6b4f1942fefe3eb0b50faf9d3b1523"} # TODO: switch to official https://github.com/larry0x/cw-plus-plus once merged cw-paginate-storage = { version = "^2.4", git = "https://github.com/DA0-DA0/dao-contracts.git" } diff --git a/Makefile.toml b/Makefile.toml index 77817fcfb..b4b90b62d 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -6,19 +6,19 @@ skip_core_tasks = true [tasks.fmt] command = "cargo" -args = ["fmt", "--all", "--check"] +args = ["fmt", "--all", "--check"] [tasks.test] command = "cargo" -args = ["test", "--locked"] +args = ["test", "--locked"] [tasks.lint] command = "cargo" -args = ["clippy", "--tests", "--", "-D", "warnings"] +args = ["clippy", "--tests", "--", "-D", "warnings"] [tasks.build] command = "cargo" -args = ["build", "--release", "--locked", "--target", "wasm32-unknown-unknown"] +args = ["build", "--release", "--locked", "--target", "wasm32-unknown-unknown"] [tasks.optimize] # https://hub.docker.com/r/cosmwasm/workspace-optimizer/tags https://hub.docker.com/r/cosmwasm/workspace-optimizer-arm64/tags @@ -32,7 +32,7 @@ fi 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 \ - ${image}:0.16.0 + ${image}:0.15.1 """ [tasks.schema] diff --git a/contracts/cw1155-base/.cargo/config b/contracts/cw1155-base/.cargo/config new file mode 100644 index 000000000..7d1a066c8 --- /dev/null +++ b/contracts/cw1155-base/.cargo/config @@ -0,0 +1,5 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +wasm-debug = "build --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/cw1155-base/Cargo.toml b/contracts/cw1155-base/Cargo.toml new file mode 100644 index 000000000..cd8a63d2c --- /dev/null +++ b/contracts/cw1155-base/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "cw1155-base" +authors = ["shab "] +description = "Basic implementation cw1155" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +documentation = { workspace = true } + +exclude = [ + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "artifacts/*", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cw-utils = { workspace = true } +cw2 = { workspace = true } +cw1155 = { workspace = true } +cw721 = { workspace = true } +cw721-base = { workspace = true, features = ["library"] } +cw-ownable = { workspace = true } +cw-storage-plus = { workspace = true } +cosmwasm-std = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +cosmwasm-schema = { workspace = true } diff --git a/contracts/cw1155-base/examples/schema.rs b/contracts/cw1155-base/examples/schema.rs new file mode 100644 index 000000000..1666716a7 --- /dev/null +++ b/contracts/cw1155-base/examples/schema.rs @@ -0,0 +1,12 @@ +use cosmwasm_schema::write_api; +use cw1155::msg::Cw1155InstantiateMsg; + +use cw1155_base::{Cw1155BaseExecuteMsg, Cw1155BaseQueryMsg}; + +fn main() { + write_api! { + instantiate: Cw1155InstantiateMsg, + execute: Cw1155BaseExecuteMsg, + query: Cw1155BaseQueryMsg, + } +} diff --git a/contracts/cw1155-base/src/contract_tests.rs b/contracts/cw1155-base/src/contract_tests.rs new file mode 100644 index 000000000..d91f61cd0 --- /dev/null +++ b/contracts/cw1155-base/src/contract_tests.rs @@ -0,0 +1,1517 @@ +#[cfg(test)] +mod tests { + use crate::{Cw1155BaseContract, Cw1155BaseExecuteMsg, Cw1155BaseQueryMsg}; + use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; + use cosmwasm_std::{ + from_json, to_json_binary, Addr, Binary, Empty, OverflowError, Response, StdError, Uint128, + }; + use cw1155::error::Cw1155ContractError; + use cw1155::execute::Cw1155Execute; + use cw1155::msg::{ + ApprovedForAllResponse, Balance, BalanceResponse, BalancesResponse, Cw1155InstantiateMsg, + Cw1155MintMsg, Cw1155QueryMsg, IsApprovedForAllResponse, NumTokensResponse, OwnerToken, + TokenAmount, TokenInfoResponse, + }; + use cw1155::query::Cw1155Query; + use cw1155::receiver::Cw1155BatchReceiveMsg; + use cw721::msg::TokensResponse; + use cw721::Approval; + use cw_ownable::OwnershipError; + use cw_utils::Expiration; + + #[test] + fn check_transfers() { + let contract = Cw1155BaseContract::default(); + // A long test case that try to cover as many cases as possible. + // Summary of what it does: + // - try mint without permission, fail + // - mint with permission, success + // - query balance of recipient, success + // - try transfer without approval, fail + // - approve + // - transfer again, success + // - query balance of transfer participants + // - try batch transfer without approval, fail + // - approve and try batch transfer again, success + // - batch query balances + // - user1 revoke approval to minter + // - query approval status + // - minter try to transfer, fail + // - user1 burn token1 + // - user1 batch burn token2 and token3 + let token1 = "token1".to_owned(); + let token2 = "token2".to_owned(); + let token3 = "token3".to_owned(); + let minter = String::from("minter"); + let user1 = String::from("user1"); + let user2 = String::from("user2"); + + let mut deps = mock_dependencies(); + let msg = Cw1155InstantiateMsg { + name: "name".to_string(), + symbol: "symbol".to_string(), + minter: Some(minter.to_string()), + }; + let res = contract + .instantiate( + deps.as_mut(), + mock_env(), + mock_info("operator", &[]), + msg, + "contract_name", + "contract_version", + ) + .unwrap(); + assert_eq!(0, res.messages.len()); + + // invalid mint, user1 don't mint permission + let mint_msg = Cw1155BaseExecuteMsg::Mint { + recipient: user1.to_string(), + msg: Cw1155MintMsg { + token_id: token1.to_string(), + amount: 1u64.into(), + token_uri: None, + extension: None, + }, + }; + assert!(matches!( + contract.execute( + deps.as_mut(), + mock_env(), + mock_info(user1.as_ref(), &[]), + mint_msg.clone(), + ), + Err(Cw1155ContractError::Ownership(OwnershipError::NotOwner)) + )); + + // valid mint + assert_eq!( + contract + .execute( + deps.as_mut(), + mock_env(), + mock_info(minter.as_ref(), &[]), + mint_msg, + ) + .unwrap(), + Response::new().add_attributes(vec![ + ("action", "mint_single"), + ("sender", minter.as_str()), + ("recipient", user1.as_str()), + ("token_id", token1.as_str()), + ("amount", "1"), + ]) + ); + + // verify supply + assert_eq!( + from_json::( + contract + .query( + deps.as_ref(), + mock_env(), + Cw1155BaseQueryMsg::NumTokens { + token_id: Some(token1.clone()), + }, + ) + .unwrap() + ) + .unwrap(), + NumTokensResponse { + count: Uint128::one() + } + ); + assert_eq!( + from_json::( + contract + .query( + deps.as_ref(), + mock_env(), + Cw1155BaseQueryMsg::NumTokens { token_id: None }, + ) + .unwrap() + ) + .unwrap(), + NumTokensResponse { + count: Uint128::one() + } + ); + + // query balance + assert_eq!( + to_json_binary(&BalanceResponse { + balance: 1u64.into() + }), + contract.query( + deps.as_ref(), + mock_env(), + Cw1155BaseQueryMsg::BalanceOf(OwnerToken { + owner: user1.clone(), + token_id: token1.clone(), + }) + ), + ); + + let transfer_msg = Cw1155BaseExecuteMsg::Send { + from: Some(user1.to_string()), + to: user2.clone(), + token_id: token1.clone(), + amount: 1u64.into(), + msg: None, + }; + + // not approved yet + assert!(matches!( + contract.execute( + deps.as_mut(), + mock_env(), + mock_info(minter.as_ref(), &[]), + transfer_msg.clone(), + ), + Err(Cw1155ContractError::Unauthorized {}) + )); + + // approve + contract + .execute( + deps.as_mut(), + mock_env(), + mock_info(user1.as_ref(), &[]), + Cw1155BaseExecuteMsg::ApproveAll { + operator: minter.clone(), + expires: None, + }, + ) + .unwrap(); + + // transfer + assert_eq!( + contract + .execute( + deps.as_mut(), + mock_env(), + mock_info(minter.as_ref(), &[]), + transfer_msg, + ) + .unwrap(), + Response::new().add_attributes(vec![ + ("action", "transfer_single"), + ("owner", user1.as_str()), + ("sender", minter.as_str()), + ("recipient", user2.as_str()), + ("token_id", token1.as_str()), + ("amount", "1"), + ]) + ); + + // query balance + assert_eq!( + contract.query( + deps.as_ref(), + mock_env(), + Cw1155BaseQueryMsg::BalanceOf(OwnerToken { + owner: user2.clone(), + token_id: token1.clone(), + }) + ), + to_json_binary(&BalanceResponse { + balance: 1u64.into() + }), + ); + assert_eq!( + contract.query( + deps.as_ref(), + mock_env(), + Cw1155BaseQueryMsg::BalanceOf(OwnerToken { + owner: user1.clone(), + token_id: token1.clone(), + }) + ), + to_json_binary(&BalanceResponse { + balance: 0u64.into() + }), + ); + + // mint token2 and token3 + contract + .execute( + deps.as_mut(), + mock_env(), + mock_info(minter.as_ref(), &[]), + Cw1155BaseExecuteMsg::Mint { + recipient: user2.clone(), + msg: Cw1155MintMsg { + token_id: token2.clone(), + amount: 1u64.into(), + token_uri: None, + extension: None, + }, + }, + ) + .unwrap(); + + contract + .execute( + deps.as_mut(), + mock_env(), + mock_info(minter.as_ref(), &[]), + Cw1155BaseExecuteMsg::Mint { + recipient: user2.clone(), + msg: Cw1155MintMsg { + token_id: token3.clone(), + amount: 1u64.into(), + token_uri: None, + extension: None, + }, + }, + ) + .unwrap(); + + // verify supply + assert_eq!( + from_json::( + contract + .query( + deps.as_ref(), + mock_env(), + Cw1155BaseQueryMsg::NumTokens { + token_id: Some(token2.clone()), + }, + ) + .unwrap() + ) + .unwrap(), + NumTokensResponse { + count: Uint128::one() + } + ); + assert_eq!( + from_json::( + contract + .query( + deps.as_ref(), + mock_env(), + Cw1155BaseQueryMsg::NumTokens { + token_id: Some(token3.clone()), + }, + ) + .unwrap() + ) + .unwrap(), + NumTokensResponse { + count: Uint128::one() + } + ); + assert_eq!( + from_json::( + contract + .query( + deps.as_ref(), + mock_env(), + Cw1155BaseQueryMsg::NumTokens { token_id: None }, + ) + .unwrap() + ) + .unwrap(), + NumTokensResponse { + count: Uint128::new(3) + } + ); + + // invalid batch transfer, (user2 not approved yet) + let batch_transfer_msg = Cw1155BaseExecuteMsg::SendBatch { + from: Some(user2.clone()), + to: user1.clone(), + batch: vec![ + TokenAmount { + token_id: token1.to_string(), + amount: 1u64.into(), + }, + TokenAmount { + token_id: token2.to_string(), + amount: 1u64.into(), + }, + TokenAmount { + token_id: token3.to_string(), + amount: 1u64.into(), + }, + ], + msg: None, + }; + assert!(matches!( + contract.execute( + deps.as_mut(), + mock_env(), + mock_info(minter.as_ref(), &[]), + batch_transfer_msg.clone(), + ), + Err(Cw1155ContractError::Unauthorized {}), + )); + + // user2 approve + contract + .execute( + deps.as_mut(), + mock_env(), + mock_info(user2.as_ref(), &[]), + Cw1155BaseExecuteMsg::ApproveAll { + operator: minter.clone(), + expires: None, + }, + ) + .unwrap(); + + // verify approval status + assert_eq!( + contract.query( + deps.as_ref(), + mock_env(), + Cw1155QueryMsg::IsApprovedForAll { + owner: user1.to_string(), + operator: minter.to_string(), + }, + ), + to_json_binary(&IsApprovedForAllResponse { approved: true }) + ); + + // valid batch transfer + assert_eq!( + contract + .execute( + deps.as_mut(), + mock_env(), + mock_info(minter.as_ref(), &[]), + batch_transfer_msg, + ) + .unwrap(), + Response::new().add_attributes(vec![ + ("action", "transfer_batch"), + ("owner", user2.as_str()), + ("sender", minter.as_str()), + ("recipient", user1.as_str()), + ("token_ids", &format!("{},{},{}", token1, token2, token3)), + ("amounts", "1,1,1"), + ]) + ); + + // batch query + assert_eq!( + from_json( + contract + .query( + deps.as_ref(), + mock_env(), + Cw1155BaseQueryMsg::BalanceOfBatch(vec![ + OwnerToken { + owner: user1.clone(), + token_id: token1.clone(), + }, + OwnerToken { + owner: user1.clone(), + token_id: token2.clone(), + }, + OwnerToken { + owner: user1.clone(), + token_id: token3.clone(), + } + ]), + ) + .unwrap() + ), + Ok(BalancesResponse { + balances: vec![ + Balance { + token_id: token1.to_string(), + owner: Addr::unchecked(user1.to_string()), + amount: Uint128::one(), + }, + Balance { + token_id: token2.to_string(), + owner: Addr::unchecked(user1.to_string()), + amount: Uint128::one(), + }, + Balance { + token_id: token3.to_string(), + owner: Addr::unchecked(user1.to_string()), + amount: Uint128::one(), + } + ] + }), + ); + + // user1 revoke approval + contract + .execute( + deps.as_mut(), + mock_env(), + mock_info(user1.as_ref(), &[]), + Cw1155BaseExecuteMsg::RevokeAll { + operator: minter.clone(), + }, + ) + .unwrap(); + + // query approval status + let approvals: ApprovedForAllResponse = from_json( + contract + .query( + deps.as_ref(), + mock_env(), + Cw1155QueryMsg::ApprovalsForAll { + owner: user1.clone(), + include_expired: None, + start_after: None, + limit: None, + }, + ) + .unwrap(), + ) + .unwrap(); + assert!( + approvals.operators.is_empty() + || !approvals + .operators + .iter() + .all(|approval| approval.spender == minter) + ); + + assert_eq!( + contract.query( + deps.as_ref(), + mock_env(), + Cw1155QueryMsg::IsApprovedForAll { + owner: user1.to_string(), + operator: minter.to_string(), + }, + ), + to_json_binary(&IsApprovedForAllResponse { approved: false }) + ); + + // transfer without approval + assert!(matches!( + contract.execute( + deps.as_mut(), + mock_env(), + mock_info(minter.as_ref(), &[]), + Cw1155BaseExecuteMsg::Send { + from: Some(user1.clone()), + to: user2, + token_id: token1.clone(), + amount: 1u64.into(), + msg: None, + }, + ), + Err(Cw1155ContractError::Unauthorized {}) + )); + + // burn token1 + assert_eq!( + contract + .execute( + deps.as_mut(), + mock_env(), + mock_info(user1.as_ref(), &[]), + Cw1155BaseExecuteMsg::Burn { + from: None, + token_id: token1.clone(), + amount: 1u64.into(), + }, + ) + .unwrap(), + Response::new().add_attributes(vec![ + ("action", "burn_single"), + ("owner", user1.as_str()), + ("sender", user1.as_str()), + ("token_id", &token1), + ("amount", "1"), + ]) + ); + + // verify supply + assert_eq!( + from_json::( + contract + .query( + deps.as_ref(), + mock_env(), + Cw1155BaseQueryMsg::NumTokens { + token_id: Some(token1.clone()), + }, + ) + .unwrap() + ) + .unwrap(), + NumTokensResponse { + count: Uint128::zero() + } + ); + assert_eq!( + from_json::( + contract + .query( + deps.as_ref(), + mock_env(), + Cw1155BaseQueryMsg::NumTokens { token_id: None }, + ) + .unwrap() + ) + .unwrap(), + NumTokensResponse { + count: Uint128::new(2) + } + ); + + // verify balance + assert_eq!( + from_json::( + contract + .query( + deps.as_ref(), + mock_env(), + Cw1155BaseQueryMsg::BalanceOf(OwnerToken { + owner: user1.clone(), + token_id: token1.clone(), + }), + ) + .unwrap() + ) + .unwrap(), + BalanceResponse { + balance: Uint128::zero() + } + ); + + // burn them all + assert_eq!( + contract + .execute( + deps.as_mut(), + mock_env(), + mock_info(user1.as_ref(), &[]), + Cw1155BaseExecuteMsg::BurnBatch { + from: None, + batch: vec![ + TokenAmount { + token_id: token2.to_string(), + amount: 1u64.into(), + }, + TokenAmount { + token_id: token3.to_string(), + amount: 1u64.into(), + }, + ], + }, + ) + .unwrap(), + Response::new().add_attributes(vec![ + ("action", "burn_batch"), + ("owner", user1.as_str()), + ("sender", user1.as_str()), + ("token_ids", &format!("{},{}", token2, token3)), + ("amounts", "1,1"), + ]) + ); + + // verify supply + assert_eq!( + from_json::( + contract + .query( + deps.as_ref(), + mock_env(), + Cw1155BaseQueryMsg::NumTokens { + token_id: Some(token2.clone()), + }, + ) + .unwrap() + ) + .unwrap(), + NumTokensResponse { + count: Uint128::zero() + } + ); + assert_eq!( + from_json::( + contract + .query( + deps.as_ref(), + mock_env(), + Cw1155BaseQueryMsg::NumTokens { + token_id: Some(token3.clone()), + }, + ) + .unwrap() + ) + .unwrap(), + NumTokensResponse { + count: Uint128::zero() + } + ); + assert_eq!( + from_json::( + contract + .query( + deps.as_ref(), + mock_env(), + Cw1155BaseQueryMsg::NumTokens { token_id: None }, + ) + .unwrap() + ) + .unwrap(), + NumTokensResponse { + count: Uint128::zero() + } + ); + + // verify balances + assert_eq!( + from_json::( + contract + .query( + deps.as_ref(), + mock_env(), + Cw1155BaseQueryMsg::BalanceOfBatch(vec![ + OwnerToken { + owner: user1.clone(), + token_id: token2.clone(), + }, + OwnerToken { + owner: user1.clone(), + token_id: token3.clone(), + }, + ]), + ) + .unwrap() + ) + .unwrap(), + BalancesResponse { + balances: vec![ + Balance { + token_id: token2.to_string(), + owner: Addr::unchecked(&user1), + amount: Uint128::zero(), + }, + Balance { + token_id: token3.to_string(), + owner: Addr::unchecked(&user1), + amount: Uint128::zero(), + } + ] + }, + ); + } + + #[test] + fn check_send_contract() { + let contract = Cw1155BaseContract::default(); + let receiver = String::from("receive_contract"); + let minter = String::from("minter"); + let user1 = String::from("user1"); + let token1 = "token1".to_owned(); + let token2 = "token2".to_owned(); + let operator_info = mock_info("operator", &[]); + let dummy_msg = Binary::default(); + + let mut deps = mock_dependencies(); + let msg = Cw1155InstantiateMsg { + name: "name".to_string(), + symbol: "symbol".to_string(), + minter: Some(minter.to_string()), + }; + let res = contract + .instantiate( + deps.as_mut(), + mock_env(), + operator_info.clone(), + msg, + "contract_name", + "contract_version", + ) + .unwrap(); + assert_eq!(0, res.messages.len()); + + contract + .execute( + deps.as_mut(), + mock_env(), + mock_info(minter.as_ref(), &[]), + Cw1155BaseExecuteMsg::MintBatch { + recipient: user1.clone(), + msgs: vec![ + Cw1155MintMsg { + token_id: token1.clone(), + amount: 5u64.into(), + token_uri: None, + extension: None, + }, + Cw1155MintMsg { + token_id: token2.clone(), + amount: 5u64.into(), + token_uri: None, + extension: None, + }, + ], + }, + ) + .unwrap(); + contract + .execute( + deps.as_mut(), + mock_env(), + mock_info(minter.as_ref(), &[]), + Cw1155BaseExecuteMsg::MintBatch { + recipient: receiver.clone(), + msgs: vec![Cw1155MintMsg { + token_id: token1.clone(), + amount: 1u64.into(), + token_uri: None, + extension: None, + }], + }, + ) + .unwrap(); + + // BatchSendFrom + assert_eq!( + contract + .execute( + deps.as_mut(), + mock_env(), + mock_info(user1.as_ref(), &[]), + Cw1155BaseExecuteMsg::SendBatch { + from: Some(user1.clone()), + to: receiver.clone(), + batch: vec![TokenAmount { + token_id: token2.to_string(), + amount: 1u64.into(), + },], + msg: Some(dummy_msg.clone()), + }, + ) + .unwrap(), + Response::new() + .add_message( + Cw1155BatchReceiveMsg { + operator: user1.clone(), + from: Some(user1.clone()), + batch: vec![TokenAmount { + token_id: token2.to_string(), + amount: 1u64.into(), + }], + msg: dummy_msg.clone(), + } + .into_cosmos_msg(&operator_info, receiver.clone()) + .unwrap() + ) + .add_attributes(vec![ + ("action", "transfer_single"), + ("owner", user1.as_str()), + ("sender", user1.as_str()), + ("recipient", receiver.as_str()), + ("token_id", token2.as_str()), + ("amount", "1"), + ]) + ); + + // verify balances + assert_eq!( + from_json::( + contract + .query( + deps.as_ref(), + mock_env(), + Cw1155BaseQueryMsg::BalanceOfBatch(vec![ + OwnerToken { + owner: user1.clone(), + token_id: token2.clone(), + }, + OwnerToken { + owner: receiver.clone(), + token_id: token2.clone(), + } + ]), + ) + .unwrap() + ) + .unwrap(), + BalancesResponse { + balances: vec![ + Balance { + token_id: token2.to_string(), + owner: Addr::unchecked(&user1), + amount: Uint128::new(4), + }, + Balance { + token_id: token2.to_string(), + owner: Addr::unchecked(&receiver), + amount: Uint128::one(), + } + ] + }, + ); + + // BatchSend + assert_eq!( + contract + .execute( + deps.as_mut(), + mock_env(), + mock_info(user1.as_ref(), &[]), + Cw1155BaseExecuteMsg::SendBatch { + from: None, + to: receiver.clone(), + batch: vec![ + TokenAmount { + token_id: token1.to_string(), + amount: 1u64.into(), + }, + TokenAmount { + token_id: token2.to_string(), + amount: 1u64.into(), + }, + ], + msg: Some(dummy_msg.clone()), + }, + ) + .unwrap(), + Response::new() + .add_message( + Cw1155BatchReceiveMsg { + operator: user1.clone(), + from: Some(user1.clone()), + batch: vec![ + TokenAmount { + token_id: token1.to_string(), + amount: 1u64.into(), + }, + TokenAmount { + token_id: token2.to_string(), + amount: 1u64.into(), + } + ], + msg: dummy_msg, + } + .into_cosmos_msg(&operator_info, receiver.clone()) + .unwrap() + ) + .add_attributes(vec![ + ("action", "transfer_batch"), + ("owner", user1.as_str()), + ("sender", user1.as_str()), + ("recipient", receiver.as_str()), + ( + "token_ids", + &format!("{},{}", token1.as_str(), token2.as_str()) + ), + ("amounts", &format!("{},{}", 1, 1)), + ]) + ); + + // verify balances + assert_eq!( + from_json::( + contract + .query( + deps.as_ref(), + mock_env(), + Cw1155BaseQueryMsg::BalanceOfBatch(vec![ + OwnerToken { + owner: user1.clone(), + token_id: token1.clone(), + }, + OwnerToken { + owner: user1.clone(), + token_id: token2.clone(), + }, + OwnerToken { + owner: receiver.clone(), + token_id: token1.clone(), + }, + OwnerToken { + owner: receiver.clone(), + token_id: token2.clone(), + } + ]), + ) + .unwrap() + ) + .unwrap(), + BalancesResponse { + balances: vec![ + Balance { + token_id: token1.to_string(), + owner: Addr::unchecked(&user1), + amount: Uint128::new(4), + }, + Balance { + token_id: token2.to_string(), + owner: Addr::unchecked(&user1), + amount: Uint128::new(3), + }, + Balance { + token_id: token1.to_string(), + owner: Addr::unchecked(&receiver), + amount: Uint128::new(2), + }, + Balance { + token_id: token2.to_string(), + owner: Addr::unchecked(&receiver), + amount: Uint128::new(2), + } + ] + }, + ); + } + + #[test] + fn check_queries() { + let contract = Cw1155BaseContract::default(); + // mint multiple types of tokens, and query them + // grant approval to multiple operators, and query them + let tokens = (0..10).map(|i| format!("token{}", i)).collect::>(); + let users = (0..10).map(|i| format!("user{}", i)).collect::>(); + let minter = String::from("minter"); + + let mut deps = mock_dependencies(); + let msg = Cw1155InstantiateMsg { + name: "name".to_string(), + symbol: "symbol".to_string(), + minter: Some(minter.to_string()), + }; + let res = contract + .instantiate( + deps.as_mut(), + mock_env(), + mock_info("operator", &[]), + msg, + "contract_name", + "contract_version", + ) + .unwrap(); + assert_eq!(0, res.messages.len()); + + for token_id in tokens.clone() { + contract + .execute( + deps.as_mut(), + mock_env(), + mock_info(minter.as_ref(), &[]), + Cw1155BaseExecuteMsg::Mint { + recipient: users[0].clone(), + msg: Cw1155MintMsg { + token_id: token_id.clone(), + amount: 1u64.into(), + token_uri: None, + extension: None, + }, + }, + ) + .unwrap(); + } + + for user in users[1..].iter() { + contract + .execute( + deps.as_mut(), + mock_env(), + mock_info(minter.as_ref(), &[]), + Cw1155BaseExecuteMsg::Mint { + recipient: user.clone(), + msg: Cw1155MintMsg { + token_id: tokens[9].clone(), + amount: 1u64.into(), + token_uri: None, + extension: None, + }, + }, + ) + .unwrap(); + } + + assert_eq!( + contract.query( + deps.as_ref(), + mock_env(), + Cw1155QueryMsg::NumTokens { + token_id: Some(tokens[0].clone()), + }, + ), + to_json_binary(&NumTokensResponse { + count: Uint128::new(1), + }) + ); + + assert_eq!( + contract.query( + deps.as_ref(), + mock_env(), + Cw1155QueryMsg::NumTokens { + token_id: Some(tokens[0].clone()), + }, + ), + to_json_binary(&NumTokensResponse { + count: Uint128::new(1), + }) + ); + + assert_eq!( + contract.query( + deps.as_ref(), + mock_env(), + Cw1155QueryMsg::AllBalances { + token_id: tokens[9].clone(), + start_after: None, + limit: Some(5), + }, + ), + to_json_binary(&BalancesResponse { + balances: users[..5] + .iter() + .map(|user| { + Balance { + owner: Addr::unchecked(user), + amount: Uint128::new(1), + token_id: tokens[9].clone(), + } + }) + .collect(), + }) + ); + + assert_eq!( + contract.query( + deps.as_ref(), + mock_env(), + Cw1155QueryMsg::AllBalances { + token_id: tokens[9].clone(), + start_after: Some("user5".to_owned()), + limit: Some(5), + }, + ), + to_json_binary(&BalancesResponse { + balances: users[6..] + .iter() + .map(|user| { + Balance { + owner: Addr::unchecked(user), + amount: Uint128::new(1), + token_id: tokens[9].clone(), + } + }) + .collect(), + }) + ); + + assert_eq!( + contract.query( + deps.as_ref(), + mock_env(), + Cw1155QueryMsg::Tokens { + owner: users[0].clone(), + start_after: None, + limit: Some(5), + }, + ), + to_json_binary(&TokensResponse { + tokens: tokens[..5].to_owned() + }) + ); + + assert_eq!( + contract.query( + deps.as_ref(), + mock_env(), + Cw1155QueryMsg::Tokens { + owner: users[0].clone(), + start_after: Some("token5".to_owned()), + limit: Some(5), + }, + ), + to_json_binary(&TokensResponse { + tokens: tokens[6..].to_owned() + }) + ); + + assert_eq!( + contract.query( + deps.as_ref(), + mock_env(), + Cw1155QueryMsg::AllTokens { + start_after: Some("token5".to_owned()), + limit: Some(5), + }, + ), + to_json_binary(&TokensResponse { + tokens: tokens[6..].to_owned() + }) + ); + + assert_eq!( + contract.query( + deps.as_ref(), + mock_env(), + Cw1155QueryMsg::TokenInfo { + token_id: "token5".to_owned() + }, + ), + to_json_binary(&TokenInfoResponse::> { + token_uri: None, + extension: None, + }), + ); + + for user in users[1..].iter() { + contract + .execute( + deps.as_mut(), + mock_env(), + mock_info(users[0].as_ref(), &[]), + Cw1155BaseExecuteMsg::ApproveAll { + operator: user.clone(), + expires: None, + }, + ) + .unwrap(); + } + + assert_eq!( + contract.query( + deps.as_ref(), + mock_env(), + Cw1155QueryMsg::ApprovalsForAll { + owner: users[0].clone(), + include_expired: None, + start_after: Some(String::from("user2")), + limit: Some(1), + }, + ), + to_json_binary(&ApprovedForAllResponse { + operators: vec![Approval { + spender: Addr::unchecked(&users[3]), + expires: Expiration::Never {}, + }], + }) + ); + + assert_eq!( + contract.query( + deps.as_ref(), + mock_env(), + Cw1155QueryMsg::IsApprovedForAll { + owner: users[0].to_string(), + operator: users[3].to_string(), + }, + ), + to_json_binary(&IsApprovedForAllResponse { approved: true }) + ); + } + + #[test] + fn approval_expires() { + let contract = Cw1155BaseContract::default(); + let mut deps = mock_dependencies(); + let token1 = "token1".to_owned(); + let minter = String::from("minter"); + let user1 = String::from("user1"); + let user2 = String::from("user2"); + + let env = { + let mut env = mock_env(); + env.block.height = 10; + env + }; + + let msg = Cw1155InstantiateMsg { + name: "name".to_string(), + symbol: "symbol".to_string(), + minter: Some(minter.to_string()), + }; + let res = contract + .instantiate( + deps.as_mut(), + env.clone(), + mock_info("operator", &[]), + msg, + "contract_name", + "contract_version", + ) + .unwrap(); + assert_eq!(0, res.messages.len()); + + contract + .execute( + deps.as_mut(), + env.clone(), + mock_info(minter.as_ref(), &[]), + Cw1155BaseExecuteMsg::Mint { + recipient: user1.clone(), + msg: Cw1155MintMsg { + token_id: token1, + amount: 1u64.into(), + token_uri: None, + extension: None, + }, + }, + ) + .unwrap(); + + // invalid expires should be rejected + assert!(contract + .execute( + deps.as_mut(), + env.clone(), + mock_info(user1.as_ref(), &[]), + Cw1155BaseExecuteMsg::ApproveAll { + operator: user2.clone(), + expires: Some(Expiration::AtHeight(5)), + }, + ) + .is_err()); + + contract + .execute( + deps.as_mut(), + env, + mock_info(user1.as_ref(), &[]), + Cw1155BaseExecuteMsg::ApproveAll { + operator: user2.clone(), + expires: Some(Expiration::AtHeight(100)), + }, + ) + .unwrap(); + + let approvals: ApprovedForAllResponse = from_json( + contract + .query( + deps.as_ref(), + mock_env(), + Cw1155QueryMsg::ApprovalsForAll { + owner: user1.to_string(), + include_expired: None, + start_after: None, + limit: None, + }, + ) + .unwrap(), + ) + .unwrap(); + assert!(approvals + .operators + .iter() + .all(|approval| approval.spender == user2)); + + let env = { + let mut env = mock_env(); + env.block.height = 100; + env + }; + + let approvals: ApprovedForAllResponse = from_json( + contract + .query( + deps.as_ref(), + env, + Cw1155QueryMsg::ApprovalsForAll { + owner: user1, + include_expired: None, + start_after: None, + limit: None, + }, + ) + .unwrap(), + ) + .unwrap(); + assert!( + approvals.operators.is_empty() + || !approvals + .operators + .iter() + .all(|approval| approval.spender == user2) + ); + } + + #[test] + fn mint_overflow() { + let contract = Cw1155BaseContract::default(); + let mut deps = mock_dependencies(); + let token1 = "token1".to_owned(); + let token2 = "token2".to_owned(); + let minter = String::from("minter"); + let user1 = String::from("user1"); + + let env = mock_env(); + let msg = Cw1155InstantiateMsg { + name: "name".to_string(), + symbol: "symbol".to_string(), + minter: Some(minter.to_string()), + }; + let res = contract + .instantiate( + deps.as_mut(), + env.clone(), + mock_info("operator", &[]), + msg, + "contract_name", + "contract_version", + ) + .unwrap(); + assert_eq!(0, res.messages.len()); + + // minting up to max u128 should pass + let res = contract.execute( + deps.as_mut(), + env.clone(), + mock_info(minter.as_ref(), &[]), + Cw1155BaseExecuteMsg::Mint { + recipient: user1.clone(), + msg: Cw1155MintMsg { + token_id: token1.clone(), + amount: u128::MAX.into(), + token_uri: None, + extension: None, + }, + }, + ); + + assert!(res.is_ok()); + + // minting one more should fail + let res = contract.execute( + deps.as_mut(), + env.clone(), + mock_info(minter.as_ref(), &[]), + Cw1155BaseExecuteMsg::Mint { + recipient: user1.clone(), + msg: Cw1155MintMsg { + token_id: token1, + amount: 1u128.into(), + token_uri: None, + extension: None, + }, + }, + ); + + assert!(matches!( + res, + Err(Cw1155ContractError::Std(StdError::Overflow { + source: OverflowError { .. }, + .. + })) + )); + + // minting one more of a different token id should fail + let res = contract.execute( + deps.as_mut(), + env, + mock_info(minter.as_ref(), &[]), + Cw1155BaseExecuteMsg::Mint { + recipient: user1, + msg: Cw1155MintMsg { + token_id: token2, + amount: 1u128.into(), + token_uri: None, + extension: None, + }, + }, + ); + + assert!(matches!( + res, + Err(Cw1155ContractError::Std(StdError::Overflow { + source: OverflowError { .. }, + .. + })) + )); + } + + #[test] + fn token_uri() { + let contract = Cw1155BaseContract::default(); + let minter = String::from("minter"); + let user1 = String::from("user1"); + let token1 = "token1".to_owned(); + let url1 = "url1".to_owned(); + let url2 = "url2".to_owned(); + + let mut deps = mock_dependencies(); + let msg = Cw1155InstantiateMsg { + name: "name".to_string(), + symbol: "symbol".to_string(), + minter: Some(minter.to_string()), + }; + let res = contract + .instantiate( + deps.as_mut(), + mock_env(), + mock_info("operator", &[]), + msg, + "contract_name", + "contract_version", + ) + .unwrap(); + assert_eq!(0, res.messages.len()); + + // first mint + contract + .execute( + deps.as_mut(), + mock_env(), + mock_info(minter.as_ref(), &[]), + Cw1155BaseExecuteMsg::Mint { + recipient: user1.clone(), + msg: Cw1155MintMsg { + token_id: token1.clone(), + amount: 1u64.into(), + token_uri: Some(url1.clone()), + extension: None, + }, + }, + ) + .unwrap(); + + assert_eq!( + contract.query( + deps.as_ref(), + mock_env(), + Cw1155QueryMsg::TokenInfo { + token_id: token1.clone() + }, + ), + to_json_binary(&TokenInfoResponse::> { + token_uri: Some(url1.clone()), + extension: None, + }) + ); + + // mint after the first mint + contract + .execute( + deps.as_mut(), + mock_env(), + mock_info(minter.as_ref(), &[]), + Cw1155BaseExecuteMsg::Mint { + recipient: user1, + msg: Cw1155MintMsg { + token_id: token1.clone(), + amount: 1u64.into(), + token_uri: Some(url2), + extension: None, + }, + }, + ) + .unwrap(); + + // url doesn't changed + assert_eq!( + contract.query( + deps.as_ref(), + mock_env(), + Cw1155QueryMsg::TokenInfo { token_id: token1 }, + ), + to_json_binary(&TokenInfoResponse::> { + token_uri: Some(url1), + extension: None, + }) + ); + } +} diff --git a/contracts/cw1155-base/src/execute.rs b/contracts/cw1155-base/src/execute.rs new file mode 100644 index 000000000..1969f5443 --- /dev/null +++ b/contracts/cw1155-base/src/execute.rs @@ -0,0 +1,27 @@ +use crate::Cw1155Contract; +use cosmwasm_std::CustomMsg; +use cw1155::execute::Cw1155Execute; +use serde::de::DeserializeOwned; +use serde::Serialize; + +impl<'a, TMetadataExtension, TCustomResponseMessage, TMetadataExtensionMsg, TQueryExtensionMsg> + Cw1155Execute< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + > + for Cw1155Contract< + 'a, + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + > +where + TMetadataExtension: Serialize + DeserializeOwned + Clone, + TCustomResponseMessage: CustomMsg, + TMetadataExtensionMsg: CustomMsg, + TQueryExtensionMsg: Serialize + DeserializeOwned + Clone, +{ +} diff --git a/contracts/cw1155-base/src/lib.rs b/contracts/cw1155-base/src/lib.rs new file mode 100644 index 000000000..e962a4499 --- /dev/null +++ b/contracts/cw1155-base/src/lib.rs @@ -0,0 +1,72 @@ +mod contract_tests; +mod execute; +mod query; +pub mod state; + +pub use crate::state::Cw1155Contract; +use cosmwasm_std::Empty; +use cw1155::msg::{Cw1155ExecuteMsg, Cw1155QueryMsg}; +use cw1155::state::{Cw1155Config, DefaultOptionMetadataExtension}; + +// Version info for migration +pub const CONTRACT_NAME: &str = "crates.io:cw1155-base"; +pub const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub type Cw1155BaseContract<'a> = + Cw1155Contract<'a, DefaultOptionMetadataExtension, Empty, Empty, Empty>; +pub type Cw1155BaseExecuteMsg = Cw1155ExecuteMsg; +pub type Cw1155BaseQueryMsg = Cw1155QueryMsg; +pub type Cw1155BaseConfig<'a> = + Cw1155Config<'a, DefaultOptionMetadataExtension, Empty, Empty, Empty>; + +pub mod entry { + use super::*; + + #[cfg(not(feature = "library"))] + use cosmwasm_std::entry_point; + use cosmwasm_std::{Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; + use cw1155::error::Cw1155ContractError; + use cw1155::execute::Cw1155Execute; + use cw1155::msg::{Cw1155ExecuteMsg, Cw1155InstantiateMsg, Cw1155QueryMsg}; + use cw1155::query::Cw1155Query; + use cw1155::state::DefaultOptionMetadataExtension; + + // This makes a conscious choice on the various generics used by the contract + #[cfg_attr(not(feature = "library"), entry_point)] + pub fn instantiate( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: Cw1155InstantiateMsg, + ) -> Result { + let tract = Cw1155BaseContract::default(); + tract.instantiate(deps, env, info, msg, CONTRACT_NAME, CONTRACT_VERSION) + } + + #[cfg_attr(not(feature = "library"), entry_point)] + pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: Cw1155ExecuteMsg, + ) -> Result { + let tract = Cw1155BaseContract::default(); + tract.execute(deps, env, info, msg) + } + + #[cfg_attr(not(feature = "library"), entry_point)] + pub fn query( + deps: Deps, + env: Env, + msg: Cw1155QueryMsg, + ) -> StdResult { + let tract = Cw1155BaseContract::default(); + tract.query(deps, env, msg) + } + + #[cfg_attr(not(feature = "library"), entry_point)] + pub fn migrate(deps: DepsMut, env: Env, msg: Empty) -> Result { + let contract = Cw1155BaseContract::default(); + contract.migrate(deps, env, msg, CONTRACT_NAME, CONTRACT_VERSION) + } +} diff --git a/contracts/cw1155-base/src/query.rs b/contracts/cw1155-base/src/query.rs new file mode 100644 index 000000000..30a305c96 --- /dev/null +++ b/contracts/cw1155-base/src/query.rs @@ -0,0 +1,27 @@ +use crate::Cw1155Contract; +use cosmwasm_std::CustomMsg; +use cw1155::query::Cw1155Query; +use serde::de::DeserializeOwned; +use serde::Serialize; + +impl<'a, TMetadataExtension, TCustomResponseMessage, TMetadataExtensionMsg, TQueryExtensionMsg> + Cw1155Query< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + > + for Cw1155Contract< + 'a, + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + > +where + TMetadataExtension: Serialize + DeserializeOwned + Clone, + TCustomResponseMessage: CustomMsg, + TMetadataExtensionMsg: CustomMsg, + TQueryExtensionMsg: Serialize + DeserializeOwned + Clone, +{ +} diff --git a/contracts/cw1155-base/src/state.rs b/contracts/cw1155-base/src/state.rs new file mode 100644 index 000000000..89734b62a --- /dev/null +++ b/contracts/cw1155-base/src/state.rs @@ -0,0 +1,48 @@ +use cosmwasm_std::CustomMsg; +use cw1155::state::Cw1155Config; +use serde::de::DeserializeOwned; +use serde::Serialize; + +pub struct Cw1155Contract< + 'a, + // Metadata defined in NftInfo (used for mint). + TMetadataExtension, + // Defines for `CosmosMsg::Custom` in response. Barely used, so `Empty` can be used. + TCustomResponseMessage, + // Message passed for updating metadata. + TMetadataExtensionMsg, + // Extension query message. + TQueryExtensionMsg, +> where + TMetadataExtension: Serialize + DeserializeOwned + Clone, + TMetadataExtensionMsg: CustomMsg, + TQueryExtensionMsg: Serialize + DeserializeOwned + Clone, +{ + pub config: Cw1155Config< + 'a, + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >, +} + +impl Default + for Cw1155Contract< + 'static, + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + > +where + TMetadataExtension: Serialize + DeserializeOwned + Clone, + TMetadataExtensionMsg: CustomMsg, + TQueryExtensionMsg: Serialize + DeserializeOwned + Clone, +{ + fn default() -> Self { + Self { + config: Cw1155Config::default(), + } + } +} diff --git a/contracts/cw1155-royalties/.cargo/config b/contracts/cw1155-royalties/.cargo/config new file mode 100644 index 000000000..7d1a066c8 --- /dev/null +++ b/contracts/cw1155-royalties/.cargo/config @@ -0,0 +1,5 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +wasm-debug = "build --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/cw1155-royalties/Cargo.toml b/contracts/cw1155-royalties/Cargo.toml new file mode 100644 index 000000000..f39e955af --- /dev/null +++ b/contracts/cw1155-royalties/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "cw1155-royalties" +authors = [ + "Alex Lynham ", + "shab " +] +description = "Basic implementation of royalties for cw1155 with token level royalties" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +documentation = { workspace = true } + +exclude = [ + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "artifacts/*", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cw2 = { workspace = true } +cw1155 = { workspace = true } +cw1155-base = { workspace = true, features = ["library"] } +cw2981-royalties = { path = "../cw2981-royalties", features = ["library"] } +cosmwasm-std = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +cosmwasm-schema = { workspace = true } diff --git a/contracts/cw1155-royalties/README.md b/contracts/cw1155-royalties/README.md new file mode 100644 index 000000000..b4b1c7830 --- /dev/null +++ b/contracts/cw1155-royalties/README.md @@ -0,0 +1,63 @@ +# CW-1155 Token-level Royalties + +An example of porting EIP-2981 to implement royalties at a token mint level. + +Builds on top of the metadata pattern in `cw1155-metadata-onchain`. + +All of the CW-1155 logic and behaviour you would expect for a token is implemented as normal, but additionally at mint time, royalty information can be attached to a token. + +Exposes two new query message types that can be called: + +```rust +// Should be called on sale to see if royalties are owed +// by the marketplace selling the tokens. +// See https://eips.ethereum.org/EIPS/eip-2981 +RoyaltyInfo { + token_id: String, + // the denom of this sale must also be the denom returned by RoyaltiesInfoResponse + sale_price: Uint128, +}, +// Called against the contract to signal that CW-2981 is implemented +CheckRoyalties {}, +``` + +The responses are: + +```rust +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct RoyaltiesInfoResponse { + pub address: String, + // Note that this must be the same denom as that passed in to RoyaltyInfo + // rounding up or down is at the discretion of the implementer + pub royalty_amount: Uint128, +} + +/// Shows if the contract implements royalties +/// if royalty_payments is true, marketplaces should pay them +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct CheckRoyaltiesResponse { + pub royalty_payments: bool, +} +``` + + +To set this information, new meta fields are available on mint: + +```rust + /// This is how much the minter takes as a cut when sold + pub royalty_percentage: Option, + /// The payment address, may be different to or the same + /// as the minter addr + /// question: how do we validate this? + pub royalty_payment_address: Option, +``` + +Note that the `royalty_payment_address` could of course be a single address, a multisig, or a DAO. + +## A note on CheckRoyalties + +For this contract, there's nothing to check. This hook is expected to be present to check if the contract does implement CW2981 and signal that on sale royalties should be checked. With the implementation at token level it should always return true because it's up to the token. + +Of course contracts that extend this can determine their own behaviour and replace this function if they have more complex behaviour (for example, you could maintain a secondary index of which tokens actually have royalties). + +In this super simple case that isn't necessary. diff --git a/contracts/cw1155-royalties/examples/schema.rs b/contracts/cw1155-royalties/examples/schema.rs new file mode 100644 index 000000000..6b7a4aa60 --- /dev/null +++ b/contracts/cw1155-royalties/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use cw1155::msg::Cw1155InstantiateMsg; +use cw1155_royalties::{Cw1155RoyaltiesExecuteMsg, Cw1155RoyaltiesQueryMsg}; + +fn main() { + write_api! { + instantiate: Cw1155InstantiateMsg, + execute: Cw1155RoyaltiesExecuteMsg, + query: Cw1155RoyaltiesQueryMsg, + } +} diff --git a/contracts/cw1155-royalties/src/error.rs b/contracts/cw1155-royalties/src/error.rs new file mode 100644 index 000000000..457f46411 --- /dev/null +++ b/contracts/cw1155-royalties/src/error.rs @@ -0,0 +1,18 @@ +use cosmwasm_std::StdError; +use cw1155::error::Cw1155ContractError; +use thiserror::Error; + +#[derive(Debug, Error, PartialEq)] +pub enum Cw1155RoyaltiesContractError { + #[error(transparent)] + Std(#[from] StdError), + + #[error(transparent)] + Base(#[from] Cw1155ContractError), + + #[error("Royalty percentage must be between 0 and 100")] + InvalidRoyaltyPercentage, + + #[error("Invalid royalty payment address")] + InvalidRoyaltyPaymentAddress, +} diff --git a/contracts/cw1155-royalties/src/lib.rs b/contracts/cw1155-royalties/src/lib.rs new file mode 100644 index 000000000..2fc0a4195 --- /dev/null +++ b/contracts/cw1155-royalties/src/lib.rs @@ -0,0 +1,325 @@ +use cosmwasm_std::Empty; +use cw1155::msg::{Cw1155ExecuteMsg, Cw1155QueryMsg}; +use cw1155::state::Cw1155Config; +use cw1155_base::Cw1155Contract; +use cw2981_royalties::msg::QueryMsg as Cw2981QueryMsg; +use cw2981_royalties::DefaultOptionMetadataExtensionWithRoyalty; + +mod query; +pub use query::query_royalties_info; + +mod error; +pub use error::Cw1155RoyaltiesContractError; + +// Version info for migration +const CONTRACT_NAME: &str = "crates.io:cw1155-royalties"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub type Cw1155RoyaltiesContract<'a> = + Cw1155Contract<'a, DefaultOptionMetadataExtensionWithRoyalty, Empty, Empty, Cw2981QueryMsg>; +pub type Cw1155RoyaltiesExecuteMsg = + Cw1155ExecuteMsg; +pub type Cw1155RoyaltiesQueryMsg = + Cw1155QueryMsg; +pub type Cw1155RoyaltiesConfig<'a> = + Cw1155Config<'a, DefaultOptionMetadataExtensionWithRoyalty, Empty, Empty, Cw2981QueryMsg>; + +#[cfg(not(feature = "library"))] +pub mod entry { + use super::*; + + use cosmwasm_std::{entry_point, to_json_binary}; + use cosmwasm_std::{Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; + use cw1155::execute::Cw1155Execute; + use cw1155::query::Cw1155Query; + use cw2981_royalties::msg::QueryMsg as Cw2981QueryMsg; + use cw2981_royalties::{check_royalties, MetadataWithRoyalty}; + + // This makes a conscious choice on the various generics used by the contract + #[entry_point] + pub fn instantiate( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + msg: cw1155::msg::Cw1155InstantiateMsg, + ) -> Result { + Cw1155RoyaltiesContract::default() + .instantiate( + deps.branch(), + env, + info, + msg, + CONTRACT_NAME, + CONTRACT_VERSION, + ) + .map_err(Into::into) + } + + #[entry_point] + pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: Cw1155RoyaltiesExecuteMsg, + ) -> Result { + if let Cw1155RoyaltiesExecuteMsg::Mint { + msg: + cw1155::msg::Cw1155MintMsg { + extension: + Some(MetadataWithRoyalty { + royalty_percentage: Some(royalty_percentage), + royalty_payment_address, + .. + }), + .. + }, + .. + } = &msg + { + // validate royalty_percentage to be between 0 and 100 + // no need to check < 0 because royalty_percentage is u64 + if *royalty_percentage > 100 { + return Err(Cw1155RoyaltiesContractError::InvalidRoyaltyPercentage); + } + + // validate royalty_payment_address to be a valid address + if let Some(royalty_payment_address) = royalty_payment_address { + deps.api.addr_validate(royalty_payment_address)?; + } else { + return Err(Cw1155RoyaltiesContractError::InvalidRoyaltyPaymentAddress); + } + } + Ok(Cw1155RoyaltiesContract::default().execute(deps, env, info, msg)?) + } + + #[entry_point] + pub fn query(deps: Deps, env: Env, msg: Cw1155RoyaltiesQueryMsg) -> StdResult { + match msg { + Cw1155RoyaltiesQueryMsg::Extension { msg: ext_msg, .. } => match ext_msg { + Cw2981QueryMsg::RoyaltyInfo { + token_id, + sale_price, + } => to_json_binary(&query_royalties_info(deps, token_id, sale_price)?), + Cw2981QueryMsg::CheckRoyalties {} => to_json_binary(&check_royalties(deps)?), + _ => unimplemented!(), + }, + _ => Cw1155RoyaltiesContract::default().query(deps, env, msg), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use cosmwasm_std::{from_json, Uint128}; + + use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; + use cw1155::msg::{Cw1155InstantiateMsg, Cw1155MintMsg}; + use cw2981_royalties::msg::{CheckRoyaltiesResponse, RoyaltiesInfoResponse}; + use cw2981_royalties::{check_royalties, MetadataWithRoyalty}; + + const CREATOR: &str = "creator"; + + #[test] + fn use_metadata_extension() { + let mut deps = mock_dependencies(); + let config = Cw1155RoyaltiesConfig::default(); + + let info = mock_info(CREATOR, &[]); + let init_msg = Cw1155InstantiateMsg { + name: "SpaceShips".to_string(), + symbol: "SPACE".to_string(), + minter: None, + }; + entry::instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + + let token_id = "Enterprise"; + let token_uri = Some("https://starships.example.com/Starship/Enterprise.json".into()); + let extension = Some(MetadataWithRoyalty { + description: Some("Spaceship with Warp Drive".into()), + name: Some("Starship USS Enterprise".to_string()), + ..MetadataWithRoyalty::default() + }); + let exec_msg = Cw1155RoyaltiesExecuteMsg::Mint { + recipient: "john".to_string(), + msg: Cw1155MintMsg { + token_id: token_id.to_string(), + token_uri: token_uri.clone(), + extension: extension.clone(), + amount: Uint128::one(), + }, + }; + entry::execute(deps.as_mut(), mock_env(), info, exec_msg).unwrap(); + + let res = config.tokens.load(&deps.storage, token_id).unwrap(); + assert_eq!(res.token_uri, token_uri); + assert_eq!(res.extension, extension); + } + + #[test] + fn validate_royalty_information() { + let mut deps = mock_dependencies(); + let _contract = Cw1155RoyaltiesContract::default(); + + let info = mock_info(CREATOR, &[]); + let init_msg = Cw1155InstantiateMsg { + name: "SpaceShips".to_string(), + symbol: "SPACE".to_string(), + minter: None, + }; + entry::instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + + let token_id = "Enterprise"; + let exec_msg = Cw1155RoyaltiesExecuteMsg::Mint { + recipient: "john".to_string(), + msg: Cw1155MintMsg { + token_id: token_id.to_string(), + amount: Uint128::one(), + token_uri: Some("https://starships.example.com/Starship/Enterprise.json".into()), + extension: Some(MetadataWithRoyalty { + description: Some("Spaceship with Warp Drive".into()), + name: Some("Starship USS Enterprise".to_string()), + royalty_percentage: Some(101), + ..MetadataWithRoyalty::default() + }), + }, + }; + // mint will return StdError + let err = entry::execute(deps.as_mut(), mock_env(), info, exec_msg).unwrap_err(); + assert_eq!(err, Cw1155RoyaltiesContractError::InvalidRoyaltyPercentage); + } + + #[test] + fn check_royalties_response() { + let mut deps = mock_dependencies(); + let _contract = Cw1155RoyaltiesContract::default(); + + let info = mock_info(CREATOR, &[]); + let init_msg = Cw1155InstantiateMsg { + name: "SpaceShips".to_string(), + symbol: "SPACE".to_string(), + minter: None, + }; + entry::instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + + let token_id = "Enterprise"; + let exec_msg = Cw1155RoyaltiesExecuteMsg::Mint { + recipient: "john".to_string(), + msg: Cw1155MintMsg { + token_id: token_id.to_string(), + amount: Uint128::one(), + token_uri: Some("https://starships.example.com/Starship/Enterprise.json".into()), + extension: Some(MetadataWithRoyalty { + description: Some("Spaceship with Warp Drive".into()), + name: Some("Starship USS Enterprise".to_string()), + ..MetadataWithRoyalty::default() + }), + }, + }; + entry::execute(deps.as_mut(), mock_env(), info, exec_msg).unwrap(); + + let expected = CheckRoyaltiesResponse { + royalty_payments: true, + }; + let res = check_royalties(deps.as_ref()).unwrap(); + assert_eq!(res, expected); + + // also check the longhand way + let query_msg = Cw1155RoyaltiesQueryMsg::Extension { + msg: Cw2981QueryMsg::CheckRoyalties {}, + phantom: None, + }; + let query_res: CheckRoyaltiesResponse = + from_json(entry::query(deps.as_ref(), mock_env(), query_msg).unwrap()).unwrap(); + assert_eq!(query_res, expected); + } + + #[test] + fn check_token_royalties() { + let mut deps = mock_dependencies(); + + let info = mock_info(CREATOR, &[]); + let init_msg = Cw1155InstantiateMsg { + name: "SpaceShips".to_string(), + symbol: "SPACE".to_string(), + minter: None, + }; + entry::instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + + let token_id = "Enterprise"; + let owner = "jeanluc"; + let exec_msg = Cw1155RoyaltiesExecuteMsg::Mint { + recipient: owner.into(), + msg: Cw1155MintMsg { + token_id: token_id.to_string(), + amount: Uint128::one(), + token_uri: Some("https://starships.example.com/Starship/Enterprise.json".into()), + extension: Some(MetadataWithRoyalty { + description: Some("Spaceship with Warp Drive".into()), + name: Some("Starship USS Enterprise".to_string()), + royalty_payment_address: Some("jeanluc".to_string()), + royalty_percentage: Some(10), + ..MetadataWithRoyalty::default() + }), + }, + }; + entry::execute(deps.as_mut(), mock_env(), info.clone(), exec_msg).unwrap(); + + let expected = RoyaltiesInfoResponse { + address: owner.into(), + royalty_amount: Uint128::new(10), + }; + let res = + query_royalties_info(deps.as_ref(), token_id.to_string(), Uint128::new(100)).unwrap(); + assert_eq!(res, expected); + + // also check the longhand way + let query_msg = Cw1155RoyaltiesQueryMsg::Extension { + msg: Cw2981QueryMsg::RoyaltyInfo { + token_id: token_id.to_string(), + sale_price: Uint128::new(100), + }, + phantom: None, + }; + let query_res: RoyaltiesInfoResponse = + from_json(entry::query(deps.as_ref(), mock_env(), query_msg).unwrap()).unwrap(); + assert_eq!(query_res, expected); + + // check for rounding down + // which is the default behaviour + let voyager_token_id = "Voyager"; + let owner = "janeway"; + let voyager_exec_msg = Cw1155RoyaltiesExecuteMsg::Mint { + recipient: owner.into(), + msg: Cw1155MintMsg { + token_id: voyager_token_id.to_string(), + amount: Uint128::one(), + token_uri: Some("https://starships.example.com/Starship/Voyager.json".into()), + extension: Some(MetadataWithRoyalty { + description: Some("Spaceship with Warp Drive".into()), + name: Some("Starship USS Voyager".to_string()), + royalty_payment_address: Some("janeway".to_string()), + royalty_percentage: Some(4), + ..MetadataWithRoyalty::default() + }), + }, + }; + entry::execute(deps.as_mut(), mock_env(), info, voyager_exec_msg).unwrap(); + + // 43 x 0.04 (i.e., 4%) should be 1.72 + // we expect this to be rounded down to 1 + let voyager_expected = RoyaltiesInfoResponse { + address: owner.into(), + royalty_amount: Uint128::new(1), + }; + + let res = query_royalties_info( + deps.as_ref(), + voyager_token_id.to_string(), + Uint128::new(43), + ) + .unwrap(); + assert_eq!(res, voyager_expected); + } +} diff --git a/contracts/cw1155-royalties/src/query.rs b/contracts/cw1155-royalties/src/query.rs new file mode 100644 index 000000000..a5d310f29 --- /dev/null +++ b/contracts/cw1155-royalties/src/query.rs @@ -0,0 +1,34 @@ +use crate::Cw1155RoyaltiesConfig; +use cosmwasm_std::{Decimal, Deps, StdResult, Uint128}; +use cw2981_royalties::msg::RoyaltiesInfoResponse; + +/// NOTE: default behaviour here is to round down +/// EIP2981 specifies that the rounding behaviour is at the discretion of the implementer +/// NOTE: This implementation is copied from the cw2981-royalties contract, only difference is the TokenInfo struct (no owner field in cw1155) +pub fn query_royalties_info( + deps: Deps, + token_id: String, + sale_price: Uint128, +) -> StdResult { + let config = Cw1155RoyaltiesConfig::default(); + let token_info = config.tokens.load(deps.storage, &token_id)?; + + let royalty_percentage = match token_info.extension { + Some(ref ext) => match ext.royalty_percentage { + Some(percentage) => Decimal::percent(percentage), + None => Decimal::percent(0), + }, + None => Decimal::percent(0), + }; + let royalty_from_sale_price = sale_price * royalty_percentage; + + let royalty_address = match token_info.extension { + Some(ext) => ext.royalty_payment_address.unwrap_or_default(), + None => String::from(""), + }; + + Ok(RoyaltiesInfoResponse { + address: royalty_address, + royalty_amount: royalty_from_sale_price, + }) +} diff --git a/contracts/cw2981-royalties/src/lib.rs b/contracts/cw2981-royalties/src/lib.rs index 5f7bf9fd8..fa5ead489 100644 --- a/contracts/cw2981-royalties/src/lib.rs +++ b/contracts/cw2981-royalties/src/lib.rs @@ -12,9 +12,7 @@ use cw721::{ pub use query::{check_royalties, query_royalties_info}; use cosmwasm_schema::cw_serde; -use cosmwasm_std::{to_json_binary, Empty}; - -use crate::error::ContractError; +use cosmwasm_std::Empty; // Version info for migration const CONTRACT_NAME: &str = "crates.io:cw2981-royalties"; @@ -142,6 +140,7 @@ mod tests { use cosmwasm_std::{from_json, Uint128}; + use crate::error::ContractError; use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; use cw721::msg::Cw721InstantiateMsg; use cw721::traits::Cw721Query; diff --git a/packages/cw1155/.cargo/config b/packages/cw1155/.cargo/config new file mode 100644 index 000000000..b613a59f1 --- /dev/null +++ b/packages/cw1155/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +wasm-debug = "build --target wasm32-unknown-unknown" +schema = "run --example schema" diff --git a/packages/cw1155/Cargo.toml b/packages/cw1155/Cargo.toml new file mode 100644 index 000000000..103a3224a --- /dev/null +++ b/packages/cw1155/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "cw1155" +authors = ["shab "] +description = "Definition and types for the CosmWasm-1155 interface" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +documentation = { workspace = true } + +[dependencies] +cw2 = { workspace = true } +cw721 = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +cw-ownable = { workspace = true } +cosmwasm-std = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +cosmwasm-schema = { workspace = true } diff --git a/packages/cw1155/README.md b/packages/cw1155/README.md new file mode 100644 index 000000000..c4851929e --- /dev/null +++ b/packages/cw1155/README.md @@ -0,0 +1,104 @@ +# CW1155 Spec: Multiple Tokens + +CW1155 is a specification for managing multiple tokens based on CosmWasm. +The name and design is based on Ethereum's ERC1155 standard. + +The specification is split into multiple sections, a contract may only +implement some of this functionality, but must implement the base. + +Fungible tokens and non-fungible tokens are treated equally, non-fungible tokens just have one max supply. + +Approval is set or unset to some operator over entire set of tokens. (More nuanced control is defined in +[ERC1761](https://eips.ethereum.org/EIPS/eip-1761)) + +## Base + +### Messages + +`SendFrom{from, to, token_id, value, msg}` - This transfers some amount of tokens between two accounts. If `to` is an +address controlled by a smart contract, it must implement the `CW1155Receiver` interface, `msg` will be passed to it +along with other fields, otherwise, `msg` should be `None`. The operator should either be the `from` account or have +approval from it. + +`BatchSendFrom{from, to, batch: Vec<(token_id, value)>, msg}` - Batched version of `SendFrom` which can handle multiple +types of tokens at once. + +`Burn {from, token_id, value}` - This burns some tokens from `from` account. + +`BatchBurn {from, batch: Vec<(token_id, value)>}` - Batched version of `Burn`. + +`ApproveAll{operator, expires}` - Allows operator to transfer / send any token from the owner's account. If expiration +is set, then this allowance has a time/height limit. + +`RevokeAll {operator}` - Remove previously granted ApproveAll permission + +### Queries + +`Minter {}` - Query Minter. + +`Balance {owner, token_id}` - Query the balance of `owner` on particular type of token, default to `0` when record not +exist. + +`AllBalances {token_id, start_after, limit}` - Query all balances of the given `token_id`. + +`BatchBalance {owner, token_ids}` - Query the balance of `owner` on multiple types of tokens, batched version of +`Balance`. + +`NumTokens {token_id}` - Total number of tokens of `token_id` issued. + +`ApprovedForAll{owner, include_expired, start_after, limit}` - List all operators that can access all of the owner's +tokens. Return type is `ApprovedForAllResponse`. If `include_expired` is set, show expired owners in the results, +otherwise, ignore them. + +`IsApprovedForAll{owner, operator}` - Query approved status `owner` granted to `operator`. Return type is +`IsApprovedForAllResponse`. + +### Receiver + +Any contract wish to receive CW1155 tokens must implement `Cw1155ReceiveMsg` and `Cw1155BatchReceiveMsg`. + +`Cw1155ReceiveMsg {operator, from, token_id, amount, msg}` - + +`Cw1155BatchReceiveMsg {operator, from, batch, msg}` - + +### Events + +- `transfer(from, to, token_id, value)` + + `from`/`to` are optional, no `from` attribute means minting, no `to` attribute means burning, but they mustn't be +neglected at the same time. + + +## Metadata + +### Queries + +`TokenInfo{token_id}` - Query metadata and token url of `token_id`. + +### Events + +`token_info(url, token_id)` + +Metadata url of `token_id` is changed, `url` should point to a json file. + +## Enumerable + +### Queries + +Pagination is acheived via `start_after` and `limit`. Limit is a request +set by the client, if unset, the contract will automatically set it to +`DefaultLimit` (suggested 10). If set, it will be used up to a `MaxLimit` +value (suggested 30). Contracts can define other `DefaultLimit` and `MaxLimit` +values without violating the CW1155 spec, and clients should not rely on +any particular values. + +If `start_after` is unset, the query returns the first results, ordered by +lexogaphically by `token_id`. If `start_after` is set, then it returns the +first `limit` tokens *after* the given one. This allows straight-forward +pagination by taking the last result returned (a `token_id`) and using it +as the `start_after` value in a future query. + +`Tokens{owner, start_after, limit}` - List all token_ids that belong to a given owner. +Return type is `TokensResponse{tokens: Vec}`. + +`AllTokens{start_after, limit}` - Requires pagination. Lists all token_ids controlled by the contract. \ No newline at end of file diff --git a/packages/cw1155/examples/schema.rs b/packages/cw1155/examples/schema.rs new file mode 100644 index 000000000..19706e535 --- /dev/null +++ b/packages/cw1155/examples/schema.rs @@ -0,0 +1,12 @@ +use cosmwasm_schema::write_api; +use cosmwasm_std::Empty; +use cw1155::msg::{Cw1155ExecuteMsg, Cw1155InstantiateMsg, Cw1155QueryMsg}; +use cw721::DefaultOptionalNftExtension; + +fn main() { + write_api! { + instantiate: Cw1155InstantiateMsg, + execute: Cw1155ExecuteMsg, + query: Cw1155QueryMsg, + } +} diff --git a/packages/cw1155/src/error.rs b/packages/cw1155/src/error.rs new file mode 100644 index 000000000..167b4d99f --- /dev/null +++ b/packages/cw1155/src/error.rs @@ -0,0 +1,37 @@ +use cosmwasm_std::{OverflowError, StdError, Uint128}; +use cw2::VersionError; +use cw_ownable::OwnershipError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum Cw1155ContractError { + #[error("StdError: {0}")] + Std(#[from] StdError), + + #[error("OverflowError: {0}")] + Overflow(#[from] OverflowError), + + #[error("VersionError: {0}")] + Version(#[from] VersionError), + + #[error("OwnershipError: {0}")] + Ownership(#[from] OwnershipError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Expired")] + Expired {}, + + #[error("Zero amount provided")] + InvalidZeroAmount {}, + + #[error("Not enough tokens available for this action. Available: {available}, Requested: {requested}.")] + NotEnoughTokens { + available: Uint128, + requested: Uint128, + }, + + #[error("Must provide either 'token_uri' or 'extension' to update.")] + NoUpdatesRequested {}, +} diff --git a/packages/cw1155/src/event.rs b/packages/cw1155/src/event.rs new file mode 100644 index 000000000..497c836e1 --- /dev/null +++ b/packages/cw1155/src/event.rs @@ -0,0 +1,295 @@ +use crate::msg::TokenAmount; +use cosmwasm_std::{attr, Addr, Attribute, MessageInfo, Uint128}; + +/// Tracks token transfer actions +pub struct TransferEvent { + pub owner: Addr, + pub sender: Addr, + pub recipient: Addr, + pub tokens: Vec, +} + +impl TransferEvent { + pub fn new( + info: &MessageInfo, + from: Option, + recipient: &Addr, + tokens: Vec, + ) -> Self { + Self { + owner: from.unwrap_or_else(|| info.sender.clone()), + sender: info.sender.clone(), + recipient: recipient.clone(), + tokens, + } + } +} + +impl IntoIterator for TransferEvent { + type Item = Attribute; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + let mut attrs = vec![ + event_action("transfer", &self.tokens), + attr("owner", self.owner.as_str()), + attr("sender", self.sender.as_str()), + attr("recipient", self.recipient.as_str()), + ]; + attrs.extend(token_attributes(self.tokens)); + attrs.into_iter() + } +} + +/// Tracks token mint actions +pub struct MintEvent { + pub sender: Addr, + pub recipient: Addr, + pub tokens: Vec, +} + +impl MintEvent { + pub fn new(info: &MessageInfo, recipient: &Addr, tokens: Vec) -> Self { + Self { + sender: info.sender.clone(), + recipient: recipient.clone(), + tokens, + } + } +} + +impl IntoIterator for MintEvent { + type Item = Attribute; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + let mut attrs = vec![ + event_action("mint", &self.tokens), + attr("sender", self.sender.as_str()), + attr("recipient", self.recipient.as_str()), + ]; + attrs.extend(token_attributes(self.tokens)); + attrs.into_iter() + } +} + +/// Tracks token burn actions +pub struct BurnEvent { + pub owner: Addr, + pub sender: Addr, + pub tokens: Vec, +} + +impl BurnEvent { + pub fn new(info: &MessageInfo, from: Option, tokens: Vec) -> Self { + Self { + owner: from.unwrap_or_else(|| info.sender.clone()), + sender: info.sender.clone(), + tokens, + } + } +} + +impl IntoIterator for BurnEvent { + type Item = Attribute; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + let mut attrs = vec![ + event_action("burn", &self.tokens), + attr("owner", self.owner.as_str()), + attr("sender", self.sender.as_str()), + ]; + attrs.extend(token_attributes(self.tokens)); + attrs.into_iter() + } +} + +/// Tracks approve status changes +pub struct ApproveEvent { + pub sender: Addr, + pub operator: Addr, + pub token_id: String, + pub amount: Uint128, +} + +impl ApproveEvent { + pub fn new(sender: &Addr, operator: &Addr, token_id: &str, amount: Uint128) -> Self { + Self { + sender: sender.clone(), + operator: operator.clone(), + token_id: token_id.to_string(), + amount, + } + } +} + +impl IntoIterator for ApproveEvent { + type Item = Attribute; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + vec![ + attr("action", "approve_single"), + attr("sender", self.sender.as_str()), + attr("operator", self.operator.as_str()), + attr("token_id", self.token_id), + attr("amount", self.amount.to_string()), + ] + .into_iter() + } +} + +/// Tracks revoke status changes +pub struct RevokeEvent { + pub sender: Addr, + pub operator: Addr, + pub token_id: String, + pub amount: Uint128, +} + +impl RevokeEvent { + pub fn new(sender: &Addr, operator: &Addr, token_id: &str, amount: Uint128) -> Self { + Self { + sender: sender.clone(), + operator: operator.clone(), + token_id: token_id.to_string(), + amount, + } + } +} + +impl IntoIterator for RevokeEvent { + type Item = Attribute; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + vec![ + attr("action", "revoke_single"), + attr("sender", self.sender.as_str()), + attr("operator", self.operator.as_str()), + attr("token_id", self.token_id), + attr("amount", self.amount.to_string()), + ] + .into_iter() + } +} + +/// Tracks approve_all status changes +pub struct ApproveAllEvent { + pub sender: Addr, + pub operator: Addr, +} + +impl ApproveAllEvent { + pub fn new(sender: &Addr, operator: &Addr) -> Self { + Self { + sender: sender.clone(), + operator: operator.clone(), + } + } +} + +impl IntoIterator for ApproveAllEvent { + type Item = Attribute; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + vec![ + attr("action", "approve_all"), + attr("sender", self.sender.as_str()), + attr("operator", self.operator.as_str()), + ] + .into_iter() + } +} + +/// Tracks revoke_all status changes +pub struct RevokeAllEvent { + pub sender: Addr, + pub operator: Addr, +} + +impl RevokeAllEvent { + pub fn new(sender: &Addr, operator: &Addr) -> Self { + Self { + sender: sender.clone(), + operator: operator.clone(), + } + } +} + +impl IntoIterator for RevokeAllEvent { + type Item = Attribute; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + vec![ + attr("action", "revoke_all"), + attr("sender", self.sender.as_str()), + attr("operator", self.operator.as_str()), + ] + .into_iter() + } +} + +pub struct UpdateMetadataEvent { + pub token_id: String, + pub token_uri: String, + pub extension_update: bool, +} + +impl UpdateMetadataEvent { + pub fn new(token_id: &str, token_uri: &str, extension_update: bool) -> Self { + Self { + token_id: token_id.to_string(), + token_uri: token_uri.to_string(), + extension_update, + } + } +} + +impl IntoIterator for UpdateMetadataEvent { + type Item = Attribute; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + vec![ + attr("action", "update_metadata"), + attr("token_id", self.token_id), + attr("token_uri", self.token_uri), + attr("extension_update", self.extension_update.to_string()), + ] + .into_iter() + } +} + +pub fn event_action(action: &str, tokens: &[TokenAmount]) -> Attribute { + let action = format!( + "{}_{}", + action, + if tokens.len() == 1 { "single" } else { "batch" } + ); + attr("action", action) +} + +pub fn token_attributes(tokens: Vec) -> Vec { + vec![ + attr( + format!("token_id{}", if tokens.len() == 1 { "" } else { "s" }), + tokens + .iter() + .map(|t| t.token_id.to_string()) + .collect::>() + .join(","), + ), + attr( + format!("amount{}", if tokens.len() == 1 { "" } else { "s" }), + tokens + .iter() + .map(|t| t.amount.to_string()) + .collect::>() + .join(","), + ), + ] +} diff --git a/packages/cw1155/src/execute.rs b/packages/cw1155/src/execute.rs new file mode 100644 index 000000000..1d4189102 --- /dev/null +++ b/packages/cw1155/src/execute.rs @@ -0,0 +1,876 @@ +use cosmwasm_std::{ + Addr, Attribute, BankMsg, Binary, CustomMsg, DepsMut, Empty, Env, MessageInfo, Order, Response, + StdResult, Storage, SubMsg, Uint128, +}; +use cw2::set_contract_version; +use cw721::execute::migrate_version; +use cw_ownable::initialize_owner; +use cw_utils::Expiration; +use serde::de::DeserializeOwned; +use serde::Serialize; +use std::vec::IntoIter; + +use crate::event::{ + ApproveAllEvent, ApproveEvent, BurnEvent, MintEvent, RevokeAllEvent, RevokeEvent, + TransferEvent, UpdateMetadataEvent, +}; +use crate::msg::{Balance, CollectionInfo, Cw1155MintMsg, TokenAmount, TokenApproval}; +use crate::receiver::Cw1155BatchReceiveMsg; +use crate::state::TokenInfo; +use crate::{ + error::Cw1155ContractError, + msg::{Cw1155ExecuteMsg, Cw1155InstantiateMsg}, + receiver::Cw1155ReceiveMsg, + state::Cw1155Config, +}; + +pub trait Cw1155Execute< + // Metadata defined in NftInfo (used for mint). + TMetadataExtension, + // Defines for `CosmosMsg::Custom` in response. Barely used, so `Empty` can be used. + TCustomResponseMessage, + // Message passed for updating metadata. + TMetadataExtensionMsg, + // Extension query message. + TQueryExtensionMsg, +> where + TMetadataExtension: Serialize + DeserializeOwned + Clone, + TCustomResponseMessage: CustomMsg, + TMetadataExtensionMsg: CustomMsg, + TQueryExtensionMsg: Serialize + DeserializeOwned + Clone, +{ + fn instantiate( + &self, + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: Cw1155InstantiateMsg, + contract_name: &str, + contract_version: &str, + ) -> Result, Cw1155ContractError> { + set_contract_version(deps.storage, contract_name, contract_version)?; + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + let collection_info = CollectionInfo { + name: msg.name, + symbol: msg.symbol, + }; + config + .collection_info + .save(deps.storage, &collection_info)?; + + // store minter + let minter = match msg.minter { + Some(owner) => deps.api.addr_validate(&owner)?, + None => info.sender, + }; + initialize_owner(deps.storage, deps.api, Some(minter.as_ref()))?; + + // store total supply + config.supply.save(deps.storage, &Uint128::zero())?; + + Ok(Response::default().add_attribute("minter", minter)) + } + + fn execute( + &self, + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: Cw1155ExecuteMsg, + ) -> Result, Cw1155ContractError> { + let env = ExecuteEnv { deps, env, info }; + match msg { + // cw1155 + Cw1155ExecuteMsg::SendBatch { + from, + to, + batch, + msg, + } => self.send_batch(env, from, to, batch, msg), + Cw1155ExecuteMsg::MintBatch { recipient, msgs } => { + self.mint_batch(env, recipient, msgs) + } + Cw1155ExecuteMsg::BurnBatch { from, batch } => self.burn_batch(env, from, batch), + Cw1155ExecuteMsg::ApproveAll { operator, expires } => { + self.approve_all_cw1155(env, operator, expires) + } + Cw1155ExecuteMsg::RevokeAll { operator } => self.revoke_all_cw1155(env, operator), + + // cw721 + Cw1155ExecuteMsg::Send { + from, + to, + token_id, + amount, + msg, + } => self.send(env, from, to, token_id, amount, msg), + Cw1155ExecuteMsg::Mint { recipient, msg } => self.mint_cw1155(env, recipient, msg), + Cw1155ExecuteMsg::Burn { + from, + token_id, + amount, + } => self.burn(env, from, token_id, amount), + Cw1155ExecuteMsg::Approve { + spender, + token_id, + amount, + expires, + } => self.approve_token(env, spender, token_id, amount, expires), + Cw1155ExecuteMsg::Revoke { + spender, + token_id, + amount, + } => self.revoke_token(env, spender, token_id, amount), + Cw1155ExecuteMsg::UpdateOwnership(action) => Self::update_ownership(env, action), + + Cw1155ExecuteMsg::Extension { .. } => unimplemented!(), + } + } + + fn migrate( + &self, + deps: DepsMut, + _env: Env, + _msg: Empty, + contract_name: &str, + contract_version: &str, + ) -> Result { + let response = Response::::default(); + // migrate + let response = migrate_version(deps.storage, contract_name, contract_version, response)?; + Ok(response) + } + + fn mint_cw1155( + &self, + env: ExecuteEnv, + recipient: String, + msg: Cw1155MintMsg, + ) -> Result, Cw1155ContractError> { + let ExecuteEnv { + mut deps, + info, + env, + } = env; + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + let to = deps.api.addr_validate(&recipient)?; + + let mut rsp = Response::default(); + + let event = self.update_balances( + &mut deps, + &env, + &info, + None, + Some(to), + vec![TokenAmount { + token_id: msg.token_id.to_string(), + amount: msg.amount, + }], + )?; + rsp = rsp.add_attributes(event); + + // store token info if not exist (if it is the first mint) + if !config.tokens.has(deps.storage, &msg.token_id) { + let token_info = TokenInfo { + token_uri: msg.token_uri, + extension: msg.extension, + }; + config + .tokens + .save(deps.storage, &msg.token_id, &token_info)?; + } + + Ok(rsp) + } + + fn mint_batch( + &self, + env: ExecuteEnv, + recipient: String, + msgs: Vec>, + ) -> Result, Cw1155ContractError> { + let ExecuteEnv { + mut deps, + info, + env, + } = env; + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + let to = deps.api.addr_validate(&recipient)?; + + let batch = msgs + .iter() + .map(|msg| { + // store token info if not exist (if it is the first mint) + if !config.tokens.has(deps.storage, &msg.token_id) { + let token_info = TokenInfo { + token_uri: msg.token_uri.clone(), + extension: msg.extension.clone(), + }; + config + .tokens + .save(deps.storage, &msg.token_id, &token_info)?; + } + Ok(TokenAmount { + token_id: msg.token_id.to_string(), + amount: msg.amount, + }) + }) + .collect::>>()?; + + let mut rsp = Response::default(); + let event = self.update_balances(&mut deps, &env, &info, None, Some(to), batch)?; + rsp = rsp.add_attributes(event); + + Ok(rsp) + } + + fn send( + &self, + env: ExecuteEnv, + from: Option, + to: String, + token_id: String, + amount: Uint128, + msg: Option, + ) -> Result, Cw1155ContractError> { + let ExecuteEnv { + mut deps, + env, + info, + } = env; + + let from = if let Some(from) = from { + deps.api.addr_validate(&from)? + } else { + info.sender.clone() + }; + let to = deps.api.addr_validate(&to)?; + + let balance_update = + self.verify_approval(deps.storage, &env, &info, &from, &token_id, amount)?; + + let mut rsp = Response::::default(); + + let event = self.update_balances( + &mut deps, + &env, + &info, + Some(from.clone()), + Some(to.clone()), + vec![TokenAmount { + token_id: token_id.to_string(), + amount: balance_update.amount, + }], + )?; + rsp.attributes.extend(event); + + if let Some(msg) = msg { + rsp.messages.push(SubMsg::new( + Cw1155ReceiveMsg { + operator: info.sender.to_string(), + from: Some(from.to_string()), + amount, + token_id, + msg, + } + .into_cosmos_msg(&info, to)?, + )); + } else { + // transfer funds along to recipient + if !info.funds.is_empty() { + let transfer_msg = BankMsg::Send { + to_address: to.to_string(), + amount: info.funds.to_vec(), + }; + rsp.messages.push(SubMsg::new(transfer_msg)); + } + } + + Ok(rsp) + } + + fn send_batch( + &self, + env: ExecuteEnv, + from: Option, + to: String, + batch: Vec, + msg: Option, + ) -> Result, Cw1155ContractError> { + let ExecuteEnv { + mut deps, + env, + info, + } = env; + + let from = if let Some(from) = from { + deps.api.addr_validate(&from)? + } else { + info.sender.clone() + }; + let to = deps.api.addr_validate(&to)?; + + let batch = self.verify_approvals(deps.storage, &env, &info, &from, batch)?; + + let mut rsp = Response::::default(); + let event = self.update_balances( + &mut deps, + &env, + &info, + Some(from.clone()), + Some(to.clone()), + batch.to_vec(), + )?; + rsp.attributes.extend(event); + + if let Some(msg) = msg { + rsp.messages.push(SubMsg::new( + Cw1155BatchReceiveMsg { + operator: info.sender.to_string(), + from: Some(from.to_string()), + batch, + msg, + } + .into_cosmos_msg(&info, to)?, + )); + } else { + // transfer funds along to recipient + if !info.funds.is_empty() { + let transfer_msg = BankMsg::Send { + to_address: to.to_string(), + amount: info.funds.to_vec(), + }; + rsp.messages.push(SubMsg::new(transfer_msg)); + } + } + + Ok(rsp) + } + + fn burn( + &self, + env: ExecuteEnv, + from: Option, + token_id: String, + amount: Uint128, + ) -> Result, Cw1155ContractError> { + let ExecuteEnv { + mut deps, + info, + env, + } = env; + + let from = if let Some(from) = from { + deps.api.addr_validate(&from)? + } else { + info.sender.clone() + }; + + // whoever can transfer these tokens can burn + let balance_update = + self.verify_approval(deps.storage, &env, &info, &from, &token_id, amount)?; + + let mut rsp = Response::default(); + + let event = self.update_balances( + &mut deps, + &env, + &info, + Some(from), + None, + vec![TokenAmount { + token_id, + amount: balance_update.amount, + }], + )?; + rsp = rsp.add_attributes(event); + + Ok(rsp) + } + + fn burn_batch( + &self, + env: ExecuteEnv, + from: Option, + batch: Vec, + ) -> Result, Cw1155ContractError> { + let ExecuteEnv { + mut deps, + info, + env, + } = env; + + let from = if let Some(from) = from { + deps.api.addr_validate(&from)? + } else { + info.sender.clone() + }; + + let batch = self.verify_approvals(deps.storage, &env, &info, &from, batch)?; + + let mut rsp = Response::default(); + let event = self.update_balances(&mut deps, &env, &info, Some(from), None, batch)?; + rsp = rsp.add_attributes(event); + + Ok(rsp) + } + + fn approve_token( + &self, + env: ExecuteEnv, + operator: String, + token_id: String, + amount: Option, + expiration: Option, + ) -> Result, Cw1155ContractError> { + let ExecuteEnv { deps, info, env } = env; + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + + // reject expired data as invalid + let expiration = expiration.unwrap_or_default(); + if expiration.is_expired(&env.block) { + return Err(Cw1155ContractError::Expired {}); + } + + // get sender's token balance to get valid approval amount + let balance = config + .balances + .load(deps.storage, (info.sender.clone(), token_id.to_string()))?; + let approval_amount = amount.unwrap_or(Uint128::MAX).min(balance.amount); + + // store the approval + let operator = deps.api.addr_validate(&operator)?; + config.token_approves.save( + deps.storage, + (&token_id, &info.sender, &operator), + &TokenApproval { + amount: approval_amount, + expiration, + }, + )?; + + let mut rsp = Response::default(); + + let event = ApproveEvent::new(&info.sender, &operator, &token_id, approval_amount); + rsp = rsp.add_attributes(event); + + Ok(rsp) + } + + fn approve_all_cw1155( + &self, + env: ExecuteEnv, + operator: String, + expires: Option, + ) -> Result, Cw1155ContractError> { + let ExecuteEnv { deps, info, env } = env; + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + + // reject expired data as invalid + let expires = expires.unwrap_or_default(); + if expires.is_expired(&env.block) { + return Err(Cw1155ContractError::Expired {}); + } + + // set the operator for us + let operator = deps.api.addr_validate(&operator)?; + config + .approves + .save(deps.storage, (&info.sender, &operator), &expires)?; + + let mut rsp = Response::default(); + + let event = ApproveAllEvent::new(&info.sender, &operator); + rsp = rsp.add_attributes(event); + + Ok(rsp) + } + + fn revoke_token( + &self, + env: ExecuteEnv, + operator: String, + token_id: String, + amount: Option, + ) -> Result, Cw1155ContractError> { + let ExecuteEnv { deps, info, .. } = env; + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + let operator = deps.api.addr_validate(&operator)?; + + // get prev approval amount to get valid revoke amount + let prev_approval = config + .token_approves + .load(deps.storage, (&token_id, &info.sender, &operator))?; + let revoke_amount = amount.unwrap_or(Uint128::MAX).min(prev_approval.amount); + + // remove or update approval + if revoke_amount == prev_approval.amount { + config + .token_approves + .remove(deps.storage, (&token_id, &info.sender, &operator)); + } else { + config.token_approves.update( + deps.storage, + (&token_id, &info.sender, &operator), + |prev| -> StdResult<_> { + let mut new_approval = prev.unwrap(); + new_approval.amount = new_approval.amount.checked_sub(revoke_amount)?; + Ok(new_approval) + }, + )?; + } + + let mut rsp = Response::default(); + + let event = RevokeEvent::new(&info.sender, &operator, &token_id, revoke_amount); + rsp = rsp.add_attributes(event); + + Ok(rsp) + } + + fn revoke_all_cw1155( + &self, + env: ExecuteEnv, + operator: String, + ) -> Result, Cw1155ContractError> { + let ExecuteEnv { deps, info, .. } = env; + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + let operator = deps.api.addr_validate(&operator)?; + + config + .approves + .remove(deps.storage, (&info.sender, &operator)); + + let mut rsp = Response::default(); + + let event = RevokeAllEvent::new(&info.sender, &operator); + rsp = rsp.add_attributes(event); + + Ok(rsp) + } + + /// When from is None: mint new tokens + /// When to is None: burn tokens + /// When both are Some: transfer tokens + /// + /// Make sure permissions are checked before calling this. + fn update_balances( + &self, + deps: &mut DepsMut, + env: &Env, + info: &MessageInfo, + from: Option, + to: Option, + tokens: Vec, + ) -> Result, Cw1155ContractError> { + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + if let Some(from) = &from { + for TokenAmount { token_id, amount } in tokens.iter() { + if amount.is_zero() { + return Err(Cw1155ContractError::InvalidZeroAmount {}); + } + config.balances.update( + deps.storage, + (from.clone(), token_id.to_string()), + |balance: Option| -> StdResult<_> { + let mut new_balance = balance.unwrap(); + new_balance.amount = new_balance.amount.checked_sub(*amount)?; + Ok(new_balance) + }, + )?; + } + } + + if let Some(to) = &to { + for TokenAmount { token_id, amount } in tokens.iter() { + if amount.is_zero() { + return Err(Cw1155ContractError::InvalidZeroAmount {}); + } + config.balances.update( + deps.storage, + (to.clone(), token_id.to_string()), + |balance: Option| -> StdResult<_> { + let mut new_balance: Balance = if let Some(balance) = balance { + balance + } else { + Balance { + owner: to.clone(), + amount: Uint128::zero(), + token_id: token_id.to_string(), + } + }; + + new_balance.amount = new_balance.amount.checked_add(*amount)?; + Ok(new_balance) + }, + )?; + } + } + + let event: IntoIter = if let Some(from) = &from { + for TokenAmount { token_id, amount } in &tokens { + if amount.is_zero() { + return Err(Cw1155ContractError::InvalidZeroAmount {}); + } + // remove token approvals + for (operator, approval) in config + .token_approves + .prefix((token_id, from)) + .range(deps.storage, None, None, Order::Ascending) + .collect::>>()? + { + if approval.is_expired(env) || approval.amount <= *amount { + config + .token_approves + .remove(deps.storage, (token_id, from, &operator)); + } else { + config.token_approves.update( + deps.storage, + (token_id, from, &operator), + |prev| -> StdResult<_> { + let mut new_approval = prev.unwrap(); + new_approval.amount = new_approval.amount.checked_sub(*amount)?; + Ok(new_approval) + }, + )?; + } + } + + // decrement tokens if burning + if to.is_none() { + config.decrement_tokens(deps.storage, token_id, amount)?; + } + } + + if let Some(to) = &to { + // transfer + TransferEvent::new(info, Some(from.clone()), to, tokens).into_iter() + } else { + // burn + BurnEvent::new(info, Some(from.clone()), tokens).into_iter() + } + } else if let Some(to) = &to { + // mint + for TokenAmount { token_id, amount } in &tokens { + if amount.is_zero() { + return Err(Cw1155ContractError::InvalidZeroAmount {}); + } + config.increment_tokens(deps.storage, token_id, amount)?; + } + MintEvent::new(info, to, tokens).into_iter() + } else { + panic!("Invalid transfer: from and to cannot both be None") + }; + + Ok(event) + } + + /// returns valid token amount if the sender can execute or is approved to execute + fn verify_approval( + &self, + storage: &dyn Storage, + env: &Env, + info: &MessageInfo, + owner: &Addr, + token_id: &str, + amount: Uint128, + ) -> Result { + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + let operator = &info.sender; + + let balance_update = TokenAmount { + token_id: token_id.to_string(), + amount, + }; + + // owner or all operator can execute + if owner == operator || config.verify_all_approval(storage, env, owner, operator) { + let owner_balance = config + .balances + .load(storage, (owner.clone(), token_id.to_string()))?; + if owner_balance.amount < amount { + return Err(Cw1155ContractError::NotEnoughTokens { + available: owner_balance.amount, + requested: amount, + }); + } + return Ok(balance_update); + } + + // token operator can execute up to approved amount + if let Some(token_approval) = + self.get_active_token_approval(storage, env, owner, operator, token_id) + { + if token_approval.amount < amount { + return Err(Cw1155ContractError::NotEnoughTokens { + available: token_approval.amount, + requested: amount, + }); + } + return Ok(balance_update); + } + + Err(Cw1155ContractError::Unauthorized {}) + } + + /// returns valid token amounts if the sender can execute or is approved to execute on all provided tokens + fn verify_approvals( + &self, + storage: &dyn Storage, + env: &Env, + info: &MessageInfo, + owner: &Addr, + tokens: Vec, + ) -> Result, Cw1155ContractError> { + tokens + .iter() + .map(|TokenAmount { token_id, amount }| { + self.verify_approval(storage, env, info, owner, token_id, *amount) + }) + .collect() + } + + fn get_active_token_approval( + &self, + storage: &dyn Storage, + env: &Env, + owner: &Addr, + operator: &Addr, + token_id: &str, + ) -> Option { + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + match config + .token_approves + .load(storage, (token_id, owner, operator)) + { + Ok(approval) => { + if !approval.is_expired(env) { + Some(approval) + } else { + None + } + } + Err(_) => None, + } + } + + fn update_ownership( + env: ExecuteEnv, + action: cw_ownable::Action, + ) -> Result, Cw1155ContractError> { + let ExecuteEnv { deps, info, env } = env; + let ownership = + cw_ownable::update_ownership(deps.api, deps.storage, &env.block, &info.sender, action)?; + Ok(Response::new().add_attributes(ownership.into_attributes())) + } + + /// Allows creator to update onchain metadata and token uri. This is not available on the base, but the implementation + /// is available here for contracts that want to use it. + /// From `update_uri` on ERC-1155. + fn update_metadata( + &self, + deps: DepsMut, + info: MessageInfo, + token_id: String, + extension: Option, + token_uri: Option, + ) -> Result, Cw1155ContractError> { + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + if extension.is_none() && token_uri.is_none() { + return Err(Cw1155ContractError::NoUpdatesRequested {}); + } + + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + let mut token_info = config.tokens.load(deps.storage, &token_id)?; + + // update extension + let extension_update = if let Some(extension) = extension { + token_info.extension = extension; + true + } else { + false + }; + + // update token uri + token_info.token_uri = token_uri; + + // store token + config.tokens.save(deps.storage, &token_id, &token_info)?; + + Ok(Response::new().add_attributes(UpdateMetadataEvent::new( + &token_id, + &token_info.token_uri.unwrap_or_default(), + extension_update, + ))) + } +} + +/// To mitigate clippy::too_many_arguments warning +pub struct ExecuteEnv<'a> { + deps: DepsMut<'a>, + env: Env, + info: MessageInfo, +} diff --git a/packages/cw1155/src/lib.rs b/packages/cw1155/src/lib.rs new file mode 100644 index 000000000..18183c764 --- /dev/null +++ b/packages/cw1155/src/lib.rs @@ -0,0 +1,9 @@ +pub mod error; +pub mod event; +pub mod execute; +pub mod msg; +pub mod query; +pub mod receiver; +pub mod state; + +pub use cw_utils::Expiration; diff --git a/packages/cw1155/src/msg.rs b/packages/cw1155/src/msg.rs new file mode 100644 index 000000000..83f125ea2 --- /dev/null +++ b/packages/cw1155/src/msg.rs @@ -0,0 +1,292 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use std::fmt::{Display, Formatter}; + +use cosmwasm_std::{Addr, Binary, Env, Uint128}; +use cw721::Approval; +use cw_ownable::{cw_ownable_execute, cw_ownable_query}; +use cw_utils::Expiration; + +#[cw_serde] +pub struct Cw1155InstantiateMsg { + /// Name of the token contract + pub name: String, + /// Symbol of the token contract + pub symbol: String, + + /// The minter is the only one who can create new tokens. + /// This is designed for a base token platform that is controlled by an external program or + /// contract. + /// If None, sender is the minter. + pub minter: Option, +} + +/// This is like Cw1155ExecuteMsg but we add a Mint command for a minter +/// to make this stand-alone. You will likely want to remove mint and +/// use other control logic in any contract that inherits this. +#[cw_ownable_execute] +#[cw_serde] +pub enum Cw1155ExecuteMsg { + // cw1155 + /// BatchSendFrom is a base message to move multiple types of tokens in batch, + /// if `env.sender` is the owner or has sufficient pre-approval. + SendBatch { + /// check approval if from is Some, otherwise assume sender is owner + from: Option, + /// if `to` is not contract, `msg` should be `None` + to: String, + batch: Vec, + /// `None` means don't call the receiver interface + msg: Option, + }, + /// Mint a batch of tokens, can only be called by the contract minter + MintBatch { + recipient: String, + msgs: Vec>, + }, + /// BatchBurn is a base message to burn multiple types of tokens in batch. + BurnBatch { + /// check approval if from is Some, otherwise assume sender is owner + from: Option, + batch: Vec, + }, + /// Allows operator to transfer / send any token from the owner's account. + /// If expiration is set, then this allowance has a time/height limit + ApproveAll { + operator: String, + expires: Option, + }, + /// Remove previously granted ApproveAll permission + RevokeAll { operator: String }, + + // cw721 + /// SendFrom is a base message to move tokens, + /// if `env.sender` is the owner or has sufficient pre-approval. + Send { + /// check approval if from is Some, otherwise assume sender is owner + from: Option, + /// If `to` is not contract, `msg` should be `None` + to: String, + token_id: String, + amount: Uint128, + /// `None` means don't call the receiver interface + msg: Option, + }, + /// Mint a new NFT, can only be called by the contract minter + Mint { + recipient: String, + msg: Cw1155MintMsg, + }, + /// Burn is a base message to burn tokens. + Burn { + /// check approval if from is Some, otherwise assume sender is owner + from: Option, + token_id: String, + amount: Uint128, + }, + /// Allows operator to transfer / send the token from the owner's account. + /// If expiration is set, then this allowance has a time/height limit + Approve { + spender: String, + token_id: String, + /// Optional amount to approve. If None, approve entire balance. + amount: Option, + expires: Option, + }, + /// Remove previously granted Approval + Revoke { + spender: String, + token_id: String, + /// Optional amount to revoke. If None, revoke entire amount. + amount: Option, + }, + + /// Extension msg + Extension { msg: TMetadataExtensionMsg }, +} + +#[cw_ownable_query] +#[cw_serde] +#[derive(QueryResponses)] +pub enum Cw1155QueryMsg { + // cw1155 + /// Returns the current balance of the given account, 0 if unset. + #[returns(BalanceResponse)] + BalanceOf(OwnerToken), + #[returns(OwnersOfResponse)] + OwnersOf { + token_id: String, + limit: Option, + start_after: Option, + }, + /// Returns the current balance of the given batch of accounts/tokens, 0 if unset. + #[returns(BalancesResponse)] + BalanceOfBatch(Vec), + /// Query approved status `owner` granted to `operator`. + #[returns(IsApprovedForAllResponse)] + IsApprovedForAll { owner: String, operator: String }, + /// Return approvals that a token owner has + #[returns(Vec)] + TokenApprovals { + owner: String, + token_id: String, + include_expired: Option, + }, + /// List all operators that can access all of the owner's tokens. + #[returns(ApprovedForAllResponse)] + ApprovalsForAll { + owner: String, + /// unset or false will filter out expired approvals, you must set to true to see them + include_expired: Option, + start_after: Option, + limit: Option, + }, + /// Returns all current balances of the given token id. Supports pagination + #[returns(BalancesResponse)] + AllBalances { + token_id: String, + start_after: Option, + limit: Option, + }, + /// Total number of tokens issued + #[returns(NumTokensResponse)] + NumTokens { + token_id: Option, // optional token id to get supply of, otherwise total supply + }, + + // cw721 + /// With MetaData Extension. + /// Returns top-level metadata about the contract. + #[returns(cw721::state::CollectionInfo)] + ContractInfo {}, + /// Query Minter. + #[returns(cw721::msg::MinterResponse)] + Minter {}, + /// With MetaData Extension. + /// Query metadata of token + #[returns(TokenInfoResponse)] + TokenInfo { token_id: String }, + /// With Enumerable extension. + /// Returns all tokens owned by the given address, [] if unset. + #[returns(cw721::msg::TokensResponse)] + Tokens { + owner: String, + start_after: Option, + limit: Option, + }, + /// With Enumerable extension. + /// Requires pagination. Lists all token_ids controlled by the contract. + #[returns(cw721::msg::TokensResponse)] + AllTokens { + start_after: Option, + limit: Option, + }, + + /// Extension query + #[returns(())] + Extension { + msg: TQueryExtensionMsg, + phantom: Option, // dummy field to infer type + }, +} + +#[cw_serde] +pub struct BalanceResponse { + pub balance: Uint128, +} + +#[cw_serde] +pub struct BalancesResponse { + pub balances: Vec, +} + +#[cw_serde] +pub struct NumTokensResponse { + pub count: Uint128, +} + +#[cw_serde] +pub struct ApprovedForAllResponse { + pub operators: Vec, +} + +#[cw_serde] +pub struct IsApprovedForAllResponse { + pub approved: bool, +} + +#[cw_serde] +pub struct AllTokenInfoResponse { + pub token_id: String, + pub info: TokenInfoResponse, +} + +#[cw_serde] +pub struct TokenInfoResponse { + /// Should be a url point to a json file + pub token_uri: Option, + /// You can add any custom metadata here when you extend cw1155-base + pub extension: T, +} + +#[cw_serde] +pub struct Cw1155MintMsg { + pub token_id: String, + /// The amount of the newly minted tokens + pub amount: Uint128, + + /// Only first mint can set `token_uri` and `extension` + /// Metadata JSON Schema + pub token_uri: Option, + /// Any custom extension used by this contract + pub extension: T, +} + +#[cw_serde] +#[derive(Eq)] +pub struct TokenAmount { + pub token_id: String, + pub amount: Uint128, +} + +impl Display for TokenAmount { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.token_id, self.amount) + } +} + +#[cw_serde] +pub struct TokenApproval { + pub amount: Uint128, + pub expiration: Expiration, +} + +impl TokenApproval { + pub fn is_expired(&self, env: &Env) -> bool { + self.expiration.is_expired(&env.block) + } +} + +#[cw_serde] +pub struct OwnerToken { + pub owner: String, + pub token_id: String, +} + +#[cw_serde] +pub struct Balance { + pub token_id: String, + pub owner: Addr, + pub amount: Uint128, +} + +#[cw_serde] +pub struct OwnersOfResponse { + pub balances: Vec, + pub count: u64, +} + +#[cw_serde] +pub struct CollectionInfo { + pub name: String, + pub symbol: String, +} diff --git a/packages/cw1155/src/query.rs b/packages/cw1155/src/query.rs new file mode 100644 index 000000000..a8d991f82 --- /dev/null +++ b/packages/cw1155/src/query.rs @@ -0,0 +1,373 @@ +use cosmwasm_std::{to_json_binary, Addr, Binary, CustomMsg, Deps, Env, Order, StdResult, Uint128}; +use cw721::msg::TokensResponse; +use cw721::query::query_creator_ownership; +use cw721::Approval; +use cw_storage_plus::Bound; +use cw_utils::{maybe_addr, Expiration}; +use serde::de::DeserializeOwned; +use serde::Serialize; + +use crate::msg::{ + ApprovedForAllResponse, Balance, BalanceResponse, BalancesResponse, Cw1155QueryMsg, + IsApprovedForAllResponse, OwnerToken, OwnersOfResponse, +}; +use crate::msg::{NumTokensResponse, TokenInfoResponse}; +use crate::state::Cw1155Config; + +pub const DEFAULT_LIMIT: u32 = 10; +pub const MAX_LIMIT: u32 = 1000; + +pub trait Cw1155Query< + // Metadata defined in NftInfo. + TMetadataExtension, + // Defines for `CosmosMsg::Custom` in response. Barely used, so `Empty` can be used. + TCustomResponseMessage, + // Message passed for updating metadata. + TMetadataExtensionMsg, + // Extension query message. + TQueryExtensionMsg, +> where + TMetadataExtension: Serialize + DeserializeOwned + Clone, + TCustomResponseMessage: CustomMsg, + TMetadataExtensionMsg: CustomMsg, + TQueryExtensionMsg: Serialize + DeserializeOwned + Clone, +{ + fn query( + &self, + deps: Deps, + env: Env, + msg: Cw1155QueryMsg, + ) -> StdResult { + match msg { + Cw1155QueryMsg::Minter {} => to_json_binary(&query_creator_ownership(deps.storage)?), + Cw1155QueryMsg::BalanceOf(OwnerToken { owner, token_id }) => { + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + let owner_addr = deps.api.addr_validate(&owner)?; + let balance = config + .balances + .may_load(deps.storage, (owner_addr.clone(), token_id.clone()))? + .unwrap_or(Balance { + owner: owner_addr, + token_id, + amount: Uint128::new(0), + }); + to_json_binary(&BalanceResponse { + balance: balance.amount, + }) + } + Cw1155QueryMsg::OwnersOf { + token_id, + limit, + start_after, + } => { + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + let start_after = start_after.map(|address| { + Bound::exclusive((Addr::unchecked(address), token_id.to_string())) + }); + let balances = config + .balances + .idx + .token_id + .prefix(token_id.to_string()) + .range_raw(deps.storage, start_after, None, Order::Ascending) + .take(limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize) + .map(|item| { + let (_, v) = item.unwrap(); + v + }) + .collect::>(); + let count = config + .balances + .idx + .token_id + .prefix(token_id) + .keys(deps.storage, None, None, Order::Ascending) + .count() as u64; + to_json_binary(&OwnersOfResponse { balances, count }) + } + Cw1155QueryMsg::AllBalances { + token_id, + start_after, + limit, + } => to_json_binary(&self.query_all_balances(deps, token_id, start_after, limit)?), + Cw1155QueryMsg::BalanceOfBatch(batch) => { + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + let balances = batch + .into_iter() + .map(|OwnerToken { owner, token_id }| { + let owner = Addr::unchecked(owner); + config + .balances + .load(deps.storage, (owner.clone(), token_id.to_string())) + .unwrap_or(Balance { + owner, + token_id, + amount: Uint128::zero(), + }) + }) + .collect::>(); + to_json_binary(&BalancesResponse { balances }) + } + Cw1155QueryMsg::TokenApprovals { + owner, + token_id, + include_expired, + } => { + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + let owner = deps.api.addr_validate(&owner)?; + let approvals = config + .token_approves + .prefix((&token_id, &owner)) + .range(deps.storage, None, None, Order::Ascending) + .filter_map(|approval| { + let (_, approval) = approval.unwrap(); + if include_expired.unwrap_or(false) || !approval.is_expired(&env) { + Some(approval) + } else { + None + } + }) + .collect::>(); + to_json_binary(&approvals) + } + Cw1155QueryMsg::ApprovalsForAll { + owner, + include_expired, + start_after, + limit, + } => { + let owner_addr = deps.api.addr_validate(&owner)?; + let start_addr = maybe_addr(deps.api, start_after)?; + to_json_binary(&self.query_all_approvals( + deps, + env, + owner_addr, + include_expired.unwrap_or(false), + start_addr, + limit, + )?) + } + Cw1155QueryMsg::IsApprovedForAll { owner, operator } => { + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + let owner_addr = deps.api.addr_validate(&owner)?; + let operator_addr = deps.api.addr_validate(&operator)?; + let approved = + config.verify_all_approval(deps.storage, &env, &owner_addr, &operator_addr); + to_json_binary(&IsApprovedForAllResponse { approved }) + } + Cw1155QueryMsg::TokenInfo { token_id } => { + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + let token_info = config.tokens.load(deps.storage, &token_id)?; + to_json_binary(&TokenInfoResponse:: { + token_uri: token_info.token_uri, + extension: token_info.extension, + }) + } + Cw1155QueryMsg::Tokens { + owner, + start_after, + limit, + } => { + let owner_addr = deps.api.addr_validate(&owner)?; + to_json_binary(&self.query_owner_tokens(deps, owner_addr, start_after, limit)?) + } + Cw1155QueryMsg::ContractInfo {} => to_json_binary( + &Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default() + .collection_info + .load(deps.storage)?, + ), + Cw1155QueryMsg::NumTokens { token_id } => { + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + let count = if let Some(token_id) = token_id { + config.token_count(deps.storage, &token_id)? + } else { + config.supply.load(deps.storage)? + }; + to_json_binary(&NumTokensResponse { count }) + } + Cw1155QueryMsg::AllTokens { start_after, limit } => { + to_json_binary(&self.query_all_tokens_cw1155(deps, start_after, limit)?) + } + Cw1155QueryMsg::Ownership {} => { + to_json_binary(&cw_ownable::get_ownership(deps.storage)?) + } + + Cw1155QueryMsg::Extension { msg: ext_msg, .. } => { + self.query_extension(deps, env, ext_msg) + } + } + } + + fn query_all_approvals( + &self, + deps: Deps, + env: Env, + owner: Addr, + include_expired: bool, + start_after: Option, + limit: Option, + ) -> StdResult { + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.as_ref().map(Bound::exclusive); + + let operators = config + .approves + .prefix(&owner) + .range(deps.storage, start, None, Order::Ascending) + .filter(|r| { + include_expired || r.is_err() || !r.as_ref().unwrap().1.is_expired(&env.block) + }) + .take(limit) + .map(build_approval) + .collect::>()?; + Ok(ApprovedForAllResponse { operators }) + } + + fn query_owner_tokens( + &self, + deps: Deps, + owner: Addr, + start_after: Option, + limit: Option, + ) -> StdResult { + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.as_ref().map(|s| Bound::exclusive(s.as_str())); + + let tokens = config + .balances + .prefix(owner) + .keys(deps.storage, start, None, Order::Ascending) + .take(limit) + .collect::>()?; + Ok(TokensResponse { tokens }) + } + + fn query_all_tokens_cw1155( + &self, + deps: Deps, + start_after: Option, + limit: Option, + ) -> StdResult { + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.as_ref().map(|s| Bound::exclusive(s.as_str())); + let tokens = config + .tokens + .keys(deps.storage, start, None, Order::Ascending) + .take(limit) + .collect::>()?; + Ok(TokensResponse { tokens }) + } + + fn query_all_balances( + &self, + deps: Deps, + token_id: String, + start_after: Option, + limit: Option, + ) -> StdResult { + let config = Cw1155Config::< + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + >::default(); + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + + let start = if let Some(start_after) = start_after { + let start_key = (Addr::unchecked(start_after), token_id.clone()); + Some(Bound::exclusive::<(Addr, String)>(start_key)) + } else { + None + }; + + let balances: Vec = config + .balances + .idx + .token_id + .prefix(token_id) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| { + let (_, v) = item.unwrap(); + v + }) + .collect(); + + Ok(BalancesResponse { balances }) + } + + /// Custom msg query. Default implementation returns an empty binary. + fn query_extension( + &self, + _deps: Deps, + _env: Env, + _msg: TQueryExtensionMsg, + ) -> StdResult { + Ok(Binary::default()) + } +} + +fn build_approval(item: StdResult<(Addr, Expiration)>) -> StdResult { + item.map(|(addr, expires)| Approval { + spender: addr, + expires, + }) +} diff --git a/packages/cw1155/src/receiver.rs b/packages/cw1155/src/receiver.rs new file mode 100644 index 000000000..090fb8e45 --- /dev/null +++ b/packages/cw1155/src/receiver.rs @@ -0,0 +1,84 @@ +use crate::msg::TokenAmount; +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{to_json_binary, Binary, CosmosMsg, MessageInfo, StdResult, Uint128, WasmMsg}; +use schemars::JsonSchema; + +/// Cw1155ReceiveMsg should be de/serialized under `Receive()` variant in a ExecuteMsg +#[cw_serde] +pub struct Cw1155ReceiveMsg { + /// The account that executed the send message + pub operator: String, + /// The account that the token transfered from + pub from: Option, + pub token_id: String, + pub amount: Uint128, + pub msg: Binary, +} + +impl Cw1155ReceiveMsg { + /// serializes the message + pub fn into_binary(self) -> StdResult { + let msg = ReceiverExecuteMsg::Receive(self); + to_json_binary(&msg) + } + + /// creates a cosmos_msg sending this struct to the named contract + pub fn into_cosmos_msg, C>( + self, + info: &MessageInfo, + contract_addr: T, + ) -> StdResult> + where + C: Clone + std::fmt::Debug + PartialEq + JsonSchema, + { + let msg = self.into_binary()?; + let execute = WasmMsg::Execute { + contract_addr: contract_addr.into(), + msg, + funds: info.funds.to_vec(), + }; + Ok(execute.into()) + } +} + +/// Cw1155BatchReceiveMsg should be de/serialized under `BatchReceive()` variant in a ExecuteMsg +#[cw_serde] +pub struct Cw1155BatchReceiveMsg { + pub operator: String, + pub from: Option, + pub batch: Vec, + pub msg: Binary, +} + +impl Cw1155BatchReceiveMsg { + /// serializes the message + pub fn into_binary(self) -> StdResult { + let msg = ReceiverExecuteMsg::BatchReceive(self); + to_json_binary(&msg) + } + + /// creates a cosmos_msg sending this struct to the named contract + pub fn into_cosmos_msg, C>( + self, + info: &MessageInfo, + contract_addr: T, + ) -> StdResult> + where + C: Clone + std::fmt::Debug + PartialEq + JsonSchema, + { + let msg = self.into_binary()?; + let execute = WasmMsg::Execute { + contract_addr: contract_addr.into(), + msg, + funds: info.funds.to_vec(), + }; + Ok(execute.into()) + } +} + +// This is just a helper to properly serialize the above message +#[cw_serde] +enum ReceiverExecuteMsg { + Receive(Cw1155ReceiveMsg), + BatchReceive(Cw1155BatchReceiveMsg), +} diff --git a/packages/cw1155/src/state.rs b/packages/cw1155/src/state.rs new file mode 100644 index 000000000..2502fde27 --- /dev/null +++ b/packages/cw1155/src/state.rs @@ -0,0 +1,191 @@ +use crate::msg::{Balance, CollectionInfo, TokenApproval}; +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, CustomMsg, Env, StdError, StdResult, Storage, Uint128}; +use cw_storage_plus::{Index, IndexList, IndexedMap, Item, Map, MultiIndex}; +use cw_utils::Expiration; +use serde::de::DeserializeOwned; +use serde::Serialize; +use std::marker::PhantomData; + +pub struct Cw1155Config< + 'a, + // Metadata defined in NftInfo (used for mint). + TMetadataExtension, + // Defines for `CosmosMsg::Custom` in response. Barely used, so `Empty` can be used. + TCustomResponseMessage, + // Message passed for updating metadata. + TMetadataExtensionMsg, + // Extension query message. + TQueryExtensionMsg, +> where + TMetadataExtension: Serialize + DeserializeOwned + Clone, + TMetadataExtensionMsg: CustomMsg, + TQueryExtensionMsg: Serialize + DeserializeOwned + Clone, +{ + pub collection_info: Item<'a, CollectionInfo>, + pub supply: Item<'a, Uint128>, // total supply of all tokens + // key: token id + pub token_count: Map<'a, &'a str, Uint128>, // total supply of a specific token + // key: (owner, token id) + pub balances: IndexedMap<'a, (Addr, String), Balance, BalanceIndexes<'a>>, + // key: (owner, spender) + pub approves: Map<'a, (&'a Addr, &'a Addr), Expiration>, + // key: (token id, owner, spender) + pub token_approves: Map<'a, (&'a str, &'a Addr, &'a Addr), TokenApproval>, + // key: token id + pub tokens: Map<'a, &'a str, TokenInfo>, + + pub(crate) _custom_response: PhantomData, + pub(crate) _custom_execute: PhantomData, + pub(crate) _custom_query: PhantomData, +} + +impl Default + for Cw1155Config< + 'static, + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + > +where + TMetadataExtension: Serialize + DeserializeOwned + Clone, + TMetadataExtensionMsg: CustomMsg, + TQueryExtensionMsg: Serialize + DeserializeOwned + Clone, +{ + fn default() -> Self { + Self::new( + "collection_info", + "tokens", + "token_count", + "supply", + "balances", + "balances__token_id", + "approves", + "token_approves", + ) + } +} + +impl<'a, TMetadataExtension, TCustomResponseMessage, TMetadataExtensionMsg, TQueryExtensionMsg> + Cw1155Config< + 'a, + TMetadataExtension, + TCustomResponseMessage, + TMetadataExtensionMsg, + TQueryExtensionMsg, + > +where + TMetadataExtension: Serialize + DeserializeOwned + Clone, + TMetadataExtensionMsg: CustomMsg, + TQueryExtensionMsg: Serialize + DeserializeOwned + Clone, +{ + #[allow(clippy::too_many_arguments)] + fn new( + contract_info_key: &'a str, + tokens_key: &'a str, + token_count_key: &'a str, + supply_key: &'a str, + balances_key: &'a str, + balances_token_id_key: &'a str, + approves_key: &'a str, + token_approves_key: &'a str, + ) -> Self { + let balances_indexes = BalanceIndexes { + token_id: MultiIndex::new( + |_, b| b.token_id.to_string(), + balances_key, + balances_token_id_key, + ), + }; + Self { + collection_info: Item::new(contract_info_key), + tokens: Map::new(tokens_key), + token_count: Map::new(token_count_key), + supply: Item::new(supply_key), + balances: IndexedMap::new(balances_key, balances_indexes), + approves: Map::new(approves_key), + token_approves: Map::new(token_approves_key), + _custom_execute: PhantomData, + _custom_response: PhantomData, + _custom_query: PhantomData, + } + } + + pub fn token_count(&self, storage: &dyn Storage, token_id: &'a str) -> StdResult { + Ok(self + .token_count + .may_load(storage, token_id)? + .unwrap_or_default()) + } + + pub fn increment_tokens( + &self, + storage: &mut dyn Storage, + token_id: &'a str, + amount: &Uint128, + ) -> StdResult { + // increment token count + let val = self.token_count(storage, token_id)? + amount; + self.token_count.save(storage, token_id, &val)?; + + // increment total supply + self.supply.update(storage, |prev| { + Ok::(prev.checked_add(*amount)?) + })?; + + Ok(val) + } + + pub fn decrement_tokens( + &self, + storage: &mut dyn Storage, + token_id: &'a str, + amount: &Uint128, + ) -> StdResult { + // decrement token count + let val = self.token_count(storage, token_id)?.checked_sub(*amount)?; + self.token_count.save(storage, token_id, &val)?; + + // decrement total supply + self.supply.update(storage, |prev| { + Ok::(prev.checked_sub(*amount)?) + })?; + + Ok(val) + } + + pub fn verify_all_approval( + &self, + storage: &dyn Storage, + env: &Env, + owner: &Addr, + operator: &Addr, + ) -> bool { + match self.approves.load(storage, (owner, operator)) { + Ok(ex) => !ex.is_expired(&env.block), + Err(_) => false, + } + } +} + +#[cw_serde] +pub struct TokenInfo { + /// Metadata JSON Schema + pub token_uri: Option, + /// You can add any custom metadata here when you extend cw1155-base + pub extension: T, +} + +pub struct BalanceIndexes<'a> { + pub token_id: MultiIndex<'a, String, Balance, (Addr, String)>, +} + +impl<'a> IndexList for BalanceIndexes<'a> { + fn get_indexes(&'_ self) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.token_id]; + Box::new(v.into_iter()) + } +} + +pub type DefaultOptionMetadataExtension = Option;