From ea483f1a4ad8f0a523895e32cd2e3ff56d301d5b Mon Sep 17 00:00:00 2001 From: Dimitrije Dragasevic Date: Wed, 11 Dec 2024 14:53:18 +0100 Subject: [PATCH] Added Discarded state to cf contract (#715) --- CHANGELOG.md | 2 + Cargo.lock | 2 +- .../andromeda-crowdfund/Cargo.toml | 2 +- .../andromeda-crowdfund/src/contract.rs | 102 +++++----- .../andromeda-crowdfund/src/testing/tests.rs | 178 ++++++++++++------ .../src/crowdfund.rs | 3 + packages/std/src/error.rs | 3 +- tests-integration/tests/crowdfund_app.rs | 11 +- 8 files changed, 191 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26275e344..5b5b5c1d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added +- Crowdfund, added additional state [(#715)](https://github.com/andromedaprotocol/andromeda-core/pull/715) - Added optional config for Send in Splitter contracts [(#686)](https://github.com/andromedaprotocol/andromeda-core/pull/686) - Added CW20 suppport in Splitter contracts [(#703)](https://github.com/andromedaprotocol/andromeda-core/pull/703) @@ -70,6 +71,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Include ADOBase Version in Schema [(#574)](https://github.com/andromedaprotocol/andromeda-core/pull/574) - Added multi-hop support for IBC [(#604)](https://github.com/andromedaprotocol/andromeda-core/pull/604) + ### Changed - Merkle Root: stage expiration now uses `Milliseconds`[(#417)](https://github.com/andromedaprotocol/andromeda-core/pull/417) diff --git a/Cargo.lock b/Cargo.lock index 371f6e488..77e4e6d68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -248,7 +248,7 @@ dependencies = [ [[package]] name = "andromeda-crowdfund" -version = "2.1.4-beta" +version = "2.1.5-beta" dependencies = [ "andromeda-app", "andromeda-non-fungible-tokens", diff --git a/contracts/non-fungible-tokens/andromeda-crowdfund/Cargo.toml b/contracts/non-fungible-tokens/andromeda-crowdfund/Cargo.toml index e33bac69a..ecf9464d7 100644 --- a/contracts/non-fungible-tokens/andromeda-crowdfund/Cargo.toml +++ b/contracts/non-fungible-tokens/andromeda-crowdfund/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "andromeda-crowdfund" -version = "2.1.4-beta" +version = "2.1.5-beta" edition = "2021" rust-version = "1.75.0" diff --git a/contracts/non-fungible-tokens/andromeda-crowdfund/src/contract.rs b/contracts/non-fungible-tokens/andromeda-crowdfund/src/contract.rs index 5db4bd69e..dbad9f6c0 100644 --- a/contracts/non-fungible-tokens/andromeda-crowdfund/src/contract.rs +++ b/contracts/non-fungible-tokens/andromeda-crowdfund/src/contract.rs @@ -141,8 +141,8 @@ pub fn handle_execute(mut ctx: ExecuteContext, msg: ExecuteMsg) -> Result execute_start_campaign(ctx, start_time, end_time, presale), ExecuteMsg::PurchaseTiers { orders } => execute_purchase_tiers(ctx, orders), ExecuteMsg::Receive(msg) => handle_receive_cw20(ctx, msg), - ExecuteMsg::EndCampaign {} => execute_end_campaign(ctx, false), - ExecuteMsg::DiscardCampaign {} => execute_end_campaign(ctx, true), + ExecuteMsg::EndCampaign {} => execute_end_campaign(ctx), + ExecuteMsg::DiscardCampaign {} => execute_discard_campaign(ctx), ExecuteMsg::Claim {} => execute_claim(ctx), _ => ADOContract::default().execute(ctx, msg), }?; @@ -370,9 +370,42 @@ fn handle_receive_cw20( } } +fn execute_discard_campaign(mut ctx: ExecuteContext) -> Result { + nonpayable(&ctx.info)?; + + let ExecuteContext { + ref mut deps, + ref info, + .. + } = ctx; + + // Only owner can discard the campaign + let contract = ADOContract::default(); + ensure!( + contract.is_contract_owner(deps.storage, info.sender.as_str())?, + ContractError::Unauthorized {} + ); + + let curr_stage = get_current_stage(deps.storage); + // Ensure that the campaign is in ONGOING, or READY stage + ensure!( + curr_stage == CampaignStage::ONGOING || curr_stage == CampaignStage::READY, + ContractError::InvalidCampaignOperation { + operation: "discard_campaign".to_string(), + stage: curr_stage.to_string() + } + ); + + // Set to DISCARDED state + set_current_stage(deps.storage, CampaignStage::DISCARDED)?; + + Ok(Response::new() + .add_attribute("action", "discard_campaign") + .add_attribute("result", CampaignStage::DISCARDED.to_string())) +} + fn execute_end_campaign( - mut ctx: ExecuteContext, - is_discard: bool, + mut ctx: ExecuteContext ) -> Result { nonpayable(&ctx.info)?; @@ -390,66 +423,46 @@ fn execute_end_campaign( ContractError::Unauthorized {} ); - // Campaign is finished already successfully - // NOTE: ending failed campaign has no effect and is ignored let curr_stage = get_current_stage(deps.storage); - let action = if is_discard { - "discard_campaign" - } else { - "end_campaign" - }; - ensure!( - curr_stage == CampaignStage::ONGOING - || (is_discard && curr_stage != CampaignStage::SUCCESS), + curr_stage == CampaignStage::ONGOING, ContractError::InvalidCampaignOperation { - operation: action.to_string(), + operation: "end_campaign".to_string(), stage: curr_stage.to_string() } ); + let duration = get_duration(deps.storage)?; let current_capital = get_current_capital(deps.storage); let campaign_config = get_config(deps.storage)?; - let duration = get_duration(deps.storage)?; let soft_cap = campaign_config.soft_cap.unwrap_or(Uint128::one()); - let end_time = duration.end_time; - - // Decide the next stage - let next_stage = match ( - is_discard, - current_capital >= soft_cap, - end_time.is_expired(&env.block), - ) { - // discard the campaign as there are some unexpected issues - (true, _, _) => CampaignStage::FAILED, - // Capital hit the target capital and thus campaign is successful - (false, true, _) => CampaignStage::SUCCESS, - // Capital did hit the target capital and is expired, failed - (false, false, true) => CampaignStage::FAILED, - // Capital did not hit the target capital and campaign is not expired - (false, false, false) => { - if current_capital != Uint128::zero() { - // Need to wait until campaign expires - return Err(ContractError::CampaignNotExpired {}); - } - // No capital is gained and thus it can be paused and restart again - CampaignStage::READY + + // Decide the next stage based on capital and expiry + let final_stage = match (duration.end_time.is_expired(&env.block), current_capital >= soft_cap) { + // Success if soft cap is met + (_, true) => CampaignStage::SUCCESS, + // Failed if expired and soft cap not met + (true, false) => CampaignStage::FAILED, + // Error only if not expired and soft cap not met + (false, false) => { + return Err(ContractError::CampaignNotExpired {}); } }; - set_current_stage(deps.storage, next_stage.clone())?; + set_current_stage(deps.storage, final_stage.clone())?; let mut resp = Response::new() - .add_attribute("action", action) - .add_attribute("result", next_stage.to_string()); - if next_stage == CampaignStage::SUCCESS { + .add_attribute("action", "end_campaign") + .add_attribute("result", final_stage.to_string()); + + // If campaign is successful, withdraw funds to recipient + if final_stage == CampaignStage::SUCCESS { let campaign_denom = match campaign_config.denom { Asset::Cw20Token(ref cw20_token) => Asset::Cw20Token(AndrAddr::from_string( cw20_token.get_raw_address(&deps.as_ref())?.to_string(), )), denom => denom, }; - resp = resp.add_submessage(withdraw_to_recipient( ctx, campaign_config.withdrawal_recipient, @@ -457,6 +470,7 @@ fn execute_end_campaign( campaign_denom, )?); } + Ok(resp) } @@ -618,7 +632,7 @@ fn execute_claim(ctx: ExecuteContext) -> Result { let sub_response = match curr_stage { CampaignStage::SUCCESS => handle_successful_claim(deps.branch(), &info.sender)?, - CampaignStage::FAILED => handle_failed_claim(deps.branch(), &info.sender)?, + CampaignStage::FAILED | CampaignStage::DISCARDED => handle_failed_claim(deps.branch(), &info.sender)?, _ => { return Err(ContractError::InvalidCampaignOperation { operation: "Claim".to_string(), diff --git a/contracts/non-fungible-tokens/andromeda-crowdfund/src/testing/tests.rs b/contracts/non-fungible-tokens/andromeda-crowdfund/src/testing/tests.rs index c12c6a956..6b9155a6e 100644 --- a/contracts/non-fungible-tokens/andromeda-crowdfund/src/testing/tests.rs +++ b/contracts/non-fungible-tokens/andromeda-crowdfund/src/testing/tests.rs @@ -1092,7 +1092,6 @@ mod test { soft_cap: Option, end_time: MillisecondsExpiration, denom: Asset, - is_discard: bool, expected_res: Result, expected_stage: CampaignStage, } @@ -1126,7 +1125,6 @@ mod test { soft_cap: Some(Uint128::new(9000u128)), end_time: MillisecondsExpiration::from_seconds(env.block.time.seconds()), denom: Asset::NativeToken(MOCK_NATIVE_DENOM.to_string()), - is_discard: false, expected_res: Ok(Response::new() .add_attribute("action", "end_campaign") .add_attribute("result", CampaignStage::SUCCESS.to_string()) @@ -1153,7 +1151,6 @@ mod test { soft_cap: Some(Uint128::new(9000u128)), end_time: MillisecondsExpiration::from_seconds(env.block.time.seconds()), denom: Asset::Cw20Token(AndrAddr::from_string(MOCK_CW20_CONTRACT.to_string())), - is_discard: false, expected_res: Ok(Response::new() .add_attribute("action", "end_campaign") .add_attribute("result", CampaignStage::SUCCESS.to_string()) @@ -1190,7 +1187,6 @@ mod test { soft_cap: Some(Uint128::new(11000u128)), end_time: MillisecondsExpiration::from_seconds(env.block.time.seconds()), denom: Asset::Cw20Token(AndrAddr::from_string(MOCK_CW20_CONTRACT.to_string())), - is_discard: false, expected_res: Ok(Response::new() .add_attribute("action", "end_campaign") .add_attribute("result", CampaignStage::FAILED.to_string()) @@ -1209,56 +1205,15 @@ mod test { expected_stage: CampaignStage::FAILED, }, EndCampaignTestCase { - name: "Discard campaign using native token".to_string(), - stage: CampaignStage::ONGOING, - sender: MOCK_DEFAULT_OWNER.to_string(), - current_capital: Uint128::new(10000u128), - soft_cap: Some(Uint128::new(9000u128)), - end_time: MillisecondsExpiration::from_seconds(env.block.time.seconds()), - denom: Asset::NativeToken(MOCK_NATIVE_DENOM.to_string()), - is_discard: true, - expected_res: Ok(Response::new() - .add_attribute("action", "discard_campaign") - .add_attribute("result", CampaignStage::FAILED.to_string()) - .add_submessage(SubMsg::reply_on_error( - CosmosMsg::Wasm(WasmMsg::Execute { - contract_addr: "economics_contract".to_string(), - msg: to_json_binary(&EconomicsExecuteMsg::PayFee { - payee: Addr::unchecked(MOCK_DEFAULT_OWNER.to_string()), - action: "DiscardCampaign".to_string(), - }) - .unwrap(), - funds: vec![], - }), - ReplyId::PayFee.repr(), - ))), - expected_stage: CampaignStage::FAILED, - }, - EndCampaignTestCase { - name: "Pause campaign".to_string(), + name: "Cannot end non-expired campaign".to_string(), // Changed name to better reflect behavior stage: CampaignStage::ONGOING, sender: MOCK_DEFAULT_OWNER.to_string(), current_capital: Uint128::new(0u128), soft_cap: None, end_time: MillisecondsExpiration::from_seconds(env.block.time.seconds() + 1000), denom: Asset::Cw20Token(AndrAddr::from_string(MOCK_CW20_CONTRACT.to_string())), - is_discard: false, - expected_res: Ok(Response::new() - .add_attribute("action", "end_campaign") - .add_attribute("result", CampaignStage::READY.to_string()) - .add_submessage(SubMsg::reply_on_error( - CosmosMsg::Wasm(WasmMsg::Execute { - contract_addr: "economics_contract".to_string(), - msg: to_json_binary(&EconomicsExecuteMsg::PayFee { - payee: Addr::unchecked(MOCK_DEFAULT_OWNER.to_string()), - action: "EndCampaign".to_string(), - }) - .unwrap(), - funds: vec![], - }), - ReplyId::PayFee.repr(), - ))), - expected_stage: CampaignStage::READY, + expected_res: Err(ContractError::CampaignNotExpired {}), + expected_stage: CampaignStage::ONGOING, // Stage won't change on error }, EndCampaignTestCase { name: "End campaign from unauthorized sender".to_string(), @@ -1268,7 +1223,6 @@ mod test { soft_cap: None, end_time: MillisecondsExpiration::from_seconds(env.block.time.seconds()), denom: Asset::NativeToken(MOCK_NATIVE_DENOM.to_string()), - is_discard: false, expected_res: Err(ContractError::Unauthorized {}), expected_stage: CampaignStage::ONGOING, }, @@ -1280,7 +1234,6 @@ mod test { soft_cap: None, end_time: MillisecondsExpiration::from_seconds(env.block.time.seconds()), denom: Asset::NativeToken(MOCK_NATIVE_DENOM.to_string()), - is_discard: false, expected_res: Err(ContractError::InvalidCampaignOperation { operation: "end_campaign".to_string(), stage: CampaignStage::READY.to_string(), @@ -1295,7 +1248,7 @@ mod test { soft_cap: Some(Uint128::new(11000u128)), end_time: MillisecondsExpiration::from_seconds(env.block.time.seconds() + 100), denom: Asset::NativeToken(MOCK_NATIVE_DENOM.to_string()), - is_discard: false, + expected_res: Err(ContractError::CampaignNotExpired {}), expected_stage: CampaignStage::ONGOING, }, @@ -1317,11 +1270,7 @@ mod test { set_campaign_config(deps.as_mut().storage, &mock_config); set_campaign_duration(deps.as_mut().storage, &duration); - let msg = if test.is_discard { - ExecuteMsg::DiscardCampaign {} - } else { - ExecuteMsg::EndCampaign {} - }; + let msg = ExecuteMsg::EndCampaign {}; let res = execute(deps.as_mut(), env.clone(), info, msg); assert_eq!(res, test.expected_res, "Test case: {}", test.name); @@ -1338,6 +1287,123 @@ mod test { } } + struct DiscardCampaign { + name: String, + stage: CampaignStage, + sender: String, + current_capital: Uint128, + soft_cap: Option, + end_time: MillisecondsExpiration, + denom: Asset, + expected_res: Result, + expected_stage: CampaignStage, + } + + #[test] + fn test_execute_discard_campaign() { + let env: Env = mock_env(); + + let test_cases: Vec = vec![ + DiscardCampaign { + name: "Discard campaign using native token".to_string(), + stage: CampaignStage::READY, + sender: MOCK_DEFAULT_OWNER.to_string(), + current_capital: Uint128::zero(), + soft_cap: Some(Uint128::new(9000u128)), + end_time: MillisecondsExpiration::from_seconds(env.block.time.seconds()), + denom: Asset::NativeToken(MOCK_NATIVE_DENOM.to_string()), + expected_res: Ok(Response::new() + .add_attribute("action", "discard_campaign") + .add_attribute("result", "DISCARDED") + .add_submessage(SubMsg::reply_on_error( + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: "economics_contract".to_string(), + msg: to_json_binary(&EconomicsExecuteMsg::PayFee { + payee: Addr::unchecked(MOCK_DEFAULT_OWNER), + action: "DiscardCampaign".to_string(), + }) + .unwrap(), + funds: vec![], + }), + ReplyId::PayFee.repr(), + ))), + expected_stage: CampaignStage::DISCARDED, + }, + DiscardCampaign { + name: "Cannot discard campaign in SUCCESS state".to_string(), + stage: CampaignStage::SUCCESS, + sender: MOCK_DEFAULT_OWNER.to_string(), + current_capital: Uint128::zero(), + soft_cap: Some(Uint128::new(9000u128)), + end_time: MillisecondsExpiration::from_seconds(env.block.time.seconds()), + denom: Asset::NativeToken(MOCK_NATIVE_DENOM.to_string()), + expected_res: Err(ContractError::InvalidCampaignOperation { + operation: "discard_campaign".to_string(), + stage: CampaignStage::SUCCESS.to_string(), + }), + expected_stage: CampaignStage::SUCCESS, + }, + DiscardCampaign { + name: "Cannot discard campaign in FAILED state".to_string(), + stage: CampaignStage::FAILED, + sender: MOCK_DEFAULT_OWNER.to_string(), + current_capital: Uint128::zero(), + soft_cap: Some(Uint128::new(9000u128)), + end_time: MillisecondsExpiration::from_seconds(env.block.time.seconds()), + denom: Asset::NativeToken(MOCK_NATIVE_DENOM.to_string()), + expected_res: Err(ContractError::InvalidCampaignOperation { + operation: "discard_campaign".to_string(), + stage: CampaignStage::FAILED.to_string(), + }), + expected_stage: CampaignStage::FAILED, + }, + DiscardCampaign { + name: "Cannot discard campaign with unauthorized sender".to_string(), + stage: CampaignStage::READY, + sender: "unauthorized".to_string(), + current_capital: Uint128::zero(), + soft_cap: Some(Uint128::new(9000u128)), + end_time: MillisecondsExpiration::from_seconds(env.block.time.seconds()), + denom: Asset::NativeToken(MOCK_NATIVE_DENOM.to_string()), + expected_res: Err(ContractError::Unauthorized {}), + expected_stage: CampaignStage::READY, + }, + ]; + + for test in test_cases { + let mut deps = mock_dependencies_custom(&[coin(100000, MOCK_NATIVE_DENOM)]); + let mut mock_config = mock_campaign_config(test.denom.clone()); + let _ = init(deps.as_mut(), mock_config.clone(), vec![]); + + let info = mock_info(&test.sender, &[]); + set_campaign_stage(deps.as_mut().storage, &test.stage); + set_current_capital(deps.as_mut().storage, &test.current_capital); + + mock_config.soft_cap = test.soft_cap; + let duration = Duration { + start_time: None, + end_time: test.end_time, + }; + + set_campaign_config(deps.as_mut().storage, &mock_config); + set_campaign_duration(deps.as_mut().storage, &duration); + let msg = ExecuteMsg::DiscardCampaign {}; + + let res = execute(deps.as_mut(), env.clone(), info, msg); + assert_eq!(res, test.expected_res, "Test case: {}", test.name); + if res.is_ok() { + assert_eq!( + CAMPAIGN_STAGE + .load(&deps.storage) + .unwrap_or(CampaignStage::DISCARDED), + test.expected_stage, + "Test case: {}", + test.name + ); + } + } + } + struct ClaimTestCase { name: String, stage: CampaignStage, diff --git a/packages/andromeda-non-fungible-tokens/src/crowdfund.rs b/packages/andromeda-non-fungible-tokens/src/crowdfund.rs index e3783aeab..a85f1bd80 100644 --- a/packages/andromeda-non-fungible-tokens/src/crowdfund.rs +++ b/packages/andromeda-non-fungible-tokens/src/crowdfund.rs @@ -118,6 +118,8 @@ pub enum CampaignStage { SUCCESS, /// Stage when campaign failed to meet the target cap before expiration FAILED, + /// Stage when campaign is discarded + DISCARDED } impl ToString for CampaignStage { @@ -128,6 +130,7 @@ impl ToString for CampaignStage { Self::ONGOING => "ONGOING".to_string(), Self::SUCCESS => "SUCCESS".to_string(), Self::FAILED => "FAILED".to_string(), + Self::DISCARDED => "DISCARDED".to_string(), } } } diff --git a/packages/std/src/error.rs b/packages/std/src/error.rs index 2d652287f..4a2775b2f 100644 --- a/packages/std/src/error.rs +++ b/packages/std/src/error.rs @@ -72,9 +72,10 @@ pub enum ContractError { operation: String, validator: String, }, + #[error("Invalid Campaign Operation: {operation} on {stage}")] InvalidCampaignOperation { operation: String, stage: String }, - + #[error("No Staking Reward")] InvalidClaim {}, diff --git a/tests-integration/tests/crowdfund_app.rs b/tests-integration/tests/crowdfund_app.rs index e9cd015b2..05d697e86 100644 --- a/tests-integration/tests/crowdfund_app.rs +++ b/tests-integration/tests/crowdfund_app.rs @@ -408,19 +408,12 @@ fn test_crowdfund_app_native_discard( buyer_one_original_balance - Uint128::new(10 * 100 + 200 * 10) ); - let _ = crowdfund.execute_end_campaign(owner.clone(), &mut router); - - let summary = crowdfund.query_campaign_summary(&mut router); - - // Campaign could not be ended due to invalid withdrawal recipient msg - assert_eq!(summary.current_stage, CampaignStage::ONGOING.to_string()); - // Discard campaign let _ = crowdfund.execute_discard_campaign(owner.clone(), &mut router); let summary = crowdfund.query_campaign_summary(&mut router); - assert_eq!(summary.current_stage, CampaignStage::FAILED.to_string()); + assert_eq!(summary.current_stage, CampaignStage::DISCARDED.to_string()); - // Refund + // Verify refunds after discard let buyer_one_original_balance = router .wrap() .query_balance(buyer_one.clone(), "uandr")