From e3b524ffcf21a15ed8547c34c6fc6e719f6832dc Mon Sep 17 00:00:00 2001 From: matthiasmatt Date: Wed, 10 Jan 2024 11:55:14 +0100 Subject: [PATCH] feat: add option to deactivate a campaign --- contracts/airdrop/Cargo.toml | 9 +- contracts/airdrop/src/contract.rs | 81 ++++++++++++-- contracts/airdrop/src/msg.rs | 14 +-- contracts/airdrop/src/state.rs | 5 +- .../airdrop/src/tests/execute/reward_users.rs | 104 +++++++++++++++--- .../airdrop/src/tests/execute/withdraw.rs | 90 ++++++++++++++- contracts/airdrop/src/tests/instantiate.rs | 22 ++-- contracts/airdrop/src/tests/query/campaign.rs | 35 +++--- 8 files changed, 291 insertions(+), 69 deletions(-) diff --git a/contracts/airdrop/Cargo.toml b/contracts/airdrop/Cargo.toml index c4ed42b..2e30cf1 100644 --- a/contracts/airdrop/Cargo.toml +++ b/contracts/airdrop/Cargo.toml @@ -5,9 +5,9 @@ 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", + # 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 @@ -52,4 +52,5 @@ cw2 = { workspace = true } semver = "1" [dev-dependencies] -anyhow = { workspace = true } \ No newline at end of file +anyhow = { workspace = true } +serde-json-wasm = "1.0.0" diff --git a/contracts/airdrop/src/contract.rs b/contracts/airdrop/src/contract.rs index aeac361..cae5d46 100644 --- a/contracts/airdrop/src/contract.rs +++ b/contracts/airdrop/src/contract.rs @@ -37,12 +37,12 @@ pub fn instantiate( } 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, + is_active: true, }; CAMPAIGN.save(deps.storage, &campaign)?; @@ -92,6 +92,7 @@ pub fn execute( } ExecuteMsg::Claim {} => claim(deps, env, info), ExecuteMsg::Withdraw { amount } => withdraw(deps, env, info, amount), + ExecuteMsg::DesactivateCampaign {} => desactivate(deps, env, info), } } @@ -103,18 +104,20 @@ pub fn reward_users( ) -> Result { let mut res = vec![]; - let mut campaign = CAMPAIGN.load(deps.storage).map_err(|_| { - StdError::generic_err("Failed to load campaign data") - })?; + 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) + if campaign.owner != info.sender && !campaign.managers.contains(&info.sender) { return Err(StdError::generic_err("Unauthorized")); } - for req in requests { + if !campaign.is_active { + return Err(StdError::generic_err("Campaign is not active")); + } + for req in requests { if campaign.unallocated_amount < req.amount { return Err(StdError::generic_err( "Not enough funds in the campaign", @@ -160,10 +163,15 @@ pub fn claim( ) -> Result { let bond_denom = deps.querier.query_bonded_denom()?; + let campaign = CAMPAIGN.load(deps.storage)?; + + if !campaign.is_active { + return Err(StdError::generic_err("Campaign is not active")); + } + match USER_REWARDS.may_load(deps.storage, info.sender.clone())? { Some(user_reward) => { - USER_REWARDS - .remove(deps.storage, info.sender.clone()); + USER_REWARDS.remove(deps.storage, info.sender.clone()); Ok(Response::new() .add_attribute("method", "claim") @@ -179,6 +187,37 @@ pub fn claim( } } +pub fn desactivate( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result { + 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) + { + return Err(StdError::generic_err("Unauthorized")); + } + + if !campaign.is_active { + return Err(StdError::generic_err("Campaign is not active")); + } + + campaign.is_active = false; + CAMPAIGN.save(deps.storage, &campaign)?; + + 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; + + return withdraw(deps, env, info, own_balance); +} + pub fn withdraw( deps: DepsMut, env: Env, @@ -213,6 +252,25 @@ pub fn withdraw( }], })); + // Update campaign unallocated amount + if amount > campaign.unallocated_amount { + CAMPAIGN.update( + deps.storage, + |mut campaign| -> StdResult { + campaign.unallocated_amount = Uint128::zero(); + Ok(campaign) + }, + )?; + } else { + CAMPAIGN.update( + deps.storage, + |mut campaign| -> StdResult { + campaign.unallocated_amount -= amount; + Ok(campaign) + }, + )?; + } + return Ok(res); } @@ -240,6 +298,11 @@ pub fn query_user_reward( _env: Env, user_address: Addr, ) -> StdResult { + let campaign = CAMPAIGN.load(deps.storage)?; + if !campaign.is_active { + return Err(StdError::generic_err("Campaign is not active")); + } + match USER_REWARDS.load(deps.storage, user_address) { Ok(user_reward) => return to_json_binary(&user_reward), Err(_) => { diff --git a/contracts/airdrop/src/msg.rs b/contracts/airdrop/src/msg.rs index 5cc1369..9a4f5e0 100644 --- a/contracts/airdrop/src/msg.rs +++ b/contracts/airdrop/src/msg.rs @@ -1,4 +1,4 @@ -use cosmwasm_std::{Uint128, Addr}; +use cosmwasm_std::{Addr, Uint128}; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] @@ -25,19 +25,15 @@ pub struct RewardUserResponse { #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[serde(rename_all = "snake_case")] pub enum ExecuteMsg { - RewardUsers { - requests: Vec - }, + RewardUsers { requests: Vec }, Claim {}, - Withdraw { - amount: Uint128, - }, + Withdraw { amount: Uint128 }, + DesactivateCampaign {}, } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[serde(rename_all = "snake_case")] pub enum QueryMsg { - Campaign { }, + Campaign {}, GetUserReward { user_address: Addr }, } - diff --git a/contracts/airdrop/src/state.rs b/contracts/airdrop/src/state.rs index 4296a65..76be4d5 100644 --- a/contracts/airdrop/src/state.rs +++ b/contracts/airdrop/src/state.rs @@ -1,17 +1,18 @@ use cosmwasm_std::{Addr, Uint128}; -use cw_storage_plus::{Map, Item}; +use cw_storage_plus::{Item, Map}; 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 is_active: bool, } pub const CAMPAIGN: Item = Item::new("campaign"); diff --git a/contracts/airdrop/src/tests/execute/reward_users.rs b/contracts/airdrop/src/tests/execute/reward_users.rs index 8da802c..97de55f 100644 --- a/contracts/airdrop/src/tests/execute/reward_users.rs +++ b/contracts/airdrop/src/tests/execute/reward_users.rs @@ -1,8 +1,10 @@ -use crate::contract::{instantiate, reward_users}; +use crate::contract::{ + claim, desactivate, instantiate, query_user_reward, 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, StdError}; +use cosmwasm_std::{coins, from_json, Addr, StdError, Uint128}; use std::vec; #[test] @@ -18,7 +20,10 @@ fn test_reward_users_fully_allocated() { 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")], + managers: vec![ + Addr::unchecked("manager1"), + Addr::unchecked("manager2"), + ], }, ) .unwrap(); @@ -66,10 +71,13 @@ fn test_reward_users_fully_allocated() { 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")], + managers: vec![ + Addr::unchecked("manager1"), + Addr::unchecked("manager2") + ], + is_active: true, } ); @@ -88,7 +96,6 @@ fn test_reward_users_fully_allocated() { ); } - #[test] fn test_reward_users_as_manager() { let mut deps = mock_dependencies(); @@ -102,7 +109,10 @@ fn test_reward_users_as_manager() { 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")], + managers: vec![ + Addr::unchecked("manager1"), + Addr::unchecked("manager2"), + ], }, ) .unwrap(); @@ -150,10 +160,13 @@ fn test_reward_users_as_manager() { 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")], + managers: vec![ + Addr::unchecked("manager1"), + Addr::unchecked("manager2") + ], + is_active: true, } ); @@ -185,7 +198,10 @@ fn test_fails_when_we_try_to_allocate_more_than_available() { 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")], + managers: vec![ + Addr::unchecked("manager1"), + Addr::unchecked("manager2"), + ], }, ) .unwrap(); @@ -210,7 +226,67 @@ fn test_fails_when_we_try_to_allocate_more_than_available() { ], ); - assert_eq!(resp, Err(StdError::generic_err( - "Not enough funds in the campaign", - ))); -} \ No newline at end of file + assert_eq!( + resp, + Err(StdError::generic_err("Not enough funds in the campaign",)) + ); +} + +#[test] +fn test_fails_we_allocate_inactive() { + 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("manager1", &[]), + vec![RewardUserRequest { + user_address: Addr::unchecked("user1"), + amount: Uint128::new(1), + }], + ) + .unwrap(); + + // desactivate campaign -- fail because not owner + let resp = desactivate(deps.as_mut(), env.clone(), mock_info("user1", &[])); + assert_eq!(resp, Err(StdError::generic_err("Unauthorized"))); + + desactivate(deps.as_mut(), env.clone(), mock_info("owner", &[])).unwrap(); + + let resp = reward_users( + deps.as_mut(), + env.clone(), + mock_info("manager1", &[]), + vec![RewardUserRequest { + user_address: Addr::unchecked("user2"), + amount: Uint128::new(1), + }], + ); + assert_eq!(resp, Err(StdError::generic_err("Campaign is not active",))); + + // user1 should not be able to claim anymore + let resp = claim(deps.as_mut(), env.clone(), mock_info("user1", &[])); + assert_eq!(resp, Err(StdError::generic_err("Campaign is not active"))); + + // user1 query reward says campaign is not active + let resp = + query_user_reward(deps.as_ref(), env.clone(), Addr::unchecked("user1")); + assert_eq!(resp, Err(StdError::generic_err("Campaign is not active"))); +} diff --git a/contracts/airdrop/src/tests/execute/withdraw.rs b/contracts/airdrop/src/tests/execute/withdraw.rs index 5144f57..318380e 100644 --- a/contracts/airdrop/src/tests/execute/withdraw.rs +++ b/contracts/airdrop/src/tests/execute/withdraw.rs @@ -1,9 +1,11 @@ -use crate::contract::{instantiate, withdraw}; +use crate::contract::{desactivate, instantiate, query_campaign, withdraw}; use crate::msg::InstantiateMsg; +use crate::state::Campaign; 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 cosmwasm_std::{coins, Addr, BankMsg, CosmosMsg, StdError, SubMsg, Uint128}; +use serde_json_wasm::from_slice; use std::vec; #[test] @@ -19,7 +21,10 @@ fn test_withdraw_ok() { 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")], + managers: vec![ + Addr::unchecked("manager1"), + Addr::unchecked("manager2"), + ], }, ) .unwrap(); @@ -40,6 +45,75 @@ fn test_withdraw_ok() { amount: coins(1000, ""), }))] ); + + // check that the contract unallocated amount is zero + let binary_campaign = query_campaign(deps.as_ref(), env).unwrap(); + + let campaign: Campaign = from_slice(&binary_campaign).unwrap(); + assert_eq!(campaign.unallocated_amount, Uint128::zero()); +} + +#[test] +fn test_withdraw_less_than_total_amount() { + let mut deps = mock_dependencies_with_balance(&coins(1000, "")); + let env = mock_env(); + + instantiate( + deps.as_mut(), + env.clone(), + mock_info("owner", &coins(1500, "")), + 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(500), + ) + .unwrap(); + + assert_eq!( + resp.messages, + vec![SubMsg::new(CosmosMsg::Bank(BankMsg::Send { + to_address: "owner".to_string(), + amount: coins(500, ""), + }))] + ); + + // check that the contract unallocated amount is zero + let binary_campaign = query_campaign(deps.as_ref(), env.clone()).unwrap(); + + let campaign: Campaign = from_slice(&binary_campaign).unwrap(); + assert_eq!(campaign.unallocated_amount, Uint128::new(1000)); + + // if i desactivate the campaign, everything should be withdrawn + let resp = desactivate(deps.as_mut(), env.clone(), mock_info("owner", &[])) + .unwrap(); + + // We sent the remaining 1000 coins to the owner + assert_eq!( + resp.messages, + vec![SubMsg::new(CosmosMsg::Bank(BankMsg::Send { + to_address: "owner".to_string(), + amount: coins(1000, ""), + }))] + ); + + // check that the contract unallocated amount is zero + let binary_campaign = query_campaign(deps.as_ref(), env.clone()).unwrap(); + let campaign: Campaign = from_slice(&binary_campaign).unwrap(); + assert_eq!(campaign.unallocated_amount, Uint128::zero()); } #[test] @@ -55,7 +129,10 @@ fn test_withdraw_too_much() { 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")], + managers: vec![ + Addr::unchecked("manager1"), + Addr::unchecked("manager2"), + ], }, ) .unwrap(); @@ -87,7 +164,10 @@ fn test_withdraw_unauthorized() { 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")], + managers: vec![ + Addr::unchecked("manager1"), + Addr::unchecked("manager2"), + ], }, ) .unwrap(); diff --git a/contracts/airdrop/src/tests/instantiate.rs b/contracts/airdrop/src/tests/instantiate.rs index 393e553..a2e127a 100644 --- a/contracts/airdrop/src/tests/instantiate.rs +++ b/contracts/airdrop/src/tests/instantiate.rs @@ -1,5 +1,5 @@ use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; -use cosmwasm_std::{Uint128, Addr, coins, StdError}; +use cosmwasm_std::{coins, Addr, StdError, Uint128}; use crate::contract::instantiate; use crate::msg::InstantiateMsg; @@ -24,11 +24,14 @@ fn test_instantiate() { campaign, Campaign { owner: Addr::unchecked("sender"), - managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], + 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(), + is_active: true, } ); } @@ -45,7 +48,8 @@ fn test_instantiate_with_no_funds() { managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], }; - let resp = instantiate(deps.as_mut(), env.clone(), info.clone(), msg.clone()); + let resp = + instantiate(deps.as_mut(), env.clone(), info.clone(), msg.clone()); assert_eq!(resp, Err(StdError::generic_err("Only one coin is allowed"))); } @@ -61,6 +65,10 @@ fn test_instantiate_with_invalid_denom() { 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 + let resp = + instantiate(deps.as_mut(), env.clone(), info.clone(), msg.clone()); + assert_eq!( + resp, + Err(StdError::generic_err("Only native tokens are allowed")) + ); +} diff --git a/contracts/airdrop/src/tests/query/campaign.rs b/contracts/airdrop/src/tests/query/campaign.rs index bacd199..b4c95ec 100644 --- a/contracts/airdrop/src/tests/query/campaign.rs +++ b/contracts/airdrop/src/tests/query/campaign.rs @@ -1,17 +1,9 @@ -use cosmwasm_std::testing::{ - mock_dependencies, mock_env, mock_info, -}; -use cosmwasm_std::{ - coins, from_json, Addr, Uint128, -}; +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}; +use crate::contract::{instantiate, query_campaign}; +use crate::msg::InstantiateMsg; +use crate::state::Campaign; #[test] fn test_query_campaign() { @@ -26,23 +18,28 @@ fn test_query_campaign() { 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")], + managers: vec![ + Addr::unchecked("manager1"), + Addr::unchecked("manager2"), + ], }, ) .unwrap(); - let res = - query_campaign(deps.as_ref(), env.clone()).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")], + managers: vec![ + Addr::unchecked("manager1"), + Addr::unchecked("manager2") + ], unallocated_amount: Uint128::new(1000), + is_active: true, } ); -} \ No newline at end of file +}