diff --git a/.gitignore b/.gitignore index 75238c5..aad66ca 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ yarn.lock contracts/*/.editorconfig packages/*/.editorconfig -lcov.info \ No newline at end of file +lcov.info + +.DS_Store \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 98f371b..b0afd7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "airdrop" +version = "0.0.1" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-ownable", + "cw-storage-plus", + "cw2", + "nibiru-std", + "schemars", + "semver", + "serde", + "thiserror", +] + [[package]] name = "anstream" version = "0.6.5" @@ -87,9 +104,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.75" +version = "1.0.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" +checksum = "59d2a3357dde987206219e78ecfbbb6e8dad06cbb65292758d3270e6254f7355" dependencies = [ "backtrace", ] @@ -316,7 +333,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.43", ] [[package]] @@ -593,21 +610,20 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.16" +version = "0.9.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2fe95351b870527a5d09bf563ed3c97c0cffb87cf1c78a591bf48bb218d9aa" +checksum = "0e3681d554572a651dda4186cd47240627c3d0114d45a95f6ad27f2f22e7548d" dependencies = [ "autocfg", "cfg-if", "crossbeam-utils", - "memoffset 0.9.0", ] [[package]] name = "crossbeam-queue" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9bcf5bdbfdd6030fb4a1c497b5d5fc5921aa2f60d359a17e249c0e6df3de153" +checksum = "adc6598521bb5a83d491e8c1fe51db7296019d2ca3cb93cc6c2a20369a4d78a2" dependencies = [ "cfg-if", "crossbeam-utils", @@ -615,9 +631,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.17" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d96137f14f244c37f989d9fff8f95e6c18b918e71f36638f8c49112e4c78f" +checksum = "c3a430a770ebd84726f584a90ee7f020d28db52c6d02138900f22341f866d39c" dependencies = [ "cfg-if", ] @@ -921,7 +937,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.43", ] [[package]] @@ -932,7 +948,7 @@ checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" dependencies = [ "darling_core", "quote", - "syn 2.0.41", + "syn 2.0.43", ] [[package]] @@ -1135,7 +1151,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.43", ] [[package]] @@ -1220,42 +1236,42 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures-channel" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" [[package]] name = "futures-io" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf34a163b5c4c52d0478a4d757da8fb65cabef42ba90515efee0f6f9fa45aaa" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" [[package]] name = "futures-sink" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" [[package]] name = "futures-task" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" [[package]] name = "futures-util" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ "futures-core", "futures-io", @@ -1670,15 +1686,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "memoffset" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" -dependencies = [ - "autocfg", -] - [[package]] name = "mime" version = "0.3.17" @@ -1785,7 +1792,7 @@ dependencies = [ "cosmwasm-schema", "cosmwasm-std", "quote", - "syn 2.0.41", + "syn 2.0.43", ] [[package]] @@ -1831,9 +1838,9 @@ dependencies = [ [[package]] name = "object" -version = "0.32.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" dependencies = [ "memchr", ] @@ -1852,9 +1859,9 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" [[package]] name = "openssl" -version = "0.10.61" +version = "0.10.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b8419dc8cc6d866deb801274bba2e6f8f6108c1bb7fcc10ee5ab864931dbb45" +checksum = "8cde4d2d9200ad5909f8dac647e29482e07c3a35de8a13fce7c9c7747ad9f671" dependencies = [ "bitflags 2.4.1", "cfg-if", @@ -1873,7 +1880,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.43", ] [[package]] @@ -1884,9 +1891,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.97" +version = "0.9.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3eaad34cdd97d81de97964fc7f29e2d104f483840d906ef56daa1912338460b" +checksum = "c1665caf8ab2dc9aef43d1c0023bd904633a6a05cb30b0ad59bec2ae986e57a7" dependencies = [ "cc", "libc", @@ -1937,9 +1944,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.27" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +checksum = "69d3587f8a9e599cc7ec2c00e331f71c4e69a5f9a4b8a6efd5b07466b9736f9a" [[package]] name = "predicates" @@ -2010,9 +2017,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.70" +version = "1.0.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" +checksum = "75cb1540fadbd5b8fbccc4dddad2734eba435053f725621c070711a14bb5f4b8" dependencies = [ "unicode-ident", ] @@ -2037,7 +2044,7 @@ dependencies = [ "itertools 0.11.0", "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.43", ] [[package]] @@ -2400,9 +2407,9 @@ dependencies = [ [[package]] name = "self_cell" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e388332cd64eb80cd595a00941baf513caffae8dce9cfd0467fc9c66397dade6" +checksum = "58bf37232d3bb9a2c4e641ca2a11d83b5062066f88df7fed36c28772046d65ba" [[package]] name = "semver" @@ -2456,7 +2463,7 @@ checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.43", ] [[package]] @@ -2637,9 +2644,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.41" +version = "2.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c8b28c477cc3bf0e7966561e3460130e1255f7a1cf71931075f1c5e7a7e269" +checksum = "ee659fb5f3d355364e1f3e5bc10fb82068efbf824a1e9d1c9504244a6469ad53" dependencies = [ "proc-macro2", "quote", @@ -2715,7 +2722,7 @@ checksum = "01742297787513b79cf8e29d1056ede1313e2420b7b3b15d0a768b4921f549df" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.43", ] [[package]] @@ -2814,7 +2821,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.43", ] [[package]] @@ -2949,7 +2956,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.43", "wasm-bindgen-shared", ] @@ -3006,7 +3013,7 @@ checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.43", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3169,7 +3176,7 @@ dependencies = [ "lazy_static", "libc", "mach", - "memoffset 0.8.0", + "memoffset", "more-asserts", "region", "scopeguard", diff --git a/Cargo.toml b/Cargo.toml index 8796233..57de6f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ nibiru-macro = { path = "packages/nibiru-macro" } bash-rs = { path = "packages/bash-rs" } # deps: CosmWasm -cosmwasm-std = { version = "1.5.0", features = ["stargate"] } +cosmwasm-std = { version = "1.5.0", features = ["stargate", "staking"] } cosmwasm-schema = "1.5.0" cw-storage-plus = { version = "1.2.0" } cw-multi-test = { version = "0.20.0" } diff --git a/contracts/airdrop/.cargo/config b/contracts/airdrop/.cargo/config new file mode 100644 index 0000000..b613a59 --- /dev/null +++ b/contracts/airdrop/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +wasm-debug = "build --target wasm32-unknown-unknown" +schema = "run --example schema" diff --git a/contracts/airdrop/Cargo.toml b/contracts/airdrop/Cargo.toml new file mode 100644 index 0000000..c4ed42b --- /dev/null +++ b/contracts/airdrop/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "airdrop" +version = "0.0.1" +edition = "2021" +homepage = "https://nibiru.fi" +repository = "https://github.com/NibiruChain/cw-nibiru" +exclude = [ + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "contract.wasm", + "hash.txt", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +crate-type = ["cdylib", "rlib"] + +[profile.release] +opt-level = 3 +debug = false +rpath = false +lto = true +debug-assertions = false +codegen-units = 1 +panic = 'abort' +incremental = false +overflow-checks = true + +[package.metadata.scripts] +optimize = """docker run --rm -v "$(pwd)":/code \ + -e CARGO_TERM_COLOR=always \ + --mount type=volume,source="$(basename "$(pwd)")_cache",target=/target \ + --mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \ + cosmwasm/optimizer:0.15.0 +""" + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +nibiru-std = { workspace = true } +thiserror = { workspace = true } +schemars = "0.8.15" +serde = { version = "1.0.188", default-features = false, features = ["derive"] } +cw-ownable = { workspace = true } +cw2 = { workspace = true } +semver = "1" + +[dev-dependencies] +anyhow = { workspace = true } \ No newline at end of file diff --git a/contracts/airdrop/README.md b/contracts/airdrop/README.md new file mode 100644 index 0000000..fae09cc --- /dev/null +++ b/contracts/airdrop/README.md @@ -0,0 +1,13 @@ +# Airdrop Contract + +## Overview + +The airdrop contract is used to distribute tokens to a list of addresses. An instance of a contract represents a campaign. The contract is initialized with a campaign id, a campaign name, a campaign description, an owner (the deployer of the contract), a list of managers, and funds which become the unallocated amount. + +The token allocation amount starts unallocated and eventually gets allocated to users by the owner and managers. + +The contract owner and managers can allocate/reward users with tokens by calling the `reward_users` function. The `reward_users` function takes a list of addresses and amounts. The total reward amount must be less than the unallocated token amount of the contract. + +## Withdraw + +Only the contract owner can withdraw from the contract (not the managers). The `withdraw` exists to withdraw any leftover tokens after the campaign has ended. There is no check for if the total outstanding reward amount is greater than the amount of funds left in the contract. `withdraw` should only be called after the campaign ends because it could leave the contract in a state where it cannot fulfill a user's outstanding reward amount. Additional funds can be sent to the contract to reverse the withdrawal. diff --git a/contracts/airdrop/src/contract.rs b/contracts/airdrop/src/contract.rs new file mode 100644 index 0000000..471345b --- /dev/null +++ b/contracts/airdrop/src/contract.rs @@ -0,0 +1,256 @@ +use crate::{ + msg::{ + ExecuteMsg, InstantiateMsg, QueryMsg, RewardUserRequest, + RewardUserResponse, + }, + state::{Campaign, CAMPAIGN, USER_REWARDS}, +}; +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_json_binary, Addr, BankMsg, Binary, Coin, CosmosMsg, Deps, DepsMut, + Empty, Env, MessageInfo, Response, StdError, StdResult, Uint128, +}; +use cw2::{get_contract_version, set_contract_version}; +use semver::Version; + +pub const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); +pub const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + if info.funds.len() != 1 { + return Err(StdError::generic_err("Only one coin is allowed")); + } + + let bond_denom = deps.querier.query_bonded_denom()?; + let coin = info.funds.get(0).unwrap(); + if coin.denom != bond_denom { + return Err(StdError::generic_err("Only native tokens are allowed")); + } + + let campaign = Campaign { + campaign_id: msg.campaign_id, + campaign_name: msg.campaign_name, + campaign_description: msg.campaign_description, + owner: info.sender.clone(), + managers: msg.managers, + unallocated_amount: coin.amount, + }; + CAMPAIGN.save(deps.storage, &campaign)?; + + return Ok(Response::new() + .add_attribute("method", "instantiate") + .add_attribute("owner", info.sender)); +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate( + deps: DepsMut, + _env: Env, + _msg: Empty, +) -> Result { + let new_version: Version = CONTRACT_VERSION + .parse() + .map_err(|_| StdError::generic_err("Invalid contract version format"))?; + let current_version = get_contract_version(deps.storage)?; + + if current_version.contract != CONTRACT_NAME { + return Err(StdError::generic_err( + "Can only upgrade from same contract type", + )); + } + + if current_version.version.parse::().unwrap() >= new_version { + return Err(StdError::generic_err( + "Cannot upgrade from a newer contract version", + )); + } + + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + Ok(Response::new().add_attribute("method", "migrate")) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::RewardUsers { requests } => { + reward_users(deps, env, info, requests) + } + ExecuteMsg::Claim {} => claim(deps, env, info), + ExecuteMsg::Withdraw { amount } => withdraw(deps, env, info, amount), + } +} + +pub fn reward_users( + deps: DepsMut, + _env: Env, + info: MessageInfo, + requests: Vec, +) -> Result { + let mut res = vec![]; + + for req in requests { + let mut campaign = CAMPAIGN.load(deps.storage).map_err(|_| { + StdError::generic_err("Failed to load campaign data") + })?; + + if campaign.owner != info.sender + && !campaign.managers.contains(&info.sender) + { + res.push(RewardUserResponse { + user_address: req.user_address.clone(), + success: false, + error_msg: "Unauthorized".to_string(), + }); + continue; + } + + if campaign.unallocated_amount < req.amount { + res.push(RewardUserResponse { + user_address: req.user_address.clone(), + success: false, + error_msg: "Not enough funds in campaign".to_string(), + }); + continue; + } + + match USER_REWARDS.may_load(deps.storage, req.user_address.clone())? { + Some(mut user_reward) => { + user_reward += req.amount; + USER_REWARDS.save( + deps.storage, + req.user_address.clone(), + &user_reward, + )?; + } + None => { + USER_REWARDS.save( + deps.storage, + req.user_address.clone(), + &req.amount, + )?; + } + }; + campaign.unallocated_amount -= req.amount; + CAMPAIGN.save(deps.storage, &campaign)?; + + res.push(RewardUserResponse { + user_address: req.user_address.clone(), + success: true, + error_msg: "".to_string(), + }); + } + + return Ok(Response::new() + .add_attribute("method", "reward_users") + .set_data(to_json_binary(&res).unwrap())); +} + +pub fn claim( + deps: DepsMut, + _env: Env, + info: MessageInfo, +) -> Result { + let bond_denom = deps.querier.query_bonded_denom()?; + + match USER_REWARDS.may_load(deps.storage, info.sender.clone())? { + Some(user_reward) => { + USER_REWARDS + .remove(deps.storage, info.sender.clone()); + + Ok(Response::new() + .add_attribute("method", "claim") + .add_message(CosmosMsg::Bank(BankMsg::Send { + to_address: info.sender.to_string(), + amount: vec![Coin { + denom: bond_denom.clone(), + amount: user_reward, + }], + }))) + } + None => Err(StdError::generic_err("User pool does not exist")), + } +} + +pub fn withdraw( + deps: DepsMut, + env: Env, + info: MessageInfo, + amount: Uint128, +) -> Result { + let campaign = CAMPAIGN.load(deps.storage)?; + + if info.sender != campaign.owner { + return Err(StdError::generic_err("Only contract owner can withdraw")); + } + + let bond_denom = deps.querier.query_bonded_denom()?; + + let own_balance: Uint128 = deps + .querier + .query_balance(&env.contract.address, bond_denom.clone()) + .map_err(|_| StdError::generic_err("Failed to query contract balance"))? + .amount; + + if amount > own_balance { + return Err(StdError::generic_err("Not enough funds in the contract")); + } + + let res = Response::new() + .add_attribute("method", "withdraw") + .add_message(CosmosMsg::Bank(BankMsg::Send { + to_address: info.sender.to_string(), + amount: vec![Coin { + denom: bond_denom.clone(), + amount, + }], + })); + + return Ok(res); +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Campaign {} => query_campaign(deps, env), + QueryMsg::GetUserReward { user_address } => { + query_user_reward(deps, env, user_address) + } + } +} + +pub fn query_campaign(deps: Deps, _env: Env) -> StdResult { + match CAMPAIGN.load(deps.storage) { + Ok(campaign) => return to_json_binary(&campaign), + Err(_) => { + return Err(StdError::generic_err("Failed to load campaign data")) + } + } +} + +pub fn query_user_reward( + deps: Deps, + _env: Env, + user_address: Addr, +) -> StdResult { + match USER_REWARDS.load(deps.storage, user_address) { + Ok(user_reward) => return to_json_binary(&user_reward), + Err(_) => { + return Err(StdError::generic_err("User reward does not exist")) + } + }; +} diff --git a/contracts/airdrop/src/lib.rs b/contracts/airdrop/src/lib.rs new file mode 100644 index 0000000..b88f588 --- /dev/null +++ b/contracts/airdrop/src/lib.rs @@ -0,0 +1,6 @@ +pub mod contract; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; diff --git a/contracts/airdrop/src/msg.rs b/contracts/airdrop/src/msg.rs new file mode 100644 index 0000000..5cc1369 --- /dev/null +++ b/contracts/airdrop/src/msg.rs @@ -0,0 +1,43 @@ +use cosmwasm_std::{Uint128, Addr}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct InstantiateMsg { + pub campaign_id: String, + pub campaign_name: String, + pub campaign_description: String, + pub managers: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct RewardUserRequest { + pub user_address: Addr, + pub amount: Uint128, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct RewardUserResponse { + pub user_address: Addr, + pub success: bool, + pub error_msg: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ExecuteMsg { + RewardUsers { + requests: Vec + }, + Claim {}, + Withdraw { + amount: Uint128, + }, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum QueryMsg { + Campaign { }, + GetUserReward { user_address: Addr }, +} + diff --git a/contracts/airdrop/src/state.rs b/contracts/airdrop/src/state.rs new file mode 100644 index 0000000..4296a65 --- /dev/null +++ b/contracts/airdrop/src/state.rs @@ -0,0 +1,18 @@ +use cosmwasm_std::{Addr, Uint128}; +use cw_storage_plus::{Map, Item}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Campaign { + pub campaign_id: String, + pub campaign_name: String, + pub campaign_description: String, + + pub unallocated_amount: Uint128, + pub owner: Addr, + pub managers: Vec, +} + +pub const CAMPAIGN: Item = Item::new("campaign"); +pub const USER_REWARDS: Map = Map::new("user_rewards"); diff --git a/contracts/airdrop/src/tests/execute/claim.rs b/contracts/airdrop/src/tests/execute/claim.rs new file mode 100644 index 0000000..a9b65fe --- /dev/null +++ b/contracts/airdrop/src/tests/execute/claim.rs @@ -0,0 +1,79 @@ +use crate::contract::{claim, instantiate, reward_users}; +use crate::msg::{InstantiateMsg, RewardUserRequest}; +use crate::state::{USER_REWARDS}; +use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; +use cosmwasm_std::{coins, Addr, BankMsg, CosmosMsg, StdError, SubMsg, Uint128}; +use std::vec; + +#[test] +fn test_claim() { + let mut deps = mock_dependencies(); + let env = mock_env(); + + instantiate( + deps.as_mut(), + env.clone(), + mock_info("owner", &coins(1000, "")), + InstantiateMsg { + campaign_id: "campaign_id".to_string(), + campaign_name: "campaign_name".to_string(), + campaign_description: "campaign_description".to_string(), + managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], + }, + ) + .unwrap(); + + reward_users( + deps.as_mut(), + env.clone(), + mock_info("owner", &[]), + vec![ + RewardUserRequest { + user_address: Addr::unchecked("user1"), + amount: Uint128::new(750), + }, + RewardUserRequest { + user_address: Addr::unchecked("user2"), + amount: Uint128::new(250), + }, + ], + ) + .unwrap(); + + // try to claim from user1 + let resp = + claim(deps.as_mut(), env.clone(), mock_info("user1", &[])).unwrap(); + + assert_eq!( + resp.messages, + vec![SubMsg::new(CosmosMsg::Bank(BankMsg::Send { + to_address: "user1".to_string(), + amount: coins(750, ""), + }))] + ); + assert_eq!( + USER_REWARDS.has(deps.as_ref().storage, Addr::unchecked("user1")), + false + ); + + // try to claim from user2 + let resp = + claim(deps.as_mut(), env.clone(), mock_info("user2", &[])).unwrap(); + + assert_eq!( + resp.messages, + vec![SubMsg::new(CosmosMsg::Bank(BankMsg::Send { + to_address: "user2".to_string(), + amount: coins(250, ""), + }))] + ); + assert_eq!( + USER_REWARDS.has(deps.as_ref().storage, Addr::unchecked("user2")), + false + ); + + // try to claim from user3 who doesn't exist + let resp = claim(deps.as_mut(), env.clone(), mock_info("user3", &[])); + + assert_eq!(resp, Err(StdError::generic_err("User pool does not exist"))); +} diff --git a/contracts/airdrop/src/tests/execute/mod.rs b/contracts/airdrop/src/tests/execute/mod.rs new file mode 100644 index 0000000..38d240a --- /dev/null +++ b/contracts/airdrop/src/tests/execute/mod.rs @@ -0,0 +1,3 @@ +mod claim; +mod reward_users; +mod withdraw; \ No newline at end of file diff --git a/contracts/airdrop/src/tests/execute/reward_users.rs b/contracts/airdrop/src/tests/execute/reward_users.rs new file mode 100644 index 0000000..6d3f1b8 --- /dev/null +++ b/contracts/airdrop/src/tests/execute/reward_users.rs @@ -0,0 +1,173 @@ +use crate::contract::{instantiate, reward_users}; +use crate::msg::{InstantiateMsg, RewardUserRequest, RewardUserResponse}; +use crate::state::{Campaign, CAMPAIGN, USER_REWARDS}; +use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; +use cosmwasm_std::{coins, from_json, Addr, Uint128}; +use std::vec; + +#[test] +fn test_reward_users_fully_allocated() { + let mut deps = mock_dependencies(); + let env = mock_env(); + + instantiate( + deps.as_mut(), + env.clone(), + mock_info("owner", &coins(1000, "")), + InstantiateMsg { + campaign_id: "campaign_id".to_string(), + campaign_name: "campaign_name".to_string(), + campaign_description: "campaign_description".to_string(), + managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], + }, + ) + .unwrap(); + + let resp = reward_users( + deps.as_mut(), + env.clone(), + mock_info("owner", &[]), + vec![ + RewardUserRequest { + user_address: Addr::unchecked("user1"), + amount: Uint128::new(750), + }, + RewardUserRequest { + user_address: Addr::unchecked("user2"), + amount: Uint128::new(250), + }, + ], + ) + .unwrap(); + + // assert response + let user_responses: Vec = + from_json(resp.data.unwrap()).unwrap(); + assert_eq!( + user_responses, + vec![ + RewardUserResponse { + user_address: Addr::unchecked("user1"), + success: true, + error_msg: "".to_string(), + }, + RewardUserResponse { + user_address: Addr::unchecked("user2"), + success: true, + error_msg: "".to_string(), + }, + ] + ); + + // assert inner state of the contract + let campaign = CAMPAIGN.load(deps.as_ref().storage).unwrap(); + assert_eq!( + campaign, + Campaign { + owner: Addr::unchecked("owner"), + unallocated_amount: Uint128::zero(), + campaign_id: "campaign_id".to_string(), + campaign_name: "campaign_name".to_string(), + campaign_description: "campaign_description".to_string(), + managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], + } + ); + + assert_eq!( + USER_REWARDS + .load(deps.as_ref().storage, Addr::unchecked("user1")) + .unwrap(), + Uint128::new(750) + ); + + assert_eq!( + USER_REWARDS + .load(deps.as_ref().storage, Addr::unchecked("user2")) + .unwrap(), + Uint128::new(250) + ); +} + + +#[test] +fn test_reward_users_as_manager() { + let mut deps = mock_dependencies(); + let env = mock_env(); + + instantiate( + deps.as_mut(), + env.clone(), + mock_info("owner", &coins(1000, "")), + InstantiateMsg { + campaign_id: "campaign_id".to_string(), + campaign_name: "campaign_name".to_string(), + campaign_description: "campaign_description".to_string(), + managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], + }, + ) + .unwrap(); + + let resp = reward_users( + deps.as_mut(), + env.clone(), + mock_info("manager1", &[]), + vec![ + RewardUserRequest { + user_address: Addr::unchecked("user1"), + amount: Uint128::new(750), + }, + RewardUserRequest { + user_address: Addr::unchecked("user2"), + amount: Uint128::new(250), + }, + ], + ) + .unwrap(); + + // assert response + let user_responses: Vec = + from_json(resp.data.unwrap()).unwrap(); + assert_eq!( + user_responses, + vec![ + RewardUserResponse { + user_address: Addr::unchecked("user1"), + success: true, + error_msg: "".to_string(), + }, + RewardUserResponse { + user_address: Addr::unchecked("user2"), + success: true, + error_msg: "".to_string(), + }, + ] + ); + + // assert inner state of the contract + let campaign = CAMPAIGN.load(deps.as_ref().storage).unwrap(); + assert_eq!( + campaign, + Campaign { + owner: Addr::unchecked("owner"), + unallocated_amount: Uint128::zero(), + campaign_id: "campaign_id".to_string(), + campaign_name: "campaign_name".to_string(), + campaign_description: "campaign_description".to_string(), + managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], + } + ); + + assert_eq!( + USER_REWARDS + .load(deps.as_ref().storage, Addr::unchecked("user1")) + .unwrap(), + Uint128::new(750) + ); + + assert_eq!( + USER_REWARDS + .load(deps.as_ref().storage, Addr::unchecked("user2")) + .unwrap(), + Uint128::new(250) + ); +} diff --git a/contracts/airdrop/src/tests/execute/withdraw.rs b/contracts/airdrop/src/tests/execute/withdraw.rs new file mode 100644 index 0000000..5144f57 --- /dev/null +++ b/contracts/airdrop/src/tests/execute/withdraw.rs @@ -0,0 +1,106 @@ +use crate::contract::{instantiate, withdraw}; +use crate::msg::InstantiateMsg; +use cosmwasm_std::testing::{ + mock_dependencies, mock_dependencies_with_balance, mock_env, mock_info, +}; +use cosmwasm_std::{coins, BankMsg, CosmosMsg, StdError, SubMsg, Uint128, Addr}; +use std::vec; + +#[test] +fn test_withdraw_ok() { + let mut deps = mock_dependencies_with_balance(&coins(1000, "")); + let env = mock_env(); + + instantiate( + deps.as_mut(), + env.clone(), + mock_info("owner", &coins(1000, "")), + InstantiateMsg { + campaign_id: "campaign_id".to_string(), + campaign_name: "campaign_name".to_string(), + campaign_description: "campaign_description".to_string(), + managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], + }, + ) + .unwrap(); + + // try to withdraw + let resp = withdraw( + deps.as_mut(), + env.clone(), + mock_info("owner", &[]), + Uint128::new(1000), + ) + .unwrap(); + + assert_eq!( + resp.messages, + vec![SubMsg::new(CosmosMsg::Bank(BankMsg::Send { + to_address: "owner".to_string(), + amount: coins(1000, ""), + }))] + ); +} + +#[test] +fn test_withdraw_too_much() { + let mut deps = mock_dependencies_with_balance(&coins(1000, "")); + let env = mock_env(); + + instantiate( + deps.as_mut(), + env.clone(), + mock_info("owner", &coins(1000, "")), + InstantiateMsg { + campaign_id: "campaign_id".to_string(), + campaign_name: "campaign_name".to_string(), + campaign_description: "campaign_description".to_string(), + managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], + }, + ) + .unwrap(); + + // try to withdraw + let resp = withdraw( + deps.as_mut(), + env.clone(), + mock_info("owner", &[]), + Uint128::new(1001), + ); + + assert_eq!( + resp, + Err(StdError::generic_err("Not enough funds in the contract")) + ); +} + +#[test] +fn test_withdraw_unauthorized() { + let mut deps = mock_dependencies(); + let env = mock_env(); + + instantiate( + deps.as_mut(), + env.clone(), + mock_info("owner", &coins(1000, "")), + InstantiateMsg { + campaign_id: "campaign_id".to_string(), + campaign_name: "campaign_name".to_string(), + campaign_description: "campaign_description".to_string(), + managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], + }, + ) + .unwrap(); + + // try to withdraw + let res = withdraw( + deps.as_mut(), + env.clone(), + mock_info("not_owner", &[]), + Uint128::new(1000), + ); + assert_eq!( + res, + Err(StdError::generic_err("Only contract owner can withdraw")) + ); +} diff --git a/contracts/airdrop/src/tests/instantiate.rs b/contracts/airdrop/src/tests/instantiate.rs new file mode 100644 index 0000000..393e553 --- /dev/null +++ b/contracts/airdrop/src/tests/instantiate.rs @@ -0,0 +1,66 @@ +use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; +use cosmwasm_std::{Uint128, Addr, coins, StdError}; + +use crate::contract::instantiate; +use crate::msg::InstantiateMsg; +use crate::state::{Campaign, CAMPAIGN}; + +#[test] +fn test_instantiate() { + let mut deps = mock_dependencies(); + let info = mock_info("sender", &coins(1000, "")); + let env = mock_env(); + let msg = InstantiateMsg { + campaign_id: "campaign_id".to_string(), + campaign_name: "campaign_name".to_string(), + campaign_description: "campaign_description".to_string(), + managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], + }; + + instantiate(deps.as_mut(), env.clone(), info.clone(), msg.clone()).unwrap(); + + let campaign = CAMPAIGN.load(deps.as_ref().storage).unwrap(); + assert_eq!( + campaign, + Campaign { + owner: Addr::unchecked("sender"), + managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], + unallocated_amount: Uint128::new(1000), + campaign_id: "campaign_id".to_string(), + campaign_name: "campaign_name".to_string(), + campaign_description: "campaign_description".to_string(), + } + ); +} + +#[test] +fn test_instantiate_with_no_funds() { + let mut deps = mock_dependencies(); + let info = mock_info("sender", &[]); + let env = mock_env(); + let msg = InstantiateMsg { + campaign_id: "campaign_id".to_string(), + campaign_name: "campaign_name".to_string(), + campaign_description: "campaign_description".to_string(), + managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], + }; + + let resp = instantiate(deps.as_mut(), env.clone(), info.clone(), msg.clone()); + assert_eq!(resp, Err(StdError::generic_err("Only one coin is allowed"))); +} + +#[test] +fn test_instantiate_with_invalid_denom() { + let mut deps = mock_dependencies(); + let info = mock_info("sender", &coins(1000, "foo")); + let env = mock_env(); + let msg = InstantiateMsg { + campaign_id: "campaign_id".to_string(), + campaign_name: "campaign_name".to_string(), + campaign_description: "campaign_description".to_string(), + managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], + }; + + let resp = instantiate(deps.as_mut(), env.clone(), info.clone(), msg.clone()); + assert_eq!(resp, Err(StdError::generic_err("Only native tokens are allowed"))); +} \ No newline at end of file diff --git a/contracts/airdrop/src/tests/mod.rs b/contracts/airdrop/src/tests/mod.rs new file mode 100644 index 0000000..0d532e5 --- /dev/null +++ b/contracts/airdrop/src/tests/mod.rs @@ -0,0 +1,3 @@ +mod instantiate; +mod execute; +mod query; \ No newline at end of file diff --git a/contracts/airdrop/src/tests/query/campaign.rs b/contracts/airdrop/src/tests/query/campaign.rs new file mode 100644 index 0000000..bacd199 --- /dev/null +++ b/contracts/airdrop/src/tests/query/campaign.rs @@ -0,0 +1,48 @@ +use cosmwasm_std::testing::{ + mock_dependencies, mock_env, mock_info, +}; +use cosmwasm_std::{ + coins, from_json, Addr, Uint128, +}; + +use crate::contract::{ + instantiate, query_campaign, +}; +use crate::msg:: + InstantiateMsg +; +use crate::state::{Campaign}; + +#[test] +fn test_query_campaign() { + let mut deps = mock_dependencies(); + let env = mock_env(); + + instantiate( + deps.as_mut(), + env.clone(), + mock_info("owner", &coins(1000, "")), + InstantiateMsg { + campaign_id: "campaign_id".to_string(), + campaign_name: "campaign_name".to_string(), + campaign_description: "campaign_description".to_string(), + managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], + }, + ) + .unwrap(); + + let res = + query_campaign(deps.as_ref(), env.clone()).unwrap(); + let campaign: Campaign = from_json(res).unwrap(); + assert_eq!( + campaign, + Campaign { + campaign_id: "campaign_id".to_string(), + campaign_name: "campaign_name".to_string(), + campaign_description: "campaign_description".to_string(), + owner: Addr::unchecked("owner"), + managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], + unallocated_amount: Uint128::new(1000), + } + ); +} \ No newline at end of file diff --git a/contracts/airdrop/src/tests/query/mod.rs b/contracts/airdrop/src/tests/query/mod.rs new file mode 100644 index 0000000..f0340ec --- /dev/null +++ b/contracts/airdrop/src/tests/query/mod.rs @@ -0,0 +1,2 @@ +mod campaign; +mod user_pool; \ No newline at end of file diff --git a/contracts/airdrop/src/tests/query/user_pool.rs b/contracts/airdrop/src/tests/query/user_pool.rs new file mode 100644 index 0000000..e650845 --- /dev/null +++ b/contracts/airdrop/src/tests/query/user_pool.rs @@ -0,0 +1,64 @@ +use crate::contract::{instantiate, query_user_reward, reward_users}; +use crate::msg::{InstantiateMsg, RewardUserRequest}; +use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; +use cosmwasm_std::{coins, from_json, Addr, StdError, Uint128}; +use std::vec; + +#[test] +fn test_query_user_pool() { + let mut deps = mock_dependencies(); + let env = mock_env(); + + instantiate( + deps.as_mut(), + env.clone(), + mock_info("owner", &coins(1000, "")), + InstantiateMsg { + campaign_id: "campaign_id".to_string(), + campaign_name: "campaign_name".to_string(), + campaign_description: "campaign_description".to_string(), + managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], + }, + ) + .unwrap(); + + reward_users( + deps.as_mut(), + env.clone(), + mock_info("owner", &[]), + vec![RewardUserRequest { + user_address: Addr::unchecked("user1"), + amount: Uint128::new(999), + }], + ) + .unwrap(); + + let res = + query_user_reward(deps.as_ref(), env.clone(), Addr::unchecked("user1")) + .unwrap(); + let user_pool: Uint128 = from_json(res).unwrap(); + assert_eq!(user_pool, Uint128::new(999)); +} + +#[test] +fn test_query_user_pool_empty() { + let mut deps = mock_dependencies(); + let env = mock_env(); + + instantiate( + deps.as_mut(), + env.clone(), + mock_info("owner", &coins(1000, "")), + InstantiateMsg { + campaign_id: "campaign_id".to_string(), + campaign_name: "campaign_name".to_string(), + campaign_description: "campaign_description".to_string(), + managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], + }, + ) + .unwrap(); + + let res = + query_user_reward(deps.as_ref(), env.clone(), Addr::unchecked("user1")); + assert_eq!(res, Err(StdError::generic_err("User reward does not exist"))); +}