diff --git a/.gitignore b/.gitignore index 62abf6fb6..e86f6182e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ tarpaulin-report.* packages/*/schema contracts/*/schema + +.env diff --git a/Cargo.lock b/Cargo.lock index 3912176c7..3de6bf585 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -382,6 +382,23 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cw20-snapshot" +version = "2.0.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-storage-plus", + "cw-utils", + "cw2", + "cw20", + "schemars", + "semver", + "serde", + "thiserror", +] + [[package]] name = "cw3" version = "2.0.0" diff --git a/contracts/cw20-snapshot/.cargo/config.toml b/contracts/cw20-snapshot/.cargo/config.toml new file mode 100644 index 000000000..f5174787c --- /dev/null +++ b/contracts/cw20-snapshot/.cargo/config.toml @@ -0,0 +1,6 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" +wasm-debug = "build --lib --target wasm32-unknown-unknown" +unit-test = "test --lib" +integration-test = "test --test integration" +schema = "run --bin schema" diff --git a/contracts/cw20-snapshot/Cargo.toml b/contracts/cw20-snapshot/Cargo.toml new file mode 100644 index 000000000..7c2aa6f44 --- /dev/null +++ b/contracts/cw20-snapshot/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "cw20-snapshot" +version = { workspace = true } +authors = ["Ethan Frey "] +edition = "2021" +description = "Basic implementation of a CosmWasm-20 compliant token" +license = "Apache-2.0" +repository = "https://github.com/CosmWasm/cw-plus" +homepage = "https://cosmwasm.com" +documentation = "https://docs.cosmwasm.com" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-schema = { workspace = true } +cw2 = { workspace = true } +cw20 = { workspace = true } +cw-storage-plus = { workspace = true } +cosmwasm-std = { workspace = true } +schemars = { workspace = true } +semver = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } +cw-utils = { workspace = true } diff --git a/contracts/cw20-snapshot/README.md b/contracts/cw20-snapshot/README.md new file mode 100644 index 000000000..01db9e054 --- /dev/null +++ b/contracts/cw20-snapshot/README.md @@ -0,0 +1,48 @@ +# CW20 Basic + +This is a basic implementation of a cw20 contract. It implements +the [CW20 spec](../../packages/cw20/README.md) and is designed to +be deployed as is, or imported into other contracts to easily build +cw20-compatible tokens with custom logic. + +Implements: + +- [x] CW20 Base +- [x] Mintable extension +- [x] Allowances extension + +## Running this contract + +You will need Rust 1.44.1+ with `wasm32-unknown-unknown` target installed. + +You can run unit tests on this via: + +`cargo test` + +Once you are happy with the content, you can compile it to wasm via: + +``` +RUSTFLAGS='-C link-arg=-s' cargo wasm +cp ../../target/wasm32-unknown-unknown/release/cw20_base.wasm . +ls -l cw20_base.wasm +sha256sum cw20_base.wasm +``` + +Or for a production-ready (optimized) build, run a build command in the +the repository root: https://github.com/CosmWasm/cw-plus#compiling. + +## Importing this contract + +You can also import much of the logic of this contract to build another +ERC20-contract, such as a bonding curve, overiding or extending what you +need. + +Basically, you just need to write your handle function and import +`cw20_base::contract::handle_transfer`, etc and dispatch to them. +This allows you to use custom `ExecuteMsg` and `QueryMsg` with your additional +calls, but then use the underlying implementation for the standard cw20 +messages you want to support. The same with `QueryMsg`. You *could* reuse `instantiate` +as it, but it is likely you will want to change it. And it is rather simple. + +Look at [`cw20-staking`](https://github.com/CosmWasm/cw-tokens/tree/main/contracts/cw20-staking) for an example of how to "inherit" +all this token functionality and combine it with custom logic. diff --git a/contracts/cw20-snapshot/src/allowances.rs b/contracts/cw20-snapshot/src/allowances.rs new file mode 100644 index 000000000..e330f4095 --- /dev/null +++ b/contracts/cw20-snapshot/src/allowances.rs @@ -0,0 +1,885 @@ +use cosmwasm_std::{ + attr, Addr, Binary, BlockInfo, Deps, DepsMut, Env, MessageInfo, Response, StdError, StdResult, + Storage, Uint128, +}; +use cw20::{AllowanceResponse, Cw20ReceiveMsg, Expiration}; + +use crate::error::ContractError; +use crate::state::{ALLOWANCES, ALLOWANCES_SPENDER, BALANCES, TOKEN_INFO}; + +pub fn execute_increase_allowance( + deps: DepsMut, + env: Env, + info: MessageInfo, + spender: String, + amount: Uint128, + expires: Option, +) -> Result { + let spender_addr = deps.api.addr_validate(&spender)?; + if spender_addr == info.sender { + return Err(ContractError::CannotSetOwnAccount {}); + } + + let update_fn = |allow: Option| -> Result<_, _> { + let mut val = allow.unwrap_or_default(); + if let Some(exp) = expires { + if exp.is_expired(&env.block) { + return Err(ContractError::InvalidExpiration {}); + } + val.expires = exp; + } + val.allowance += amount; + Ok(val) + }; + ALLOWANCES.update(deps.storage, (&info.sender, &spender_addr), update_fn)?; + ALLOWANCES_SPENDER.update(deps.storage, (&spender_addr, &info.sender), update_fn)?; + + let res = Response::new().add_attributes(vec![ + attr("action", "increase_allowance"), + attr("owner", info.sender), + attr("spender", spender), + attr("amount", amount), + ]); + Ok(res) +} + +pub fn execute_decrease_allowance( + deps: DepsMut, + env: Env, + info: MessageInfo, + spender: String, + amount: Uint128, + expires: Option, +) -> Result { + let spender_addr = deps.api.addr_validate(&spender)?; + if spender_addr == info.sender { + return Err(ContractError::CannotSetOwnAccount {}); + } + + let key = (&info.sender, &spender_addr); + + fn reverse<'a>(t: (&'a Addr, &'a Addr)) -> (&'a Addr, &'a Addr) { + (t.1, t.0) + } + + // load value and delete if it hits 0, or update otherwise + let mut allowance = ALLOWANCES.load(deps.storage, key)?; + if amount < allowance.allowance { + // update the new amount + allowance.allowance = allowance + .allowance + .checked_sub(amount) + .map_err(StdError::overflow)?; + if let Some(exp) = expires { + if exp.is_expired(&env.block) { + return Err(ContractError::InvalidExpiration {}); + } + allowance.expires = exp; + } + ALLOWANCES.save(deps.storage, key, &allowance)?; + ALLOWANCES_SPENDER.save(deps.storage, reverse(key), &allowance)?; + } else { + ALLOWANCES.remove(deps.storage, key); + ALLOWANCES_SPENDER.remove(deps.storage, reverse(key)); + } + + let res = Response::new().add_attributes(vec![ + attr("action", "decrease_allowance"), + attr("owner", info.sender), + attr("spender", spender), + attr("amount", amount), + ]); + Ok(res) +} + +// this can be used to update a lower allowance - call bucket.update with proper keys +pub fn deduct_allowance( + storage: &mut dyn Storage, + owner: &Addr, + spender: &Addr, + block: &BlockInfo, + amount: Uint128, +) -> Result { + let update_fn = |current: Option| -> _ { + match current { + Some(mut a) => { + if a.expires.is_expired(block) { + Err(ContractError::Expired {}) + } else { + // deduct the allowance if enough + a.allowance = a + .allowance + .checked_sub(amount) + .map_err(StdError::overflow)?; + Ok(a) + } + } + None => Err(ContractError::NoAllowance {}), + } + }; + ALLOWANCES.update(storage, (owner, spender), update_fn)?; + ALLOWANCES_SPENDER.update(storage, (spender, owner), update_fn) +} + +pub fn execute_transfer_from( + deps: DepsMut, + env: Env, + info: MessageInfo, + owner: String, + recipient: String, + amount: Uint128, +) -> Result { + let rcpt_addr = deps.api.addr_validate(&recipient)?; + let owner_addr = deps.api.addr_validate(&owner)?; + + // deduct allowance before doing anything else have enough allowance + deduct_allowance(deps.storage, &owner_addr, &info.sender, &env.block, amount)?; + let height = env.block.height; + + BALANCES.update( + deps.storage, + &owner_addr, + height, + |balance: Option| -> StdResult<_> { + Ok(balance.unwrap_or_default().checked_sub(amount)?) + }, + )?; + BALANCES.update( + deps.storage, + &rcpt_addr, + height, + |balance: Option| -> StdResult<_> { Ok(balance.unwrap_or_default() + amount) }, + )?; + + let res = Response::new().add_attributes(vec![ + attr("action", "transfer_from"), + attr("from", owner), + attr("to", recipient), + attr("by", info.sender), + attr("amount", amount), + ]); + Ok(res) +} + +pub fn execute_burn_from( + deps: DepsMut, + + env: Env, + info: MessageInfo, + owner: String, + amount: Uint128, +) -> Result { + let owner_addr = deps.api.addr_validate(&owner)?; + + // deduct allowance before doing anything else have enough allowance + deduct_allowance(deps.storage, &owner_addr, &info.sender, &env.block, amount)?; + + // lower balance + BALANCES.update( + deps.storage, + &owner_addr, + env.block.height, + |balance: Option| -> StdResult<_> { + Ok(balance.unwrap_or_default().checked_sub(amount)?) + }, + )?; + // reduce total_supply + TOKEN_INFO.update(deps.storage, |mut meta| -> StdResult<_> { + meta.total_supply = meta.total_supply.checked_sub(amount)?; + Ok(meta) + })?; + + let res = Response::new().add_attributes(vec![ + attr("action", "burn_from"), + attr("from", owner), + attr("by", info.sender), + attr("amount", amount), + ]); + Ok(res) +} + +pub fn execute_send_from( + deps: DepsMut, + env: Env, + info: MessageInfo, + owner: String, + contract: String, + amount: Uint128, + msg: Binary, +) -> Result { + let rcpt_addr = deps.api.addr_validate(&contract)?; + let owner_addr = deps.api.addr_validate(&owner)?; + + // deduct allowance before doing anything else have enough allowance + deduct_allowance(deps.storage, &owner_addr, &info.sender, &env.block, amount)?; + let height = env.block.height; + // move the tokens to the contract + BALANCES.update( + deps.storage, + &owner_addr, + height, + |balance: Option| -> StdResult<_> { + Ok(balance.unwrap_or_default().checked_sub(amount)?) + }, + )?; + BALANCES.update( + deps.storage, + &rcpt_addr, + height, + |balance: Option| -> StdResult<_> { Ok(balance.unwrap_or_default() + amount) }, + )?; + + let attrs = vec![ + attr("action", "send_from"), + attr("from", &owner), + attr("to", &contract), + attr("by", &info.sender), + attr("amount", amount), + ]; + + // create a send message + let msg = Cw20ReceiveMsg { + sender: info.sender.into(), + amount, + msg, + } + .into_cosmos_msg(contract)?; + + let res = Response::new().add_message(msg).add_attributes(attrs); + Ok(res) +} + +pub fn query_allowance(deps: Deps, owner: String, spender: String) -> StdResult { + let owner_addr = deps.api.addr_validate(&owner)?; + let spender_addr = deps.api.addr_validate(&spender)?; + let allowance = ALLOWANCES + .may_load(deps.storage, (&owner_addr, &spender_addr))? + .unwrap_or_default(); + Ok(allowance) +} + +#[cfg(test)] +mod tests { + use super::*; + + use cosmwasm_std::testing::{mock_dependencies_with_balance, mock_env, mock_info}; + use cosmwasm_std::{coins, CosmosMsg, SubMsg, Timestamp, WasmMsg}; + use cw20::{Cw20Coin, TokenInfoResponse}; + + use crate::contract::{execute, instantiate, query_balance, query_token_info}; + use crate::msg::{ExecuteMsg, InstantiateMsg}; + + fn get_balance>(deps: Deps, address: T, height: Option) -> Uint128 { + query_balance(deps, address.into(), height).unwrap().balance + } + + // this will set up the instantiation for other tests + fn do_instantiate>( + mut deps: DepsMut, + addr: T, + amount: Uint128, + ) -> TokenInfoResponse { + let instantiate_msg = InstantiateMsg { + name: "Auto Gen".to_string(), + symbol: "AUTO".to_string(), + decimals: 3, + initial_balances: vec![Cw20Coin { + address: addr.into(), + amount, + }], + mint: None, + marketing: None, + }; + let info = mock_info("creator", &[]); + let env = mock_env(); + instantiate(deps.branch(), env, info, instantiate_msg).unwrap(); + query_token_info(deps.as_ref()).unwrap() + } + + #[test] + fn increase_decrease_allowances() { + let mut deps = mock_dependencies_with_balance(&coins(2, "token")); + + let owner = deps.api.addr_make("addr0001").to_string(); + let spender = deps.api.addr_make("addr0002").to_string(); + let info = mock_info(owner.as_ref(), &[]); + let env = mock_env(); + do_instantiate(deps.as_mut(), owner.clone(), Uint128::new(12340000)); + + // no allowance to start + let allowance = query_allowance(deps.as_ref(), owner.clone(), spender.clone()).unwrap(); + assert_eq!(allowance, AllowanceResponse::default()); + + // set allowance with height expiration + let allow1 = Uint128::new(7777); + let expires = Expiration::AtHeight(123_456); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender.clone(), + amount: allow1, + expires: Some(expires), + }; + execute(deps.as_mut(), env.clone(), info.clone(), msg).unwrap(); + + // ensure it looks good + let allowance = query_allowance(deps.as_ref(), owner.clone(), spender.clone()).unwrap(); + assert_eq!( + allowance, + AllowanceResponse { + allowance: allow1, + expires + } + ); + + // decrease it a bit with no expire set - stays the same + let lower = Uint128::new(4444); + let allow2 = allow1.checked_sub(lower).unwrap(); + let msg = ExecuteMsg::DecreaseAllowance { + spender: spender.clone(), + amount: lower, + expires: None, + }; + execute(deps.as_mut(), env.clone(), info.clone(), msg).unwrap(); + let allowance = query_allowance(deps.as_ref(), owner.clone(), spender.clone()).unwrap(); + assert_eq!( + allowance, + AllowanceResponse { + allowance: allow2, + expires + } + ); + + // increase it some more and override the expires + let raise = Uint128::new(87654); + let allow3 = allow2 + raise; + let new_expire = Expiration::AtTime(Timestamp::from_seconds(8888888888)); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender.clone(), + amount: raise, + expires: Some(new_expire), + }; + execute(deps.as_mut(), env.clone(), info.clone(), msg).unwrap(); + let allowance = query_allowance(deps.as_ref(), owner.clone(), spender.clone()).unwrap(); + assert_eq!( + allowance, + AllowanceResponse { + allowance: allow3, + expires: new_expire + } + ); + + // decrease it below 0 + let msg = ExecuteMsg::DecreaseAllowance { + spender: spender.clone(), + amount: Uint128::new(99988647623876347), + expires: None, + }; + execute(deps.as_mut(), env, info, msg).unwrap(); + let allowance = query_allowance(deps.as_ref(), owner, spender).unwrap(); + assert_eq!(allowance, AllowanceResponse::default()); + } + + #[test] + fn allowances_independent() { + let mut deps = mock_dependencies_with_balance(&coins(2, "token")); + + let owner = deps.api.addr_make("addr0001").to_string(); + let spender = deps.api.addr_make("addr0002").to_string(); + let spender2 = deps.api.addr_make("addr0003").to_string(); + let info = mock_info(owner.as_ref(), &[]); + let env = mock_env(); + do_instantiate(deps.as_mut(), &owner, Uint128::new(12340000)); + + // no allowance to start + assert_eq!( + query_allowance(deps.as_ref(), owner.clone(), spender.clone()).unwrap(), + AllowanceResponse::default() + ); + assert_eq!( + query_allowance(deps.as_ref(), owner.clone(), spender2.clone()).unwrap(), + AllowanceResponse::default() + ); + assert_eq!( + query_allowance(deps.as_ref(), spender.clone(), spender2.clone()).unwrap(), + AllowanceResponse::default() + ); + + // set allowance with height expiration + let allow1 = Uint128::new(7777); + let expires = Expiration::AtHeight(123_456); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender.clone(), + amount: allow1, + expires: Some(expires), + }; + execute(deps.as_mut(), env.clone(), info.clone(), msg).unwrap(); + + // set other allowance with no expiration + let allow2 = Uint128::new(87654); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender2.clone(), + amount: allow2, + expires: None, + }; + execute(deps.as_mut(), env, info, msg).unwrap(); + + // check they are proper + let expect_one = AllowanceResponse { + allowance: allow1, + expires, + }; + let expect_two = AllowanceResponse { + allowance: allow2, + expires: Expiration::Never {}, + }; + assert_eq!( + query_allowance(deps.as_ref(), owner.clone(), spender.clone()).unwrap(), + expect_one + ); + assert_eq!( + query_allowance(deps.as_ref(), owner.clone(), spender2.clone()).unwrap(), + expect_two + ); + assert_eq!( + query_allowance(deps.as_ref(), spender.clone(), spender2.clone()).unwrap(), + AllowanceResponse::default() + ); + + // also allow spender -> spender2 with no interference + let info = mock_info(spender.as_ref(), &[]); + let env = mock_env(); + let allow3 = Uint128::new(1821); + let expires3 = Expiration::AtTime(Timestamp::from_seconds(3767626296)); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender2.clone(), + amount: allow3, + expires: Some(expires3), + }; + execute(deps.as_mut(), env, info, msg).unwrap(); + let expect_three = AllowanceResponse { + allowance: allow3, + expires: expires3, + }; + assert_eq!( + query_allowance(deps.as_ref(), owner.clone(), spender.clone()).unwrap(), + expect_one + ); + assert_eq!( + query_allowance(deps.as_ref(), owner, spender2.clone()).unwrap(), + expect_two + ); + assert_eq!( + query_allowance(deps.as_ref(), spender, spender2).unwrap(), + expect_three + ); + } + + #[test] + fn no_self_allowance() { + let mut deps = mock_dependencies_with_balance(&coins(2, "token")); + + let owner = deps.api.addr_make("addr0001").to_string(); + let info = mock_info(owner.as_ref(), &[]); + let env = mock_env(); + do_instantiate(deps.as_mut(), &owner, Uint128::new(12340000)); + + // self-allowance + let msg = ExecuteMsg::IncreaseAllowance { + spender: owner.clone(), + amount: Uint128::new(7777), + expires: None, + }; + let err = execute(deps.as_mut(), env.clone(), info.clone(), msg).unwrap_err(); + assert_eq!(err, ContractError::CannotSetOwnAccount {}); + + // decrease self-allowance + let msg = ExecuteMsg::DecreaseAllowance { + spender: owner, + amount: Uint128::new(7777), + expires: None, + }; + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert_eq!(err, ContractError::CannotSetOwnAccount {}); + } + + #[test] + fn transfer_from_respects_limits() { + let mut deps = mock_dependencies_with_balance(&[]); + let owner = deps.api.addr_make("addr0001").to_string(); + let spender = deps.api.addr_make("addr0002").to_string(); + let rcpt = deps.api.addr_make("addr0003").to_string(); + + let start = Uint128::new(999999); + do_instantiate(deps.as_mut(), &owner, start); + + // provide an allowance + let allow1 = Uint128::new(77777); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender.clone(), + amount: allow1, + expires: None, + }; + let info = mock_info(owner.as_ref(), &[]); + let env = mock_env(); + execute(deps.as_mut(), env, info, msg).unwrap(); + + // valid transfer of part of the allowance + let transfer = Uint128::new(44444); + let msg = ExecuteMsg::TransferFrom { + owner: owner.clone(), + recipient: rcpt.clone(), + amount: transfer, + }; + let info = mock_info(spender.as_ref(), &[]); + let env = mock_env(); + let res = execute(deps.as_mut(), env, info, msg).unwrap(); + assert_eq!(res.attributes[0], attr("action", "transfer_from")); + + // make sure money arrived + assert_eq!( + get_balance(deps.as_ref(), owner.clone(), None), + start.checked_sub(transfer).unwrap() + ); + assert_eq!(get_balance(deps.as_ref(), rcpt.clone(), None), transfer); + + // ensure it looks good + let allowance = query_allowance(deps.as_ref(), owner.clone(), spender.clone()).unwrap(); + let expect = AllowanceResponse { + allowance: allow1.checked_sub(transfer).unwrap(), + expires: Expiration::Never {}, + }; + assert_eq!(expect, allowance); + + // cannot send more than the allowance + let msg = ExecuteMsg::TransferFrom { + owner: owner.clone(), + recipient: rcpt.clone(), + amount: Uint128::new(33443), + }; + let info = mock_info(spender.as_ref(), &[]); + let env = mock_env(); + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert!(matches!(err, ContractError::Std(StdError::Overflow { .. }))); + + // let us increase limit, but set the expiration to expire in the next block + let info = mock_info(owner.as_ref(), &[]); + let mut env = mock_env(); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender.clone(), + amount: Uint128::new(1000), + expires: Some(Expiration::AtHeight(env.block.height + 1)), + }; + execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + env.block.height += 1; + + // we should now get the expiration error + let msg = ExecuteMsg::TransferFrom { + owner, + recipient: rcpt, + amount: Uint128::new(33443), + }; + let info = mock_info(spender.as_ref(), &[]); + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert_eq!(err, ContractError::Expired {}); + } + + #[test] + fn burn_from_respects_limits() { + let mut deps = mock_dependencies_with_balance(&[]); + let owner = deps.api.addr_make("addr0001").to_string(); + let spender = deps.api.addr_make("addr0002").to_string(); + + let start = Uint128::new(999999); + do_instantiate(deps.as_mut(), &owner, start); + + // provide an allowance + let allow1 = Uint128::new(77777); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender.clone(), + amount: allow1, + expires: None, + }; + let info = mock_info(owner.as_ref(), &[]); + let env = mock_env(); + execute(deps.as_mut(), env, info, msg).unwrap(); + + // valid burn of part of the allowance + let transfer = Uint128::new(44444); + let msg = ExecuteMsg::BurnFrom { + owner: owner.clone(), + amount: transfer, + }; + let info = mock_info(spender.as_ref(), &[]); + let env = mock_env(); + let res = execute(deps.as_mut(), env, info, msg).unwrap(); + assert_eq!(res.attributes[0], attr("action", "burn_from")); + + // make sure money burnt + assert_eq!( + get_balance(deps.as_ref(), owner.clone(), None), + start.checked_sub(transfer).unwrap() + ); + + // ensure it looks good + let allowance = query_allowance(deps.as_ref(), owner.clone(), spender.clone()).unwrap(); + let expect = AllowanceResponse { + allowance: allow1.checked_sub(transfer).unwrap(), + expires: Expiration::Never {}, + }; + assert_eq!(expect, allowance); + + // cannot burn more than the allowance + let msg = ExecuteMsg::BurnFrom { + owner: owner.clone(), + amount: Uint128::new(33443), + }; + let info = mock_info(spender.as_ref(), &[]); + let env = mock_env(); + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert!(matches!(err, ContractError::Std(StdError::Overflow { .. }))); + + // let us increase limit, but set the expiration to expire in the next block + let info = mock_info(owner.as_ref(), &[]); + let mut env = mock_env(); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender.clone(), + amount: Uint128::new(1000), + expires: Some(Expiration::AtHeight(env.block.height + 1)), + }; + execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // increase block height, so the limit is expired now + env.block.height += 1; + + // we should now get the expiration error + let msg = ExecuteMsg::BurnFrom { + owner, + amount: Uint128::new(33443), + }; + let info = mock_info(spender.as_ref(), &[]); + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert_eq!(err, ContractError::Expired {}); + } + + #[test] + fn send_from_respects_limits() { + let mut deps = mock_dependencies_with_balance(&[]); + let owner = deps.api.addr_make("addr0001").to_string(); + let spender = deps.api.addr_make("addr0002").to_string(); + let contract = deps.api.addr_make("addr0003").to_string(); + let send_msg = Binary::from(r#"{"some":123}"#.as_bytes()); + + let start = Uint128::new(999999); + do_instantiate(deps.as_mut(), &owner, start); + + // provide an allowance + let allow1 = Uint128::new(77777); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender.clone(), + amount: allow1, + expires: None, + }; + let info = mock_info(owner.as_ref(), &[]); + let env = mock_env(); + execute(deps.as_mut(), env, info, msg).unwrap(); + + // valid send of part of the allowance + let transfer = Uint128::new(44444); + let msg = ExecuteMsg::SendFrom { + owner: owner.clone(), + amount: transfer, + contract: contract.clone(), + msg: send_msg.clone(), + }; + let info = mock_info(spender.as_ref(), &[]); + let env = mock_env(); + let res = execute(deps.as_mut(), env, info, msg).unwrap(); + assert_eq!(res.attributes[0], attr("action", "send_from")); + assert_eq!(1, res.messages.len()); + + // we record this as sent by the one who requested, not the one who was paying + let binary_msg = Cw20ReceiveMsg { + sender: spender.clone(), + amount: transfer, + msg: send_msg.clone(), + } + .into_json_binary() + .unwrap(); + assert_eq!( + res.messages[0], + SubMsg::new(CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: contract.clone(), + msg: binary_msg, + funds: vec![], + })) + ); + + // make sure money sent + assert_eq!( + get_balance(deps.as_ref(), owner.clone(), None), + start.checked_sub(transfer).unwrap() + ); + assert_eq!(get_balance(deps.as_ref(), contract.clone(), None), transfer); + + // ensure it looks good + let allowance = query_allowance(deps.as_ref(), owner.clone(), spender.clone()).unwrap(); + let expect = AllowanceResponse { + allowance: allow1.checked_sub(transfer).unwrap(), + expires: Expiration::Never {}, + }; + assert_eq!(expect, allowance); + + // cannot send more than the allowance + let msg = ExecuteMsg::SendFrom { + owner: owner.clone(), + amount: Uint128::new(33443), + contract: contract.clone(), + msg: send_msg.clone(), + }; + let info = mock_info(spender.as_ref(), &[]); + let env = mock_env(); + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert!(matches!(err, ContractError::Std(StdError::Overflow { .. }))); + + // let us increase limit, but set the expiration to the next block + let info = mock_info(owner.as_ref(), &[]); + let mut env = mock_env(); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender.clone(), + amount: Uint128::new(1000), + expires: Some(Expiration::AtHeight(env.block.height + 1)), + }; + execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // increase block height, so the limit is expired now + env.block.height += 1; + + // we should now get the expiration error + let msg = ExecuteMsg::SendFrom { + owner, + amount: Uint128::new(33443), + contract, + msg: send_msg, + }; + let info = mock_info(spender.as_ref(), &[]); + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert_eq!(err, ContractError::Expired {}); + } + + #[test] + fn no_past_expiration() { + let mut deps = mock_dependencies_with_balance(&coins(2, "token")); + + let owner = deps.api.addr_make("addr0001").to_string(); + let spender = deps.api.addr_make("addr0002").to_string(); + let info = mock_info(owner.as_ref(), &[]); + let env = mock_env(); + do_instantiate(deps.as_mut(), owner.clone(), Uint128::new(12340000)); + + // set allowance with height expiration at current block height + let expires = Expiration::AtHeight(env.block.height); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender.clone(), + amount: Uint128::new(7777), + expires: Some(expires), + }; + + // ensure it is rejected + assert_eq!( + Err(ContractError::InvalidExpiration {}), + execute(deps.as_mut(), env.clone(), info.clone(), msg) + ); + + // set allowance with time expiration in the past + let expires = Expiration::AtTime(env.block.time.minus_seconds(1)); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender.clone(), + amount: Uint128::new(7777), + expires: Some(expires), + }; + + // ensure it is rejected + assert_eq!( + Err(ContractError::InvalidExpiration {}), + execute(deps.as_mut(), env.clone(), info.clone(), msg) + ); + + // set allowance with height expiration at next block height + let expires = Expiration::AtHeight(env.block.height + 1); + let allow = Uint128::new(7777); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender.clone(), + amount: allow, + expires: Some(expires), + }; + + execute(deps.as_mut(), env.clone(), info.clone(), msg).unwrap(); + + // ensure it looks good + let allowance = query_allowance(deps.as_ref(), owner.clone(), spender.clone()).unwrap(); + assert_eq!( + allowance, + AllowanceResponse { + allowance: allow, + expires + } + ); + + // set allowance with time expiration in the future + let expires = Expiration::AtTime(env.block.time.plus_seconds(10)); + let allow = Uint128::new(7777); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender.clone(), + amount: allow, + expires: Some(expires), + }; + + execute(deps.as_mut(), env.clone(), info.clone(), msg).unwrap(); + + // ensure it looks good + let allowance = query_allowance(deps.as_ref(), owner.clone(), spender.clone()).unwrap(); + assert_eq!( + allowance, + AllowanceResponse { + allowance: allow + allow, // we increased twice + expires + } + ); + + // decrease with height expiration at current block height + let expires = Expiration::AtHeight(env.block.height); + let allow = Uint128::new(7777); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender.clone(), + amount: allow, + expires: Some(expires), + }; + + // ensure it is rejected + assert_eq!( + Err(ContractError::InvalidExpiration {}), + execute(deps.as_mut(), env.clone(), info.clone(), msg) + ); + + // decrease with height expiration at next block height + let expires = Expiration::AtHeight(env.block.height + 1); + let allow = Uint128::new(7777); + let msg = ExecuteMsg::DecreaseAllowance { + spender: spender.clone(), + amount: allow, + expires: Some(expires), + }; + + execute(deps.as_mut(), env, info, msg).unwrap(); + + // ensure it looks good + let allowance = query_allowance(deps.as_ref(), owner, spender).unwrap(); + assert_eq!( + allowance, + AllowanceResponse { + allowance: allow, + expires + } + ); + } +} diff --git a/contracts/cw20-snapshot/src/bin/schema.rs b/contracts/cw20-snapshot/src/bin/schema.rs new file mode 100644 index 000000000..d66c2943b --- /dev/null +++ b/contracts/cw20-snapshot/src/bin/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; + +use cw20_snapshot::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + execute: ExecuteMsg, + query: QueryMsg, + } +} diff --git a/contracts/cw20-snapshot/src/contract.rs b/contracts/cw20-snapshot/src/contract.rs new file mode 100644 index 000000000..a341ad795 --- /dev/null +++ b/contracts/cw20-snapshot/src/contract.rs @@ -0,0 +1,2393 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::Order::Ascending; +use cosmwasm_std::{ + to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdError, StdResult, Uint128, +}; + +use cw2::{ensure_from_older_version, set_contract_version}; +use cw20::{ + BalanceResponse, Cw20Coin, Cw20ReceiveMsg, DownloadLogoResponse, EmbeddedLogo, Logo, LogoInfo, + MarketingInfoResponse, MinterResponse, TokenInfoResponse, +}; + +use crate::allowances::{ + execute_burn_from, execute_decrease_allowance, execute_increase_allowance, execute_send_from, + execute_transfer_from, query_allowance, +}; +use crate::enumerable::{query_all_accounts, query_owner_allowances, query_spender_allowances}; +use crate::error::ContractError; +use crate::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; +use crate::state::{ + MinterData, TokenInfo, ALLOWANCES, ALLOWANCES_SPENDER, BALANCES, LOGO, MARKETING_INFO, + TOKEN_INFO, +}; + +// version info for migration info +const CONTRACT_NAME: &str = "crates.io:cw20-base"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +const LOGO_SIZE_CAP: usize = 5 * 1024; + +/// Checks if data starts with XML preamble +fn verify_xml_preamble(data: &[u8]) -> Result<(), ContractError> { + // The easiest way to perform this check would be just match on regex, however regex + // compilation is heavy and probably not worth it. + + let preamble = data + .split_inclusive(|c| *c == b'>') + .next() + .ok_or(ContractError::InvalidXmlPreamble {})?; + + const PREFIX: &[u8] = b""; + + if !(preamble.starts_with(PREFIX) && preamble.ends_with(POSTFIX)) { + Err(ContractError::InvalidXmlPreamble {}) + } else { + Ok(()) + } + + // Additionally attributes format could be validated as they are well defined, as well as + // comments presence inside of preable, but it is probably not worth it. +} + +/// Validates XML logo +fn verify_xml_logo(logo: &[u8]) -> Result<(), ContractError> { + verify_xml_preamble(logo)?; + + if logo.len() > LOGO_SIZE_CAP { + Err(ContractError::LogoTooBig {}) + } else { + Ok(()) + } +} + +/// Validates png logo +fn verify_png_logo(logo: &[u8]) -> Result<(), ContractError> { + // PNG header format: + // 0x89 - magic byte, out of ASCII table to fail on 7-bit systems + // "PNG" ascii representation + // [0x0d, 0x0a] - dos style line ending + // 0x1a - dos control character, stop displaying rest of the file + // 0x0a - unix style line ending + const HEADER: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]; + if logo.len() > LOGO_SIZE_CAP { + Err(ContractError::LogoTooBig {}) + } else if !logo.starts_with(&HEADER) { + Err(ContractError::InvalidPngHeader {}) + } else { + Ok(()) + } +} + +/// Checks if passed logo is correct, and if not, returns an error +fn verify_logo(logo: &Logo) -> Result<(), ContractError> { + match logo { + Logo::Embedded(EmbeddedLogo::Svg(logo)) => verify_xml_logo(logo), + Logo::Embedded(EmbeddedLogo::Png(logo)) => verify_png_logo(logo), + Logo::Url(_) => Ok(()), // Any reasonable url validation would be regex based, probably not worth it + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + mut deps: DepsMut, + env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + // check valid token info + msg.validate()?; + // create initial accounts + let total_supply = create_accounts(&mut deps, &msg.initial_balances, env.block.height)?; + + if let Some(limit) = msg.get_cap() { + if total_supply > limit { + return Err(StdError::generic_err("Initial supply greater than cap").into()); + } + } + + let mint = match msg.mint { + Some(m) => Some(MinterData { + minter: deps.api.addr_validate(&m.minter)?, + cap: m.cap, + }), + None => None, + }; + + // store token info + let data = TokenInfo { + name: msg.name, + symbol: msg.symbol, + decimals: msg.decimals, + total_supply, + mint, + }; + TOKEN_INFO.save(deps.storage, &data)?; + + if let Some(marketing) = msg.marketing { + let logo = if let Some(logo) = marketing.logo { + verify_logo(&logo)?; + LOGO.save(deps.storage, &logo)?; + + match logo { + Logo::Url(url) => Some(LogoInfo::Url(url)), + Logo::Embedded(_) => Some(LogoInfo::Embedded), + } + } else { + None + }; + + let data = MarketingInfoResponse { + project: marketing.project, + description: marketing.description, + marketing: marketing + .marketing + .map(|addr| deps.api.addr_validate(&addr)) + .transpose()?, + logo, + }; + MARKETING_INFO.save(deps.storage, &data)?; + } + + Ok(Response::default()) +} + +pub fn create_accounts( + deps: &mut DepsMut, + accounts: &[Cw20Coin], + height: u64, +) -> Result { + validate_accounts(accounts)?; + + let mut total_supply = Uint128::zero(); + for row in accounts { + let address = deps.api.addr_validate(&row.address)?; + BALANCES.save(deps.storage, &address, &row.amount, height)?; + total_supply += row.amount; + } + + Ok(total_supply) +} + +pub fn validate_accounts(accounts: &[Cw20Coin]) -> Result<(), ContractError> { + let mut addresses = accounts.iter().map(|c| &c.address).collect::>(); + addresses.sort(); + addresses.dedup(); + + if addresses.len() != accounts.len() { + Err(ContractError::DuplicateInitialBalanceAddresses {}) + } else { + Ok(()) + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Transfer { recipient, amount } => { + execute_transfer(deps, env, info, recipient, amount) + } + ExecuteMsg::Burn { amount } => execute_burn(deps, env, info, amount), + ExecuteMsg::Send { + contract, + amount, + msg, + } => execute_send(deps, env, info, contract, amount, msg), + ExecuteMsg::Mint { recipient, amount } => execute_mint(deps, env, info, recipient, amount), + ExecuteMsg::IncreaseAllowance { + spender, + amount, + expires, + } => execute_increase_allowance(deps, env, info, spender, amount, expires), + ExecuteMsg::DecreaseAllowance { + spender, + amount, + expires, + } => execute_decrease_allowance(deps, env, info, spender, amount, expires), + ExecuteMsg::TransferFrom { + owner, + recipient, + amount, + } => execute_transfer_from(deps, env, info, owner, recipient, amount), + ExecuteMsg::BurnFrom { owner, amount } => execute_burn_from(deps, env, info, owner, amount), + ExecuteMsg::SendFrom { + owner, + contract, + amount, + msg, + } => execute_send_from(deps, env, info, owner, contract, amount, msg), + ExecuteMsg::UpdateMarketing { + project, + description, + marketing, + } => execute_update_marketing(deps, env, info, project, description, marketing), + ExecuteMsg::UploadLogo(logo) => execute_upload_logo(deps, env, info, logo), + ExecuteMsg::UpdateMinter { new_minter } => { + execute_update_minter(deps, env, info, new_minter) + } + } +} + +pub fn execute_transfer( + deps: DepsMut, + env: Env, + info: MessageInfo, + recipient: String, + amount: Uint128, +) -> Result { + let rcpt_addr = deps.api.addr_validate(&recipient)?; + let height = env.block.height; + BALANCES.update( + deps.storage, + &info.sender, + height, + |balance: Option| -> StdResult<_> { + Ok(balance.unwrap_or_default().checked_sub(amount)?) + }, + )?; + BALANCES.update( + deps.storage, + &rcpt_addr, + height, + |balance: Option| -> StdResult<_> { Ok(balance.unwrap_or_default() + amount) }, + )?; + + let res = Response::new() + .add_attribute("action", "transfer") + .add_attribute("from", info.sender) + .add_attribute("to", recipient) + .add_attribute("amount", amount); + Ok(res) +} + +pub fn execute_burn( + deps: DepsMut, + env: Env, + info: MessageInfo, + amount: Uint128, +) -> Result { + let height = env.block.height; + // lower balance + BALANCES.update( + deps.storage, + &info.sender, + height, + |balance: Option| -> StdResult<_> { + Ok(balance.unwrap_or_default().checked_sub(amount)?) + }, + )?; + // reduce total_supply + TOKEN_INFO.update(deps.storage, |mut info| -> StdResult<_> { + info.total_supply = info.total_supply.checked_sub(amount)?; + Ok(info) + })?; + + let res = Response::new() + .add_attribute("action", "burn") + .add_attribute("from", info.sender) + .add_attribute("amount", amount); + Ok(res) +} + +pub fn execute_mint( + deps: DepsMut, + env: Env, + info: MessageInfo, + recipient: String, + amount: Uint128, +) -> Result { + let mut config = TOKEN_INFO + .may_load(deps.storage)? + .ok_or(ContractError::Unauthorized {})?; + + if config + .mint + .as_ref() + .ok_or(ContractError::Unauthorized {})? + .minter + != info.sender + { + return Err(ContractError::Unauthorized {}); + } + + // update supply and enforce cap + config.total_supply += amount; + if let Some(limit) = config.get_cap() { + if config.total_supply > limit { + return Err(ContractError::CannotExceedCap {}); + } + } + TOKEN_INFO.save(deps.storage, &config)?; + + // add amount to recipient balance + let rcpt_addr = deps.api.addr_validate(&recipient)?; + BALANCES.update( + deps.storage, + &rcpt_addr, + env.block.height, + |balance: Option| -> StdResult<_> { Ok(balance.unwrap_or_default() + amount) }, + )?; + + let res = Response::new() + .add_attribute("action", "mint") + .add_attribute("to", recipient) + .add_attribute("amount", amount); + Ok(res) +} + +pub fn execute_send( + deps: DepsMut, + env: Env, + info: MessageInfo, + contract: String, + amount: Uint128, + msg: Binary, +) -> Result { + let rcpt_addr = deps.api.addr_validate(&contract)?; + let height = env.block.height; + + // move the tokens to the contract + BALANCES.update( + deps.storage, + &info.sender, + height, + |balance: Option| -> StdResult<_> { + Ok(balance.unwrap_or_default().checked_sub(amount)?) + }, + )?; + BALANCES.update( + deps.storage, + &rcpt_addr, + height, + |balance: Option| -> StdResult<_> { Ok(balance.unwrap_or_default() + amount) }, + )?; + + let res = Response::new() + .add_attribute("action", "send") + .add_attribute("from", &info.sender) + .add_attribute("to", &contract) + .add_attribute("amount", amount) + .add_message( + Cw20ReceiveMsg { + sender: info.sender.into(), + amount, + msg, + } + .into_cosmos_msg(contract)?, + ); + Ok(res) +} + +pub fn execute_update_minter( + deps: DepsMut, + _env: Env, + info: MessageInfo, + new_minter: Option, +) -> Result { + let mut config = TOKEN_INFO + .may_load(deps.storage)? + .ok_or(ContractError::Unauthorized {})?; + + let mint = config.mint.as_ref().ok_or(ContractError::Unauthorized {})?; + if mint.minter != info.sender { + return Err(ContractError::Unauthorized {}); + } + + let minter_data = new_minter + .map(|new_minter| deps.api.addr_validate(&new_minter)) + .transpose()? + .map(|minter| MinterData { + minter, + cap: mint.cap, + }); + + config.mint = minter_data; + + TOKEN_INFO.save(deps.storage, &config)?; + + Ok(Response::default() + .add_attribute("action", "update_minter") + .add_attribute( + "new_minter", + config + .mint + .map(|m| m.minter.into_string()) + .unwrap_or_else(|| "None".to_string()), + )) +} + +pub fn execute_update_marketing( + deps: DepsMut, + _env: Env, + info: MessageInfo, + project: Option, + description: Option, + marketing: Option, +) -> Result { + let mut marketing_info = MARKETING_INFO + .may_load(deps.storage)? + .ok_or(ContractError::Unauthorized {})?; + + if marketing_info + .marketing + .as_ref() + .ok_or(ContractError::Unauthorized {})? + != info.sender + { + return Err(ContractError::Unauthorized {}); + } + + match project { + Some(empty) if empty.trim().is_empty() => marketing_info.project = None, + Some(project) => marketing_info.project = Some(project), + None => (), + } + + match description { + Some(empty) if empty.trim().is_empty() => marketing_info.description = None, + Some(description) => marketing_info.description = Some(description), + None => (), + } + + match marketing { + Some(empty) if empty.trim().is_empty() => marketing_info.marketing = None, + Some(marketing) => marketing_info.marketing = Some(deps.api.addr_validate(&marketing)?), + None => (), + } + + if marketing_info.project.is_none() + && marketing_info.description.is_none() + && marketing_info.marketing.is_none() + && marketing_info.logo.is_none() + { + MARKETING_INFO.remove(deps.storage); + } else { + MARKETING_INFO.save(deps.storage, &marketing_info)?; + } + + let res = Response::new().add_attribute("action", "update_marketing"); + Ok(res) +} + +pub fn execute_upload_logo( + deps: DepsMut, + _env: Env, + info: MessageInfo, + logo: Logo, +) -> Result { + let mut marketing_info = MARKETING_INFO + .may_load(deps.storage)? + .ok_or(ContractError::Unauthorized {})?; + + verify_logo(&logo)?; + + if marketing_info + .marketing + .as_ref() + .ok_or(ContractError::Unauthorized {})? + != info.sender + { + return Err(ContractError::Unauthorized {}); + } + + LOGO.save(deps.storage, &logo)?; + + let logo_info = match logo { + Logo::Url(url) => LogoInfo::Url(url), + Logo::Embedded(_) => LogoInfo::Embedded, + }; + + marketing_info.logo = Some(logo_info); + MARKETING_INFO.save(deps.storage, &marketing_info)?; + + let res = Response::new().add_attribute("action", "upload_logo"); + Ok(res) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Balance { address, height } => { + to_json_binary(&query_balance(deps, address, height)?) + } + QueryMsg::TokenInfo {} => to_json_binary(&query_token_info(deps)?), + QueryMsg::Minter {} => to_json_binary(&query_minter(deps)?), + QueryMsg::Allowance { owner, spender } => { + to_json_binary(&query_allowance(deps, owner, spender)?) + } + QueryMsg::AllAllowances { + owner, + start_after, + limit, + } => to_json_binary(&query_owner_allowances(deps, owner, start_after, limit)?), + QueryMsg::AllSpenderAllowances { + spender, + start_after, + limit, + } => to_json_binary(&query_spender_allowances( + deps, + spender, + start_after, + limit, + )?), + QueryMsg::AllAccounts { start_after, limit } => { + to_json_binary(&query_all_accounts(deps, start_after, limit)?) + } + QueryMsg::MarketingInfo {} => to_json_binary(&query_marketing_info(deps)?), + QueryMsg::DownloadLogo {} => to_json_binary(&query_download_logo(deps)?), + } +} + +pub fn query_balance( + deps: Deps, + address: String, + height: Option, +) -> StdResult { + let address = deps.api.addr_validate(&address)?; + let balance = match height { + Some(h) => BALANCES.may_load_at_height(deps.storage, &address, h)?, + None => BALANCES.may_load(deps.storage, &address)?, + } + .unwrap_or_default(); + Ok(BalanceResponse { balance }) +} + +pub fn query_token_info(deps: Deps) -> StdResult { + let info = TOKEN_INFO.load(deps.storage)?; + let res = TokenInfoResponse { + name: info.name, + symbol: info.symbol, + decimals: info.decimals, + total_supply: info.total_supply, + }; + Ok(res) +} + +pub fn query_minter(deps: Deps) -> StdResult> { + let meta = TOKEN_INFO.load(deps.storage)?; + let minter = match meta.mint { + Some(m) => Some(MinterResponse { + minter: m.minter.into(), + cap: m.cap, + }), + None => None, + }; + Ok(minter) +} + +pub fn query_marketing_info(deps: Deps) -> StdResult { + Ok(MARKETING_INFO.may_load(deps.storage)?.unwrap_or_default()) +} + +pub fn query_download_logo(deps: Deps) -> StdResult { + let logo = LOGO.load(deps.storage)?; + match logo { + Logo::Embedded(EmbeddedLogo::Svg(logo)) => Ok(DownloadLogoResponse { + mime_type: "image/svg+xml".to_owned(), + data: logo, + }), + Logo::Embedded(EmbeddedLogo::Png(logo)) => Ok(DownloadLogoResponse { + mime_type: "image/png".to_owned(), + data: logo, + }), + Logo::Url(_) => Err(StdError::not_found("logo")), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + let original_version = + ensure_from_older_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + if original_version < "0.14.0".parse::().unwrap() { + // Build reverse map of allowances per spender + let data = ALLOWANCES + .range(deps.storage, None, None, Ascending) + .collect::>>()?; + for ((owner, spender), allowance) in data { + ALLOWANCES_SPENDER.save(deps.storage, (&spender, &owner), &allowance)?; + } + } + Ok(Response::default()) +} + +#[cfg(test)] +mod tests { + use cosmwasm_std::testing::{ + mock_dependencies, mock_dependencies_with_balance, mock_env, mock_info, + }; + use cosmwasm_std::{coins, from_json, Addr, CosmosMsg, StdError, SubMsg, WasmMsg}; + + use super::*; + use crate::msg::InstantiateMarketingInfo; + + fn get_balance>(deps: Deps, address: T, height: Option) -> Uint128 { + query_balance(deps, address.into(), height).unwrap().balance + } + + // this will set up the instantiation for other tests + fn do_instantiate_with_minter( + deps: DepsMut, + addr: &str, + amount: Uint128, + minter: &str, + cap: Option, + ) -> TokenInfoResponse { + _do_instantiate( + deps, + addr, + amount, + Some(MinterResponse { + minter: minter.to_string(), + cap, + }), + ) + } + + // this will set up the instantiation for other tests + fn do_instantiate(deps: DepsMut, addr: &str, amount: Uint128) -> TokenInfoResponse { + _do_instantiate(deps, addr, amount, None) + } + + // this will set up the instantiation for other tests + fn _do_instantiate( + mut deps: DepsMut, + addr: &str, + amount: Uint128, + mint: Option, + ) -> TokenInfoResponse { + let instantiate_msg = InstantiateMsg { + name: "Auto Gen".to_string(), + symbol: "AUTO".to_string(), + decimals: 3, + initial_balances: vec![Cw20Coin { + address: addr.to_string(), + amount, + }], + mint: mint.clone(), + marketing: None, + }; + let info = mock_info("creator", &[]); + let env = mock_env(); + let res = instantiate(deps.branch(), env, info, instantiate_msg).unwrap(); + assert_eq!(0, res.messages.len()); + + let meta = query_token_info(deps.as_ref()).unwrap(); + assert_eq!( + meta, + TokenInfoResponse { + name: "Auto Gen".to_string(), + symbol: "AUTO".to_string(), + decimals: 3, + total_supply: amount, + } + ); + assert_eq!(get_balance(deps.as_ref(), addr, None), amount); + assert_eq!(query_minter(deps.as_ref()).unwrap(), mint,); + meta + } + + const PNG_HEADER: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]; + + mod instantiate { + use super::*; + + #[test] + fn basic() { + let mut deps = mock_dependencies(); + let addr = deps.api.addr_make("addr0000"); + let amount = Uint128::from(11223344u128); + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![Cw20Coin { + address: addr.to_string(), + amount, + }], + mint: None, + marketing: None, + }; + let info = mock_info("creator", &[]); + let env = mock_env(); + let res = instantiate(deps.as_mut(), env, info, instantiate_msg).unwrap(); + assert_eq!(0, res.messages.len()); + + assert_eq!( + query_token_info(deps.as_ref()).unwrap(), + TokenInfoResponse { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + total_supply: amount, + } + ); + assert_eq!( + get_balance(deps.as_ref(), addr, None), + Uint128::new(11223344) + ); + } + + #[test] + fn mintable() { + let mut deps = mock_dependencies(); + let addr = deps.api.addr_make("addr0000"); + let amount = Uint128::new(11223344); + let minter = deps.api.addr_make("asmodat").to_string(); + let limit = Uint128::new(511223344); + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![Cw20Coin { + address: addr.to_string(), + amount, + }], + mint: Some(MinterResponse { + minter: minter.clone(), + cap: Some(limit), + }), + marketing: None, + }; + let info = mock_info("creator", &[]); + let env = mock_env(); + let res = instantiate(deps.as_mut(), env, info, instantiate_msg).unwrap(); + assert_eq!(0, res.messages.len()); + + assert_eq!( + query_token_info(deps.as_ref()).unwrap(), + TokenInfoResponse { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + total_supply: amount, + } + ); + assert_eq!(get_balance(deps.as_ref(), addr, None), amount); + assert_eq!( + query_minter(deps.as_ref()).unwrap(), + Some(MinterResponse { + minter, + cap: Some(limit), + }), + ); + } + + #[test] + fn mintable_over_cap() { + let mut deps = mock_dependencies(); + let amount = Uint128::new(11223344); + let minter = deps.api.addr_make("asmodat"); + let addr = deps.api.addr_make("addr0000"); + let limit = Uint128::new(11223300); + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![Cw20Coin { + address: addr.to_string(), + amount, + }], + mint: Some(MinterResponse { + minter: minter.to_string(), + cap: Some(limit), + }), + marketing: None, + }; + let info = mock_info("creator", &[]); + let env = mock_env(); + let err = instantiate(deps.as_mut(), env, info, instantiate_msg).unwrap_err(); + assert_eq!( + err, + StdError::generic_err("Initial supply greater than cap").into() + ); + } + + mod marketing { + use super::*; + + #[test] + fn basic() { + let mut deps = mock_dependencies(); + + let marketing = deps.api.addr_make("marketing"); + + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(marketing.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info("creator", &[]); + let env = mock_env(); + let res = instantiate(deps.as_mut(), env, info, instantiate_msg).unwrap(); + assert_eq!(0, res.messages.len()); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(marketing), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn invalid_marketing() { + let mut deps = mock_dependencies(); + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some("m".to_owned()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info("creator", &[]); + let env = mock_env(); + instantiate(deps.as_mut(), env, info, instantiate_msg).unwrap_err(); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + } + } + + #[test] + fn can_mint_by_minter() { + let mut deps = mock_dependencies(); + + let genesis = deps.api.addr_make("genesis").to_string(); + let amount = Uint128::new(11223344); + let minter = deps.api.addr_make("asmodat").to_string(); + let limit = Uint128::new(511223344); + do_instantiate_with_minter(deps.as_mut(), &genesis, amount, &minter, Some(limit)); + + // minter can mint coins to some winner + let winner = deps.api.addr_make("winner").to_string(); + let prize = Uint128::new(222_222_222); + let msg = ExecuteMsg::Mint { + recipient: winner.clone(), + amount: prize, + }; + + let info = mock_info(minter.as_ref(), &[]); + let env = mock_env(); + let res = execute(deps.as_mut(), env, info, msg).unwrap(); + assert_eq!(0, res.messages.len()); + assert_eq!(get_balance(deps.as_ref(), genesis, None), amount); + assert_eq!(get_balance(deps.as_ref(), winner.clone(), None), prize); + + // Allows minting 0 + let msg = ExecuteMsg::Mint { + recipient: winner.clone(), + amount: Uint128::zero(), + }; + let info = mock_info(minter.as_ref(), &[]); + let env = mock_env(); + execute(deps.as_mut(), env, info, msg).unwrap(); + + // but if it exceeds cap (even over multiple rounds), it fails + // cap is enforced + let msg = ExecuteMsg::Mint { + recipient: winner, + amount: Uint128::new(333_222_222), + }; + let info = mock_info(minter.as_ref(), &[]); + let env = mock_env(); + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert_eq!(err, ContractError::CannotExceedCap {}); + } + + #[test] + fn others_cannot_mint() { + let mut deps = mock_dependencies(); + + let genesis = deps.api.addr_make("genesis").to_string(); + let minter = deps.api.addr_make("minter").to_string(); + let winner = deps.api.addr_make("winner").to_string(); + + do_instantiate_with_minter(deps.as_mut(), &genesis, Uint128::new(1234), &minter, None); + + let msg = ExecuteMsg::Mint { + recipient: winner, + amount: Uint128::new(222), + }; + let info = mock_info("anyone else", &[]); + let env = mock_env(); + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert_eq!(err, ContractError::Unauthorized {}); + } + + #[test] + fn minter_can_update_minter_but_not_cap() { + let mut deps = mock_dependencies(); + + let genesis = deps.api.addr_make("genesis").to_string(); + let minter = deps.api.addr_make("minter").to_string(); + + let cap = Some(Uint128::from(3000000u128)); + do_instantiate_with_minter(deps.as_mut(), &genesis, Uint128::new(1234), &minter, cap); + + let new_minter = deps.api.addr_make("new_minter").to_string(); + let msg = ExecuteMsg::UpdateMinter { + new_minter: Some(new_minter.clone()), + }; + + let info = mock_info(&minter, &[]); + let env = mock_env(); + let res = execute(deps.as_mut(), env.clone(), info, msg); + assert!(res.is_ok()); + let query_minter_msg = QueryMsg::Minter {}; + let res = query(deps.as_ref(), env, query_minter_msg); + let mint: MinterResponse = from_json(res.unwrap()).unwrap(); + + // Minter cannot update cap. + assert!(mint.cap == cap); + assert!(mint.minter == new_minter) + } + + #[test] + fn others_cannot_update_minter() { + let mut deps = mock_dependencies(); + + let genesis = deps.api.addr_make("genesis").to_string(); + let minter = deps.api.addr_make("minter").to_string(); + let new_minter = deps.api.addr_make("new_minter").to_string(); + + do_instantiate_with_minter(deps.as_mut(), &genesis, Uint128::new(1234), &minter, None); + + let msg = ExecuteMsg::UpdateMinter { + new_minter: Some(new_minter), + }; + + let info = mock_info("not the minter", &[]); + let env = mock_env(); + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert_eq!(err, ContractError::Unauthorized {}); + } + + #[test] + fn unset_minter() { + let mut deps = mock_dependencies(); + + let genesis = deps.api.addr_make("genesis").to_string(); + let minter = deps.api.addr_make("minter").to_string(); + let winner = deps.api.addr_make("winner").to_string(); + + let cap = None; + do_instantiate_with_minter(deps.as_mut(), &genesis, Uint128::new(1234), &minter, cap); + + let msg = ExecuteMsg::UpdateMinter { new_minter: None }; + + let info = mock_info(&minter, &[]); + let env = mock_env(); + let res = execute(deps.as_mut(), env.clone(), info, msg); + assert!(res.is_ok()); + let query_minter_msg = QueryMsg::Minter {}; + let res = query(deps.as_ref(), env, query_minter_msg); + let mint: Option = from_json(res.unwrap()).unwrap(); + + // Check that mint information was removed. + assert_eq!(mint, None); + + // Check that old minter can no longer mint. + let msg = ExecuteMsg::Mint { + recipient: winner, + amount: Uint128::new(222), + }; + let info = mock_info(&minter, &[]); + let env = mock_env(); + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert_eq!(err, ContractError::Unauthorized {}); + } + + #[test] + fn no_one_mints_if_minter_unset() { + let mut deps = mock_dependencies(); + + let genesis = deps.api.addr_make("genesis").to_string(); + let winner = deps.api.addr_make("winner").to_string(); + + do_instantiate(deps.as_mut(), &genesis, Uint128::new(1234)); + + let msg = ExecuteMsg::Mint { + recipient: winner, + amount: Uint128::new(222), + }; + let info = mock_info(&genesis, &[]); + let env = mock_env(); + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert_eq!(err, ContractError::Unauthorized {}); + } + + #[test] + fn instantiate_multiple_accounts() { + let mut deps = mock_dependencies(); + let amount1 = Uint128::from(11223344u128); + let addr1 = deps.api.addr_make("addr0001").to_string(); + let amount2 = Uint128::from(7890987u128); + let addr2 = deps.api.addr_make("addr0002").to_string(); + let info = mock_info("creator", &[]); + let env = mock_env(); + + // Fails with duplicate addresses + let instantiate_msg = InstantiateMsg { + name: "Bash Shell".to_string(), + symbol: "BASH".to_string(), + decimals: 6, + initial_balances: vec![ + Cw20Coin { + address: addr1.clone(), + amount: amount1, + }, + Cw20Coin { + address: addr1.clone(), + amount: amount2, + }, + ], + mint: None, + marketing: None, + }; + let err = + instantiate(deps.as_mut(), env.clone(), info.clone(), instantiate_msg).unwrap_err(); + assert_eq!(err, ContractError::DuplicateInitialBalanceAddresses {}); + + // Works with unique addresses + let instantiate_msg = InstantiateMsg { + name: "Bash Shell".to_string(), + symbol: "BASH".to_string(), + decimals: 6, + initial_balances: vec![ + Cw20Coin { + address: addr1.clone(), + amount: amount1, + }, + Cw20Coin { + address: addr2.clone(), + amount: amount2, + }, + ], + mint: None, + marketing: None, + }; + let res = instantiate(deps.as_mut(), env, info, instantiate_msg).unwrap(); + assert_eq!(0, res.messages.len()); + assert_eq!( + query_token_info(deps.as_ref()).unwrap(), + TokenInfoResponse { + name: "Bash Shell".to_string(), + symbol: "BASH".to_string(), + decimals: 6, + total_supply: amount1 + amount2, + } + ); + assert_eq!(get_balance(deps.as_ref(), addr1, None), amount1); + assert_eq!(get_balance(deps.as_ref(), addr2, None), amount2); + } + + #[test] + fn queries_work() { + let mut deps = mock_dependencies_with_balance(&coins(2, "token")); + + let addr1 = deps.api.addr_make("addr0001").to_string(); + let addr2 = deps.api.addr_make("addr0002").to_string(); + + let amount1 = Uint128::from(12340000u128); + + let expected = do_instantiate(deps.as_mut(), &addr1, amount1); + + // check meta query + let loaded = query_token_info(deps.as_ref()).unwrap(); + assert_eq!(expected, loaded); + + let _info = mock_info("test", &[]); + let env = mock_env(); + // check balance query (full) + let data = query( + deps.as_ref(), + env.clone(), + QueryMsg::Balance { + address: addr1, + height: None, + }, + ) + .unwrap(); + let loaded: BalanceResponse = from_json(data).unwrap(); + assert_eq!(loaded.balance, amount1); + + // check balance query (empty) + let data = query( + deps.as_ref(), + env, + QueryMsg::Balance { + address: addr2, + height: None, + }, + ) + .unwrap(); + } + + #[test] + fn query_balance_at_height_works() { + let mut deps = mock_dependencies_with_balance(&coins(2, "token")); + let addr1 = deps.api.addr_make("addr0001").to_string(); + let addr2 = deps.api.addr_make("addr0002").to_string(); + let amount1 = Uint128::from(12340000u128); + + // Instantiate the contract + do_instantiate(deps.as_mut(), &addr1, amount1); + + // Transfer some tokens at height + 5 + let mut env = mock_env(); + env.block.height += 5; + let transfer1 = Uint128::from(54321u128); + let msg = ExecuteMsg::Transfer { + recipient: addr2.clone(), + amount: transfer1, + }; + let info = mock_info(&addr1, &[]); + execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // Check balances at before and after the transfer + assert_eq!( + get_balance(deps.as_ref(), &addr1, Some(env.block.height)), + amount1 + ); + assert_eq!( + get_balance(deps.as_ref(), &addr1, Some(env.block.height + 1)), + amount1.checked_sub(transfer1).unwrap() + ); + assert_eq!( + get_balance(deps.as_ref(), &addr2, Some(env.block.height + 1)), + transfer1 + ); + assert_eq!( + get_balance(deps.as_ref(), &addr2, Some(env.block.height)), + Uint128::zero() + ); + + // Transfer more tokens at height + 10 + env.block.height += 10; + let transfer2 = Uint128::from(12345u128); + let msg = ExecuteMsg::Transfer { + recipient: addr2.clone(), + amount: transfer2, + }; + let info = mock_info(&addr1, &[]); + execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // Check balances at different heights + + // At height 0 (initial state) + assert_eq!(get_balance(deps.as_ref(), &addr1, Some(0)), Uint128::zero()); + assert_eq!(get_balance(deps.as_ref(), &addr2, Some(0)), Uint128::zero()); + + // At height 10 (after second transfer) + assert_eq!( + get_balance(deps.as_ref(), &addr1, Some(env.block.height)), + amount1.checked_sub(transfer1).unwrap() + ); + assert_eq!( + get_balance(deps.as_ref(), &addr1, Some(env.block.height + 1)), + amount1 + .checked_sub(transfer1) + .unwrap() + .checked_sub(transfer2) + .unwrap() + ); + assert_eq!( + get_balance(deps.as_ref(), &addr2, Some(env.block.height)), + transfer1 + ); + assert_eq!( + get_balance(deps.as_ref(), &addr2, Some(env.block.height + 1)), + transfer1.checked_add(transfer2).unwrap() + ); + + // Current height (should be same as height 10) + assert_eq!( + get_balance(deps.as_ref(), &addr1, None), + amount1 + .checked_sub(transfer1) + .unwrap() + .checked_sub(transfer2) + .unwrap() + ); + assert_eq!( + get_balance(deps.as_ref(), &addr2, None), + transfer1.checked_add(transfer2).unwrap() + ); + + // Query a height in between snapshots (should return closest height <= query height) + assert_eq!( + get_balance(deps.as_ref(), &addr1, Some(env.block.height - 10)), + get_balance(deps.as_ref(), &addr1, Some(env.block.height - 11)) + ); + assert_eq!( + get_balance(deps.as_ref(), &addr2, Some(env.block.height - 10)), + get_balance(deps.as_ref(), &addr2, Some(env.block.height - 11)) + ); + } + + #[test] + fn transfer() { + let mut deps = mock_dependencies_with_balance(&coins(2, "token")); + let addr1 = deps.api.addr_make("addr0001").to_string(); + let addr2 = deps.api.addr_make("addr0002").to_string(); + let amount1 = Uint128::from(12340000u128); + let transfer = Uint128::from(76543u128); + let too_much = Uint128::from(12340321u128); + + do_instantiate(deps.as_mut(), &addr1, amount1); + + // Allows transferring 0 + let info = mock_info(addr1.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Transfer { + recipient: addr2.clone(), + amount: Uint128::zero(), + }; + execute(deps.as_mut(), env, info, msg).unwrap(); + + // cannot send more than we have + let info = mock_info(addr1.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Transfer { + recipient: addr2.clone(), + amount: too_much, + }; + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert!(matches!(err, ContractError::Std(StdError::Overflow { .. }))); + + // cannot send from empty account + let info = mock_info(addr2.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Transfer { + recipient: addr1.clone(), + amount: transfer, + }; + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert!(matches!(err, ContractError::Std(StdError::Overflow { .. }))); + + // valid transfer + let info = mock_info(addr1.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Transfer { + recipient: addr2.clone(), + amount: transfer, + }; + let res = execute(deps.as_mut(), env, info, msg).unwrap(); + assert_eq!(res.messages.len(), 0); + + let remainder = amount1.checked_sub(transfer).unwrap(); + assert_eq!(get_balance(deps.as_ref(), addr1, None), remainder); + assert_eq!(get_balance(deps.as_ref(), addr2, None), transfer); + assert_eq!( + query_token_info(deps.as_ref()).unwrap().total_supply, + amount1 + ); + } + + #[test] + fn burn() { + let mut deps = mock_dependencies_with_balance(&coins(2, "token")); + let addr1 = deps.api.addr_make("addr0001").to_string(); + let amount1 = Uint128::from(12340000u128); + let burn = Uint128::from(76543u128); + let too_much = Uint128::from(12340321u128); + + do_instantiate(deps.as_mut(), &addr1, amount1); + + // Allows burning 0 + let info = mock_info(addr1.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Burn { + amount: Uint128::zero(), + }; + execute(deps.as_mut(), env, info, msg).unwrap(); + assert_eq!( + query_token_info(deps.as_ref()).unwrap().total_supply, + amount1 + ); + + // cannot burn more than we have + let info = mock_info(addr1.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Burn { amount: too_much }; + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert!(matches!(err, ContractError::Std(StdError::Overflow { .. }))); + assert_eq!( + query_token_info(deps.as_ref()).unwrap().total_supply, + amount1 + ); + + // valid burn reduces total supply + let info = mock_info(addr1.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Burn { amount: burn }; + let res = execute(deps.as_mut(), env, info, msg).unwrap(); + assert_eq!(res.messages.len(), 0); + + let remainder = amount1.checked_sub(burn).unwrap(); + assert_eq!(get_balance(deps.as_ref(), addr1, None), remainder); + assert_eq!( + query_token_info(deps.as_ref()).unwrap().total_supply, + remainder + ); + } + + #[test] + fn send() { + let mut deps = mock_dependencies_with_balance(&coins(2, "token")); + let addr1 = deps.api.addr_make("addr0001").to_string(); + let contract = deps.api.addr_make("contract0001").to_string(); + let amount1 = Uint128::from(12340000u128); + let transfer = Uint128::from(76543u128); + let too_much = Uint128::from(12340321u128); + let send_msg = Binary::from(r#"{"some":123}"#.as_bytes()); + + do_instantiate(deps.as_mut(), &addr1, amount1); + + // Allows sending 0 + let info = mock_info(addr1.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Send { + contract: contract.clone(), + amount: Uint128::zero(), + msg: send_msg.clone(), + }; + execute(deps.as_mut(), env, info, msg).unwrap(); + + // cannot send more than we have + let info = mock_info(addr1.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Send { + contract: contract.clone(), + amount: too_much, + msg: send_msg.clone(), + }; + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert!(matches!(err, ContractError::Std(StdError::Overflow { .. }))); + + // valid transfer + let info = mock_info(addr1.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Send { + contract: contract.clone(), + amount: transfer, + msg: send_msg.clone(), + }; + let res = execute(deps.as_mut(), env, info, msg).unwrap(); + assert_eq!(res.messages.len(), 1); + + // ensure proper send message sent + // this is the message we want delivered to the other side + let binary_msg = Cw20ReceiveMsg { + sender: addr1.clone(), + amount: transfer, + msg: send_msg, + } + .into_json_binary() + .unwrap(); + // and this is how it must be wrapped for the vm to process it + assert_eq!( + res.messages[0], + SubMsg::new(CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: contract.clone(), + msg: binary_msg, + funds: vec![], + })) + ); + + // ensure balance is properly transferred + let remainder = amount1.checked_sub(transfer).unwrap(); + assert_eq!(get_balance(deps.as_ref(), addr1, None), remainder); + assert_eq!(get_balance(deps.as_ref(), contract, None), transfer); + assert_eq!( + query_token_info(deps.as_ref()).unwrap().total_supply, + amount1 + ); + } + + mod migration { + use super::*; + + use cosmwasm_std::Empty; + use cw20::{AllAllowancesResponse, AllSpenderAllowancesResponse, SpenderAllowanceInfo}; + use cw_multi_test::{App, Contract, ContractWrapper, Executor}; + use cw_utils::Expiration; + + fn cw20_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_migrate(crate::contract::migrate); + Box::new(contract) + } + + #[test] + fn test_migrate() { + let mut app = App::default(); + + let sender = app.api().addr_make("sender").to_string(); + let spender = app.api().addr_make("spender").to_string(); + + let cw20_id = app.store_code(cw20_contract()); + let cw20_addr = app + .instantiate_contract( + cw20_id, + Addr::unchecked("sender"), + &InstantiateMsg { + name: "Token".to_string(), + symbol: "TOKEN".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: sender.clone(), + amount: Uint128::new(100), + }], + mint: None, + marketing: None, + }, + &[], + "TOKEN", + Some(sender.clone()), + ) + .unwrap(); + + // no allowance to start + let allowance: AllAllowancesResponse = app + .wrap() + .query_wasm_smart( + cw20_addr.to_string(), + &QueryMsg::AllAllowances { + owner: sender.clone(), + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!(allowance, AllAllowancesResponse::default()); + + // Set allowance + let allow1 = Uint128::new(7777); + let expires = Expiration::AtHeight(123_456); + let msg = CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: cw20_addr.to_string(), + msg: to_json_binary(&ExecuteMsg::IncreaseAllowance { + spender: spender.clone(), + amount: allow1, + expires: Some(expires), + }) + .unwrap(), + funds: vec![], + }); + app.execute(Addr::unchecked(&sender), msg).unwrap(); + + // Now migrate + app.execute( + Addr::unchecked(&sender), + CosmosMsg::Wasm(WasmMsg::Migrate { + contract_addr: cw20_addr.to_string(), + new_code_id: cw20_id, + msg: to_json_binary(&MigrateMsg {}).unwrap(), + }), + ) + .unwrap(); + + // Smoke check that the contract still works. + let balance: cw20::BalanceResponse = app + .wrap() + .query_wasm_smart( + cw20_addr.clone(), + &QueryMsg::Balance { + address: sender.clone(), + height: None, + }, + ) + .unwrap(); + + assert_eq!(balance.balance, Uint128::new(100)); + + // Confirm that the allowance per spender is there + let allowance: AllSpenderAllowancesResponse = app + .wrap() + .query_wasm_smart( + cw20_addr, + &QueryMsg::AllSpenderAllowances { + spender, + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!( + allowance.allowances, + &[SpenderAllowanceInfo { + owner: sender, + allowance: allow1, + expires + }] + ); + } + } + + mod marketing { + use super::*; + + #[test] + fn update_unauthorised() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + let marketing = deps.api.addr_make("marketing"); + + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(marketing.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UpdateMarketing { + project: Some("New project".to_owned()), + description: Some("Better description".to_owned()), + marketing: Some(creator.to_string()), + }, + ) + .unwrap_err(); + + assert_eq!(err, ContractError::Unauthorized {}); + + // Ensure marketing didn't change + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(marketing), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn update_project() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UpdateMarketing { + project: Some("New project".to_owned()), + description: None, + marketing: None, + }, + ) + .unwrap(); + + assert_eq!(res.messages, vec![]); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("New project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn clear_project() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UpdateMarketing { + project: Some("".to_owned()), + description: None, + marketing: None, + }, + ) + .unwrap(); + + assert_eq!(res.messages, vec![]); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: None, + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn update_description() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UpdateMarketing { + project: None, + description: Some("Better description".to_owned()), + marketing: None, + }, + ) + .unwrap(); + + assert_eq!(res.messages, vec![]); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Better description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn clear_description() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UpdateMarketing { + project: None, + description: Some("".to_owned()), + marketing: None, + }, + ) + .unwrap(); + + assert_eq!(res.messages, vec![]); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: None, + marketing: Some(creator), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn update_marketing() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + let marketing = deps.api.addr_make("marketing"); + + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UpdateMarketing { + project: None, + description: None, + marketing: Some(marketing.to_string()), + }, + ) + .unwrap(); + + assert_eq!(res.messages, vec![]); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(marketing), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn update_marketing_invalid() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UpdateMarketing { + project: None, + description: None, + marketing: Some("m".to_owned()), + }, + ) + .unwrap_err(); + + assert!( + matches!(err, ContractError::Std(_)), + "Expected Std error, received: {err}", + ); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn clear_marketing() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UpdateMarketing { + project: None, + description: None, + marketing: Some("".to_owned()), + }, + ) + .unwrap(); + + assert_eq!(res.messages, vec![]); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: None, + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn update_logo_url() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UploadLogo(Logo::Url("new_url".to_owned())), + ) + .unwrap(); + + assert_eq!(res.messages, vec![]); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Url("new_url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn update_logo_png() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UploadLogo(Logo::Embedded(EmbeddedLogo::Png(PNG_HEADER.into()))), + ) + .unwrap(); + + assert_eq!(res.messages, vec![]); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Embedded), + } + ); + + assert_eq!( + query_download_logo(deps.as_ref()).unwrap(), + DownloadLogoResponse { + mime_type: "image/png".to_owned(), + data: PNG_HEADER.into(), + } + ); + } + + #[test] + fn update_logo_svg() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let img = "".as_bytes(); + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UploadLogo(Logo::Embedded(EmbeddedLogo::Svg(img.into()))), + ) + .unwrap(); + + assert_eq!(res.messages, vec![]); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Embedded), + } + ); + + assert_eq!( + query_download_logo(deps.as_ref()).unwrap(), + DownloadLogoResponse { + mime_type: "image/svg+xml".to_owned(), + data: img.into(), + } + ); + } + + #[test] + fn update_logo_png_oversized() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let img = [&PNG_HEADER[..], &[1; 6000][..]].concat(); + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UploadLogo(Logo::Embedded(EmbeddedLogo::Png(img.into()))), + ) + .unwrap_err(); + + assert_eq!(err, ContractError::LogoTooBig {}); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn update_logo_svg_oversized() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let img = [ + "", + std::str::from_utf8(&[b'x'; 6000]).unwrap(), + "", + ] + .concat() + .into_bytes(); + + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UploadLogo(Logo::Embedded(EmbeddedLogo::Svg(img.into()))), + ) + .unwrap_err(); + + assert_eq!(err, ContractError::LogoTooBig {}); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn update_logo_png_invalid() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let img = &[1]; + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UploadLogo(Logo::Embedded(EmbeddedLogo::Png(img.into()))), + ) + .unwrap_err(); + + assert_eq!(err, ContractError::InvalidPngHeader {}); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn update_logo_svg_invalid() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info("creator", &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let img = &[1]; + + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UploadLogo(Logo::Embedded(EmbeddedLogo::Svg(img.into()))), + ) + .unwrap_err(); + + assert_eq!(err, ContractError::InvalidXmlPreamble {}); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + } +} diff --git a/contracts/cw20-snapshot/src/enumerable.rs b/contracts/cw20-snapshot/src/enumerable.rs new file mode 100644 index 000000000..6459ba636 --- /dev/null +++ b/contracts/cw20-snapshot/src/enumerable.rs @@ -0,0 +1,326 @@ +use cosmwasm_std::{Deps, Order, StdResult}; +use cw20::{ + AllAccountsResponse, AllAllowancesResponse, AllSpenderAllowancesResponse, AllowanceInfo, + SpenderAllowanceInfo, +}; + +use crate::state::{ALLOWANCES, ALLOWANCES_SPENDER, BALANCES}; +use cw_storage_plus::Bound; + +// settings for pagination +const MAX_LIMIT: u32 = 30; +const DEFAULT_LIMIT: u32 = 10; + +pub fn query_owner_allowances( + deps: Deps, + owner: String, + start_after: Option, + limit: Option, +) -> StdResult { + let owner_addr = deps.api.addr_validate(&owner)?; + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(|s| Bound::ExclusiveRaw(s.into_bytes())); + + let allowances = ALLOWANCES + .prefix(&owner_addr) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| { + item.map(|(addr, allow)| AllowanceInfo { + spender: addr.into(), + allowance: allow.allowance, + expires: allow.expires, + }) + }) + .collect::>()?; + Ok(AllAllowancesResponse { allowances }) +} + +pub fn query_spender_allowances( + deps: Deps, + spender: String, + start_after: Option, + limit: Option, +) -> StdResult { + let spender_addr = deps.api.addr_validate(&spender)?; + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(|s| Bound::ExclusiveRaw(s.into_bytes())); + + let allowances = ALLOWANCES_SPENDER + .prefix(&spender_addr) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| { + item.map(|(addr, allow)| SpenderAllowanceInfo { + owner: addr.into(), + allowance: allow.allowance, + expires: allow.expires, + }) + }) + .collect::>()?; + Ok(AllSpenderAllowancesResponse { allowances }) +} + +pub fn query_all_accounts( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(|s| Bound::ExclusiveRaw(s.into())); + + let accounts = BALANCES + .keys(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| item.map(Into::into)) + .collect::>()?; + + Ok(AllAccountsResponse { accounts }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use cosmwasm_std::testing::{mock_dependencies_with_balance, mock_env, mock_info}; + use cosmwasm_std::{coins, from_json, DepsMut, Uint128}; + use cw20::{Cw20Coin, Expiration, TokenInfoResponse}; + + use crate::contract::{execute, instantiate, query, query_token_info}; + use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + + // this will set up the instantiation for other tests + fn do_instantiate(mut deps: DepsMut, addr: &str, amount: Uint128) -> TokenInfoResponse { + let instantiate_msg = InstantiateMsg { + name: "Auto Gen".to_string(), + symbol: "AUTO".to_string(), + decimals: 3, + initial_balances: vec![Cw20Coin { + address: addr.into(), + amount, + }], + mint: None, + marketing: None, + }; + let info = mock_info("creator", &[]); + let env = mock_env(); + instantiate(deps.branch(), env, info, instantiate_msg).unwrap(); + query_token_info(deps.as_ref()).unwrap() + } + + #[test] + fn query_all_owner_allowances_works() { + let mut deps = mock_dependencies_with_balance(&coins(2, "token")); + + let owner = deps.api.addr_make("owner").to_string(); + // these are in alphabetical order same than insert order + let spender1 = deps.api.addr_make("earlier").to_string(); + let spender2 = deps.api.addr_make("later").to_string(); + + let info = mock_info(owner.as_ref(), &[]); + let env = mock_env(); + do_instantiate(deps.as_mut(), &owner, Uint128::new(12340000)); + + // no allowance to start + let allowances = query_owner_allowances(deps.as_ref(), owner.clone(), None, None).unwrap(); + assert_eq!(allowances.allowances, vec![]); + + // set allowance with height expiration + let allow1 = Uint128::new(7777); + let expires = Expiration::AtHeight(123_456); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender1.clone(), + amount: allow1, + expires: Some(expires), + }; + execute(deps.as_mut(), env.clone(), info.clone(), msg).unwrap(); + + // set allowance with no expiration + let allow2 = Uint128::new(54321); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender2.clone(), + amount: allow2, + expires: None, + }; + execute(deps.as_mut(), env, info, msg).unwrap(); + + // query list gets 2 + let allowances = query_owner_allowances(deps.as_ref(), owner.clone(), None, None).unwrap(); + assert_eq!(allowances.allowances.len(), 2); + + // first one is spender1 (order of CanonicalAddr uncorrelated with String) + let allowances = + query_owner_allowances(deps.as_ref(), owner.clone(), None, Some(1)).unwrap(); + assert_eq!(allowances.allowances.len(), 1); + let allow = &allowances.allowances[0]; + assert_eq!(&allow.spender, &spender1); + assert_eq!(&allow.expires, &expires); + assert_eq!(&allow.allowance, &allow1); + + // next one is spender2 + let allowances = query_owner_allowances( + deps.as_ref(), + owner, + Some(allow.spender.clone()), + Some(10000), + ) + .unwrap(); + assert_eq!(allowances.allowances.len(), 1); + let allow = &allowances.allowances[0]; + assert_eq!(&allow.spender, &spender2); + assert_eq!(&allow.expires, &Expiration::Never {}); + assert_eq!(&allow.allowance, &allow2); + } + + #[test] + fn query_all_spender_allowances_works() { + let mut deps = mock_dependencies_with_balance(&coins(2, "token")); + + let mut addresses = [ + deps.api.addr_make("owner1").to_string(), + deps.api.addr_make("owner2").to_string(), + deps.api.addr_make("spender").to_string(), + ]; + addresses.sort(); + + // these are in alphabetical order same than insert order + let [owner1, owner2, spender] = addresses; + + let info = mock_info(owner1.as_ref(), &[]); + let env = mock_env(); + do_instantiate(deps.as_mut(), &owner1, Uint128::new(12340000)); + + // no allowance to start + let allowances = + query_spender_allowances(deps.as_ref(), spender.clone(), None, None).unwrap(); + assert_eq!(allowances.allowances, vec![]); + + // set allowance with height expiration + let allow1 = Uint128::new(7777); + let expires = Expiration::AtHeight(123_456); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender.clone(), + amount: allow1, + expires: Some(expires), + }; + execute(deps.as_mut(), env, info, msg).unwrap(); + + // set allowance with no expiration, from the other owner + let info = mock_info(owner2.as_ref(), &[]); + let env = mock_env(); + do_instantiate(deps.as_mut(), &owner2, Uint128::new(12340000)); + + let allow2 = Uint128::new(54321); + let msg = ExecuteMsg::IncreaseAllowance { + spender: spender.clone(), + amount: allow2, + expires: None, + }; + execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // query list gets both + let msg = QueryMsg::AllSpenderAllowances { + spender: spender.clone(), + start_after: None, + limit: None, + }; + let allowances: AllSpenderAllowancesResponse = + from_json(query(deps.as_ref(), env.clone(), msg).unwrap()).unwrap(); + assert_eq!(allowances.allowances.len(), 2); + + // one is owner1 (order of CanonicalAddr uncorrelated with String) + let msg = QueryMsg::AllSpenderAllowances { + spender: spender.clone(), + start_after: None, + limit: Some(1), + }; + let allowances: AllSpenderAllowancesResponse = + from_json(query(deps.as_ref(), env.clone(), msg).unwrap()).unwrap(); + assert_eq!(allowances.allowances.len(), 1); + let allow = &allowances.allowances[0]; + assert_eq!(&allow.owner, &owner1); + assert_eq!(&allow.expires, &expires); + assert_eq!(&allow.allowance, &allow1); + + // other one is owner2 + let msg = QueryMsg::AllSpenderAllowances { + spender, + start_after: Some(owner1), + limit: Some(10000), + }; + let allowances: AllSpenderAllowancesResponse = + from_json(query(deps.as_ref(), env, msg).unwrap()).unwrap(); + assert_eq!(allowances.allowances.len(), 1); + let allow = &allowances.allowances[0]; + assert_eq!(&allow.owner, &owner2); + assert_eq!(&allow.expires, &Expiration::Never {}); + assert_eq!(&allow.allowance, &allow2); + } + + #[test] + fn query_all_accounts_works() { + let mut deps = mock_dependencies_with_balance(&coins(2, "token")); + + // insert order and lexicographical order are different + let acct1 = deps.api.addr_make("acct1").to_string(); + let acct2 = deps.api.addr_make("zebra").to_string(); + let acct3 = deps.api.addr_make("nice").to_string(); + let acct4 = deps.api.addr_make("aaardvark").to_string(); + + let mut expected_order = [acct1.clone(), acct2.clone(), acct3.clone(), acct4.clone()]; + expected_order.sort(); + + do_instantiate(deps.as_mut(), &acct1, Uint128::new(12340000)); + + // put money everywhere (to create balanaces) + let info = mock_info(acct1.as_ref(), &[]); + let env = mock_env(); + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::Transfer { + recipient: acct2, + amount: Uint128::new(222222), + }, + ) + .unwrap(); + execute( + deps.as_mut(), + env.clone(), + info.clone(), + ExecuteMsg::Transfer { + recipient: acct3, + amount: Uint128::new(333333), + }, + ) + .unwrap(); + execute( + deps.as_mut(), + env, + info, + ExecuteMsg::Transfer { + recipient: acct4, + amount: Uint128::new(444444), + }, + ) + .unwrap(); + + // make sure we get the proper results + let accounts = query_all_accounts(deps.as_ref(), None, None).unwrap(); + assert_eq!(accounts.accounts, expected_order); + + // let's do pagination + let accounts = query_all_accounts(deps.as_ref(), None, Some(2)).unwrap(); + assert_eq!(accounts.accounts, expected_order[0..2].to_vec()); + + let accounts = + query_all_accounts(deps.as_ref(), Some(accounts.accounts[1].clone()), Some(1)).unwrap(); + assert_eq!(accounts.accounts, expected_order[2..3].to_vec()); + + let accounts = + query_all_accounts(deps.as_ref(), Some(accounts.accounts[0].clone()), Some(777)) + .unwrap(); + assert_eq!(accounts.accounts, expected_order[3..].to_vec()); + } +} diff --git a/contracts/cw20-snapshot/src/error.rs b/contracts/cw20-snapshot/src/error.rs new file mode 100644 index 000000000..a0b880c97 --- /dev/null +++ b/contracts/cw20-snapshot/src/error.rs @@ -0,0 +1,43 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Cannot set to own account")] + CannotSetOwnAccount {}, + + // Unused error case. Zero is now treated like every other value. + #[deprecated(note = "Unused. All zero amount checks have been removed")] + #[error("Invalid zero amount")] + InvalidZeroAmount {}, + + #[error("Allowance is expired")] + Expired {}, + + #[error("No allowance for this account")] + NoAllowance {}, + + #[error("Minting cannot exceed the cap")] + CannotExceedCap {}, + + #[error("Logo binary data exceeds 5KB limit")] + LogoTooBig {}, + + #[error("Invalid xml preamble for SVG")] + InvalidXmlPreamble {}, + + #[error("Invalid png header")] + InvalidPngHeader {}, + + #[error("Invalid expiration value")] + InvalidExpiration {}, + + #[error("Duplicate initial balance addresses")] + DuplicateInitialBalanceAddresses {}, +} diff --git a/contracts/cw20-snapshot/src/lib.rs b/contracts/cw20-snapshot/src/lib.rs new file mode 100644 index 000000000..7b601f25d --- /dev/null +++ b/contracts/cw20-snapshot/src/lib.rs @@ -0,0 +1,24 @@ +/*! +This is a basic implementation of a cw20 contract. It implements +the [CW20 spec](https://github.com/CosmWasm/cw-plus/blob/main/packages/cw20/README.md) and is designed to +be deployed as is, or imported into other contracts to easily build +cw20-compatible tokens with custom logic. + +Implements: + +- [x] CW20 Base +- [x] Mintable extension +- [x] Allowances extension + +For more information on this contract, please check out the +[README](https://github.com/CosmWasm/cw-plus/blob/main/contracts/cw20-base/README.md). +*/ + +pub mod allowances; +pub mod contract; +pub mod enumerable; +mod error; +pub mod msg; +pub mod state; + +pub use crate::error::ContractError; diff --git a/contracts/cw20-snapshot/src/msg.rs b/contracts/cw20-snapshot/src/msg.rs new file mode 100644 index 000000000..abcc06218 --- /dev/null +++ b/contracts/cw20-snapshot/src/msg.rs @@ -0,0 +1,178 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{StdError, StdResult, Uint128}; +use cw20::{Cw20Coin, Logo, MinterResponse}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +pub use cw20::Cw20ExecuteMsg as ExecuteMsg; + +#[cw_serde] +pub struct InstantiateMarketingInfo { + pub project: Option, + pub description: Option, + pub marketing: Option, + pub logo: Option, +} + +#[cw_serde] +#[cfg_attr(test, derive(Default))] +pub struct InstantiateMsg { + pub name: String, + pub symbol: String, + pub decimals: u8, + pub initial_balances: Vec, + pub mint: Option, + pub marketing: Option, +} + +impl InstantiateMsg { + pub fn get_cap(&self) -> Option { + self.mint.as_ref().and_then(|v| v.cap) + } + + pub fn validate(&self) -> StdResult<()> { + // Check name, symbol, decimals + if !self.has_valid_name() { + return Err(StdError::generic_err( + "Name is not in the expected format (3-50 UTF-8 bytes)", + )); + } + if !self.has_valid_symbol() { + return Err(StdError::generic_err( + "Ticker symbol is not in expected format [a-zA-Z\\-]{3,12}", + )); + } + if self.decimals > 18 { + return Err(StdError::generic_err("Decimals must not exceed 18")); + } + Ok(()) + } + + fn has_valid_name(&self) -> bool { + let bytes = self.name.as_bytes(); + if bytes.len() < 3 || bytes.len() > 50 { + return false; + } + true + } + + fn has_valid_symbol(&self) -> bool { + let bytes = self.symbol.as_bytes(); + if bytes.len() < 3 || bytes.len() > 12 { + return false; + } + for byte in bytes.iter() { + if (*byte != 45) && (*byte < 65 || *byte > 90) && (*byte < 97 || *byte > 122) { + return false; + } + } + true + } +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Returns the current balance of the given address, 0 if unset. + #[returns(cw20::BalanceResponse)] + Balance { + address: String, + height: Option, + }, + /// Returns metadata on the contract - name, decimals, supply, etc. + #[returns(cw20::TokenInfoResponse)] + TokenInfo {}, + /// Only with "mintable" extension. + /// Returns who can mint and the hard cap on maximum tokens after minting. + #[returns(cw20::MinterResponse)] + Minter {}, + /// Only with "allowance" extension. + /// Returns how much spender can use from owner account, 0 if unset. + #[returns(cw20::AllowanceResponse)] + Allowance { owner: String, spender: String }, + /// Only with "enumerable" extension (and "allowances") + /// Returns all allowances this owner has approved. Supports pagination. + #[returns(cw20::AllAllowancesResponse)] + AllAllowances { + owner: String, + start_after: Option, + limit: Option, + }, + /// Only with "enumerable" extension (and "allowances") + /// Returns all allowances this spender has been granted. Supports pagination. + #[returns(cw20::AllSpenderAllowancesResponse)] + AllSpenderAllowances { + spender: String, + start_after: Option, + limit: Option, + }, + /// Only with "enumerable" extension + /// Returns all accounts that have balances. Supports pagination. + #[returns(cw20::AllAccountsResponse)] + AllAccounts { + start_after: Option, + limit: Option, + }, + /// Only with "marketing" extension + /// Returns more metadata on the contract to display in the client: + /// - description, logo, project url, etc. + #[returns(cw20::MarketingInfoResponse)] + MarketingInfo {}, + /// Only with "marketing" extension + /// Downloads the embedded logo data (if stored on chain). Errors if no logo data is stored for this + /// contract. + #[returns(cw20::DownloadLogoResponse)] + DownloadLogo {}, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct MigrateMsg {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_instantiatemsg_name() { + // Too short + let mut msg = InstantiateMsg { + name: str::repeat("a", 2), + ..InstantiateMsg::default() + }; + assert!(!msg.has_valid_name()); + + // In the correct length range + msg.name = str::repeat("a", 3); + assert!(msg.has_valid_name()); + + // Too long + msg.name = str::repeat("a", 51); + assert!(!msg.has_valid_name()); + } + + #[test] + fn validate_instantiatemsg_symbol() { + // Too short + let mut msg = InstantiateMsg { + symbol: str::repeat("a", 2), + ..InstantiateMsg::default() + }; + assert!(!msg.has_valid_symbol()); + + // In the correct length range + msg.symbol = str::repeat("a", 3); + assert!(msg.has_valid_symbol()); + + // Too long + msg.symbol = str::repeat("a", 13); + assert!(!msg.has_valid_symbol()); + + // Has illegal char + let illegal_chars = [[64u8], [91u8], [123u8]]; + illegal_chars.iter().for_each(|c| { + let c = std::str::from_utf8(c).unwrap(); + msg.symbol = str::repeat(c, 3); + assert!(!msg.has_valid_symbol()); + }); + } +} diff --git a/contracts/cw20-snapshot/src/state.rs b/contracts/cw20-snapshot/src/state.rs new file mode 100644 index 000000000..962b810d0 --- /dev/null +++ b/contracts/cw20-snapshot/src/state.rs @@ -0,0 +1,42 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint128}; +use cw_storage_plus::{Item, Map, SnapshotMap, Strategy}; + +use cw20::{AllowanceResponse, Logo, MarketingInfoResponse}; + +#[cw_serde] +pub struct TokenInfo { + pub name: String, + pub symbol: String, + pub decimals: u8, + pub total_supply: Uint128, + pub mint: Option, +} + +#[cw_serde] +pub struct MinterData { + pub minter: Addr, + /// cap is how many more tokens can be issued by the minter + pub cap: Option, +} + +impl TokenInfo { + pub fn get_cap(&self) -> Option { + self.mint.as_ref().and_then(|v| v.cap) + } +} + +pub const TOKEN_INFO: Item = Item::new("token_info"); +pub const MARKETING_INFO: Item = Item::new("marketing_info"); +pub const LOGO: Item = Item::new("logo"); +// pub const BALANCES: Map<&Addr, Uint128> = Map::new("balance"); +pub const ALLOWANCES: Map<(&Addr, &Addr), AllowanceResponse> = Map::new("allowance"); +// TODO: After https://github.com/CosmWasm/cw-plus/issues/670 is implemented, replace this with a `MultiIndex` over `ALLOWANCES` +pub const ALLOWANCES_SPENDER: Map<(&Addr, &Addr), AllowanceResponse> = + Map::new("allowance_spender"); +pub const BALANCES: SnapshotMap<&Addr, Uint128> = SnapshotMap::new( + "balance", + "balance_checkpoints", + "balance_changelog", + Strategy::EveryBlock, +);