diff --git a/chain/tests/e2e/marketplace/buy_credits_test.go b/chain/tests/e2e/marketplace/buy_credits_test.go index 138903614..aa701bf31 100644 --- a/chain/tests/e2e/marketplace/buy_credits_test.go +++ b/chain/tests/e2e/marketplace/buy_credits_test.go @@ -59,7 +59,7 @@ func (s *E2ETestSuite) TestBuyCreditsWithoutFeeSplit() { // Buy some credits out, err = clitestutil.ExecTestCLICmd(val.ClientCtx, executeContractCmd, append([]string{ marketplaceAddress, - fmt.Sprintf(`{"buy_credits": {"owner": "%s", "denom": "PTEST/00001", "number_of_credits_to_buy": 2}}`, creditOwnerAddress.String()), + fmt.Sprintf(`{"buy_credits": {"owner": "%s", "denom": "PTEST/00001", "number_of_credits_to_buy": 2, "retire": false}}`, creditOwnerAddress.String()), fmt.Sprintf("--amount=%s%s", "3000000", sdk.DefaultBondDenom), fmt.Sprintf("--%s=%s", flags.FlagFrom, buyerKey.Name), }, s.CommonFlags...)) @@ -146,7 +146,7 @@ func (s *E2ETestSuite) TestBuyCreditsWithFeeSplit() { // Buy some credits out, err = clitestutil.ExecTestCLICmd(val.ClientCtx, executeContractCmd, append([]string{ marketplaceAddress, - fmt.Sprintf(`{"buy_credits": {"owner": "%s", "denom": "PTEST/00001", "number_of_credits_to_buy": 2}}`, creditOwnerAddress.String()), + fmt.Sprintf(`{"buy_credits": {"owner": "%s", "denom": "PTEST/00001", "number_of_credits_to_buy": 2, "retire": false}}`, creditOwnerAddress.String()), fmt.Sprintf("--amount=%s%s", "3000000", sdk.DefaultBondDenom), fmt.Sprintf("--%s=%s", flags.FlagFrom, buyerKey.Name), fmt.Sprintf("--%s=%s", flags.FlagGas, "300000"), diff --git a/cosmwasm/Cargo.lock b/cosmwasm/Cargo.lock index 44aa55f9a..77c3a7724 100644 --- a/cosmwasm/Cargo.lock +++ b/cosmwasm/Cargo.lock @@ -233,6 +233,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cw-storage-macro" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "853c8ebf6d20542ea0dc57519ee458e7ee0caa3a1848beced2c603153d3f4dbe" +dependencies = [ + "syn", +] + [[package]] name = "cw-storage-plus" version = "1.0.1" @@ -552,6 +561,7 @@ dependencies = [ "cosmwasm-schema", "cosmwasm-std", "cw-multi-test", + "cw-storage-macro", "cw-storage-plus", "fee-splitter", "prost 0.11.8", diff --git a/cosmwasm/Cargo.toml b/cosmwasm/Cargo.toml index 2369db591..3b841912d 100644 --- a/cosmwasm/Cargo.toml +++ b/cosmwasm/Cargo.toml @@ -10,6 +10,7 @@ repository = "https://github.com/EmpowerPlastic/empowerchain" cosmwasm-schema = "1.1.9" cosmwasm-std = "1.1.9" cw-storage-plus = "1.0.1" +cw-storage-macro = "1.0.1" serde = { version = "1.0.151", features = ["derive"] } thiserror = "1.0.38" cosmos-sdk-proto = { version = "0.16", default-features = false } diff --git a/cosmwasm/README.md b/cosmwasm/README.md index e323d40ae..dbea8fffb 100644 --- a/cosmwasm/README.md +++ b/cosmwasm/README.md @@ -1,3 +1,32 @@ # CosmWasm workspace -This workspace contains contracts and libraries meant to be used on EmpowerChain. \ No newline at end of file +This workspace contains contracts and libraries meant to be used on EmpowerChain. + +## Pre-requisites +You need to install and set up the normal CosmWasm toolchain (including cosmwasm-check). + +## Build + +To build: +```bash +$ cargo build +``` + +To build wasm: +```bash +$ cargo wasm +``` + +## Test + +To run the tests: +```bash +$ cargo test +``` + +## Generate schema + +To generate schema: +```bash +$ cargo schema +``` \ No newline at end of file diff --git a/cosmwasm/contracts/plastic-credit-marketplace/Cargo.toml b/cosmwasm/contracts/plastic-credit-marketplace/Cargo.toml index e73ee1957..8110fafab 100644 --- a/cosmwasm/contracts/plastic-credit-marketplace/Cargo.toml +++ b/cosmwasm/contracts/plastic-credit-marketplace/Cargo.toml @@ -8,6 +8,7 @@ repository = { workspace = true } cosmwasm-schema = { workspace = true } cosmwasm-std = { workspace = true } cw-storage-plus = { workspace = true } +cw-storage-macro = { workspace = true } serde = { workspace = true, features = ["derive"] } thiserror = { workspace = true } cosmos-sdk-proto = { workspace = true, default-features = false } diff --git a/cosmwasm/contracts/plastic-credit-marketplace/README.md b/cosmwasm/contracts/plastic-credit-marketplace/README.md index 65caa2498..98dc1f33c 100644 --- a/cosmwasm/contracts/plastic-credit-marketplace/README.md +++ b/cosmwasm/contracts/plastic-credit-marketplace/README.md @@ -1,4 +1,32 @@ # Plastic Credit Marketplace The plastic credit marketplace is cosmwasm smart contract built on top of the EmpowerChain `plasticcredit` module. -It allows users to list and buy plastic credits. \ No newline at end of file +It allows users to list and buy plastic credits. + +## Listings + +The plastic credit marketplace functions by storing a list of `Listing` objects in the state. +Anyone who has a plastic credit can create a listing by using the `CreateListing` message. + +It allows anyone to buy a listing by using the `BuyCredits` message. +They can then buy as many credits as they want (up to the amount listed, and given the correct amount of funds are sent with the message) + +## Operators + +TODO + +Each listing has an optional `operator` field, which allows the operator set to freeze any number of credits in the listing +for the purpose of finishing a transaction off-chain. For instance, for off-chain payments the operator is given the ability to +freeze the credits as a way to escrow the credits. When the payment has been settled off-chain the operator (or the seller) +can then release the credits to the buyer. + +## Fee split + +The plastic credit marketplace uses the `fee-splitter` package to allow for the marketplace to set up a fee structure. +The fee is a percentage of the listing price, and can be split between any number of parties. + +A typical setup might be that the developer, or the deployer of the contract, gets a small fee, as well as the chain it runs on. +For instance, it could be set up like this: +- A total 5% fee for each listing +- 75% of the fee goes to the developer +- 25% of the fee goes to the chain diff --git a/cosmwasm/contracts/plastic-credit-marketplace/src/error.rs b/cosmwasm/contracts/plastic-credit-marketplace/src/error.rs index 87f6f969c..b42b9d26e 100644 --- a/cosmwasm/contracts/plastic-credit-marketplace/src/error.rs +++ b/cosmwasm/contracts/plastic-credit-marketplace/src/error.rs @@ -16,7 +16,7 @@ pub enum ContractError { #[error("listing needs to have a price per credit")] ZeroPrice {}, - #[error("not enough credits available on listing to buy")] + #[error("not enough credits available on listing")] NotEnoughCredits {}, #[error("not enough funds sent to buy credits")] @@ -31,6 +31,18 @@ pub enum ContractError { #[error("listing already exists")] ListingAlreadyExists {}, + #[error("timeout not in future")] + TimeoutNotInFuture {}, + + #[error("timeout over maximum, which is set to {max_timeout} seconds")] + TimeoutTooLong {max_timeout: u64}, + + #[error("freeze timed out")] + TimedOut {}, + + #[error("freeze not found")] + FreezeNotFound {}, + #[error("fee split error {0}")] FeeSplitError(#[from] FeeSplitterError), } \ No newline at end of file diff --git a/cosmwasm/contracts/plastic-credit-marketplace/src/execute.rs b/cosmwasm/contracts/plastic-credit-marketplace/src/execute.rs index 5b45f9ab7..2085ac1ed 100644 --- a/cosmwasm/contracts/plastic-credit-marketplace/src/execute.rs +++ b/cosmwasm/contracts/plastic-credit-marketplace/src/execute.rs @@ -1,19 +1,24 @@ use cosmos_sdk_proto::cosmos::authz::v1beta1::MsgExec; use cosmos_sdk_proto::traits::{Message, TypeUrl}; use cosmos_sdk_proto::traits::MessageExt; -use cosmwasm_std::{entry_point, Binary, DepsMut, Env, MessageInfo, Response, Uint64, Coin, CosmosMsg, BankMsg, Addr, Decimal}; +use cosmwasm_std::{entry_point, Binary, DepsMut, Env, MessageInfo, Response, Uint64, Coin, CosmosMsg, BankMsg, Addr, Decimal, Timestamp, StdError, Uint128}; use fee_splitter::{edit_fee_split_config, get_fee_split, Share}; use crate::{msg::ExecuteMsg, error::ContractError, state::{LISTINGS, Listing}}; use crate::error::ContractError::FeeSplitError; -use crate::state::ADMIN; +use crate::state::{ADMIN, Freeze, freezes}; + +const MAX_TIMEOUT_SECONDS : u64 = 2419200; // 4 weeks #[entry_point] pub fn execute(deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg) -> Result { match msg { - ExecuteMsg::CreateListing { denom, number_of_credits, price_per_credit } => execute_create_listing(deps, env, info, denom, number_of_credits, price_per_credit), - ExecuteMsg::BuyCredits { owner, denom, number_of_credits_to_buy } => execute_buy_credits(deps, env, info, owner, denom, number_of_credits_to_buy), + ExecuteMsg::CreateListing { denom, number_of_credits, price_per_credit, operator } => execute_create_listing(deps, env, info, denom, number_of_credits, price_per_credit, operator), + ExecuteMsg::BuyCredits { owner, denom, number_of_credits_to_buy, retire, retiring_entity_name, retiring_entity_additional_data } => execute_buy_credits(deps, env, info, owner, denom, number_of_credits_to_buy, retire, retiring_entity_name, retiring_entity_additional_data), ExecuteMsg::UpdateListing { denom, number_of_credits, price_per_credit } => execute_update_listing(deps, env, info, denom, number_of_credits, price_per_credit), ExecuteMsg::CancelListing { denom } => execute_cancel_listing(deps, env, info, denom), + ExecuteMsg::FreezeCredits { owner, denom, number_of_credits_to_freeze, buyer, timeout_unix_timestamp } => execute_freeze_credits(deps, env, info, owner, denom, number_of_credits_to_freeze, buyer, timeout_unix_timestamp), + ExecuteMsg::CancelFrozenCredits { owner, denom, number_of_frozen_credits_to_cancel, buyer } => execute_cancel_frozen_credits(deps, env, info, owner, denom, buyer, number_of_frozen_credits_to_cancel), + ExecuteMsg::ReleaseFrozenCredits { owner, denom, number_of_credits_to_release, buyer, retire, retiring_entity_name, retiring_entity_additional_data } => execute_release_frozen_credits(deps, env, info, owner, denom, buyer, number_of_credits_to_release, retire, retiring_entity_name, retiring_entity_additional_data), ExecuteMsg::EditFeeSplitConfig { fee_percentage, shares } => execute_edit_fee_split_config(deps, info, fee_percentage, shares), } } @@ -25,6 +30,7 @@ pub fn execute_create_listing( denom: String, number_of_credits: Uint64, price_per_credit: Coin, + operator: Option, ) -> Result { if number_of_credits.is_zero() { return Err(ContractError::ZeroCredits {}); @@ -43,6 +49,7 @@ pub fn execute_create_listing( denom: denom.clone(), number_of_credits, price_per_credit: price_per_credit.clone(), + operator, }; LISTINGS.save(deps.storage, (info.sender.clone(), denom.clone()), listing)?; @@ -72,6 +79,9 @@ pub fn execute_buy_credits( owner: Addr, denom: String, number_of_credits_to_buy: u64, + retire: bool, + retiring_entity_name: Option, + retiring_entity_additional_data: Option, ) -> Result { if number_of_credits_to_buy == 0 { return Err(ContractError::ZeroCredits {}); @@ -87,12 +97,14 @@ pub fn execute_buy_credits( if info.funds.len() != 1 || info.funds[0].denom != listing.price_per_credit.denom || info.funds[0].amount < total_price { return Err(ContractError::NotEnoughFunds {}); } - if info.funds[0].amount > total_price { + if info.funds[0].amount > total_price { // We can skip the denom check here because it is triggered in the previous if statement return Err(ContractError::TooMuchFunds {}); } listing.number_of_credits = listing.number_of_credits.checked_sub(number_of_credits_to_buy.into()).unwrap(); - if listing.number_of_credits.is_zero() { + //let number_of_frozen_credits = listing.freezes.iter().fold(0, |acc, freeze| acc + freeze.number_of_credits.u64()); + let number_of_frozen_credits = freezes().may_load(deps.storage, (Addr::unchecked(owner.clone()), denom.clone(), info.sender.clone()))?.map_or(0, |freeze| freeze.number_of_credits.u64()); + if listing.number_of_credits.is_zero() && number_of_frozen_credits == 0 { LISTINGS.remove(deps.storage, (Addr::unchecked(owner.clone()), denom.clone())); } else { LISTINGS.save(deps.storage, (Addr::unchecked(owner.clone()), denom.clone()), &listing)?; @@ -102,13 +114,16 @@ pub fn execute_buy_credits( info.sender.to_string(), listing.denom.clone(), number_of_credits_to_buy, + retire, + retiring_entity_name, + retiring_entity_additional_data, ); let funds_before_fee_split = Coin { denom: listing.price_per_credit.denom.clone(), amount: total_price, }; - let (fee_split_msgs, funds_after_split) = get_fee_split(deps.storage, funds_before_fee_split).unwrap(); + let (fee_split_msgs, funds_after_split, _) = get_fee_split(deps.storage, funds_before_fee_split).unwrap(); let transfer_funds_to_seller_msg = CosmosMsg::Bank(BankMsg::Send { to_address: listing.owner.to_string(), amount: vec![funds_after_split], @@ -161,6 +176,9 @@ fn execute_update_listing( info.sender.to_string(), listing.denom.clone(), number_of_credits_to_transfer.into(), + false, + None, + None, )); } else if number_of_credits > listing.number_of_credits { // If the number of credits is increasing, we need to transfer the difference from the owner @@ -209,6 +227,9 @@ fn execute_cancel_listing( info.sender.to_string(), listing.denom.clone(), listing.number_of_credits.into(), + false, + None, + None, ); LISTINGS.remove(deps.storage, (listing.owner.clone(), listing.denom.clone())); @@ -221,6 +242,207 @@ fn execute_cancel_listing( ) } +fn execute_freeze_credits( + deps: DepsMut, + env: Env, + info: MessageInfo, + owner: Addr, + denom: String, + number_of_credits_to_freeze: u64, + buyer: Addr, + timeout_unix_timestamp: u64, +) -> Result { + if number_of_credits_to_freeze == 0 { + return Err(ContractError::ZeroCredits {}); + } + + if timeout_unix_timestamp <= env.block.time.seconds() { + return Err(ContractError::TimeoutNotInFuture {}); + } + + let seconds_until_timeout = timeout_unix_timestamp.checked_sub(env.block.time.seconds()).unwrap(); + if seconds_until_timeout > MAX_TIMEOUT_SECONDS { + return Err(ContractError::TimeoutTooLong {max_timeout: MAX_TIMEOUT_SECONDS}); + } + + let mut listing = LISTINGS.load(deps.storage, (Addr::unchecked(owner.clone()), denom.clone())).map_err(|_| ContractError::ListingNotFound {})?; + + // Check if the sender is either the owner or the operator + if info.sender != listing.owner && info.sender != listing.operator.clone().unwrap_or(Addr::unchecked("".to_string())) { + return Err(ContractError::Unauthorized {}); + } + + if listing.number_of_credits < number_of_credits_to_freeze.into() { + return Err(ContractError::NotEnoughCredits {}); + } + + // Create or update the freeze (if it already exists we add the new ones + update the timeout) + freezes().update(deps.storage, (Addr::unchecked(owner.clone()), denom.clone(), buyer.clone()), |freeze| -> Result { + let mut number_of_credits: Uint64 = number_of_credits_to_freeze.into(); + let mut existing_timeout = 0u64; + + if let Some(freeze) = freeze { + number_of_credits = number_of_credits.checked_add(freeze.number_of_credits).unwrap(); // Add to the existing freeze + existing_timeout = freeze.timeout.seconds(); + } + + // Find the highest timeout + let timeout_unix_timestamp = timeout_unix_timestamp.max(existing_timeout); + + Ok(Freeze { + buyer: buyer.clone(), + number_of_credits, + timeout: Timestamp::from_seconds(timeout_unix_timestamp), // We simply overwrite any existing timeout. Can consider to use the max of the two + listing_key: (Addr::unchecked(owner.clone()), denom.clone()), + }) + })?; + + listing.number_of_credits = listing.number_of_credits.checked_sub(number_of_credits_to_freeze.into()).unwrap(); + + LISTINGS.save(deps.storage, (listing.owner.clone(), listing.denom.clone()), &listing)?; + + Ok(Response::new() + .add_attribute("action", "freeze_credits") + .add_attribute("freezer", info.sender) + .add_attribute("listing_owner", owner) + .add_attribute("denom", denom) + .add_attribute("buyer", buyer) + .add_attribute("number_of_credits_frozen", number_of_credits_to_freeze.to_string()) + .add_attribute("timeout_unix_timestamp", timeout_unix_timestamp.to_string()) + ) +} + +fn execute_cancel_frozen_credits( + deps: DepsMut, + env: Env, + info: MessageInfo, + owner: Addr, + denom: String, + buyer: Addr, + mut number_of_frozen_credits_to_cancel: u64, +) -> Result { + let mut listing = LISTINGS.load(deps.storage, (Addr::unchecked(owner.clone()), denom.clone())).map_err(|_| ContractError::ListingNotFound {})?; + let mut freeze = freezes().may_load(deps.storage, (Addr::unchecked(owner.clone()), denom.clone(), buyer.clone()))?.ok_or(ContractError::FreezeNotFound {})?; + + // Check if timed out, if not, we need to check permissions + if freeze.timeout.seconds() > env.block.time.seconds() { + // Check if the sender is either the owner or the operator + if info.sender != listing.owner && info.sender != listing.operator.clone().unwrap_or(Addr::unchecked("".to_string())) { + return Err(ContractError::Unauthorized {}); + } + } + + if freeze.number_of_credits < number_of_frozen_credits_to_cancel.into() { + return Err(ContractError::NotEnoughCredits {}); + } + + // 0 means all credits, so update the number_of_credits_to_cancel_from_freeze to the number of credits in the freeze + if number_of_frozen_credits_to_cancel == 0 { + number_of_frozen_credits_to_cancel = freeze.number_of_credits.u64(); + } + + freeze.number_of_credits = freeze.number_of_credits.checked_sub(number_of_frozen_credits_to_cancel.into()).unwrap(); + if freeze.number_of_credits.is_zero() { + freezes().remove(deps.storage, (Addr::unchecked(owner.clone()), denom.clone(), buyer.clone()))?; + } else { + freezes().save(deps.storage, (Addr::unchecked(owner.clone()), denom.clone(), buyer.clone()), &freeze)?; + } + + listing.number_of_credits = listing.number_of_credits.checked_add(number_of_frozen_credits_to_cancel.into()).unwrap(); + LISTINGS.save(deps.storage, (listing.owner.clone(), listing.denom.clone()), &listing)?; + + Ok(Response::new() + .add_attribute("action", "freeze_credits") + .add_attribute("canceler", info.sender) + .add_attribute("listing_owner", owner) + .add_attribute("denom", denom) + .add_attribute("buyer", buyer) + .add_attribute("number_of_credits_cancelled_from_freeze", number_of_frozen_credits_to_cancel.to_string()) + ) +} + +fn execute_release_frozen_credits( + deps: DepsMut, + env: Env, + info: MessageInfo, + owner: Addr, + denom: String, + buyer: Addr, + number_of_credits_to_release: u64, + retire: bool, + retiring_entity_name: Option, + retiring_entity_additional_data: Option, +) -> Result { + if number_of_credits_to_release == 0 { + return Err(ContractError::ZeroCredits {}); + } + + let listing = LISTINGS.load(deps.storage, (Addr::unchecked(owner.clone()), denom.clone())).map_err(|_| ContractError::ListingNotFound {})?; + let mut freeze = freezes().may_load(deps.storage, (Addr::unchecked(owner.clone()), denom.clone(), buyer.clone()))?.ok_or(ContractError::FreezeNotFound {})?; + + // Check if timed out + if freeze.timeout.seconds() <= env.block.time.seconds() { + return Err(ContractError::TimedOut {}); + } + + // Check if the sender is either the owner or the operator + if info.sender != listing.owner && info.sender != listing.operator.clone().unwrap_or(Addr::unchecked("".to_string())) { + return Err(ContractError::Unauthorized {}); + } + + if freeze.number_of_credits < number_of_credits_to_release.into() { + return Err(ContractError::NotEnoughCredits {}); + } + + freeze.number_of_credits = freeze.number_of_credits.checked_sub(number_of_credits_to_release.into()).unwrap(); + if freeze.number_of_credits.is_zero() { + freezes().remove(deps.storage, (Addr::unchecked(owner.clone()), denom.clone(), buyer.clone()))?; + } else { + freezes().save(deps.storage, (Addr::unchecked(owner.clone()), denom.clone(), buyer.clone()), &freeze)?; + } + + // We need the original_price to calculate the fee split + let original_price = listing.price_per_credit.amount.checked_mul(number_of_credits_to_release.into()).unwrap(); + let original_price_as_coin = Coin { + denom: listing.price_per_credit.denom.clone(), + amount: original_price, + }; + let (fee_split_msgs, _, fee_amount) = get_fee_split(deps.storage, original_price_as_coin).unwrap(); + if fee_amount.amount > Uint128::from(0u128) { + if info.funds.len() != 1 || info.funds[0].denom != listing.price_per_credit.denom || info.funds[0].amount < fee_amount.amount { + return Err(ContractError::NotEnoughFunds {}); + } + } + + if info.funds.len() > 0 && info.funds[0].amount > fee_amount.amount { // We can skip the denom check here because it is triggered in the previous if statement + return Err(ContractError::TooMuchFunds {}); + } + + let transfer_credits_msg = create_transfer_credits_from_contract_msg( + env, + freeze.buyer.to_string(), + listing.denom.clone(), + number_of_credits_to_release, + retire, + retiring_entity_name, + retiring_entity_additional_data, + ); + let mut msgs = vec![transfer_credits_msg]; + if fee_split_msgs.len() > 0 { + msgs.extend(fee_split_msgs); + } + + Ok(Response::new() + .add_attribute("action", "freeze_credits") + .add_attribute("releaser", info.sender) + .add_attribute("listing_owner", owner) + .add_attribute("denom", denom) + .add_attribute("buyer", buyer) + .add_attribute("number_of_credits_released", number_of_credits_to_release.to_string()) + .add_messages(msgs) + ) +} + fn execute_edit_fee_split_config( deps: DepsMut, info: MessageInfo, @@ -260,6 +482,8 @@ fn create_transfer_credits_to_contract_msg(from: String, to: String, denom: Stri denom, amount, retire: false, + retiring_entity_name: "".to_string(), + retiring_entity_additional_data: "".to_string(), }; let exec_msg = MsgExec { msgs: vec![transfer_msg.to_any().unwrap()], @@ -271,13 +495,15 @@ fn create_transfer_credits_to_contract_msg(from: String, to: String, denom: Stri } } -fn create_transfer_credits_from_contract_msg(env: Env, to: String, denom: String, number_of_credits: u64) -> CosmosMsg { +fn create_transfer_credits_from_contract_msg(env: Env, to: String, denom: String, number_of_credits: u64, retire: bool, retiring_entity_name: Option, retiring_entity_additional_data: Option,) -> CosmosMsg { let transfer_to_buyer_msg = MsgTransferCredits { from: env.contract.address.to_string(), to, denom, amount: number_of_credits, - retire: false, + retire, + retiring_entity_name: retiring_entity_name.unwrap_or("".to_string()), + retiring_entity_additional_data: retiring_entity_additional_data.unwrap_or("".to_string()), }; CosmosMsg::Stargate { type_url: MsgTransferCredits::TYPE_URL.to_string(), @@ -298,6 +524,10 @@ pub struct MsgTransferCredits { pub amount: u64, #[prost(bool, tag = "5")] pub retire: bool, + #[prost(string, tag = "6")] + pub retiring_entity_name: ::prost::alloc::string::String, + #[prost(string, tag = "7")] + pub retiring_entity_additional_data: ::prost::alloc::string::String, } impl TypeUrl for MsgTransferCredits { @@ -322,6 +552,7 @@ mod tests { state::{Listing, LISTINGS}, }; use crate::error::ContractError; + use crate::state::{Freeze, FreezeKey, freezes}; #[test] fn test_create_listing() { @@ -336,6 +567,7 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }, + operator: None, }; let res = execute(deps.as_mut(), mock_env(), info.clone(), msg).unwrap(); @@ -367,6 +599,13 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }); + assert_eq!(listing.operator, None); + + let freezes = freezes().range(deps.as_ref().storage, None, None, Order::Ascending) + .map(|item| item.unwrap()) + .collect::>(); + assert_eq!(freezes.len(), 0); + let all_listings = LISTINGS.range(deps.as_ref().storage, None, None, Order::Ascending) .map(|item| item.unwrap()) @@ -374,6 +613,41 @@ mod tests { assert_eq!(all_listings.len(), 1); } + #[test] + fn test_create_listing_with_operator() { + let mut deps = mock_dependencies(); + let info = mock_info("creator", &coins(2, "token")); + let operator = mock_info("operator", &[]); + instantiate(deps.as_mut(), mock_env(), info.clone(), InstantiateMsg { admin: info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Some(operator.sender.clone()), + }; + + execute(deps.as_mut(), mock_env(), info.clone(), msg).unwrap(); + + let listing = LISTINGS.load(deps.as_ref().storage, (info.sender.clone(), "pcrd".to_string())).unwrap(); + assert_eq!(listing.owner, info.sender); + assert_eq!(listing.denom, "pcrd"); + assert_eq!(listing.number_of_credits, Uint64::from(42u64)); + assert_eq!(listing.price_per_credit, Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }); + assert_eq!(listing.operator, Some(operator.sender.clone())); + + let freezes = freezes().range(deps.as_ref().storage, None, None, Order::Ascending) + .map(|item| item.unwrap()) + .collect::>(); + assert_eq!(freezes.len(), 0); + } + #[test] fn test_create_multiple_listings() { let mut deps = mock_dependencies(); @@ -389,6 +663,7 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }, + operator: None, }; execute(deps.as_mut(), mock_env(), info.clone(), msg.clone()).unwrap(); @@ -414,6 +689,7 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }, + operator: None, }; let err = execute(deps.as_mut(), mock_env(), info.clone(), msg).unwrap_err(); @@ -438,6 +714,7 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(0u128), }, + operator: None, }; let err = execute(deps.as_mut(), mock_env(), info.clone(), msg).unwrap_err(); @@ -462,6 +739,7 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }, + operator: None, }; execute(deps.as_mut(), mock_env(), info.clone(), msg.clone()).unwrap(); @@ -483,13 +761,13 @@ mod tests { traits::MessageExt, traits::Message, }; - use cosmwasm_std::{Coin, CosmosMsg, Decimal, Uint128, Uint64}; + use cosmwasm_std::{Coin, CosmosMsg, Decimal, Order, Uint128, Uint64}; use cosmwasm_std::testing::{MOCK_CONTRACT_ADDR, mock_dependencies, mock_env, mock_info}; use crate::error::ContractError; use crate::execute::{execute, MsgTransferCredits}; use crate::instantiate; use crate::msg::{ExecuteMsg, InstantiateMsg}; - use crate::state::LISTINGS; + use crate::state::{Freeze, FreezeKey, freezes, LISTINGS}; #[test] fn test_update_listing_happy_path_increase_credits() { @@ -504,6 +782,7 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }, + operator: None, }; execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); @@ -531,6 +810,8 @@ mod tests { assert_eq!(transfer_msg.denom, "pcrd"); assert_eq!(transfer_msg.amount, 58); assert_eq!(transfer_msg.retire, false); + assert_eq!(transfer_msg.retiring_entity_name, ""); + assert_eq!(transfer_msg.retiring_entity_additional_data, ""); } else { panic!("Expected Stargate message"); } @@ -543,6 +824,11 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }); + + let freezes = freezes().range(deps.as_ref().storage, None, None, Order::Ascending) + .map(|item| item.unwrap()) + .collect::>(); + assert_eq!(freezes.len(), 0); } #[test] @@ -558,6 +844,7 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }, + operator: None, }; execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); @@ -593,6 +880,11 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }); + + let freezes = freezes().range(deps.as_ref().storage, None, None, Order::Ascending) + .map(|item| item.unwrap()) + .collect::>(); + assert_eq!(freezes.len(), 0); } #[test] @@ -608,6 +900,7 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }, + operator: None, }; execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); @@ -631,6 +924,11 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(42u128), }); + + let freezes = freezes().range(deps.as_ref().storage, None, None, Order::Ascending) + .map(|item| item.unwrap()) + .collect::>(); + assert_eq!(freezes.len(), 0); } #[test] @@ -664,6 +962,7 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }, + operator: None, }; execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); @@ -679,6 +978,37 @@ mod tests { assert_eq!(err, ContractError::ListingNotFound {}); } + #[test] + fn test_update_listing_operator_should_fail() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator = mock_info("operator", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Some(operator.sender.clone()), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let update_listing_msg = ExecuteMsg::UpdateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(1337u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(42u128), + }, + }; + let err = execute(deps.as_mut(), mock_env(), operator, update_listing_msg).unwrap_err(); + assert_eq!(err, ContractError::ListingNotFound {}); + } + + #[test] fn test_update_listing_zero_price() { let mut deps = mock_dependencies(); @@ -692,6 +1022,7 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }, + operator: None, }; execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); @@ -720,6 +1051,7 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }, + operator: None, }; execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); @@ -760,6 +1092,7 @@ mod tests { denom: "umpwr".to_string(), amount: Uint128::from(2u128), }, + operator: None, }; execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); @@ -768,6 +1101,9 @@ mod tests { owner: creator_info.sender.clone(), denom: "pcrd".to_string(), number_of_credits_to_buy: 10u64, + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, }; let res = execute(deps.as_mut(), mock_env(), buyer_info.clone(), buy_credits_msg).unwrap(); assert_eq!(res.attributes.len(), 7); @@ -785,6 +1121,8 @@ mod tests { assert_eq!(transfer_msg.denom, "pcrd"); assert_eq!(transfer_msg.amount, 10); assert_eq!(transfer_msg.retire, false); + assert_eq!(transfer_msg.retiring_entity_name, ""); + assert_eq!(transfer_msg.retiring_entity_additional_data, ""); } else { panic!("Expected Stargate message"); } @@ -820,6 +1158,7 @@ mod tests { denom: "umpwr".to_string(), amount: Uint128::from(1000u128), }, + operator: None, }; execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); @@ -828,6 +1167,9 @@ mod tests { owner: creator_info.sender.clone(), denom: "pcrd".to_string(), number_of_credits_to_buy: 10u64, + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, }; let res = execute(deps.as_mut(), mock_env(), buyer_info.clone(), buy_credits_msg).unwrap(); assert_eq!(res.attributes.len(), 7); @@ -845,6 +1187,8 @@ mod tests { assert_eq!(transfer_msg.denom, "pcrd"); assert_eq!(transfer_msg.amount, 10); assert_eq!(transfer_msg.retire, false); + assert_eq!(transfer_msg.retiring_entity_name, ""); + assert_eq!(transfer_msg.retiring_entity_additional_data, ""); } else { panic!("Expected Stargate message"); } @@ -890,6 +1234,7 @@ mod tests { denom: "umpwr".to_string(), amount: Uint128::from(3u128), }, + operator: None, }; execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); @@ -898,6 +1243,9 @@ mod tests { owner: creator_info.sender.clone(), denom: "pcrd".to_string(), number_of_credits_to_buy: 10u64, + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, }; execute(deps.as_mut(), mock_env(), buyer_info.clone(), buy_credits_msg.clone()).unwrap(); execute(deps.as_mut(), mock_env(), buyer_info.clone(), buy_credits_msg.clone()).unwrap(); @@ -907,6 +1255,74 @@ mod tests { assert_eq!(listing.unwrap_err(), ContractError::ListingNotFound {}); } + #[test] + fn test_buy_credits_with_retire() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(30u64), + price_per_credit: Coin { + denom: "umpwr".to_string(), + amount: Uint128::from(3u128), + }, + operator: None, + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let buyer_info = mock_info("buyer", &coins(30, "umpwr")); + let buy_credits_msg_1 = ExecuteMsg::BuyCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_buy: 10u64, + retire: true, + retiring_entity_name: None, + retiring_entity_additional_data: None, + }; + let res1 = execute(deps.as_mut(), mock_env(), buyer_info.clone(), buy_credits_msg_1.clone()).unwrap(); + let buy_credits_msg_2 = ExecuteMsg::BuyCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_buy: 10u64, + retire: true, + retiring_entity_name: "with_name".to_string().into(), + retiring_entity_additional_data: "with_additional_data".to_string().into(), + }; + let res2 = execute(deps.as_mut(), mock_env(), buyer_info.clone(), buy_credits_msg_2.clone()).unwrap(); + + if let CosmosMsg::Stargate { type_url, value } = &res1.messages[0].msg { + assert_eq!(type_url, MsgTransferCredits::TYPE_URL); + + let transfer_msg = MsgTransferCredits::decode(value.as_slice()).unwrap(); + assert_eq!(transfer_msg.from, MOCK_CONTRACT_ADDR.to_string()); + assert_eq!(transfer_msg.to, buyer_info.sender.to_string()); + assert_eq!(transfer_msg.denom, "pcrd"); + assert_eq!(transfer_msg.amount, 10); + assert_eq!(transfer_msg.retire, true); + assert_eq!(transfer_msg.retiring_entity_name, ""); + assert_eq!(transfer_msg.retiring_entity_additional_data, ""); + } else { + panic!("Expected Stargate message"); + } + + if let CosmosMsg::Stargate { type_url, value } = &res2.messages[0].msg { + assert_eq!(type_url, MsgTransferCredits::TYPE_URL); + + let transfer_msg = MsgTransferCredits::decode(value.as_slice()).unwrap(); + assert_eq!(transfer_msg.from, MOCK_CONTRACT_ADDR.to_string()); + assert_eq!(transfer_msg.to, buyer_info.sender.to_string()); + assert_eq!(transfer_msg.denom, "pcrd"); + assert_eq!(transfer_msg.amount, 10); + assert_eq!(transfer_msg.retire, true); + assert_eq!(transfer_msg.retiring_entity_name, "with_name"); + assert_eq!(transfer_msg.retiring_entity_additional_data, "with_additional_data"); + } else { + panic!("Expected Stargate message"); + } + } + #[test] fn test_listing_does_not_exist() { let mut deps = mock_dependencies(); @@ -918,6 +1334,9 @@ mod tests { owner: creator_info.sender, denom: "pcrd".to_string(), number_of_credits_to_buy: 10u64, + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, }; let err = execute(deps.as_mut(), mock_env(), buyer_info.clone(), buy_credits_msg).unwrap_err(); assert_eq!(err, ContractError::ListingNotFound {}); @@ -936,6 +1355,7 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }, + operator: None, }; execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); @@ -944,6 +1364,9 @@ mod tests { owner: creator_info.sender, denom: "pcrd".to_string(), number_of_credits_to_buy: 0u64, + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, }; let err = execute(deps.as_mut(), mock_env(), buyer_info.clone(), buy_credits_msg).unwrap_err(); assert_eq!(err, ContractError::ZeroCredits {}); @@ -962,6 +1385,7 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(2u128), }, + operator: None, }; execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); @@ -970,6 +1394,9 @@ mod tests { owner: creator_info.sender, denom: "pcrd".to_string(), number_of_credits_to_buy: 1u64, + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, }; let err = execute(deps.as_mut(), mock_env(), buyer_info_with_not_enough_umpwr.clone(), buy_credits_msg).unwrap_err(); assert_eq!(err, ContractError::NotEnoughFunds {}); @@ -988,6 +1415,7 @@ mod tests { denom: "umpwr".to_string(), amount: Uint128::from(2u128), }, + operator: None, }; execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); @@ -996,6 +1424,9 @@ mod tests { owner: creator_info.sender, denom: "pcrd".to_string(), number_of_credits_to_buy: 1u64, + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, }; let err = execute(deps.as_mut(), mock_env(), buyer_info_with_too_much_umpwr.clone(), buy_credits_msg).unwrap_err(); assert_eq!(err, ContractError::TooMuchFunds {}); @@ -1014,6 +1445,7 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }, + operator: None, }; execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); @@ -1022,6 +1454,9 @@ mod tests { owner: creator_info.sender, denom: "pcrd".to_string(), number_of_credits_to_buy: 43u64, + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, }; let err = execute(deps.as_mut(), mock_env(), buyer_info.clone(), buy_credits_msg).unwrap_err(); assert_eq!(err, ContractError::NotEnoughCredits {}); @@ -1052,6 +1487,7 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }, + operator: None, }; let res = execute(deps.as_mut(), mock_env(), creator_info.clone(), msg).unwrap(); @@ -1072,6 +1508,8 @@ mod tests { assert_eq!(transfer_msg.denom, "pcrd"); assert_eq!(transfer_msg.amount, 42); assert_eq!(transfer_msg.retire, false); + assert_eq!(transfer_msg.retiring_entity_name, ""); + assert_eq!(transfer_msg.retiring_entity_additional_data, ""); } else { panic!("Expected Stargate message"); } @@ -1106,6 +1544,7 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }, + operator: None, }; let owner = creator_info.sender.clone(); execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); @@ -1120,6 +1559,1333 @@ mod tests { let err = execute(deps.as_mut(), mock_env(), creator_info.clone(), cancel_listing_msg).unwrap_err(); assert_eq!(err, ContractError::ListingNotFound {}); } + + #[test] + fn test_cancel_listing_not_owner_should_fail() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let not_owner_info = mock_info("not_owner", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: None, + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let cancel_listing_msg = ExecuteMsg::CancelListing { + denom: "pcrd".to_string(), + }; + let err = execute(deps.as_mut(), mock_env(), not_owner_info.clone(), cancel_listing_msg.clone()).unwrap_err(); + assert_eq!(err, ContractError::ListingNotFound {}); + } + + #[test] + fn test_cancel_listing_operator_should_fail() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator = mock_info("operator", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Some(operator.sender.clone()), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let cancel_listing_msg = ExecuteMsg::CancelListing { + denom: "pcrd".to_string(), + }; + let err = execute(deps.as_mut(), mock_env(), operator.clone(), cancel_listing_msg.clone()).unwrap_err(); + assert_eq!(err, ContractError::ListingNotFound {}); + } + } + + mod freeze_credits_tests { + use cosmwasm_std::{Addr, Coin, coins, Decimal, from_binary, Order, Timestamp, Uint128, Uint64}; + use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; + use crate::error::ContractError; + use crate::execute::{execute, MAX_TIMEOUT_SECONDS}; + use crate::{instantiate}; + use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + use crate::query::query; + use crate::state::{Freeze, freezes, LISTINGS}; + + #[test] + fn test_freeze_credits_happy_path_by_seller() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: None, + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let env = mock_env(); + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 10u64, + buyer: Addr::unchecked("buyer"), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + let res = execute(deps.as_mut(), mock_env(), creator_info.clone(), freeze_message).unwrap(); + assert_eq!(res.attributes.len(), 7); + assert_eq!(res.messages.len(), 0); + + let freezes = freezes().range(deps.as_ref().storage, None, None, Order::Ascending) + .filter_map(Result::ok) // Only keep Ok values + .map(|(_, freeze)| freeze) // Extract the Freeze from the tuple + .collect::>(); + assert_eq!(freezes.len(), 1); + assert_eq!(freezes.len(), 1); + assert_eq!(freezes[0].buyer, Addr::unchecked("buyer")); + assert_eq!(freezes[0].number_of_credits, Uint64::from(10u64)); + assert_eq!(freezes[0].timeout, Timestamp::from_seconds(env.block.time.seconds() + 1000u64)); + + let listing = LISTINGS.load(deps.as_ref().storage, (creator_info.sender.clone(), "pcrd".to_string())).unwrap(); + assert_eq!(listing.number_of_credits, Uint64::from(32u64)); // 10 less + + } + + #[test] + fn test_freeze_credits_happy_path_by_operator() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let env = mock_env(); + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 15u64, + buyer: Addr::unchecked("buyer"), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + let res = execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + assert_eq!(res.attributes.len(), 7); + assert_eq!(res.messages.len(), 0); + + let freezes = freezes().range(deps.as_ref().storage, None, None, Order::Ascending) + .map(|item| item.unwrap().1) + .collect::>(); + assert_eq!(freezes.len(), 1); + assert_eq!(freezes[0].buyer, Addr::unchecked("buyer")); + assert_eq!(freezes[0].number_of_credits, Uint64::from(15u64)); + assert_eq!(freezes[0].timeout, Timestamp::from_seconds(env.block.time.seconds() + 1000u64)); + + let listing = LISTINGS.load(deps.as_ref().storage, (creator_info.sender.clone(), "pcrd".to_string())).unwrap(); + + assert_eq!(listing.number_of_credits, Uint64::from(27u64)); // 15 less + } + + #[test] + fn test_freeze_multiple_times_happy_path() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let env = mock_env(); + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 15u64, + buyer: Addr::unchecked("buyer1"), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + let res = execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + assert_eq!(res.attributes.len(), 7); + assert_eq!(res.messages.len(), 0); + + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 10u64, + buyer: Addr::unchecked("buyer1"), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + let res = execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + assert_eq!(res.attributes.len(), 7); + assert_eq!(res.messages.len(), 0); + + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 1u64, + buyer: Addr::unchecked("buyer2"), + timeout_unix_timestamp: env.block.time.seconds() + 1001u64, + }; + let res = execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + assert_eq!(res.attributes.len(), 7); + assert_eq!(res.messages.len(), 0); + + let freezes = freezes().range(deps.as_ref().storage, None, None, Order::Ascending) + .map(|item| item.unwrap().1) + .collect::>(); + assert_eq!(freezes.len(), 2); + assert_eq!(freezes[0].buyer, Addr::unchecked("buyer1")); + assert_eq!(freezes[0].number_of_credits, Uint64::from(25u64)); + assert_eq!(freezes[0].timeout, Timestamp::from_seconds(env.block.time.seconds() + 1000u64)); + + assert_eq!(freezes[1].buyer, Addr::unchecked("buyer2")); + assert_eq!(freezes[1].number_of_credits, Uint64::from(1u64)); + assert_eq!(freezes[1].timeout, Timestamp::from_seconds(env.block.time.seconds() + 1001u64)); + + let listing = LISTINGS.load(deps.as_ref().storage, (creator_info.sender.clone(), "pcrd".to_string())).unwrap(); + + assert_eq!(listing.number_of_credits, Uint64::from(16u64)); // 26 less + } + + #[test] + fn test_freeze_message_with_invalid_values() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let env = mock_env(); + let freeze_message_zero_credits = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 0u64, + buyer: Addr::unchecked("buyer"), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + let err = execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message_zero_credits).unwrap_err(); + assert_eq!(err, ContractError::ZeroCredits {}); + + let freeze_message_timeout_in_past = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 10u64, + buyer: Addr::unchecked("buyer"), + timeout_unix_timestamp: env.block.time.seconds() - 1000u64, + }; + let err = execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message_timeout_in_past).unwrap_err(); + assert_eq!(err, ContractError::TimeoutNotInFuture {}); + + let freeze_message_timeout_too_long = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 10u64, + buyer: Addr::unchecked("buyer"), + timeout_unix_timestamp: env.block.time.seconds() + MAX_TIMEOUT_SECONDS + 1, + }; + let err = execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message_timeout_too_long).unwrap_err(); + assert_eq!(err, ContractError::TimeoutTooLong {max_timeout: MAX_TIMEOUT_SECONDS}); + } + + #[test] + fn test_freeze_message_with_non_existing_listing() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + let buyer_info = mock_info("buyer", &[]); + let env = mock_env(); + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 10u64, + buyer: buyer_info.sender.clone(), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + let err = execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap_err(); + assert_eq!(err, ContractError::ListingNotFound {}); + } + + #[test] + fn test_freeze_fail_not_enough_credits() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + let buyer_info = mock_info("buyer", &[]); + let env = mock_env(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(9u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 10u64, + buyer: buyer_info.sender.clone(), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + let err = execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap_err(); + assert_eq!(err, ContractError::NotEnoughCredits {}); + } + + #[test] + fn test_freeze_and_buy_remaining_credits_should_not_delete_listing() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + let buyer_info = mock_info("buyer", &coins(9000, "token")); + let env = mock_env(); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(18u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1000u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 9u64, + buyer: buyer_info.sender.clone(), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + + let buy_credits_msg = ExecuteMsg::BuyCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_buy: 9u64, + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, + }; + execute(deps.as_mut(), mock_env(), buyer_info.clone(), buy_credits_msg).unwrap(); + + let listing = LISTINGS.load(deps.as_ref().storage, (creator_info.sender.clone(), "pcrd".to_string())).unwrap(); + assert_eq!(listing.number_of_credits, Uint64::from(0u64)); + + let res = query(deps.as_ref(), mock_env(), QueryMsg::Listing { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + }).unwrap(); + let res: crate::msg::ListingResponse = from_binary(&res).unwrap(); + assert_eq!(res.listing.denom, "pcrd"); + assert_eq!(res.listing.number_of_credits, Uint64::from(0u64)); + } + + #[test] + fn test_buy_more_than_available_after_freeze_should_fail() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + let buyer_info = mock_info("buyer", &coins(9000, "token")); + let env = mock_env(); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::permille(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: 18u64.into(), + price_per_credit: Coin { + denom: "token".to_string(), + amount: 1000u128.into(), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 9u64, + buyer: buyer_info.sender.clone(), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + + let buy_credits_msg = ExecuteMsg::BuyCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_buy: 10u64, + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, + }; + let err = execute(deps.as_mut(), mock_env(), buyer_info.clone(), buy_credits_msg).unwrap_err(); + assert_eq!(err, ContractError::NotEnoughCredits {}); + } + + #[test] + fn test_freeze_by_unauthorized_sender() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + let buyer_info = mock_info("buyer", &coins(9000, "token")); + let env = mock_env(); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::permille(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: 18u64.into(), + price_per_credit: Coin { + denom: "token".to_string(), + amount: 1000u128.into(), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 9u64, + buyer: buyer_info.sender.clone(), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + let err = execute(deps.as_mut(), mock_env(), buyer_info.clone(), freeze_message).unwrap_err(); + assert_eq!(err, ContractError::Unauthorized {}); + } + + #[test] + fn test_freeze_too_many() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + let buyer_info = mock_info("buyer", &coins(9000, "token")); + let env = mock_env(); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::permille(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: 18u64.into(), + price_per_credit: Coin { + denom: "token".to_string(), + amount: 1000u128.into(), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 19u64, + buyer: buyer_info.sender.clone(), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + let err = execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap_err(); + assert_eq!(err, ContractError::NotEnoughCredits {}); + } + + #[test] + fn test_freeze_too_many_with_existing_freeze() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + let buyer_info = mock_info("buyer", &coins(9000, "token")); + let env = mock_env(); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::permille(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: 18u64.into(), + price_per_credit: Coin { + denom: "token".to_string(), + amount: 1000u128.into(), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 10u64, + buyer: buyer_info.sender.clone(), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + + execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message.clone()).unwrap(); + let err = execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap_err(); + assert_eq!(err, ContractError::NotEnoughCredits {}); + } + } + + mod cancel_frozen_credits_tests { + use cosmwasm_std::{Addr, Coin, Decimal, Order, Timestamp, Uint128, Uint64}; + use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; + use crate::error::ContractError; + use crate::execute::execute; + use crate::instantiate; + use crate::msg::{ExecuteMsg, InstantiateMsg}; + use crate::state::{Freeze, freezes, LISTINGS}; + + #[test] + fn test_cancel_frozen_credits_happy_path() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let env = mock_env(); + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 15u64, + buyer: Addr::unchecked("buyer"), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + + let cancel_frozen_message = ExecuteMsg::CancelFrozenCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_frozen_credits_to_cancel: 10u64, + buyer: Addr::unchecked("buyer"), + }; + let res = execute(deps.as_mut(), mock_env(), operator_info.clone(), cancel_frozen_message).unwrap(); + assert_eq!(res.attributes.len(), 6); + assert_eq!(res.messages.len(), 0); + + let listing = LISTINGS.load(deps.as_ref().storage, (creator_info.sender.clone(), "pcrd".to_string())).unwrap(); + assert_eq!(listing.number_of_credits, Uint64::from(37u64)); // 5 less, not 15 anymore + + let freeze = crate::state::freezes().load(deps.as_ref().storage, (creator_info.sender.clone(), "pcrd".to_string(), Addr::unchecked("buyer"))).unwrap(); + assert_eq!(freeze.number_of_credits, Uint64::from(5u64)); // 5 in the freeze, not 15 anymore + } + + #[test] + fn test_cancel_frozen_credits_happy_path_all_credits() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let env = mock_env(); + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 15u64, + buyer: Addr::unchecked("buyer"), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + + let freezes_before = freezes().range(deps.as_ref().storage, None, None, Order::Ascending) + .map(|item| item.unwrap().1) + .collect::>(); + assert_eq!(freezes_before.len(), 1); // Not deleted yet + + let cancel_frozen_message = ExecuteMsg::CancelFrozenCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_frozen_credits_to_cancel: 0u64, + buyer: Addr::unchecked("buyer"), + }; + let res = execute(deps.as_mut(), mock_env(), operator_info.clone(), cancel_frozen_message).unwrap(); + assert_eq!(res.attributes.len(), 6); + assert_eq!(res.messages.len(), 0); + + let listing = LISTINGS.load(deps.as_ref().storage, (creator_info.sender.clone(), "pcrd".to_string())).unwrap(); + assert_eq!(listing.number_of_credits, Uint64::from(42u64)); // All are back + + let freezes_after = freezes().range(deps.as_ref().storage, None, None, Order::Ascending) + .map(|item| item.unwrap().1) + .collect::>(); + assert_eq!(freezes_after.len(), 0); // It should have been deleted + } + + #[test] + fn test_cancel_frozen_credits_happy_path_timed_out() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let env = mock_env(); + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 15u64, + buyer: Addr::unchecked("buyer"), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + + let cancel_frozen_message = ExecuteMsg::CancelFrozenCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_frozen_credits_to_cancel: 10u64, + buyer: Addr::unchecked("buyer"), + }; + let mut timed_out_env = mock_env(); + timed_out_env.block.time = Timestamp::from_seconds(env.block.time.seconds() + 1001u64); + let res = execute(deps.as_mut(), timed_out_env, operator_info.clone(), cancel_frozen_message).unwrap(); + assert_eq!(res.attributes.len(), 6); + assert_eq!(res.messages.len(), 0); + + let listing = LISTINGS.load(deps.as_ref().storage, (creator_info.sender.clone(), "pcrd".to_string())).unwrap(); + assert_eq!(listing.number_of_credits, Uint64::from(37u64)); // 5 less, not 15 anymore + + let freeze = crate::state::freezes().load(deps.as_ref().storage, (creator_info.sender.clone(), "pcrd".to_string(), Addr::unchecked("buyer"))).unwrap(); + assert_eq!(freeze.number_of_credits, Uint64::from(5u64)); // 5 in the freeze, not 15 anymore + } + + #[test] + fn test_cancel_frozen_credits_happy_path_permissionless() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let env = mock_env(); + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 15u64, + buyer: Addr::unchecked("buyer"), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + + let cancel_frozen_message = ExecuteMsg::CancelFrozenCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_frozen_credits_to_cancel: 10u64, + buyer: Addr::unchecked("buyer"), + }; + let mut timed_out_env = mock_env(); + timed_out_env.block.time = Timestamp::from_seconds(env.block.time.seconds() + 1001u64); + let permissionless_sender = mock_info("permissionless_sender", &[]); + + let res = execute(deps.as_mut(), timed_out_env, permissionless_sender.clone(), cancel_frozen_message).unwrap(); + assert_eq!(res.attributes.len(), 6); + assert_eq!(res.messages.len(), 0); + + let listing = LISTINGS.load(deps.as_ref().storage, (creator_info.sender.clone(), "pcrd".to_string())).unwrap(); + assert_eq!(listing.number_of_credits, Uint64::from(37u64)); // 5 less, not 15 anymore + + let freeze = crate::state::freezes().load(deps.as_ref().storage, (creator_info.sender.clone(), "pcrd".to_string(), Addr::unchecked("buyer"))).unwrap(); + assert_eq!(freeze.number_of_credits, Uint64::from(5u64)); // 5 in the freeze, not 15 anymore + } + + + #[test] + fn test_cancel_frozen_credits_with_invalid_params() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let env = mock_env(); + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 15u64, + buyer: Addr::unchecked("buyer"), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + + let cancel_frozen_too_many = ExecuteMsg::CancelFrozenCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_frozen_credits_to_cancel: 16u64, + buyer: Addr::unchecked("buyer"), + }; + let err = execute(deps.as_mut(), mock_env(), operator_info.clone(), cancel_frozen_too_many).unwrap_err(); + assert_eq!(err, ContractError::NotEnoughCredits {}); + + let cancel_freeze_not_found = ExecuteMsg::CancelFrozenCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_frozen_credits_to_cancel: 10u64, + buyer: Addr::unchecked("not_buyer"), + }; + let err = execute(deps.as_mut(), mock_env(), operator_info.clone(), cancel_freeze_not_found).unwrap_err(); + assert_eq!(err, ContractError::FreezeNotFound {}); + + let cancel_listing_not_found = ExecuteMsg::CancelFrozenCredits { + owner: creator_info.sender.clone(), + denom: "not_pcrd".to_string(), + number_of_frozen_credits_to_cancel: 10u64, + buyer: Addr::unchecked("buyer"), + }; + let err = execute(deps.as_mut(), mock_env(), operator_info.clone(), cancel_listing_not_found).unwrap_err(); + assert_eq!(err, ContractError::ListingNotFound {}); + } + + #[test] + fn test_cancel_frozen_credits_as_unauthorized() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let env = mock_env(); + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 15u64, + buyer: Addr::unchecked("buyer"), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + + let cancel_frozen_message = ExecuteMsg::CancelFrozenCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_frozen_credits_to_cancel: 10u64, + buyer: Addr::unchecked("buyer"), + }; + let unauthorized_info = mock_info("unauthorized", &[]); + let err = execute(deps.as_mut(), mock_env(), unauthorized_info.clone(), cancel_frozen_message).unwrap_err(); + assert_eq!(err, ContractError::Unauthorized {}); + } + } + + mod release_frozen_credits_test { + use cosmos_sdk_proto::traits::TypeUrl; + use cosmwasm_std::{Addr, BankMsg, Coin, coins, CosmosMsg, Decimal, Order, Timestamp, Uint128, Uint64}; + use cosmwasm_std::testing::{MOCK_CONTRACT_ADDR, mock_dependencies, mock_env, mock_info}; + use prost::Message; + use fee_splitter::Share; + use crate::execute::{execute, MsgTransferCredits}; + use crate::instantiate; + use crate::msg::{ExecuteMsg, InstantiateMsg}; + use crate::state::{Freeze, freezes}; + + #[test] + fn test_release_frozen_credits_happy_path() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + let buyer_info = mock_info("buyer", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let env = mock_env(); + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 15u64, + buyer: buyer_info.sender.clone(), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + + let release_message = ExecuteMsg::ReleaseFrozenCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_release: 10u64, + buyer: buyer_info.sender.clone(), + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, + }; + let res = execute(deps.as_mut(), mock_env(), operator_info.clone(), release_message).unwrap(); + assert_eq!(res.attributes.len(), 6); + assert_eq!(res.messages.len(), 1); + + if let CosmosMsg::Stargate { type_url, value } = &res.messages[0].msg { + assert_eq!(type_url, MsgTransferCredits::TYPE_URL); + + let transfer_msg = MsgTransferCredits::decode(value.as_slice()).unwrap(); + assert_eq!(transfer_msg.from, MOCK_CONTRACT_ADDR.to_string()); + assert_eq!(transfer_msg.to, buyer_info.sender.to_string()); + assert_eq!(transfer_msg.denom, "pcrd"); + assert_eq!(transfer_msg.amount, 10); + assert_eq!(transfer_msg.retire, false); + assert_eq!(transfer_msg.retiring_entity_name, ""); + assert_eq!(transfer_msg.retiring_entity_additional_data, ""); + } else { + panic!("Expected Stargate message"); + } + + let listing = crate::state::LISTINGS.load(deps.as_ref().storage, (creator_info.sender.clone(), "pcrd".to_string())).unwrap(); + assert_eq!(listing.number_of_credits, Uint64::from(27u64)); // 10 less, still + + let freeze = crate::state::freezes().load(deps.as_ref().storage, (creator_info.sender.clone(), "pcrd".to_string(), Addr::unchecked("buyer"))).unwrap(); + assert_eq!(freeze.number_of_credits, Uint64::from(5u64)); // 5 less now + } + + #[test] + fn test_release_frozen_credits_happy_path_with_retire() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + let buyer_info = mock_info("buyer", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let env = mock_env(); + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 15u64, + buyer: buyer_info.sender.clone(), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + + let release_message = ExecuteMsg::ReleaseFrozenCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_release: 10u64, + buyer: buyer_info.sender.clone(), + retire: true, + retiring_entity_name: "retire_name".to_string().into(), + retiring_entity_additional_data: "retire_data".to_string().into(), + }; + let res = execute(deps.as_mut(), mock_env(), operator_info.clone(), release_message).unwrap(); + assert_eq!(res.attributes.len(), 6); + assert_eq!(res.messages.len(), 1); + + if let CosmosMsg::Stargate { type_url, value } = &res.messages[0].msg { + assert_eq!(type_url, MsgTransferCredits::TYPE_URL); + + let transfer_msg = MsgTransferCredits::decode(value.as_slice()).unwrap(); + assert_eq!(transfer_msg.from, MOCK_CONTRACT_ADDR.to_string()); + assert_eq!(transfer_msg.to, buyer_info.sender.to_string()); + assert_eq!(transfer_msg.denom, "pcrd"); + assert_eq!(transfer_msg.amount, 10); + assert_eq!(transfer_msg.retire, true); + assert_eq!(transfer_msg.retiring_entity_name, "retire_name"); + assert_eq!(transfer_msg.retiring_entity_additional_data, "retire_data"); + } else { + panic!("Expected Stargate message"); + } + + let listing = crate::state::LISTINGS.load(deps.as_ref().storage, (creator_info.sender.clone(), "pcrd".to_string())).unwrap(); + assert_eq!(listing.number_of_credits, Uint64::from(27u64)); // 10 less, still + + let freeze = crate::state::freezes().load(deps.as_ref().storage, (creator_info.sender.clone(), "pcrd".to_string(), Addr::unchecked("buyer"))).unwrap(); + assert_eq!(freeze.number_of_credits, Uint64::from(5u64)); // 5 less now + } + + #[test] + fn test_release_frozen_credits_happy_path_all_credits() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + let buyer_info = mock_info("buyer", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let env = mock_env(); + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 15u64, + buyer: buyer_info.sender.clone(), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + + let release_message = ExecuteMsg::ReleaseFrozenCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_release: 15u64, + buyer: buyer_info.sender.clone(), + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, + }; + let res = execute(deps.as_mut(), mock_env(), operator_info.clone(), release_message).unwrap(); + assert_eq!(res.attributes.len(), 6); + assert_eq!(res.messages.len(), 1); + + let freezes_after = freezes().range(deps.as_ref().storage, None, None, Order::Ascending) + .map(|item| item.unwrap().1) + .collect::>(); + assert_eq!(freezes_after.len(), 0); // It should have been deleted + } + + #[test] + fn test_release_frozen_credits_happy_path_with_fee_split() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + let buyer_info = mock_info("buyer", &[]); + let dev_share = Share { + address: "dev".to_string(), + percentage: Decimal::percent(90), + }; + let user_share = Share { + address: "user".to_string(), + percentage: Decimal::percent(10), + }; + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::permille(5), shares: vec![dev_share, user_share] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let env = mock_env(); + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 15u64, + buyer: buyer_info.sender.clone(), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + + let release_message = ExecuteMsg::ReleaseFrozenCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_release: 10u64, + buyer: buyer_info.sender.clone(), + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, + }; + let operator_info = mock_info("operator", &coins(66, "token")); + let res = execute(deps.as_mut(), mock_env(), operator_info.clone(), release_message).unwrap(); + assert_eq!(res.attributes.len(), 6); + assert_eq!(res.messages.len(), 3); + + + if let CosmosMsg::Bank(BankMsg::Send { to_address, amount }) = &res.messages[1].msg { + assert_eq!(to_address, &Addr::unchecked("dev".to_string())); + assert_eq!(amount.len(), 1); + assert_eq!(amount[0].denom, "token"); + assert_eq!(amount[0].amount, Uint128::from(60u128)); + } else { + panic!("Expected Bank message"); + } + + if let CosmosMsg::Bank(BankMsg::Send { to_address, amount }) = &res.messages[2].msg { + assert_eq!(to_address, &Addr::unchecked("user".to_string())); + assert_eq!(amount.len(), 1); + assert_eq!(amount[0].denom, "token"); + assert_eq!(amount[0].amount, Uint128::from(6u128)); + } else { + panic!("Expected Bank message"); + } + } + + #[test] + fn test_release_credits_with_not_enough_funds() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + let buyer_info = mock_info("buyer", &[]); + let dev_share = Share { + address: "dev".to_string(), + percentage: Decimal::percent(90), + }; + let user_share = Share { + address: "user".to_string(), + percentage: Decimal::percent(10), + }; + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::permille(5), shares: vec![dev_share, user_share] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let env = mock_env(); + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 15u64, + buyer: buyer_info.sender.clone(), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + + let release_message = ExecuteMsg::ReleaseFrozenCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_release: 10u64, + buyer: buyer_info.sender.clone(), + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, + }; + let operator_info = mock_info("operator", &coins(65, "token")); + let err = execute(deps.as_mut(), mock_env(), operator_info.clone(), release_message).unwrap_err(); + assert_eq!(err, crate::error::ContractError::NotEnoughFunds {}); + } + + #[test] + fn test_release_credits_with_too_much_funds() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + let buyer_info = mock_info("buyer", &[]); + let dev_share = Share { + address: "dev".to_string(), + percentage: Decimal::percent(90), + }; + let user_share = Share { + address: "user".to_string(), + percentage: Decimal::percent(10), + }; + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::permille(5), shares: vec![dev_share, user_share] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let env = mock_env(); + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 15u64, + buyer: buyer_info.sender.clone(), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + + let release_message = ExecuteMsg::ReleaseFrozenCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_release: 10u64, + buyer: buyer_info.sender.clone(), + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, + }; + let operator_info = mock_info("operator", &coins(67, "token")); + let err = execute(deps.as_mut(), mock_env(), operator_info.clone(), release_message).unwrap_err(); + assert_eq!(err, crate::error::ContractError::TooMuchFunds {}); + } + + + #[test] + fn test_invalid_params() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + let buyer_info = mock_info("buyer", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let env = mock_env(); + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 15u64, + buyer: buyer_info.sender.clone(), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + + let release_message_zero = ExecuteMsg::ReleaseFrozenCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_release: 0u64, + buyer: buyer_info.sender.clone(), + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, + }; + let err = execute(deps.as_mut(), mock_env(), operator_info.clone(), release_message_zero).unwrap_err(); + assert_eq!(err, crate::error::ContractError::ZeroCredits {}); + + let release_message_too_many = ExecuteMsg::ReleaseFrozenCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_release: 16u64, + buyer: buyer_info.sender.clone(), + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, + }; + let err = execute(deps.as_mut(), mock_env(), operator_info.clone(), release_message_too_many).unwrap_err(); + assert_eq!(err, crate::error::ContractError::NotEnoughCredits {}); + + let release_freeze_not_found = ExecuteMsg::ReleaseFrozenCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_release: 10u64, + buyer: Addr::unchecked("not_found"), + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, + }; + let err = execute(deps.as_mut(), mock_env(), operator_info.clone(), release_freeze_not_found).unwrap_err(); + assert_eq!(err, crate::error::ContractError::FreezeNotFound {}); + + let release_listing_not_found = ExecuteMsg::ReleaseFrozenCredits { + owner: creator_info.sender.clone(), + denom: "not_found".to_string(), + number_of_credits_to_release: 10u64, + buyer: buyer_info.sender.clone(), + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, + }; + let err = execute(deps.as_mut(), mock_env(), operator_info.clone(), release_listing_not_found).unwrap_err(); + assert_eq!(err, crate::error::ContractError::ListingNotFound {}); + } + + #[test] + fn test_release_credits_unauthorized() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + let buyer_info = mock_info("buyer", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let env = mock_env(); + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 15u64, + buyer: buyer_info.sender.clone(), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + + let release_message = ExecuteMsg::ReleaseFrozenCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_release: 10u64, + buyer: buyer_info.sender.clone(), + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, + }; + let non_operator_info = mock_info("non_operator", &[]); + let err = execute(deps.as_mut(), mock_env(), non_operator_info.clone(), release_message).unwrap_err(); + assert_eq!(err, crate::error::ContractError::Unauthorized {}); + } + + #[test] + fn test_release_timed_out_freeze() { + let mut deps = mock_dependencies(); + let creator_info = mock_info("creator", &[]); + let operator_info = mock_info("operator", &[]); + let buyer_info = mock_info("buyer", &[]); + instantiate(deps.as_mut(), mock_env(), creator_info.clone(), InstantiateMsg { admin: creator_info.sender.to_string(), fee_percentage: Decimal::percent(0), shares: vec![] }).unwrap(); + + let create_listing_msg = ExecuteMsg::CreateListing { + denom: "pcrd".to_string(), + number_of_credits: Uint64::from(42u64), + price_per_credit: Coin { + denom: "token".to_string(), + amount: Uint128::from(1337u128), + }, + operator: Option::from(operator_info.clone().sender), + }; + execute(deps.as_mut(), mock_env(), creator_info.clone(), create_listing_msg).unwrap(); + + let mut env = mock_env(); + let freeze_message = ExecuteMsg::FreezeCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_freeze: 15u64, + buyer: buyer_info.sender.clone(), + timeout_unix_timestamp: env.block.time.seconds() + 1000u64, + }; + execute(deps.as_mut(), mock_env(), operator_info.clone(), freeze_message).unwrap(); + + env.block.time = Timestamp::from_seconds(env.block.time.seconds() + 1001u64); // Set the block time to a future timestamp + let release_message = ExecuteMsg::ReleaseFrozenCredits { + owner: creator_info.sender.clone(), + denom: "pcrd".to_string(), + number_of_credits_to_release: 10u64, + buyer: buyer_info.sender.clone(), + retire: false, + retiring_entity_name: None, + retiring_entity_additional_data: None, + }; + let err = execute(deps.as_mut(), env, operator_info.clone(), release_message).unwrap_err(); + assert_eq!(err, crate::error::ContractError::TimedOut {}); + } } mod edit_fee_split_config_tests { diff --git a/cosmwasm/contracts/plastic-credit-marketplace/src/msg.rs b/cosmwasm/contracts/plastic-credit-marketplace/src/msg.rs index 241a11b31..3f5db303e 100644 --- a/cosmwasm/contracts/plastic-credit-marketplace/src/msg.rs +++ b/cosmwasm/contracts/plastic-credit-marketplace/src/msg.rs @@ -17,6 +17,7 @@ pub enum ExecuteMsg { denom: String, number_of_credits: Uint64, price_per_credit: Coin, + operator: Option, }, UpdateListing { denom: String, @@ -27,10 +28,35 @@ pub enum ExecuteMsg { owner: Addr, denom: String, number_of_credits_to_buy: u64, + retire: bool, + retiring_entity_name: Option, + retiring_entity_additional_data: Option, }, CancelListing { denom: String, }, + FreezeCredits { + owner: Addr, + denom: String, + number_of_credits_to_freeze: u64, + buyer: Addr, + timeout_unix_timestamp: u64, + }, + CancelFrozenCredits { + owner: Addr, + denom: String, + number_of_frozen_credits_to_cancel: u64, // 0 means all + buyer: Addr, + }, + ReleaseFrozenCredits { + owner: Addr, + denom: String, + number_of_credits_to_release: u64, + buyer: Addr, + retire: bool, + retiring_entity_name: Option, + retiring_entity_additional_data: Option, + }, EditFeeSplitConfig { fee_percentage: Decimal, shares: Vec diff --git a/cosmwasm/contracts/plastic-credit-marketplace/src/query.rs b/cosmwasm/contracts/plastic-credit-marketplace/src/query.rs index d8ec5ca73..6f780eaf1 100644 --- a/cosmwasm/contracts/plastic-credit-marketplace/src/query.rs +++ b/cosmwasm/contracts/plastic-credit-marketplace/src/query.rs @@ -45,7 +45,7 @@ pub fn listing( #[cfg(test)] mod tests { mod query_listings_tests { - use cosmwasm_std::{Coin, coins, from_binary, Uint128, Uint64, StdError, Decimal}; + use cosmwasm_std::{Coin, coins, from_binary, Uint128, Uint64, StdError, Decimal, Addr}; use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; use crate::execute::execute; use crate::{instantiate, query}; @@ -65,6 +65,7 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }, + operator: None, }; execute(deps.as_mut(), mock_env(), info.clone(), msg.clone()).unwrap(); @@ -79,9 +80,9 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }); + assert_eq!(res.listing.operator, None); } #[test] - fn test_query_listing_not_found() { let mut deps = mock_dependencies(); let info = mock_info("creator", &coins(2, "token")); @@ -120,7 +121,8 @@ mod tests { } } - for denom in denoms.clone() { + // Let every other listing have an operator + for (i, denom) in denoms.clone().iter().enumerate() { let msg = ExecuteMsg::CreateListing { denom: denom.to_string(), number_of_credits: Uint64::from(42u64), @@ -128,6 +130,7 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }, + operator: if i % 2 == 0 { Some(Addr::unchecked("operator".to_string())) } else { None }, }; execute(deps.as_mut(), mock_env(), info.clone(), msg.clone()).unwrap(); } @@ -147,6 +150,8 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }); + // Every other listing has an operator + assert_eq!(res.listings[i].operator, if i % 2 == 0 { Some(Addr::unchecked("operator".to_string())) } else { None }); } // Query with limit 50 should return 50 listings @@ -172,6 +177,8 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }); + // Every other listing has an operator + assert_eq!(res.listings[i].operator, if (i + 50) % 2 == 0 { Some(Addr::unchecked("operator".to_string())) } else { None }); } // Query the next 50 listings, should return the last 4 listings @@ -189,7 +196,9 @@ mod tests { denom: "token".to_string(), amount: Uint128::from(1337u128), }); - } + // Every other listing has an operator + assert_eq!(res.listings[i].operator, if (i + 100) % 2 == 0 { Some(Addr::unchecked("operator".to_string())) } else { None }); + } } } diff --git a/cosmwasm/contracts/plastic-credit-marketplace/src/state.rs b/cosmwasm/contracts/plastic-credit-marketplace/src/state.rs index 5cf43ae0f..e37195f95 100644 --- a/cosmwasm/contracts/plastic-credit-marketplace/src/state.rs +++ b/cosmwasm/contracts/plastic-credit-marketplace/src/state.rs @@ -1,6 +1,10 @@ use cosmwasm_schema::cw_serde; -use cosmwasm_std::{Coin, Uint64, Addr}; -use cw_storage_plus::{Item, Map}; +use cosmwasm_std::{Coin, Uint64, Addr, Timestamp}; +use cw_storage_macro::index_list; +use cw_storage_plus::{IndexedMap, Item, Map, MultiIndex}; + +pub type ListingKey = (Addr, String); +pub type FreezeKey = (Addr, String, Addr); #[cw_serde] pub struct Listing<> { @@ -8,6 +12,28 @@ pub struct Listing<> { pub denom: String, pub number_of_credits: Uint64, pub price_per_credit: Coin, + pub operator: Option, +} + +#[cw_serde] +pub struct Freeze { + pub buyer: Addr, + pub number_of_credits: Uint64, + pub timeout: Timestamp, + pub listing_key: ListingKey, +} + +#[index_list(Freeze)] +pub struct FreezeIndexes<'a> { + pub listing: MultiIndex<'a, ListingKey, Freeze, FreezeKey>, +} + +pub fn freezes<'a>() -> IndexedMap<'a, FreezeKey, Freeze, FreezeIndexes<'a>> { + let indexes = FreezeIndexes { + listing: MultiIndex::new(|_pk: &[u8], f: &Freeze| f.listing_key.clone(), "freezes", "freezes__buyer"), + }; + + IndexedMap::new("freezes", indexes) } pub const LISTINGS: Map<(Addr, String), Listing> = Map::new("listings"); diff --git a/cosmwasm/packages/fee-splitter/src/lib.rs b/cosmwasm/packages/fee-splitter/src/lib.rs index 0d7570dd8..e260f21e1 100644 --- a/cosmwasm/packages/fee-splitter/src/lib.rs +++ b/cosmwasm/packages/fee-splitter/src/lib.rs @@ -60,20 +60,25 @@ pub fn edit_fee_split_config(storage: &mut dyn Storage, fee_percentage: Decimal, Ok(()) } -pub fn get_fee_split(storage: &dyn Storage, full_fee: Coin) -> Result<(Vec, Coin), FeeSplitterError> { +// Returns a tuple of (msgs, remaining_amount_as_coin, fee_amount_as_coin) +pub fn get_fee_split(storage: &dyn Storage, full_price: Coin) -> Result<(Vec, Coin, Coin), FeeSplitterError> { let config = CONFIG.load(storage).unwrap(); + let denom = full_price.denom.clone(); if Decimal::is_zero(&config.fee_percentage) || config.shares.is_empty() { - return Ok((vec![], full_fee)); + return Ok((vec![], full_price, Coin { + denom: denom.clone(), + amount: 0u128.into(), + })); } let mut msgs = vec![]; - let full_fee_as_decimal = Decimal::from_atomics(full_fee.amount, 0).unwrap(); + let full_fee_as_decimal = Decimal::from_atomics(full_price.amount, 0).unwrap(); let fee = full_fee_as_decimal.checked_mul(config.fee_percentage).unwrap(); for share in config.shares.iter() { let share_amount = fee.checked_mul(share.percentage).unwrap(); let coin_amount = share_amount.numerator().checked_div(share_amount.denominator()).unwrap(); let share_amount_as_coin = Coin { - denom: full_fee.denom.clone(), + denom: denom.clone(), amount: coin_amount, }; let msg = CosmosMsg::Bank(BankMsg::Send { @@ -84,12 +89,16 @@ pub fn get_fee_split(storage: &dyn Storage, full_fee: Coin) -> Result<(Vec Result { @@ -361,13 +370,15 @@ mod tests { instantiate_fee_splitter(deps.as_mut().storage, Decimal::permille(5), vec![dev_share.clone(), stakers_share.clone()]).unwrap(); // 1000 umpwr fee - let (fee_split_msgs, remaining_amount) = get_fee_split(deps.as_mut().storage, Coin { + let (fee_split_msgs, remaining_amount, fee_amount) = get_fee_split(deps.as_mut().storage, Coin { denom: "umpwr".to_string(), amount: Uint128::new(1000), }).unwrap(); assert_eq!(fee_split_msgs.len(), 2); assert_eq!(remaining_amount.denom, "umpwr"); assert_eq!(remaining_amount.amount, Uint128::new(995)); + assert_eq!(fee_amount.denom, "umpwr"); + assert_eq!(fee_amount.amount, Uint128::new(5)); if let CosmosMsg::Bank(BankMsg::Send { to_address, amount }) = fee_split_msgs[0].clone() { assert_eq!(to_address, "dev"); @@ -403,13 +414,15 @@ mod tests { // 500 umpwr fee, with 0.5% fee and 80/20 split means that dev gets 2 umpwr and stakers get 0 (rounded down) // and the remaining 498 umpwr is returned - let (fee_split_msgs, remaining_amount) = get_fee_split(deps.as_mut().storage, Coin { + let (fee_split_msgs, remaining_amount, fee_amount) = get_fee_split(deps.as_mut().storage, Coin { denom: "umpwr".to_string(), amount: Uint128::new(500), }).unwrap(); assert_eq!(fee_split_msgs.len(), 2); assert_eq!(remaining_amount.denom, "umpwr"); assert_eq!(remaining_amount.amount, Uint128::new(498)); + assert_eq!(fee_amount.denom, "umpwr"); + assert_eq!(fee_amount.amount, Uint128::new(2)); if let CosmosMsg::Bank(BankMsg::Send { to_address, amount }) = fee_split_msgs[0].clone() { assert_eq!(to_address, "dev"); @@ -436,13 +449,15 @@ mod tests { let mut deps = mock_dependencies(); instantiate_fee_splitter(deps.as_mut().storage, Decimal::zero(), vec![]).unwrap(); - let (fee_split_msgs, remaining_amount) = get_fee_split(deps.as_mut().storage, Coin { + let (fee_split_msgs, remaining_amount, fee_amount) = get_fee_split(deps.as_mut().storage, Coin { denom: "umpwr".to_string(), amount: Uint128::new(100), }).unwrap(); assert_eq!(fee_split_msgs.len(), 0); assert_eq!(remaining_amount.denom, "umpwr"); assert_eq!(remaining_amount.amount, Uint128::new(100)); + assert_eq!(fee_amount.denom, "umpwr"); + assert_eq!(fee_amount.amount, Uint128::new(0)); } } diff --git a/cosmwasm/schema/plastic-credit-marketplace.json b/cosmwasm/schema/plastic-credit-marketplace.json index 4a02b722f..2a8bbf7c1 100644 --- a/cosmwasm/schema/plastic-credit-marketplace.json +++ b/cosmwasm/schema/plastic-credit-marketplace.json @@ -32,6 +32,16 @@ "number_of_credits": { "$ref": "#/definitions/Uint64" }, + "operator": { + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, "price_per_credit": { "$ref": "#/definitions/Coin" } @@ -122,6 +132,117 @@ }, "additionalProperties": false }, + { + "type": "object", + "required": [ + "freeze_credits" + ], + "properties": { + "freeze_credits": { + "type": "object", + "required": [ + "buyer", + "denom", + "number_of_credits_to_freeze", + "owner", + "timeout_unix_timestamp" + ], + "properties": { + "buyer": { + "$ref": "#/definitions/Addr" + }, + "denom": { + "type": "string" + }, + "number_of_credits_to_freeze": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "owner": { + "$ref": "#/definitions/Addr" + }, + "timeout_unix_timestamp": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cancel_frozen_credits" + ], + "properties": { + "cancel_frozen_credits": { + "type": "object", + "required": [ + "buyer", + "denom", + "number_of_frozen_credits_to_cancel", + "owner" + ], + "properties": { + "buyer": { + "$ref": "#/definitions/Addr" + }, + "denom": { + "type": "string" + }, + "number_of_frozen_credits_to_cancel": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "owner": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "release_frozen_credits" + ], + "properties": { + "release_frozen_credits": { + "type": "object", + "required": [ + "buyer", + "denom", + "number_of_credits_to_release", + "owner" + ], + "properties": { + "buyer": { + "$ref": "#/definitions/Addr" + }, + "denom": { + "type": "string" + }, + "number_of_credits_to_release": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "owner": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, { "type": "object", "required": [ @@ -386,6 +507,16 @@ "number_of_credits": { "$ref": "#/definitions/Uint64" }, + "operator": { + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, "owner": { "$ref": "#/definitions/Addr" }, @@ -456,6 +587,16 @@ "number_of_credits": { "$ref": "#/definitions/Uint64" }, + "operator": { + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, "owner": { "$ref": "#/definitions/Addr" },