diff --git a/.github/workflows/ci-contracts-upload-binaries.yml b/.github/workflows/ci-contracts-upload-binaries.yml index b3acd7f0ac..c85d9046db 100644 --- a/.github/workflows/ci-contracts-upload-binaries.yml +++ b/.github/workflows/ci-contracts-upload-binaries.yml @@ -56,6 +56,7 @@ jobs: cp contracts/target/wasm32-unknown-unknown/release/cw3_flex_multisig.wasm $OUTPUT_DIR cp contracts/target/wasm32-unknown-unknown/release/cw4_group.wasm $OUTPUT_DIR cp contracts/target/wasm32-unknown-unknown/release/nym_ecash.wasm $OUTPUT_DIR + cp contracts/target/wasm32-unknown-unknown/release/nym_pool_contract.wasm $OUTPUT_DIR - name: Deploy branch to CI www continue-on-error: true diff --git a/Cargo.lock b/Cargo.lock index a243d3ce70..13e135bf55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6479,6 +6479,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "nym-pool-contract-common" +version = "0.1.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "schemars", + "serde", + "thiserror 2.0.12", + "time", +] + [[package]] name = "nym-sdk" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 36bf8931b8..9d82f97bc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ members = [ "common/cosmwasm-smart-contracts/group-contract", "common/cosmwasm-smart-contracts/mixnet-contract", "common/cosmwasm-smart-contracts/multisig-contract", + "common/cosmwasm-smart-contracts/nym-pool-contract", "common/cosmwasm-smart-contracts/vesting-contract", "common/credential-storage", "common/credential-utils", diff --git a/Makefile b/Makefile index 799abcfd76..709126a6bb 100644 --- a/Makefile +++ b/Makefile @@ -133,7 +133,7 @@ clippy: sdk-wasm-lint # Build contracts ready for deploy # ----------------------------------------------------------------------------- -CONTRACTS=vesting_contract mixnet_contract nym_ecash cw3_flex_multisig cw4_group nym_coconut_dkg +CONTRACTS=vesting_contract mixnet_contract nym_ecash cw3_flex_multisig cw4_group nym_coconut_dkg nym_pool_contract CONTRACTS_WASM=$(addsuffix .wasm, $(CONTRACTS)) CONTRACTS_OUT_DIR=contracts/target/wasm32-unknown-unknown/release diff --git a/common/cosmwasm-smart-contracts/nym-pool-contract/Cargo.toml b/common/cosmwasm-smart-contracts/nym-pool-contract/Cargo.toml new file mode 100644 index 0000000000..38d7659ece --- /dev/null +++ b/common/cosmwasm-smart-contracts/nym-pool-contract/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "nym-pool-contract-common" +version = "0.1.0" +description = "Common library for the Nym Pool contract" +authors.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +readme.workspace = true + +[dependencies] +thiserror = { workspace = true } +serde = { workspace = true } +schemars = { workspace = true } + +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-controllers = { workspace = true } + +[dev-dependencies] +time = { workspace = true, features = ["macros"] } + +[features] +schema = [] diff --git a/common/cosmwasm-smart-contracts/nym-pool-contract/src/constants.rs b/common/cosmwasm-smart-contracts/nym-pool-contract/src/constants.rs new file mode 100644 index 0000000000..a71397ab2b --- /dev/null +++ b/common/cosmwasm-smart-contracts/nym-pool-contract/src/constants.rs @@ -0,0 +1,11 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod storage_keys { + pub const CONTRACT_ADMIN: &str = "contract-admin"; + pub const POOL_DENOMINATION: &str = "pool_denom"; + pub const GRANTERS: &str = "granters"; + pub const GRANTS: &str = "grants"; + pub const TOTAL_LOCKED: &str = "total_locked"; + pub const LOCKED_GRANTEES: &str = "locked_grantees"; +} diff --git a/common/cosmwasm-smart-contracts/nym-pool-contract/src/error.rs b/common/cosmwasm-smart-contracts/nym-pool-contract/src/error.rs new file mode 100644 index 0000000000..35ad7ec745 --- /dev/null +++ b/common/cosmwasm-smart-contracts/nym-pool-contract/src/error.rs @@ -0,0 +1,98 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_std::{Coin, Uint128}; +use cw_controllers::AdminError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum NymPoolContractError { + #[error("could not perform contract migration: {comment}")] + FailedMigration { comment: String }, + + #[error(transparent)] + Admin(#[from] AdminError), + + #[error(transparent)] + StdErr(#[from] cosmwasm_std::StdError), + + #[error("this sender is not authorised to revoke this grant. its neither the admin or the original (and still whitelisted) granter")] + UnauthorizedGrantRevocation, + + #[error("the specified address is already a whitelisted granter")] + AlreadyAGranter, + + #[error("{addr} is not a permitted granter")] + InvalidGranter { addr: String }, + + #[error("invalid coin denomination. got {got}, but expected {expected}")] + InvalidDenom { expected: String, got: String }, + + #[error("there already exists an active grant for {grantee}. it was granted by {granter} at block height {created_at_height}")] + GrantAlreadyExist { + granter: String, + grantee: String, + created_at_height: u64, + }, + + #[error("could not find any active grants for {grantee}")] + GrantNotFound { grantee: String }, + + #[error("the provided timestamp value ({timestamp}) is set in the past. the current block timestamp is {current_block_timestamp}")] + TimestampInThePast { + timestamp: u64, + current_block_timestamp: u64, + }, + + #[error("there are not enough tokens to process this request. {available} are available, but {required} is needed.")] + InsufficientTokens { available: Coin, required: Coin }, + + #[error("the period length can't be zero")] + ZeroAllowancePeriod, + + #[error("the provided coin value is zero")] + ZeroAmount, + + #[error("the periodic spend limit of {periodic} was set to be higher than the total spend limit {total_limit}")] + PeriodicGrantOverSpendLimit { periodic: Coin, total_limit: Coin }, + + #[error("the accumulation spend limit of {accumulation} was set to be lower than the periodic grant amount of {periodic_grant}")] + AccumulationBelowGrantAmount { + accumulation: Coin, + periodic_grant: Coin, + }, + + #[error("the accumulation spend limit of {accumulation} was set to be higher than the total spend limit of {total_limit}")] + AccumulationOverSpendLimit { + accumulation: Coin, + total_limit: Coin, + }, + + #[error("the specified delayed allowance would never be available. it would become active at {available_timestamp} yet it expires at {expiration_timestamp}")] + UnattainableDelayedAllowance { + expiration_timestamp: u64, + available_timestamp: u64, + }, + + #[error("could not unlock {requested} tokens from {grantee}. it only has {locked} locked")] + InsufficientLockedTokens { + grantee: String, + locked: Uint128, + requested: Uint128, + }, + + #[error("attempted to spend more tokens than permitted by the current allowance")] + SpendingAboveAllowance, + + #[error("attempted to send an empty allowance usage request")] + EmptyUsageRequest, + + #[error("the associated grant has already expired")] + GrantExpired, + + #[error("the associated grant hasn't expired yet")] + GrantNotExpired, + + #[error("this grant is not available yet. it will become usable at {available_at_timestamp}")] + GrantNotYetAvailable { available_at_timestamp: u64 }, +} diff --git a/common/cosmwasm-smart-contracts/nym-pool-contract/src/lib.rs b/common/cosmwasm-smart-contracts/nym-pool-contract/src/lib.rs new file mode 100644 index 0000000000..faa1b98b99 --- /dev/null +++ b/common/cosmwasm-smart-contracts/nym-pool-contract/src/lib.rs @@ -0,0 +1,12 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub mod constants; +pub mod error; +pub mod msg; +pub mod types; +mod utils; + +pub use error::*; +pub use msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; +pub use types::*; diff --git a/common/cosmwasm-smart-contracts/nym-pool-contract/src/msg.rs b/common/cosmwasm-smart-contracts/nym-pool-contract/src/msg.rs new file mode 100644 index 0000000000..734cb46d28 --- /dev/null +++ b/common/cosmwasm-smart-contracts/nym-pool-contract/src/msg.rs @@ -0,0 +1,125 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::{Allowance, TransferRecipient}; +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Coin; +use std::collections::HashMap; + +#[cfg(feature = "schema")] +use crate::types::{ + AvailableTokensResponse, GrantResponse, GranterResponse, GrantersPagedResponse, + GrantsPagedResponse, LockedTokensPagedResponse, LockedTokensResponse, + TotalLockedTokensResponse, +}; + +#[cw_serde] +pub struct InstantiateMsg { + pub pool_denomination: String, + + /// Initial map of grants to be created at instantiation + pub grants: HashMap, +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Change the admin + UpdateAdmin { + admin: String, + // flag to determine whether old admin should be removed from the granter set + // and new one should be included instead + // the reason it's provided as an option is to make it possible to skip this field + // when creating transaction directly with nyxd + update_granter_set: Option, + }, + + /// Attempt to grant new allowance to the specified grantee + GrantAllowance { + grantee: String, + allowance: Box, + }, + + /// Attempt to revoke previously granted allowance + RevokeAllowance { grantee: String }, + + /// Attempt to use allowance + UseAllowance { recipients: Vec }, + + /// Attempt to withdraw the specified amount into the grantee's account + WithdrawAllowance { amount: Coin }, + + /// Attempt to lock part of existing allowance for future use + LockAllowance { amount: Coin }, + + /// Attempt to unlock previously locked allowance + UnlockAllowance { amount: Coin }, + + /// Attempt to use part of the locked allowance + UseLockedAllowance { recipients: Vec }, + + /// Attempt to withdraw the specified amount of locked tokens into the grantee's account + WithdrawLockedAllowance { amount: Coin }, + + /// Attempt to add a new account to the permitted set of grant granters + AddNewGranter { granter: String }, + + /// Revoke the provided account from the permitted set of granters + RevokeGranter { granter: String }, + + /// Attempt to remove expired grant from the storage and unlock (if any) locked tokens + RemoveExpiredGrant { grantee: String }, +} + +#[cw_serde] +#[cfg_attr(feature = "schema", derive(cosmwasm_schema::QueryResponses))] +pub enum QueryMsg { + #[cfg_attr(feature = "schema", returns(cw_controllers::AdminResponse))] + Admin {}, + + #[cfg_attr(feature = "schema", returns(AvailableTokensResponse))] + GetAvailableTokens {}, + + #[cfg_attr(feature = "schema", returns(TotalLockedTokensResponse))] + GetTotalLockedTokens {}, + + #[cfg_attr(feature = "schema", returns(LockedTokensResponse))] + GetLockedTokens { grantee: String }, + + #[cfg_attr(feature = "schema", returns(GrantResponse))] + GetGrant { grantee: String }, + + #[cfg_attr(feature = "schema", returns(GranterResponse))] + GetGranter { granter: String }, + + #[cfg_attr(feature = "schema", returns(LockedTokensPagedResponse))] + GetLockedTokensPaged { + /// Controls the maximum number of entries returned by the query. Note that too large values will be overwritten by a saner default. + limit: Option, + + /// Pagination control for the values returned by the query. Note that the provided value itself will **not** be used for the response. + start_after: Option, + }, + + #[cfg_attr(feature = "schema", returns(GrantersPagedResponse))] + GetGrantersPaged { + /// Controls the maximum number of entries returned by the query. Note that too large values will be overwritten by a saner default. + limit: Option, + + /// Pagination control for the values returned by the query. Note that the provided value itself will **not** be used for the response. + start_after: Option, + }, + + #[cfg_attr(feature = "schema", returns(GrantsPagedResponse))] + GetGrantsPaged { + /// Controls the maximum number of entries returned by the query. Note that too large values will be overwritten by a saner default. + limit: Option, + + /// Pagination control for the values returned by the query. Note that the provided value itself will **not** be used for the response. + start_after: Option, + }, +} + +#[cw_serde] +pub struct MigrateMsg { + // +} diff --git a/common/cosmwasm-smart-contracts/nym-pool-contract/src/types.rs b/common/cosmwasm-smart-contracts/nym-pool-contract/src/types.rs new file mode 100644 index 0000000000..8bffe7dc6a --- /dev/null +++ b/common/cosmwasm-smart-contracts/nym-pool-contract/src/types.rs @@ -0,0 +1,1279 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Coin}; + +pub type GranterAddress = Addr; +pub type GranteeAddress = Addr; + +pub use grants::*; +pub use query_responses::*; + +#[cw_serde] +pub struct TransferRecipient { + pub recipient: String, + pub amount: Coin, +} + +pub mod grants { + use crate::utils::ensure_unix_timestamp_not_in_the_past; + use crate::{GranteeAddress, GranterAddress, NymPoolContractError}; + use cosmwasm_schema::cw_serde; + use cosmwasm_std::{Addr, Coin, Env, Timestamp, Uint128}; + use std::cmp::min; + + #[cw_serde] + pub struct GranterInformation { + // realistically this is always going to be the contract admin, + // but let's keep this metadata regardless just in case it ever changes, + // such as we create a granter controlled by validator multisig or governance + pub created_by: Addr, + pub created_at_height: u64, + } + + #[cw_serde] + pub struct Grant { + pub granter: GranterAddress, + pub grantee: GranteeAddress, + pub granted_at_height: u64, + pub allowance: Allowance, + } + + #[cw_serde] + pub enum Allowance { + Basic(BasicAllowance), + ClassicPeriodic(ClassicPeriodicAllowance), + CumulativePeriodic(CumulativePeriodicAllowance), + Delayed(DelayedAllowance), + } + + impl From for Allowance { + fn from(value: BasicAllowance) -> Self { + Allowance::Basic(value) + } + } + + impl From for Allowance { + fn from(value: ClassicPeriodicAllowance) -> Self { + Allowance::ClassicPeriodic(value) + } + } + + impl From for Allowance { + fn from(value: CumulativePeriodicAllowance) -> Self { + Allowance::CumulativePeriodic(value) + } + } + + impl From for Allowance { + fn from(value: DelayedAllowance) -> Self { + Allowance::Delayed(value) + } + } + + impl Allowance { + pub fn expired(&self, env: &Env) -> bool { + self.basic().expired(env) + } + + pub fn basic(&self) -> &BasicAllowance { + match self { + Allowance::Basic(allowance) => allowance, + Allowance::ClassicPeriodic(allowance) => &allowance.basic, + Allowance::CumulativePeriodic(allowance) => &allowance.basic, + Allowance::Delayed(allowance) => &allowance.basic, + } + } + + pub fn basic_mut(&mut self) -> &mut BasicAllowance { + match self { + Allowance::Basic(ref mut allowance) => allowance, + Allowance::ClassicPeriodic(ref mut allowance) => &mut allowance.basic, + Allowance::CumulativePeriodic(ref mut allowance) => &mut allowance.basic, + Allowance::Delayed(ref mut allowance) => &mut allowance.basic, + } + } + + pub fn expiration(&self) -> Option { + let expiration_unix = match self { + Allowance::Basic(allowance) => allowance.expiration_unix_timestamp, + Allowance::ClassicPeriodic(allowance) => allowance.basic.expiration_unix_timestamp, + Allowance::CumulativePeriodic(allowance) => { + allowance.basic.expiration_unix_timestamp + } + Allowance::Delayed(allowance) => allowance.basic.expiration_unix_timestamp, + }; + + expiration_unix.map(Timestamp::from_seconds) + } + + /// Perform validation of a new grant that's to be created + pub fn validate_new(&self, env: &Env, denom: &str) -> Result<(), NymPoolContractError> { + // 1. perform validation on the inner, basic, allowance + self.basic().validate(env, denom)?; + + // 2. perform additional validation specific to each variant + match self { + // we already validated basic allowance + Allowance::Basic(_) => Ok(()), + Allowance::ClassicPeriodic(allowance) => allowance.validate_new_inner(denom), + Allowance::CumulativePeriodic(allowance) => allowance.validate_new_inner(denom), + Allowance::Delayed(allowance) => allowance.validate_new_inner(env), + } + } + + /// Updates initial state of this allowance settings things such as period reset timestamps. + pub fn set_initial_state(&mut self, env: &Env) { + match self { + // nothing to do for the basic allowance + Allowance::Basic(_) => {} + Allowance::ClassicPeriodic(allowance) => allowance.set_initial_state(env), + Allowance::CumulativePeriodic(allowance) => allowance.set_initial_state(env), + // nothing to do for the delayed allowance + Allowance::Delayed(_) => {} + } + } + + pub fn try_update_state(&mut self, env: &Env) { + match self { + // nothing to do for the basic allowance + Allowance::Basic(_) => {} + Allowance::ClassicPeriodic(allowance) => allowance.try_update_state(env), + Allowance::CumulativePeriodic(allowance) => allowance.try_update_state(env), + // nothing to do for the delayed allowance + Allowance::Delayed(_) => {} + } + } + + pub fn within_spendable_limits(&self, amount: &Coin) -> bool { + match self { + Allowance::Basic(allowance) => allowance.within_spendable_limits(amount), + Allowance::ClassicPeriodic(allowance) => allowance.within_spendable_limits(amount), + Allowance::CumulativePeriodic(allowance) => { + allowance.within_spendable_limits(amount) + } + Allowance::Delayed(allowance) => allowance.within_spendable_limits(amount), + } + } + + // check whether given the current allowance state, the provided amount could be spent + // note: it's responsibility of the caller to call `try_update_state` before the call. + pub fn ensure_can_spend( + &self, + env: &Env, + amount: &Coin, + ) -> Result<(), NymPoolContractError> { + match self { + Allowance::Basic(allowance) => allowance.ensure_can_spend(env, amount), + Allowance::ClassicPeriodic(allowance) => allowance.ensure_can_spend(env, amount), + Allowance::CumulativePeriodic(allowance) => allowance.ensure_can_spend(env, amount), + Allowance::Delayed(allowance) => allowance.ensure_can_spend(env, amount), + } + } + + pub fn try_spend(&mut self, env: &Env, amount: &Coin) -> Result<(), NymPoolContractError> { + self.try_update_state(env); + + match self { + Allowance::Basic(allowance) => allowance.try_spend(env, amount), + Allowance::ClassicPeriodic(allowance) => allowance.try_spend(env, amount), + Allowance::CumulativePeriodic(allowance) => allowance.try_spend(env, amount), + Allowance::Delayed(allowance) => allowance.try_spend(env, amount), + } + } + + pub fn increase_spend_limit(&mut self, amount: Uint128) { + if let Some(ref mut limit) = self.basic_mut().spend_limit { + limit.amount += amount + } + } + + pub fn is_used_up(&self) -> bool { + let Some(ref limit) = self.basic().spend_limit else { + return false; + }; + limit.amount.is_zero() + } + } + + /// BasicAllowance is an allowance with a one-time grant of coins + /// that optionally expires. The grantee can use up to SpendLimit to cover fees. + #[cw_serde] + pub struct BasicAllowance { + /// spend_limit specifies the maximum amount of coins that can be spent + /// by this allowance and will be updated as coins are spent. If it is + /// empty, there is no spend limit and any amount of coins can be spent. + pub spend_limit: Option, + + /// expiration specifies an optional time when this allowance expires + pub expiration_unix_timestamp: Option, + } + + impl BasicAllowance { + pub fn unlimited() -> BasicAllowance { + BasicAllowance { + spend_limit: None, + expiration_unix_timestamp: None, + } + } + + pub fn validate(&self, env: &Env, denom: &str) -> Result<(), NymPoolContractError> { + // expiration shouldn't be in the past. + if let Some(expiration) = self.expiration_unix_timestamp { + ensure_unix_timestamp_not_in_the_past(expiration, env)?; + } + + // if spend limit is set, it must use the same denomination as the underlying pool + if let Some(ref spend_limit) = self.spend_limit { + if spend_limit.denom != denom { + return Err(NymPoolContractError::InvalidDenom { + expected: denom.to_string(), + got: spend_limit.denom.to_string(), + }); + } + + if spend_limit.amount.is_zero() { + return Err(NymPoolContractError::ZeroAmount); + } + } + + Ok(()) + } + + pub fn expired(&self, env: &Env) -> bool { + let Some(expiration) = self.expiration_unix_timestamp else { + return false; + }; + let current_unix_timestamp = env.block.time.seconds(); + + expiration < current_unix_timestamp + } + + fn within_spendable_limits(&self, amount: &Coin) -> bool { + let Some(ref spend_limit) = self.spend_limit else { + // if there's no spend limit then whatever the amount is, it's spendable + return true; + }; + + spend_limit.amount >= amount.amount + } + + fn ensure_can_spend(&self, env: &Env, amount: &Coin) -> Result<(), NymPoolContractError> { + if self.expired(env) { + return Err(NymPoolContractError::GrantExpired); + } + if !self.within_spendable_limits(amount) { + return Err(NymPoolContractError::SpendingAboveAllowance); + } + Ok(()) + } + + fn try_spend(&mut self, env: &Env, amount: &Coin) -> Result<(), NymPoolContractError> { + self.ensure_can_spend(env, amount)?; + + if let Some(ref mut spend_limit) = self.spend_limit { + spend_limit.amount -= amount.amount; + } + Ok(()) + } + } + + /// ClassicPeriodicAllowance extends BasicAllowance to allow for both a maximum cap, + /// as well as a limit per time period. + #[cw_serde] + pub struct ClassicPeriodicAllowance { + /// basic specifies a struct of `BasicAllowance` + pub basic: BasicAllowance, + + /// period_duration_secs specifies the time duration in which period_spend_limit coins can + /// be spent before that allowance is reset + pub period_duration_secs: u64, + + /// period_spend_limit specifies the maximum number of coins that can be spent + /// in the period + pub period_spend_limit: Coin, + + /// period_can_spend is the number of coins left to be spent before the period_reset time + // set by the contract during initialisation of the grant + #[serde(default)] + pub period_can_spend: Option, + + /// period_reset is the time at which this period resets and a new one begins, + /// it is calculated from the start time of the first transaction after the + /// last period ended + // set by the contract during initialisation of the grant + #[serde(default)] + pub period_reset_unix_timestamp: u64, + } + + impl ClassicPeriodicAllowance { + pub(super) fn validate_new_inner(&self, denom: &str) -> Result<(), NymPoolContractError> { + // period duration shouldn't be zero + if self.period_duration_secs == 0 { + return Err(NymPoolContractError::ZeroAllowancePeriod); + } + + // the denom for period spend limit must match the expected value + if self.period_spend_limit.denom != denom { + return Err(NymPoolContractError::InvalidDenom { + expected: denom.to_string(), + got: self.period_spend_limit.denom.to_string(), + }); + } + + if self.period_spend_limit.amount.is_zero() { + return Err(NymPoolContractError::ZeroAmount); + } + + // if the basic spend limit is set, the period spend limit cannot be larger than it + if let Some(ref basic_limit) = self.basic.spend_limit { + if basic_limit.amount < self.period_spend_limit.amount { + return Err(NymPoolContractError::PeriodicGrantOverSpendLimit { + periodic: self.period_spend_limit.clone(), + total_limit: basic_limit.clone(), + }); + } + } + + Ok(()) + } + + /// The value that can be spent in the period is the lesser of the basic spend limit + /// and the period spend limit + /// + /// ```go + /// if _, isNeg := a.Basic.SpendLimit.SafeSub(a.PeriodSpendLimit...); isNeg && !a.Basic.SpendLimit.Empty() { + /// a.PeriodCanSpend = a.Basic.SpendLimit + /// } else { + /// a.PeriodCanSpend = a.PeriodSpendLimit + /// } + /// ``` + fn determine_period_can_spend(&self) -> Coin { + let Some(ref basic_limit) = self.basic.spend_limit else { + // if there's no spend limit, there's nothing to compare against + return self.period_spend_limit.clone(); + }; + + if basic_limit.amount < self.period_spend_limit.amount { + basic_limit.clone() + } else { + self.period_spend_limit.clone() + } + } + + pub(super) fn set_initial_state(&mut self, env: &Env) { + self.try_update_state(env); + } + + /// try_update_state will check if the period_reset_unix_timestamp has been hit. If not, it is a no-op. + /// If we hit the reset period, it will top up the period_can_spend amount to + /// min(period_spend_limit, basic.spend_limit) so it is never more than the maximum allowed. + /// It will also update the period_reset_unix_timestamp. + /// + /// If we are within one period, it will update from the + /// last period_reset (eg. if you always do one tx per day, it will always reset the same time) + /// If we are more than one period out (eg. no activity in a week), reset is one period from the execution of this method + pub fn try_update_state(&mut self, env: &Env) { + if env.block.time.seconds() < self.period_reset_unix_timestamp { + // we haven't yet reached the reset time + return; + } + self.period_can_spend = Some(self.determine_period_can_spend()); + + // If we are within the period, step from expiration (eg. if you always do one tx per day, + // it will always reset the same time) + // If we are more then one period out (eg. no activity in a week), + // reset is one period from this time + self.period_reset_unix_timestamp += self.period_duration_secs; + if env.block.time.seconds() > self.period_duration_secs { + self.period_reset_unix_timestamp = + env.block.time.seconds() + self.period_duration_secs; + } + } + + fn within_spendable_limits(&self, amount: &Coin) -> bool { + let Some(ref available) = self.period_can_spend else { + return false; + }; + available.amount >= amount.amount + } + + fn ensure_can_spend(&self, env: &Env, amount: &Coin) -> Result<(), NymPoolContractError> { + if self.basic.expired(env) { + return Err(NymPoolContractError::GrantExpired); + } + if !self.within_spendable_limits(amount) { + return Err(NymPoolContractError::SpendingAboveAllowance); + } + Ok(()) + } + + fn try_spend(&mut self, env: &Env, amount: &Coin) -> Result<(), NymPoolContractError> { + self.ensure_can_spend(env, amount)?; + + // deduct from both the current period and the max amount + if let Some(ref mut spend_limit) = self.basic.spend_limit { + spend_limit.amount -= amount.amount; + } + + // SAFETY: initial `period_can_spend` value is always unconditionally set by the contract during + // grant creation + #[allow(clippy::unwrap_used)] + let period_can_spend = self.period_can_spend.as_mut().unwrap(); + period_can_spend.amount -= amount.amount; + + Ok(()) + } + } + + #[cw_serde] + pub struct CumulativePeriodicAllowance { + /// basic specifies a struct of `BasicAllowance` + pub basic: BasicAllowance, + + /// period_duration_secs specifies the time duration in which spendable coins can + /// be spent before that allowance is incremented + pub period_duration_secs: u64, + + /// period_grant specifies the maximum number of coins that is granted per period + pub period_grant: Coin, + + /// accumulation_limit is the maximum value the grants and accumulate to + pub accumulation_limit: Option, + + /// spendable is the number of coins left to be spent before additional grant is applied + // set by the contract during initialisation of the grant + #[serde(default)] + pub spendable: Option, + + /// last_grant_applied is the time at which last transaction associated with this allowance + /// has been sent and `spendable` value has been adjusted + // set by the contract during initialisation of the grant + #[serde(default)] + pub last_grant_applied_unix_timestamp: u64, + } + + impl CumulativePeriodicAllowance { + pub(super) fn validate_new_inner(&self, denom: &str) -> Result<(), NymPoolContractError> { + // period duration shouldn't be zero + if self.period_duration_secs == 0 { + return Err(NymPoolContractError::ZeroAllowancePeriod); + } + + // the denom for period grant must match the expected value + if self.period_grant.denom != denom { + return Err(NymPoolContractError::InvalidDenom { + expected: denom.to_string(), + got: self.period_grant.denom.to_string(), + }); + } + + if self.period_grant.amount.is_zero() { + return Err(NymPoolContractError::ZeroAmount); + } + + // the period grant must not be larger than the total spend limit, if set + if let Some(ref basic_limit) = self.basic.spend_limit { + if basic_limit.amount < self.period_grant.amount { + return Err(NymPoolContractError::PeriodicGrantOverSpendLimit { + periodic: self.period_grant.clone(), + total_limit: basic_limit.clone(), + }); + } + } + + if let Some(ref accumulation_limit) = self.accumulation_limit { + // if set, the accumulation limit must not be smaller than the period grant + if accumulation_limit.amount < self.period_grant.amount { + return Err(NymPoolContractError::AccumulationBelowGrantAmount { + accumulation: accumulation_limit.clone(), + periodic_grant: self.period_grant.clone(), + }); + } + + // if set, the denom for accumulation limit must match the expected value + if accumulation_limit.denom != denom { + return Err(NymPoolContractError::InvalidDenom { + expected: denom.to_string(), + got: accumulation_limit.denom.to_string(), + }); + } + + // if set, the accumulation limit must not be larger than the total spend limit + if let Some(ref basic_limit) = self.basic.spend_limit { + if basic_limit.amount < accumulation_limit.amount { + return Err(NymPoolContractError::AccumulationOverSpendLimit { + accumulation: accumulation_limit.clone(), + total_limit: basic_limit.clone(), + }); + } + } + } + + Ok(()) + } + + pub(super) fn set_initial_state(&mut self, env: &Env) { + self.last_grant_applied_unix_timestamp = env.block.time.seconds(); + + // initially we can spend equivalent of a single grant + self.spendable = Some(self.period_grant.clone()) + } + + #[inline] + fn missed_periods(&self, env: &Env) -> u64 { + (env.block.time.seconds() - self.last_grant_applied_unix_timestamp) + % self.period_duration_secs + } + + /// The value that can be spent is the last of the basic spend limit, the accumulation limit + /// and number of missed periods multiplied by the period grant + fn determine_spendable(&self, env: &Env) -> Coin { + // SAFETY: initial `spendable` value is always unconditionally set by the contract during + // grant creation + #[allow(clippy::unwrap_used)] + let spendable = self.spendable.as_ref().unwrap(); + + let missed_periods = self.missed_periods(env); + let mut max_spendable = spendable.clone(); + max_spendable.amount += Uint128::new(missed_periods as u128) * self.period_grant.amount; + + match (&self.basic.spend_limit, &self.accumulation_limit) { + (Some(spend_limit), Some(accumulation_limit)) => { + let limit = min(spend_limit.amount, accumulation_limit.amount); + let amount = min(limit, max_spendable.amount); + Coin::new(amount, max_spendable.denom) + } + (None, Some(accumulation_limit)) => { + let amount = min(accumulation_limit.amount, max_spendable.amount); + Coin::new(amount, max_spendable.denom) + } + (Some(spend_limit), None) => { + let amount = min(spend_limit.amount, max_spendable.amount); + Coin::new(amount, max_spendable.denom) + } + (None, None) => max_spendable, + } + } + + /// try_update_state will check if we've rolled over into the next grant period. If not, it is a no-op. + /// If we hit the next period, it will top up the spendable amount to + /// min(accumulation_limit, basic.spend_limit, spendable + period_grant * num_missed_periods) so it is never more than the maximum allowed. + /// It will also update the last_grant_applied_unix_timestamp. + pub fn try_update_state(&mut self, env: &Env) { + let missed_periods = self.missed_periods(env); + + if missed_periods == 0 { + // we haven't yet reached the next grant time + return; + } + + self.spendable = Some(self.determine_spendable(env)) + } + + fn within_spendable_limits(&self, amount: &Coin) -> bool { + let Some(ref available) = self.spendable else { + return false; + }; + available.amount >= amount.amount + } + + fn ensure_can_spend(&self, env: &Env, amount: &Coin) -> Result<(), NymPoolContractError> { + if self.basic.expired(env) { + return Err(NymPoolContractError::GrantExpired); + } + if !self.within_spendable_limits(amount) { + return Err(NymPoolContractError::SpendingAboveAllowance); + } + Ok(()) + } + + fn try_spend(&mut self, env: &Env, amount: &Coin) -> Result<(), NymPoolContractError> { + self.ensure_can_spend(env, amount)?; + + // deduct from both the current period and the max amount + if let Some(ref mut spend_limit) = self.basic.spend_limit { + spend_limit.amount -= amount.amount; + } + + // SAFETY: initial `spendable` value is always unconditionally set by the contract during + // grant creation + #[allow(clippy::unwrap_used)] + let spendable = self.spendable.as_mut().unwrap(); + spendable.amount -= amount.amount; + + Ok(()) + } + } + + /// Create a grant to allow somebody to withdraw from the pool only after the specified time. + /// For example, we could create a grant for mixnet rewarding/testing/etc + /// However, if the required work has not been completed, the grant could be revoked before it's withdrawn + #[cw_serde] + pub struct DelayedAllowance { + /// basic specifies a struct of `BasicAllowance` + pub basic: BasicAllowance, + + /// available_at specifies when this allowance is going to become usable + pub available_at_unix_timestamp: u64, + } + + impl DelayedAllowance { + pub(super) fn validate_new_inner(&self, env: &Env) -> Result<(), NymPoolContractError> { + // available at must be set in the future + ensure_unix_timestamp_not_in_the_past(self.available_at_unix_timestamp, env)?; + + // and it must become available before the underlying allowance expires + if let Some(expiration) = self.basic.expiration_unix_timestamp { + if expiration < self.available_at_unix_timestamp { + return Err(NymPoolContractError::UnattainableDelayedAllowance { + expiration_timestamp: expiration, + available_timestamp: self.available_at_unix_timestamp, + }); + } + } + + Ok(()) + } + + fn within_spendable_limits(&self, amount: &Coin) -> bool { + self.basic.within_spendable_limits(amount) + } + + fn ensure_can_spend(&self, env: &Env, amount: &Coin) -> Result<(), NymPoolContractError> { + if self.basic.expired(env) { + return Err(NymPoolContractError::GrantExpired); + } + if !self.within_spendable_limits(amount) { + return Err(NymPoolContractError::SpendingAboveAllowance); + } + if self.available_at_unix_timestamp < env.block.time.seconds() { + return Err(NymPoolContractError::GrantNotYetAvailable { + available_at_timestamp: self.available_at_unix_timestamp, + }); + } + + Ok(()) + } + + fn try_spend(&mut self, env: &Env, amount: &Coin) -> Result<(), NymPoolContractError> { + self.ensure_can_spend(env, amount)?; + + if let Some(ref mut spend_limit) = self.basic.spend_limit { + spend_limit.amount -= amount.amount; + } + + Ok(()) + } + } +} + +pub mod query_responses { + use crate::{Grant, GranteeAddress, GranterAddress, GranterInformation}; + use cosmwasm_schema::cw_serde; + use cosmwasm_std::Coin; + + #[cw_serde] + pub struct AvailableTokensResponse { + pub available: Coin, + } + + #[cw_serde] + pub struct TotalLockedTokensResponse { + pub locked: Coin, + } + + #[cw_serde] + pub struct LockedTokensResponse { + pub grantee: GranteeAddress, + + pub locked: Option, + } + + #[cw_serde] + pub struct GrantInformation { + pub grant: Grant, + pub expired: bool, + } + + #[cw_serde] + pub struct GrantResponse { + pub grantee: GranteeAddress, + pub grant: Option, + } + + #[cw_serde] + pub struct GranterResponse { + pub granter: GranterAddress, + pub information: Option, + } + + #[cw_serde] + pub struct GrantsPagedResponse { + pub grants: Vec, + pub start_next_after: Option, + } + + #[cw_serde] + pub struct GranterDetails { + pub granter: GranterAddress, + pub information: GranterInformation, + } + + impl From<(GranterAddress, GranterInformation)> for GranterDetails { + fn from((granter, information): (GranterAddress, GranterInformation)) -> Self { + GranterDetails { + granter, + information, + } + } + } + + #[cw_serde] + pub struct GrantersPagedResponse { + pub granters: Vec, + pub start_next_after: Option, + } + + #[cw_serde] + pub struct LockedTokens { + pub grantee: GranteeAddress, + pub locked: Coin, + } + + #[cw_serde] + pub struct LockedTokensPagedResponse { + pub locked: Vec, + pub start_next_after: Option, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::{coin, Uint128}; + + const TEST_DENOM: &str = "unym"; + + fn mock_basic_allowance() -> BasicAllowance { + BasicAllowance { + spend_limit: Some(coin(100000, TEST_DENOM)), + expiration_unix_timestamp: Some(1643652000), + } + } + + fn mock_classic_periodic_allowance() -> ClassicPeriodicAllowance { + ClassicPeriodicAllowance { + basic: mock_basic_allowance(), + period_duration_secs: 10, + period_spend_limit: coin(1000, TEST_DENOM), + period_can_spend: None, + period_reset_unix_timestamp: 0, + } + } + + fn mock_cumulative_periodic_allowance() -> CumulativePeriodicAllowance { + CumulativePeriodicAllowance { + basic: mock_basic_allowance(), + period_duration_secs: 10, + period_grant: coin(1000, TEST_DENOM), + accumulation_limit: Some(coin(10000, TEST_DENOM)), + spendable: None, + last_grant_applied_unix_timestamp: 0, + } + } + + fn mock_delayed_allowance() -> DelayedAllowance { + DelayedAllowance { + basic: mock_basic_allowance(), + available_at_unix_timestamp: 1643650000, + } + } + + #[test] + fn increasing_spend_limit() { + // no-op if there's no limit + let mut basic = mock_basic_allowance(); + basic.spend_limit = None; + let mut basic = Allowance::Basic(basic); + + let mut classic = mock_classic_periodic_allowance(); + classic.basic.spend_limit = None; + let mut classic = Allowance::ClassicPeriodic(classic); + + let mut cumulative = mock_cumulative_periodic_allowance(); + cumulative.basic.spend_limit = None; + let mut cumulative = Allowance::CumulativePeriodic(cumulative); + + let mut delayed = mock_delayed_allowance(); + delayed.basic.spend_limit = None; + let mut delayed = Allowance::Delayed(delayed); + + let basic_og = basic.clone(); + let classic_og = classic.clone(); + let cumulative_og = cumulative.clone(); + let delayed_og = delayed.clone(); + + basic.increase_spend_limit(Uint128::new(100)); + classic.increase_spend_limit(Uint128::new(100)); + cumulative.increase_spend_limit(Uint128::new(100)); + delayed.increase_spend_limit(Uint128::new(100)); + + assert_eq!(basic, basic_og); + assert_eq!(classic, classic_og); + assert_eq!(cumulative, cumulative_og); + assert_eq!(delayed, delayed_og); + + // adds to spend limit otherwise + let limit = coin(1000, TEST_DENOM); + let mut basic = mock_basic_allowance(); + basic.spend_limit = Some(limit.clone()); + let mut basic = Allowance::Basic(basic); + + let mut classic = mock_classic_periodic_allowance(); + classic.basic.spend_limit = Some(limit.clone()); + let mut classic = Allowance::ClassicPeriodic(classic); + + let mut cumulative = mock_cumulative_periodic_allowance(); + cumulative.basic.spend_limit = Some(limit.clone()); + let mut cumulative = Allowance::CumulativePeriodic(cumulative); + + let mut delayed = mock_delayed_allowance(); + delayed.basic.spend_limit = Some(limit.clone()); + let mut delayed = Allowance::Delayed(delayed); + + basic.increase_spend_limit(Uint128::new(100)); + classic.increase_spend_limit(Uint128::new(100)); + cumulative.increase_spend_limit(Uint128::new(100)); + delayed.increase_spend_limit(Uint128::new(100)); + + assert_eq!( + basic.basic().spend_limit.as_ref().unwrap().amount, + limit.amount + Uint128::new(100) + ); + assert_eq!( + classic.basic().spend_limit.as_ref().unwrap().amount, + limit.amount + Uint128::new(100) + ); + assert_eq!( + cumulative.basic().spend_limit.as_ref().unwrap().amount, + limit.amount + Uint128::new(100) + ); + assert_eq!( + delayed.basic().spend_limit.as_ref().unwrap().amount, + limit.amount + Uint128::new(100) + ); + } + + #[cfg(test)] + mod validating_new_allowances { + use super::*; + + #[cfg(test)] + mod basic_allowance { + use super::*; + use cosmwasm_std::testing::mock_env; + use cosmwasm_std::Timestamp; + + #[test] + fn doesnt_allow_expirations_in_the_past() { + let mut allowance = mock_basic_allowance(); + + let mut env = mock_env(); + + // allowance expiration is in the past + env.block.time = + Timestamp::from_seconds(allowance.expiration_unix_timestamp.unwrap() + 1); + assert!(allowance.validate(&env, TEST_DENOM).is_err()); + + // allowance expiration is equal to the current block time + env.block.time = + Timestamp::from_seconds(allowance.expiration_unix_timestamp.unwrap()); + assert!(allowance.validate(&env, TEST_DENOM).is_ok()); + + // allowance expiration is in the future + env.block.time = + Timestamp::from_seconds(allowance.expiration_unix_timestamp.unwrap() - 1); + assert!(allowance.validate(&env, TEST_DENOM).is_ok()); + + // no explicit expiration + allowance.expiration_unix_timestamp = None; + assert!(allowance.validate(&env, TEST_DENOM).is_ok()); + } + + #[test] + fn spend_limit_must_match_expected_denom() { + let mut allowance = mock_basic_allowance(); + + let env = mock_env(); + + // mismatched denom + assert!(allowance.validate(&env, "baddenom").is_err()); + + // matched denom + assert!(allowance.validate(&env, TEST_DENOM).is_ok()); + + // no spend limit + allowance.spend_limit = None; + assert!(allowance.validate(&env, TEST_DENOM).is_ok()); + } + + #[test] + fn spend_limit_must_be_non_zero() { + let mut allowance = mock_basic_allowance(); + + let env = mock_env(); + + // zero amount + allowance.spend_limit = Some(coin(0, TEST_DENOM)); + assert!(allowance.validate(&env, TEST_DENOM).is_err()); + + // non-zero amount + allowance.spend_limit = Some(coin(69, TEST_DENOM)); + assert!(allowance.validate(&env, TEST_DENOM).is_ok()); + } + } + + #[cfg(test)] + mod classic_periodic_allowance { + use super::*; + use crate::NymPoolContractError; + + #[test] + fn period_duration_must_be_nonzero() { + let mut allowance = mock_classic_periodic_allowance(); + + allowance.period_duration_secs = 0; + assert_eq!( + allowance.validate_new_inner(TEST_DENOM).unwrap_err(), + NymPoolContractError::ZeroAllowancePeriod + ); + + allowance.period_duration_secs = 1; + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + } + + #[test] + fn spend_limit_must_match_expected_denom() { + let allowance = mock_classic_periodic_allowance(); + + // mismatched denom + assert!(allowance.validate_new_inner("baddenom").is_err()); + + // matched denom + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + } + + #[test] + fn spend_limit_must_be_non_zero() { + let mut allowance = mock_classic_periodic_allowance(); + + // zero amount + allowance.period_spend_limit = coin(0, TEST_DENOM); + assert!(allowance.validate_new_inner(TEST_DENOM).is_err()); + + // non-zero amount + allowance.period_spend_limit = coin(69, TEST_DENOM); + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + } + + #[test] + fn period_spend_limit_must_be_smaller_than_total_limit() { + let mut allowance = mock_classic_periodic_allowance(); + + let total_limit = coin(1000, TEST_DENOM); + allowance.basic.spend_limit = Some(total_limit); + + // above total spend limit + allowance.period_spend_limit = coin(1001, TEST_DENOM); + assert!(matches!( + allowance.validate_new_inner(TEST_DENOM).unwrap_err(), + NymPoolContractError::PeriodicGrantOverSpendLimit { .. } + )); + + // below total spend limit + allowance.period_spend_limit = coin(999, TEST_DENOM); + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + + // equal to total spend limit + allowance.period_spend_limit = coin(1000, TEST_DENOM); + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + + // no total spend limit + allowance.basic.spend_limit = None; + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + } + } + + #[cfg(test)] + mod cumulative_periodic_allowance { + use super::*; + use crate::NymPoolContractError; + + #[test] + fn period_duration_must_be_nonzero() { + let mut allowance = mock_cumulative_periodic_allowance(); + + allowance.period_duration_secs = 0; + assert_eq!( + allowance.validate_new_inner(TEST_DENOM).unwrap_err(), + NymPoolContractError::ZeroAllowancePeriod + ); + + allowance.period_duration_secs = 1; + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + } + + #[test] + fn grant_must_match_expected_denom() { + let allowance = mock_cumulative_periodic_allowance(); + + // mismatched denom + assert!(allowance.validate_new_inner("baddenom").is_err()); + + // matched denom + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + } + + #[test] + fn grant_must_be_non_zero() { + let mut allowance = mock_cumulative_periodic_allowance(); + + // zero amount + allowance.period_grant = coin(0, TEST_DENOM); + assert!(allowance.validate_new_inner(TEST_DENOM).is_err()); + + // non-zero amount + allowance.period_grant = coin(69, TEST_DENOM); + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + } + + #[test] + fn grant_amount_must_be_smaller_than_total_limit() { + let mut allowance = mock_cumulative_periodic_allowance(); + + let total_limit = coin(1000, TEST_DENOM); + allowance.basic.spend_limit = Some(total_limit); + allowance.accumulation_limit = None; + + // above total spend limit + allowance.period_grant = coin(1001, TEST_DENOM); + assert!(matches!( + allowance.validate_new_inner(TEST_DENOM).unwrap_err(), + NymPoolContractError::PeriodicGrantOverSpendLimit { .. } + )); + + // below total spend limit + allowance.period_grant = coin(999, TEST_DENOM); + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + + // equal to total spend limit + allowance.period_grant = coin(1000, TEST_DENOM); + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + + // no total spend limit + allowance.basic.spend_limit = None; + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + } + + #[test] + fn accumulation_limit_must_be_smaller_than_total_limit() { + let mut allowance = mock_cumulative_periodic_allowance(); + + let total_limit = coin(1000, TEST_DENOM); + allowance.basic.spend_limit = Some(total_limit.clone()); + allowance.period_grant = coin(500, TEST_DENOM); + + // above total spend limit + allowance.accumulation_limit = Some(coin(1001, TEST_DENOM)); + assert!(matches!( + allowance.validate_new_inner(TEST_DENOM).unwrap_err(), + NymPoolContractError::AccumulationOverSpendLimit { .. } + )); + + // below total spend limit + allowance.accumulation_limit = Some(coin(999, TEST_DENOM)); + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + + // equal to total spend limit + allowance.accumulation_limit = Some(coin(1000, TEST_DENOM)); + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + + // no total spend limit + allowance.basic.spend_limit = None; + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + + // no accumulation limit + allowance.accumulation_limit = None; + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + + // no accumulation limit but with spend limit + allowance.basic.spend_limit = Some(total_limit); + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + } + + #[test] + fn accumulation_limit_must_not_be_smaller_than_grant_amount() { + let mut allowance = mock_cumulative_periodic_allowance(); + + let total_limit = coin(1000, TEST_DENOM); + allowance.basic.spend_limit = Some(total_limit); + allowance.period_grant = coin(500, TEST_DENOM); + + // below grant amount + allowance.accumulation_limit = Some(coin(499, TEST_DENOM)); + assert!(matches!( + allowance.validate_new_inner(TEST_DENOM).unwrap_err(), + NymPoolContractError::AccumulationBelowGrantAmount { .. } + )); + + // above grant amount + allowance.accumulation_limit = Some(coin(501, TEST_DENOM)); + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + + // equal to grant amount + allowance.accumulation_limit = Some(coin(500, TEST_DENOM)); + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + + // no accumulation limit + allowance.accumulation_limit = None; + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + } + + #[test] + fn accumulation_limit_must_match_expected_denom() { + let mut allowance = mock_cumulative_periodic_allowance(); + allowance.accumulation_limit = Some(coin(1000, "baddenom")); + + // mismatched denom + assert!(allowance.validate_new_inner(TEST_DENOM).is_err()); + + // matched denom + allowance.accumulation_limit = Some(coin(1000, TEST_DENOM)); + assert!(allowance.validate_new_inner(TEST_DENOM).is_ok()); + } + } + + #[cfg(test)] + mod delayed_allowance { + use super::*; + use cosmwasm_std::testing::mock_env; + use cosmwasm_std::Timestamp; + + #[test] + fn doesnt_allow_availability_in_the_past() { + let allowance = mock_delayed_allowance(); + let mut env = mock_env(); + + // availability is in the past + env.block.time = Timestamp::from_seconds(allowance.available_at_unix_timestamp + 1); + assert!(allowance.validate_new_inner(&env).is_err()); + + // availability is equal to the current block time + env.block.time = Timestamp::from_seconds(allowance.available_at_unix_timestamp); + assert!(allowance.validate_new_inner(&env).is_ok()); + + // availability is in the future + env.block.time = Timestamp::from_seconds(allowance.available_at_unix_timestamp - 1); + assert!(allowance.validate_new_inner(&env).is_ok()); + } + + #[test] + fn must_have_available_before_allowance_expiration() { + let mut allowance = mock_delayed_allowance(); + let mut env = mock_env(); + env.block.time = Timestamp::from_seconds(100); + allowance.basic.expiration_unix_timestamp = Some(1000); + + // after expiration + allowance.available_at_unix_timestamp = 1001; + assert!(allowance.validate_new_inner(&env).is_err()); + + // equal to expiration + allowance.available_at_unix_timestamp = 1000; + assert!(allowance.validate_new_inner(&env).is_ok()); + + // before expiration + allowance.available_at_unix_timestamp = 999; + assert!(allowance.validate_new_inner(&env).is_ok()); + + // with no explicit expiration + allowance.basic.expiration_unix_timestamp = None; + assert!(allowance.validate_new_inner(&env).is_ok()); + } + } + } + + #[cfg(test)] + mod setting_initial_state { + use super::*; + use cosmwasm_std::testing::mock_env; + + #[test] + fn basic_allowance() { + let mut basic = Allowance::Basic(mock_basic_allowance()); + + let og = basic.clone(); + + // this is a no-op + let env = mock_env(); + basic.set_initial_state(&env); + assert_eq!(basic, og); + } + + #[test] + fn classic_periodic_allowance() { + let mut inner = mock_classic_periodic_allowance(); + let mut cumulative = Allowance::ClassicPeriodic(inner.clone()); + + let env = mock_env(); + + let mut expected = inner.clone(); + + // sets the spendable amount to min(basic_limit, period_limit) + expected.period_can_spend = Some(expected.period_spend_limit.clone()); + + // set period reset to current block time + period duration + expected.period_reset_unix_timestamp = + env.block.time.seconds() + expected.period_duration_secs; + + inner.set_initial_state(&env); + assert_eq!(inner, expected); + + cumulative.set_initial_state(&env); + assert_eq!(cumulative, Allowance::ClassicPeriodic(inner)); + } + + #[test] + fn cumulative_periodic_allowance() { + let mut inner = mock_cumulative_periodic_allowance(); + let mut cumulative = Allowance::CumulativePeriodic(inner.clone()); + + let env = mock_env(); + + // sets the last applied grant to current time and spendable to a single grant value + let mut expected = inner.clone(); + expected.last_grant_applied_unix_timestamp = env.block.time.seconds(); + expected.spendable = Some(expected.period_grant.clone()); + + inner.set_initial_state(&env); + assert_eq!(inner, expected); + + cumulative.set_initial_state(&env); + assert_eq!(cumulative, Allowance::CumulativePeriodic(inner)); + } + + #[test] + fn delayed_allowance() { + let mut delayed = Allowance::Delayed(mock_delayed_allowance()); + + let og = delayed.clone(); + + // this is a no-op + let env = mock_env(); + delayed.set_initial_state(&env); + assert_eq!(delayed, og); + } + } +} diff --git a/common/cosmwasm-smart-contracts/nym-pool-contract/src/utils.rs b/common/cosmwasm-smart-contracts/nym-pool-contract/src/utils.rs new file mode 100644 index 0000000000..0b6f5ba1f4 --- /dev/null +++ b/common/cosmwasm-smart-contracts/nym-pool-contract/src/utils.rs @@ -0,0 +1,77 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::NymPoolContractError; +use cosmwasm_std::Env; + +pub fn ensure_unix_timestamp_not_in_the_past( + unix_timestamp: u64, + env: &Env, +) -> Result<(), NymPoolContractError> { + if unix_timestamp < env.block.time.seconds() { + return Err(NymPoolContractError::TimestampInThePast { + timestamp: unix_timestamp, + current_block_timestamp: env.block.time.seconds(), + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::testing::mock_env; + use cosmwasm_std::Timestamp; + use time::macros::datetime; + + #[test] + fn ensuring_unix_timestamp_not_in_the_past() { + let unix_epoch = 0; + + let date_in_the_past = datetime!(1984-01-02 3:45 UTC); + let sane_block_time = datetime!(2025-01-28 12:15 UTC); + + let before_block = datetime!(2025-01-28 12:00 UTC); + let after_block = datetime!(2025-01-28 12:30 UTC); + + let mut env = mock_env(); + env.block.time = Timestamp::from_seconds(sane_block_time.unix_timestamp() as u64); + + let res = ensure_unix_timestamp_not_in_the_past(unix_epoch, &env).unwrap_err(); + assert_eq!( + NymPoolContractError::TimestampInThePast { + timestamp: unix_epoch, + current_block_timestamp: env.block.time.seconds(), + }, + res + ); + + let res = + ensure_unix_timestamp_not_in_the_past(date_in_the_past.unix_timestamp() as u64, &env) + .unwrap_err(); + assert_eq!( + NymPoolContractError::TimestampInThePast { + timestamp: date_in_the_past.unix_timestamp() as u64, + current_block_timestamp: env.block.time.seconds(), + }, + res + ); + + let res = ensure_unix_timestamp_not_in_the_past(before_block.unix_timestamp() as u64, &env) + .unwrap_err(); + assert_eq!( + NymPoolContractError::TimestampInThePast { + timestamp: before_block.unix_timestamp() as u64, + current_block_timestamp: env.block.time.seconds(), + }, + res + ); + + let res = + ensure_unix_timestamp_not_in_the_past(sane_block_time.unix_timestamp() as u64, &env); + assert!(res.is_ok()); + + let res = ensure_unix_timestamp_not_in_the_past(after_block.unix_timestamp() as u64, &env); + assert!(res.is_ok()); + } +} diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index f246379774..6b4fdc7a88 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -1264,6 +1264,36 @@ dependencies = [ "tracing", ] +[[package]] +name = "nym-pool-contract" +version = "0.1.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-multi-test", + "cw-storage-plus", + "cw2", + "nym-contracts-common", + "nym-pool-contract-common", + "rand", + "rand_chacha", + "serde", +] + +[[package]] +name = "nym-pool-contract-common" +version = "0.1.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "schemars", + "serde", + "thiserror 2.0.12", +] + [[package]] name = "nym-sphinx-types" version = "0.2.0" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index ce2f7df73b..8b744af7b2 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -5,6 +5,7 @@ members = [ "ecash", "mixnet", "mixnet-vesting-integration-tests", + "nym-pool", "multisig/cw3-flex-multisig", "multisig/cw4-group", "vesting", @@ -46,6 +47,8 @@ cw3-fixed-multisig = "=2.0.0" cw4 = "=2.0.0" cw20 = "=2.0.0" cw20-base = "2.0.0" +rand = "0.8.5" +rand_chacha = "0.3.1" semver = "1.0.21" serde = "1.0.196" sylvia = "1.3.3" diff --git a/contracts/nym-pool/.cargo/config b/contracts/nym-pool/.cargo/config new file mode 100644 index 0000000000..2fb2c1afdb --- /dev/null +++ b/contracts/nym-pool/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --bin schema --features=schema-gen" \ No newline at end of file diff --git a/contracts/nym-pool/Cargo.toml b/contracts/nym-pool/Cargo.toml new file mode 100644 index 0000000000..4011deaa30 --- /dev/null +++ b/contracts/nym-pool/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "nym-pool-contract" +version = "0.1.0" +edition = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +repository = { workspace = true } + +[[bin]] +name = "schema" +required-features = ["schema-gen"] + +[lib] +name = "nym_pool_contract" +crate-type = ["cdylib", "rlib"] + +[dependencies] +cosmwasm-std = { workspace = true } +cw2 = { workspace = true } +cw-storage-plus = { workspace = true } +cw-controllers = { workspace = true } + +cosmwasm-schema = { workspace = true, optional = true } + +nym-contracts-common = { path = "../../common/cosmwasm-smart-contracts/contracts-common" } +nym-pool-contract-common = { path = "../../common/cosmwasm-smart-contracts/nym-pool-contract" } + + +[dev-dependencies] +anyhow = { workspace = true } +serde = { workspace = true } +rand_chacha = { workspace = true } +rand = { workspace = true } +cw-multi-test = { workspace = true } + +[features] +schema-gen = ["nym-pool-contract-common/schema", "cosmwasm-schema"] diff --git a/contracts/nym-pool/Makefile b/contracts/nym-pool/Makefile new file mode 100644 index 0000000000..086fa71ad3 --- /dev/null +++ b/contracts/nym-pool/Makefile @@ -0,0 +1,5 @@ +wasm: + RUSTFLAGS='-C link-arg=-s' cargo build --release --target wasm32-unknown-unknown + +generate-schema: + cargo schema diff --git a/contracts/nym-pool/schema/nym-pool-contract.json b/contracts/nym-pool/schema/nym-pool-contract.json new file mode 100644 index 0000000000..838f9a83cb --- /dev/null +++ b/contracts/nym-pool/schema/nym-pool-contract.json @@ -0,0 +1,1957 @@ +{ + "contract_name": "nym-pool-contract", + "contract_version": "0.1.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "grants", + "pool_denomination" + ], + "properties": { + "grants": { + "description": "Initial map of grants to be created at instantiation", + "type": "object", + "additionalProperties": false + }, + "pool_denomination": { + "type": "string" + } + }, + "additionalProperties": false, + "definitions": { + "Allowance": { + "oneOf": [ + { + "type": "object", + "required": [ + "basic" + ], + "properties": { + "basic": { + "$ref": "#/definitions/BasicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "classic_periodic" + ], + "properties": { + "classic_periodic": { + "$ref": "#/definitions/ClassicPeriodicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cumulative_periodic" + ], + "properties": { + "cumulative_periodic": { + "$ref": "#/definitions/CumulativePeriodicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "delayed" + ], + "properties": { + "delayed": { + "$ref": "#/definitions/DelayedAllowance" + } + }, + "additionalProperties": false + } + ] + }, + "BasicAllowance": { + "description": "BasicAllowance is an allowance with a one-time grant of coins that optionally expires. The grantee can use up to SpendLimit to cover fees.", + "type": "object", + "properties": { + "expiration_unix_timestamp": { + "description": "expiration specifies an optional time when this allowance expires", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "spend_limit": { + "description": "spend_limit specifies the maximum amount of coins that can be spent by this allowance and will be updated as coins are spent. If it is empty, there is no spend limit and any amount of coins can be spent.", + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "ClassicPeriodicAllowance": { + "description": "ClassicPeriodicAllowance extends BasicAllowance to allow for both a maximum cap, as well as a limit per time period.", + "type": "object", + "required": [ + "basic", + "period_duration_secs", + "period_spend_limit" + ], + "properties": { + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + }, + "period_can_spend": { + "description": "period_can_spend is the number of coins left to be spent before the period_reset time", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + }, + "period_duration_secs": { + "description": "period_duration_secs specifies the time duration in which period_spend_limit coins can be spent before that allowance is reset", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_reset_unix_timestamp": { + "description": "period_reset is the time at which this period resets and a new one begins, it is calculated from the start time of the first transaction after the last period ended", + "default": 0, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_spend_limit": { + "description": "period_spend_limit specifies the maximum number of coins that can be spent in the period", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "additionalProperties": false + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "CumulativePeriodicAllowance": { + "type": "object", + "required": [ + "basic", + "period_duration_secs", + "period_grant" + ], + "properties": { + "accumulation_limit": { + "description": "accumulation_limit is the maximum value the grants and accumulate to", + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + }, + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + }, + "last_grant_applied_unix_timestamp": { + "description": "last_grant_applied is the time at which last transaction associated with this allowance has been sent and `spendable` value has been adjusted", + "default": 0, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_duration_secs": { + "description": "period_duration_secs specifies the time duration in which spendable coins can be spent before that allowance is incremented", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_grant": { + "description": "period_grant specifies the maximum number of coins that is granted per period", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "spendable": { + "description": "spendable is the number of coins left to be spent before additional grant is applied", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "DelayedAllowance": { + "description": "Create a grant to allow somebody to withdraw from the pool only after the specified time. For example, we could create a grant for mixnet rewarding/testing/etc However, if the required work has not been completed, the grant could be revoked before it's withdrawn", + "type": "object", + "required": [ + "available_at_unix_timestamp", + "basic" + ], + "properties": { + "available_at_unix_timestamp": { + "description": "available_at specifies when this allowance is going to become usable", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Change the admin", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin" + ], + "properties": { + "admin": { + "type": "string" + }, + "update_granter_set": { + "type": [ + "boolean", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to grant new allowance to the specified grantee", + "type": "object", + "required": [ + "grant_allowance" + ], + "properties": { + "grant_allowance": { + "type": "object", + "required": [ + "allowance", + "grantee" + ], + "properties": { + "allowance": { + "$ref": "#/definitions/Allowance" + }, + "grantee": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to revoke previously granted allowance", + "type": "object", + "required": [ + "revoke_allowance" + ], + "properties": { + "revoke_allowance": { + "type": "object", + "required": [ + "grantee" + ], + "properties": { + "grantee": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to use allowance", + "type": "object", + "required": [ + "use_allowance" + ], + "properties": { + "use_allowance": { + "type": "object", + "required": [ + "recipients" + ], + "properties": { + "recipients": { + "type": "array", + "items": { + "$ref": "#/definitions/TransferRecipient" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to withdraw the specified amount into the grantee's account", + "type": "object", + "required": [ + "withdraw_allowance" + ], + "properties": { + "withdraw_allowance": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to lock part of existing allowance for future use", + "type": "object", + "required": [ + "lock_allowance" + ], + "properties": { + "lock_allowance": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to unlock previously locked allowance", + "type": "object", + "required": [ + "unlock_allowance" + ], + "properties": { + "unlock_allowance": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to use part of the locked allowance", + "type": "object", + "required": [ + "use_locked_allowance" + ], + "properties": { + "use_locked_allowance": { + "type": "object", + "required": [ + "recipients" + ], + "properties": { + "recipients": { + "type": "array", + "items": { + "$ref": "#/definitions/TransferRecipient" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to withdraw the specified amount of locked tokens into the grantee's account", + "type": "object", + "required": [ + "withdraw_locked_allowance" + ], + "properties": { + "withdraw_locked_allowance": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to add a new account to the permitted set of grant granters", + "type": "object", + "required": [ + "add_new_granter" + ], + "properties": { + "add_new_granter": { + "type": "object", + "required": [ + "granter" + ], + "properties": { + "granter": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Revoke the provided account from the permitted set of granters", + "type": "object", + "required": [ + "revoke_granter" + ], + "properties": { + "revoke_granter": { + "type": "object", + "required": [ + "granter" + ], + "properties": { + "granter": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to remove expired grant from the storage and unlock (if any) locked tokens", + "type": "object", + "required": [ + "remove_expired_grant" + ], + "properties": { + "remove_expired_grant": { + "type": "object", + "required": [ + "grantee" + ], + "properties": { + "grantee": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Allowance": { + "oneOf": [ + { + "type": "object", + "required": [ + "basic" + ], + "properties": { + "basic": { + "$ref": "#/definitions/BasicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "classic_periodic" + ], + "properties": { + "classic_periodic": { + "$ref": "#/definitions/ClassicPeriodicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cumulative_periodic" + ], + "properties": { + "cumulative_periodic": { + "$ref": "#/definitions/CumulativePeriodicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "delayed" + ], + "properties": { + "delayed": { + "$ref": "#/definitions/DelayedAllowance" + } + }, + "additionalProperties": false + } + ] + }, + "BasicAllowance": { + "description": "BasicAllowance is an allowance with a one-time grant of coins that optionally expires. The grantee can use up to SpendLimit to cover fees.", + "type": "object", + "properties": { + "expiration_unix_timestamp": { + "description": "expiration specifies an optional time when this allowance expires", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "spend_limit": { + "description": "spend_limit specifies the maximum amount of coins that can be spent by this allowance and will be updated as coins are spent. If it is empty, there is no spend limit and any amount of coins can be spent.", + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "ClassicPeriodicAllowance": { + "description": "ClassicPeriodicAllowance extends BasicAllowance to allow for both a maximum cap, as well as a limit per time period.", + "type": "object", + "required": [ + "basic", + "period_duration_secs", + "period_spend_limit" + ], + "properties": { + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + }, + "period_can_spend": { + "description": "period_can_spend is the number of coins left to be spent before the period_reset time", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + }, + "period_duration_secs": { + "description": "period_duration_secs specifies the time duration in which period_spend_limit coins can be spent before that allowance is reset", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_reset_unix_timestamp": { + "description": "period_reset is the time at which this period resets and a new one begins, it is calculated from the start time of the first transaction after the last period ended", + "default": 0, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_spend_limit": { + "description": "period_spend_limit specifies the maximum number of coins that can be spent in the period", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "additionalProperties": false + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "CumulativePeriodicAllowance": { + "type": "object", + "required": [ + "basic", + "period_duration_secs", + "period_grant" + ], + "properties": { + "accumulation_limit": { + "description": "accumulation_limit is the maximum value the grants and accumulate to", + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + }, + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + }, + "last_grant_applied_unix_timestamp": { + "description": "last_grant_applied is the time at which last transaction associated with this allowance has been sent and `spendable` value has been adjusted", + "default": 0, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_duration_secs": { + "description": "period_duration_secs specifies the time duration in which spendable coins can be spent before that allowance is incremented", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_grant": { + "description": "period_grant specifies the maximum number of coins that is granted per period", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "spendable": { + "description": "spendable is the number of coins left to be spent before additional grant is applied", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "DelayedAllowance": { + "description": "Create a grant to allow somebody to withdraw from the pool only after the specified time. For example, we could create a grant for mixnet rewarding/testing/etc However, if the required work has not been completed, the grant could be revoked before it's withdrawn", + "type": "object", + "required": [ + "available_at_unix_timestamp", + "basic" + ], + "properties": { + "available_at_unix_timestamp": { + "description": "available_at specifies when this allowance is going to become usable", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + } + }, + "additionalProperties": false + }, + "TransferRecipient": { + "type": "object", + "required": [ + "amount", + "recipient" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "recipient": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "admin" + ], + "properties": { + "admin": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_available_tokens" + ], + "properties": { + "get_available_tokens": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_total_locked_tokens" + ], + "properties": { + "get_total_locked_tokens": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_locked_tokens" + ], + "properties": { + "get_locked_tokens": { + "type": "object", + "required": [ + "grantee" + ], + "properties": { + "grantee": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_grant" + ], + "properties": { + "get_grant": { + "type": "object", + "required": [ + "grantee" + ], + "properties": { + "grantee": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_granter" + ], + "properties": { + "get_granter": { + "type": "object", + "required": [ + "granter" + ], + "properties": { + "granter": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_locked_tokens_paged" + ], + "properties": { + "get_locked_tokens_paged": { + "type": "object", + "properties": { + "limit": { + "description": "Controls the maximum number of entries returned by the query. Note that too large values will be overwritten by a saner default.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "description": "Pagination control for the values returned by the query. Note that the provided value itself will **not** be used for the response.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_granters_paged" + ], + "properties": { + "get_granters_paged": { + "type": "object", + "properties": { + "limit": { + "description": "Controls the maximum number of entries returned by the query. Note that too large values will be overwritten by a saner default.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "description": "Pagination control for the values returned by the query. Note that the provided value itself will **not** be used for the response.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_grants_paged" + ], + "properties": { + "get_grants_paged": { + "type": "object", + "properties": { + "limit": { + "description": "Controls the maximum number of entries returned by the query. Note that too large values will be overwritten by a saner default.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "description": "Pagination control for the values returned by the query. Note that the provided value itself will **not** be used for the response.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "additionalProperties": false + }, + "sudo": null, + "responses": { + "admin": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AdminResponse", + "description": "Returned from Admin.query_admin()", + "type": "object", + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "get_available_tokens": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AvailableTokensResponse", + "type": "object", + "required": [ + "available" + ], + "properties": { + "available": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false, + "definitions": { + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "get_grant": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GrantResponse", + "type": "object", + "required": [ + "grantee" + ], + "properties": { + "grant": { + "anyOf": [ + { + "$ref": "#/definitions/GrantInformation" + }, + { + "type": "null" + } + ] + }, + "grantee": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Allowance": { + "oneOf": [ + { + "type": "object", + "required": [ + "basic" + ], + "properties": { + "basic": { + "$ref": "#/definitions/BasicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "classic_periodic" + ], + "properties": { + "classic_periodic": { + "$ref": "#/definitions/ClassicPeriodicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cumulative_periodic" + ], + "properties": { + "cumulative_periodic": { + "$ref": "#/definitions/CumulativePeriodicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "delayed" + ], + "properties": { + "delayed": { + "$ref": "#/definitions/DelayedAllowance" + } + }, + "additionalProperties": false + } + ] + }, + "BasicAllowance": { + "description": "BasicAllowance is an allowance with a one-time grant of coins that optionally expires. The grantee can use up to SpendLimit to cover fees.", + "type": "object", + "properties": { + "expiration_unix_timestamp": { + "description": "expiration specifies an optional time when this allowance expires", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "spend_limit": { + "description": "spend_limit specifies the maximum amount of coins that can be spent by this allowance and will be updated as coins are spent. If it is empty, there is no spend limit and any amount of coins can be spent.", + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "ClassicPeriodicAllowance": { + "description": "ClassicPeriodicAllowance extends BasicAllowance to allow for both a maximum cap, as well as a limit per time period.", + "type": "object", + "required": [ + "basic", + "period_duration_secs", + "period_spend_limit" + ], + "properties": { + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + }, + "period_can_spend": { + "description": "period_can_spend is the number of coins left to be spent before the period_reset time", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + }, + "period_duration_secs": { + "description": "period_duration_secs specifies the time duration in which period_spend_limit coins can be spent before that allowance is reset", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_reset_unix_timestamp": { + "description": "period_reset is the time at which this period resets and a new one begins, it is calculated from the start time of the first transaction after the last period ended", + "default": 0, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_spend_limit": { + "description": "period_spend_limit specifies the maximum number of coins that can be spent in the period", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "additionalProperties": false + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "CumulativePeriodicAllowance": { + "type": "object", + "required": [ + "basic", + "period_duration_secs", + "period_grant" + ], + "properties": { + "accumulation_limit": { + "description": "accumulation_limit is the maximum value the grants and accumulate to", + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + }, + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + }, + "last_grant_applied_unix_timestamp": { + "description": "last_grant_applied is the time at which last transaction associated with this allowance has been sent and `spendable` value has been adjusted", + "default": 0, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_duration_secs": { + "description": "period_duration_secs specifies the time duration in which spendable coins can be spent before that allowance is incremented", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_grant": { + "description": "period_grant specifies the maximum number of coins that is granted per period", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "spendable": { + "description": "spendable is the number of coins left to be spent before additional grant is applied", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "DelayedAllowance": { + "description": "Create a grant to allow somebody to withdraw from the pool only after the specified time. For example, we could create a grant for mixnet rewarding/testing/etc However, if the required work has not been completed, the grant could be revoked before it's withdrawn", + "type": "object", + "required": [ + "available_at_unix_timestamp", + "basic" + ], + "properties": { + "available_at_unix_timestamp": { + "description": "available_at specifies when this allowance is going to become usable", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + } + }, + "additionalProperties": false + }, + "Grant": { + "type": "object", + "required": [ + "allowance", + "granted_at_height", + "grantee", + "granter" + ], + "properties": { + "allowance": { + "$ref": "#/definitions/Allowance" + }, + "granted_at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "grantee": { + "$ref": "#/definitions/Addr" + }, + "granter": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + }, + "GrantInformation": { + "type": "object", + "required": [ + "expired", + "grant" + ], + "properties": { + "expired": { + "type": "boolean" + }, + "grant": { + "$ref": "#/definitions/Grant" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "get_granter": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GranterResponse", + "type": "object", + "required": [ + "granter" + ], + "properties": { + "granter": { + "$ref": "#/definitions/Addr" + }, + "information": { + "anyOf": [ + { + "$ref": "#/definitions/GranterInformation" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "GranterInformation": { + "type": "object", + "required": [ + "created_at_height", + "created_by" + ], + "properties": { + "created_at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "created_by": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + } + }, + "get_granters_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GrantersPagedResponse", + "type": "object", + "required": [ + "granters" + ], + "properties": { + "granters": { + "type": "array", + "items": { + "$ref": "#/definitions/GranterDetails" + } + }, + "start_next_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "GranterDetails": { + "type": "object", + "required": [ + "granter", + "information" + ], + "properties": { + "granter": { + "$ref": "#/definitions/Addr" + }, + "information": { + "$ref": "#/definitions/GranterInformation" + } + }, + "additionalProperties": false + }, + "GranterInformation": { + "type": "object", + "required": [ + "created_at_height", + "created_by" + ], + "properties": { + "created_at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "created_by": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + } + }, + "get_grants_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GrantsPagedResponse", + "type": "object", + "required": [ + "grants" + ], + "properties": { + "grants": { + "type": "array", + "items": { + "$ref": "#/definitions/GrantInformation" + } + }, + "start_next_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Allowance": { + "oneOf": [ + { + "type": "object", + "required": [ + "basic" + ], + "properties": { + "basic": { + "$ref": "#/definitions/BasicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "classic_periodic" + ], + "properties": { + "classic_periodic": { + "$ref": "#/definitions/ClassicPeriodicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cumulative_periodic" + ], + "properties": { + "cumulative_periodic": { + "$ref": "#/definitions/CumulativePeriodicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "delayed" + ], + "properties": { + "delayed": { + "$ref": "#/definitions/DelayedAllowance" + } + }, + "additionalProperties": false + } + ] + }, + "BasicAllowance": { + "description": "BasicAllowance is an allowance with a one-time grant of coins that optionally expires. The grantee can use up to SpendLimit to cover fees.", + "type": "object", + "properties": { + "expiration_unix_timestamp": { + "description": "expiration specifies an optional time when this allowance expires", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "spend_limit": { + "description": "spend_limit specifies the maximum amount of coins that can be spent by this allowance and will be updated as coins are spent. If it is empty, there is no spend limit and any amount of coins can be spent.", + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "ClassicPeriodicAllowance": { + "description": "ClassicPeriodicAllowance extends BasicAllowance to allow for both a maximum cap, as well as a limit per time period.", + "type": "object", + "required": [ + "basic", + "period_duration_secs", + "period_spend_limit" + ], + "properties": { + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + }, + "period_can_spend": { + "description": "period_can_spend is the number of coins left to be spent before the period_reset time", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + }, + "period_duration_secs": { + "description": "period_duration_secs specifies the time duration in which period_spend_limit coins can be spent before that allowance is reset", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_reset_unix_timestamp": { + "description": "period_reset is the time at which this period resets and a new one begins, it is calculated from the start time of the first transaction after the last period ended", + "default": 0, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_spend_limit": { + "description": "period_spend_limit specifies the maximum number of coins that can be spent in the period", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "additionalProperties": false + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "CumulativePeriodicAllowance": { + "type": "object", + "required": [ + "basic", + "period_duration_secs", + "period_grant" + ], + "properties": { + "accumulation_limit": { + "description": "accumulation_limit is the maximum value the grants and accumulate to", + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + }, + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + }, + "last_grant_applied_unix_timestamp": { + "description": "last_grant_applied is the time at which last transaction associated with this allowance has been sent and `spendable` value has been adjusted", + "default": 0, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_duration_secs": { + "description": "period_duration_secs specifies the time duration in which spendable coins can be spent before that allowance is incremented", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_grant": { + "description": "period_grant specifies the maximum number of coins that is granted per period", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "spendable": { + "description": "spendable is the number of coins left to be spent before additional grant is applied", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "DelayedAllowance": { + "description": "Create a grant to allow somebody to withdraw from the pool only after the specified time. For example, we could create a grant for mixnet rewarding/testing/etc However, if the required work has not been completed, the grant could be revoked before it's withdrawn", + "type": "object", + "required": [ + "available_at_unix_timestamp", + "basic" + ], + "properties": { + "available_at_unix_timestamp": { + "description": "available_at specifies when this allowance is going to become usable", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + } + }, + "additionalProperties": false + }, + "Grant": { + "type": "object", + "required": [ + "allowance", + "granted_at_height", + "grantee", + "granter" + ], + "properties": { + "allowance": { + "$ref": "#/definitions/Allowance" + }, + "granted_at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "grantee": { + "$ref": "#/definitions/Addr" + }, + "granter": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + }, + "GrantInformation": { + "type": "object", + "required": [ + "expired", + "grant" + ], + "properties": { + "expired": { + "type": "boolean" + }, + "grant": { + "$ref": "#/definitions/Grant" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "get_locked_tokens": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "LockedTokensResponse", + "type": "object", + "required": [ + "grantee" + ], + "properties": { + "grantee": { + "$ref": "#/definitions/Addr" + }, + "locked": { + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "get_locked_tokens_paged": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "LockedTokensPagedResponse", + "type": "object", + "required": [ + "locked" + ], + "properties": { + "locked": { + "type": "array", + "items": { + "$ref": "#/definitions/LockedTokens" + } + }, + "start_next_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "LockedTokens": { + "type": "object", + "required": [ + "grantee", + "locked" + ], + "properties": { + "grantee": { + "$ref": "#/definitions/Addr" + }, + "locked": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "get_total_locked_tokens": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TotalLockedTokensResponse", + "type": "object", + "required": [ + "locked" + ], + "properties": { + "locked": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false, + "definitions": { + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + } + } +} diff --git a/contracts/nym-pool/schema/raw/execute.json b/contracts/nym-pool/schema/raw/execute.json new file mode 100644 index 0000000000..905bca197f --- /dev/null +++ b/contracts/nym-pool/schema/raw/execute.json @@ -0,0 +1,544 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Change the admin", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin" + ], + "properties": { + "admin": { + "type": "string" + }, + "update_granter_set": { + "type": [ + "boolean", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to grant new allowance to the specified grantee", + "type": "object", + "required": [ + "grant_allowance" + ], + "properties": { + "grant_allowance": { + "type": "object", + "required": [ + "allowance", + "grantee" + ], + "properties": { + "allowance": { + "$ref": "#/definitions/Allowance" + }, + "grantee": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to revoke previously granted allowance", + "type": "object", + "required": [ + "revoke_allowance" + ], + "properties": { + "revoke_allowance": { + "type": "object", + "required": [ + "grantee" + ], + "properties": { + "grantee": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to use allowance", + "type": "object", + "required": [ + "use_allowance" + ], + "properties": { + "use_allowance": { + "type": "object", + "required": [ + "recipients" + ], + "properties": { + "recipients": { + "type": "array", + "items": { + "$ref": "#/definitions/TransferRecipient" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to withdraw the specified amount into the grantee's account", + "type": "object", + "required": [ + "withdraw_allowance" + ], + "properties": { + "withdraw_allowance": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to lock part of existing allowance for future use", + "type": "object", + "required": [ + "lock_allowance" + ], + "properties": { + "lock_allowance": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to unlock previously locked allowance", + "type": "object", + "required": [ + "unlock_allowance" + ], + "properties": { + "unlock_allowance": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to use part of the locked allowance", + "type": "object", + "required": [ + "use_locked_allowance" + ], + "properties": { + "use_locked_allowance": { + "type": "object", + "required": [ + "recipients" + ], + "properties": { + "recipients": { + "type": "array", + "items": { + "$ref": "#/definitions/TransferRecipient" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to withdraw the specified amount of locked tokens into the grantee's account", + "type": "object", + "required": [ + "withdraw_locked_allowance" + ], + "properties": { + "withdraw_locked_allowance": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to add a new account to the permitted set of grant granters", + "type": "object", + "required": [ + "add_new_granter" + ], + "properties": { + "add_new_granter": { + "type": "object", + "required": [ + "granter" + ], + "properties": { + "granter": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Revoke the provided account from the permitted set of granters", + "type": "object", + "required": [ + "revoke_granter" + ], + "properties": { + "revoke_granter": { + "type": "object", + "required": [ + "granter" + ], + "properties": { + "granter": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Attempt to remove expired grant from the storage and unlock (if any) locked tokens", + "type": "object", + "required": [ + "remove_expired_grant" + ], + "properties": { + "remove_expired_grant": { + "type": "object", + "required": [ + "grantee" + ], + "properties": { + "grantee": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Allowance": { + "oneOf": [ + { + "type": "object", + "required": [ + "basic" + ], + "properties": { + "basic": { + "$ref": "#/definitions/BasicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "classic_periodic" + ], + "properties": { + "classic_periodic": { + "$ref": "#/definitions/ClassicPeriodicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cumulative_periodic" + ], + "properties": { + "cumulative_periodic": { + "$ref": "#/definitions/CumulativePeriodicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "delayed" + ], + "properties": { + "delayed": { + "$ref": "#/definitions/DelayedAllowance" + } + }, + "additionalProperties": false + } + ] + }, + "BasicAllowance": { + "description": "BasicAllowance is an allowance with a one-time grant of coins that optionally expires. The grantee can use up to SpendLimit to cover fees.", + "type": "object", + "properties": { + "expiration_unix_timestamp": { + "description": "expiration specifies an optional time when this allowance expires", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "spend_limit": { + "description": "spend_limit specifies the maximum amount of coins that can be spent by this allowance and will be updated as coins are spent. If it is empty, there is no spend limit and any amount of coins can be spent.", + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "ClassicPeriodicAllowance": { + "description": "ClassicPeriodicAllowance extends BasicAllowance to allow for both a maximum cap, as well as a limit per time period.", + "type": "object", + "required": [ + "basic", + "period_duration_secs", + "period_spend_limit" + ], + "properties": { + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + }, + "period_can_spend": { + "description": "period_can_spend is the number of coins left to be spent before the period_reset time", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + }, + "period_duration_secs": { + "description": "period_duration_secs specifies the time duration in which period_spend_limit coins can be spent before that allowance is reset", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_reset_unix_timestamp": { + "description": "period_reset is the time at which this period resets and a new one begins, it is calculated from the start time of the first transaction after the last period ended", + "default": 0, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_spend_limit": { + "description": "period_spend_limit specifies the maximum number of coins that can be spent in the period", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "additionalProperties": false + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "CumulativePeriodicAllowance": { + "type": "object", + "required": [ + "basic", + "period_duration_secs", + "period_grant" + ], + "properties": { + "accumulation_limit": { + "description": "accumulation_limit is the maximum value the grants and accumulate to", + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + }, + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + }, + "last_grant_applied_unix_timestamp": { + "description": "last_grant_applied is the time at which last transaction associated with this allowance has been sent and `spendable` value has been adjusted", + "default": 0, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_duration_secs": { + "description": "period_duration_secs specifies the time duration in which spendable coins can be spent before that allowance is incremented", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_grant": { + "description": "period_grant specifies the maximum number of coins that is granted per period", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "spendable": { + "description": "spendable is the number of coins left to be spent before additional grant is applied", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "DelayedAllowance": { + "description": "Create a grant to allow somebody to withdraw from the pool only after the specified time. For example, we could create a grant for mixnet rewarding/testing/etc However, if the required work has not been completed, the grant could be revoked before it's withdrawn", + "type": "object", + "required": [ + "available_at_unix_timestamp", + "basic" + ], + "properties": { + "available_at_unix_timestamp": { + "description": "available_at specifies when this allowance is going to become usable", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + } + }, + "additionalProperties": false + }, + "TransferRecipient": { + "type": "object", + "required": [ + "amount", + "recipient" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "recipient": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/nym-pool/schema/raw/instantiate.json b/contracts/nym-pool/schema/raw/instantiate.json new file mode 100644 index 0000000000..f2942f99c6 --- /dev/null +++ b/contracts/nym-pool/schema/raw/instantiate.json @@ -0,0 +1,262 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "grants", + "pool_denomination" + ], + "properties": { + "grants": { + "description": "Initial map of grants to be created at instantiation", + "type": "object", + "additionalProperties": false + }, + "pool_denomination": { + "type": "string" + } + }, + "additionalProperties": false, + "definitions": { + "Allowance": { + "oneOf": [ + { + "type": "object", + "required": [ + "basic" + ], + "properties": { + "basic": { + "$ref": "#/definitions/BasicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "classic_periodic" + ], + "properties": { + "classic_periodic": { + "$ref": "#/definitions/ClassicPeriodicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cumulative_periodic" + ], + "properties": { + "cumulative_periodic": { + "$ref": "#/definitions/CumulativePeriodicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "delayed" + ], + "properties": { + "delayed": { + "$ref": "#/definitions/DelayedAllowance" + } + }, + "additionalProperties": false + } + ] + }, + "BasicAllowance": { + "description": "BasicAllowance is an allowance with a one-time grant of coins that optionally expires. The grantee can use up to SpendLimit to cover fees.", + "type": "object", + "properties": { + "expiration_unix_timestamp": { + "description": "expiration specifies an optional time when this allowance expires", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "spend_limit": { + "description": "spend_limit specifies the maximum amount of coins that can be spent by this allowance and will be updated as coins are spent. If it is empty, there is no spend limit and any amount of coins can be spent.", + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "ClassicPeriodicAllowance": { + "description": "ClassicPeriodicAllowance extends BasicAllowance to allow for both a maximum cap, as well as a limit per time period.", + "type": "object", + "required": [ + "basic", + "period_duration_secs", + "period_spend_limit" + ], + "properties": { + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + }, + "period_can_spend": { + "description": "period_can_spend is the number of coins left to be spent before the period_reset time", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + }, + "period_duration_secs": { + "description": "period_duration_secs specifies the time duration in which period_spend_limit coins can be spent before that allowance is reset", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_reset_unix_timestamp": { + "description": "period_reset is the time at which this period resets and a new one begins, it is calculated from the start time of the first transaction after the last period ended", + "default": 0, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_spend_limit": { + "description": "period_spend_limit specifies the maximum number of coins that can be spent in the period", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "additionalProperties": false + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "CumulativePeriodicAllowance": { + "type": "object", + "required": [ + "basic", + "period_duration_secs", + "period_grant" + ], + "properties": { + "accumulation_limit": { + "description": "accumulation_limit is the maximum value the grants and accumulate to", + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + }, + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + }, + "last_grant_applied_unix_timestamp": { + "description": "last_grant_applied is the time at which last transaction associated with this allowance has been sent and `spendable` value has been adjusted", + "default": 0, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_duration_secs": { + "description": "period_duration_secs specifies the time duration in which spendable coins can be spent before that allowance is incremented", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_grant": { + "description": "period_grant specifies the maximum number of coins that is granted per period", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "spendable": { + "description": "spendable is the number of coins left to be spent before additional grant is applied", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "DelayedAllowance": { + "description": "Create a grant to allow somebody to withdraw from the pool only after the specified time. For example, we could create a grant for mixnet rewarding/testing/etc However, if the required work has not been completed, the grant could be revoked before it's withdrawn", + "type": "object", + "required": [ + "available_at_unix_timestamp", + "basic" + ], + "properties": { + "available_at_unix_timestamp": { + "description": "available_at specifies when this allowance is going to become usable", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/nym-pool/schema/raw/migrate.json b/contracts/nym-pool/schema/raw/migrate.json new file mode 100644 index 0000000000..7fbe8c5708 --- /dev/null +++ b/contracts/nym-pool/schema/raw/migrate.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "additionalProperties": false +} diff --git a/contracts/nym-pool/schema/raw/query.json b/contracts/nym-pool/schema/raw/query.json new file mode 100644 index 0000000000..1af2075d3b --- /dev/null +++ b/contracts/nym-pool/schema/raw/query.json @@ -0,0 +1,201 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "admin" + ], + "properties": { + "admin": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_available_tokens" + ], + "properties": { + "get_available_tokens": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_total_locked_tokens" + ], + "properties": { + "get_total_locked_tokens": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_locked_tokens" + ], + "properties": { + "get_locked_tokens": { + "type": "object", + "required": [ + "grantee" + ], + "properties": { + "grantee": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_grant" + ], + "properties": { + "get_grant": { + "type": "object", + "required": [ + "grantee" + ], + "properties": { + "grantee": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_granter" + ], + "properties": { + "get_granter": { + "type": "object", + "required": [ + "granter" + ], + "properties": { + "granter": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_locked_tokens_paged" + ], + "properties": { + "get_locked_tokens_paged": { + "type": "object", + "properties": { + "limit": { + "description": "Controls the maximum number of entries returned by the query. Note that too large values will be overwritten by a saner default.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "description": "Pagination control for the values returned by the query. Note that the provided value itself will **not** be used for the response.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_granters_paged" + ], + "properties": { + "get_granters_paged": { + "type": "object", + "properties": { + "limit": { + "description": "Controls the maximum number of entries returned by the query. Note that too large values will be overwritten by a saner default.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "description": "Pagination control for the values returned by the query. Note that the provided value itself will **not** be used for the response.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_grants_paged" + ], + "properties": { + "get_grants_paged": { + "type": "object", + "properties": { + "limit": { + "description": "Controls the maximum number of entries returned by the query. Note that too large values will be overwritten by a saner default.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "description": "Pagination control for the values returned by the query. Note that the provided value itself will **not** be used for the response.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] +} diff --git a/contracts/nym-pool/schema/raw/response_to_admin.json b/contracts/nym-pool/schema/raw/response_to_admin.json new file mode 100644 index 0000000000..c73969ab04 --- /dev/null +++ b/contracts/nym-pool/schema/raw/response_to_admin.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AdminResponse", + "description": "Returned from Admin.query_admin()", + "type": "object", + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false +} diff --git a/contracts/nym-pool/schema/raw/response_to_get_available_tokens.json b/contracts/nym-pool/schema/raw/response_to_get_available_tokens.json new file mode 100644 index 0000000000..cd495cbe0c --- /dev/null +++ b/contracts/nym-pool/schema/raw/response_to_get_available_tokens.json @@ -0,0 +1,36 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AvailableTokensResponse", + "type": "object", + "required": [ + "available" + ], + "properties": { + "available": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false, + "definitions": { + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/nym-pool/schema/raw/response_to_get_grant.json b/contracts/nym-pool/schema/raw/response_to_get_grant.json new file mode 100644 index 0000000000..08e4cf0ffe --- /dev/null +++ b/contracts/nym-pool/schema/raw/response_to_get_grant.json @@ -0,0 +1,312 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GrantResponse", + "type": "object", + "required": [ + "grantee" + ], + "properties": { + "grant": { + "anyOf": [ + { + "$ref": "#/definitions/GrantInformation" + }, + { + "type": "null" + } + ] + }, + "grantee": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Allowance": { + "oneOf": [ + { + "type": "object", + "required": [ + "basic" + ], + "properties": { + "basic": { + "$ref": "#/definitions/BasicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "classic_periodic" + ], + "properties": { + "classic_periodic": { + "$ref": "#/definitions/ClassicPeriodicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cumulative_periodic" + ], + "properties": { + "cumulative_periodic": { + "$ref": "#/definitions/CumulativePeriodicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "delayed" + ], + "properties": { + "delayed": { + "$ref": "#/definitions/DelayedAllowance" + } + }, + "additionalProperties": false + } + ] + }, + "BasicAllowance": { + "description": "BasicAllowance is an allowance with a one-time grant of coins that optionally expires. The grantee can use up to SpendLimit to cover fees.", + "type": "object", + "properties": { + "expiration_unix_timestamp": { + "description": "expiration specifies an optional time when this allowance expires", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "spend_limit": { + "description": "spend_limit specifies the maximum amount of coins that can be spent by this allowance and will be updated as coins are spent. If it is empty, there is no spend limit and any amount of coins can be spent.", + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "ClassicPeriodicAllowance": { + "description": "ClassicPeriodicAllowance extends BasicAllowance to allow for both a maximum cap, as well as a limit per time period.", + "type": "object", + "required": [ + "basic", + "period_duration_secs", + "period_spend_limit" + ], + "properties": { + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + }, + "period_can_spend": { + "description": "period_can_spend is the number of coins left to be spent before the period_reset time", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + }, + "period_duration_secs": { + "description": "period_duration_secs specifies the time duration in which period_spend_limit coins can be spent before that allowance is reset", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_reset_unix_timestamp": { + "description": "period_reset is the time at which this period resets and a new one begins, it is calculated from the start time of the first transaction after the last period ended", + "default": 0, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_spend_limit": { + "description": "period_spend_limit specifies the maximum number of coins that can be spent in the period", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "additionalProperties": false + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "CumulativePeriodicAllowance": { + "type": "object", + "required": [ + "basic", + "period_duration_secs", + "period_grant" + ], + "properties": { + "accumulation_limit": { + "description": "accumulation_limit is the maximum value the grants and accumulate to", + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + }, + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + }, + "last_grant_applied_unix_timestamp": { + "description": "last_grant_applied is the time at which last transaction associated with this allowance has been sent and `spendable` value has been adjusted", + "default": 0, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_duration_secs": { + "description": "period_duration_secs specifies the time duration in which spendable coins can be spent before that allowance is incremented", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_grant": { + "description": "period_grant specifies the maximum number of coins that is granted per period", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "spendable": { + "description": "spendable is the number of coins left to be spent before additional grant is applied", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "DelayedAllowance": { + "description": "Create a grant to allow somebody to withdraw from the pool only after the specified time. For example, we could create a grant for mixnet rewarding/testing/etc However, if the required work has not been completed, the grant could be revoked before it's withdrawn", + "type": "object", + "required": [ + "available_at_unix_timestamp", + "basic" + ], + "properties": { + "available_at_unix_timestamp": { + "description": "available_at specifies when this allowance is going to become usable", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + } + }, + "additionalProperties": false + }, + "Grant": { + "type": "object", + "required": [ + "allowance", + "granted_at_height", + "grantee", + "granter" + ], + "properties": { + "allowance": { + "$ref": "#/definitions/Allowance" + }, + "granted_at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "grantee": { + "$ref": "#/definitions/Addr" + }, + "granter": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + }, + "GrantInformation": { + "type": "object", + "required": [ + "expired", + "grant" + ], + "properties": { + "expired": { + "type": "boolean" + }, + "grant": { + "$ref": "#/definitions/Grant" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/nym-pool/schema/raw/response_to_get_granter.json b/contracts/nym-pool/schema/raw/response_to_get_granter.json new file mode 100644 index 0000000000..89a704a666 --- /dev/null +++ b/contracts/nym-pool/schema/raw/response_to_get_granter.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GranterResponse", + "type": "object", + "required": [ + "granter" + ], + "properties": { + "granter": { + "$ref": "#/definitions/Addr" + }, + "information": { + "anyOf": [ + { + "$ref": "#/definitions/GranterInformation" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "GranterInformation": { + "type": "object", + "required": [ + "created_at_height", + "created_by" + ], + "properties": { + "created_at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "created_by": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/nym-pool/schema/raw/response_to_get_granters_paged.json b/contracts/nym-pool/schema/raw/response_to_get_granters_paged.json new file mode 100644 index 0000000000..402cac8338 --- /dev/null +++ b/contracts/nym-pool/schema/raw/response_to_get_granters_paged.json @@ -0,0 +1,63 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GrantersPagedResponse", + "type": "object", + "required": [ + "granters" + ], + "properties": { + "granters": { + "type": "array", + "items": { + "$ref": "#/definitions/GranterDetails" + } + }, + "start_next_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "GranterDetails": { + "type": "object", + "required": [ + "granter", + "information" + ], + "properties": { + "granter": { + "$ref": "#/definitions/Addr" + }, + "information": { + "$ref": "#/definitions/GranterInformation" + } + }, + "additionalProperties": false + }, + "GranterInformation": { + "type": "object", + "required": [ + "created_at_height", + "created_by" + ], + "properties": { + "created_at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "created_by": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/nym-pool/schema/raw/response_to_get_grants_paged.json b/contracts/nym-pool/schema/raw/response_to_get_grants_paged.json new file mode 100644 index 0000000000..6e703edb69 --- /dev/null +++ b/contracts/nym-pool/schema/raw/response_to_get_grants_paged.json @@ -0,0 +1,311 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GrantsPagedResponse", + "type": "object", + "required": [ + "grants" + ], + "properties": { + "grants": { + "type": "array", + "items": { + "$ref": "#/definitions/GrantInformation" + } + }, + "start_next_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Allowance": { + "oneOf": [ + { + "type": "object", + "required": [ + "basic" + ], + "properties": { + "basic": { + "$ref": "#/definitions/BasicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "classic_periodic" + ], + "properties": { + "classic_periodic": { + "$ref": "#/definitions/ClassicPeriodicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cumulative_periodic" + ], + "properties": { + "cumulative_periodic": { + "$ref": "#/definitions/CumulativePeriodicAllowance" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "delayed" + ], + "properties": { + "delayed": { + "$ref": "#/definitions/DelayedAllowance" + } + }, + "additionalProperties": false + } + ] + }, + "BasicAllowance": { + "description": "BasicAllowance is an allowance with a one-time grant of coins that optionally expires. The grantee can use up to SpendLimit to cover fees.", + "type": "object", + "properties": { + "expiration_unix_timestamp": { + "description": "expiration specifies an optional time when this allowance expires", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "spend_limit": { + "description": "spend_limit specifies the maximum amount of coins that can be spent by this allowance and will be updated as coins are spent. If it is empty, there is no spend limit and any amount of coins can be spent.", + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "ClassicPeriodicAllowance": { + "description": "ClassicPeriodicAllowance extends BasicAllowance to allow for both a maximum cap, as well as a limit per time period.", + "type": "object", + "required": [ + "basic", + "period_duration_secs", + "period_spend_limit" + ], + "properties": { + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + }, + "period_can_spend": { + "description": "period_can_spend is the number of coins left to be spent before the period_reset time", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + }, + "period_duration_secs": { + "description": "period_duration_secs specifies the time duration in which period_spend_limit coins can be spent before that allowance is reset", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_reset_unix_timestamp": { + "description": "period_reset is the time at which this period resets and a new one begins, it is calculated from the start time of the first transaction after the last period ended", + "default": 0, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_spend_limit": { + "description": "period_spend_limit specifies the maximum number of coins that can be spent in the period", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + } + }, + "additionalProperties": false + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "CumulativePeriodicAllowance": { + "type": "object", + "required": [ + "basic", + "period_duration_secs", + "period_grant" + ], + "properties": { + "accumulation_limit": { + "description": "accumulation_limit is the maximum value the grants and accumulate to", + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + }, + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + }, + "last_grant_applied_unix_timestamp": { + "description": "last_grant_applied is the time at which last transaction associated with this allowance has been sent and `spendable` value has been adjusted", + "default": 0, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_duration_secs": { + "description": "period_duration_secs specifies the time duration in which spendable coins can be spent before that allowance is incremented", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "period_grant": { + "description": "period_grant specifies the maximum number of coins that is granted per period", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "spendable": { + "description": "spendable is the number of coins left to be spent before additional grant is applied", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "DelayedAllowance": { + "description": "Create a grant to allow somebody to withdraw from the pool only after the specified time. For example, we could create a grant for mixnet rewarding/testing/etc However, if the required work has not been completed, the grant could be revoked before it's withdrawn", + "type": "object", + "required": [ + "available_at_unix_timestamp", + "basic" + ], + "properties": { + "available_at_unix_timestamp": { + "description": "available_at specifies when this allowance is going to become usable", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "basic": { + "description": "basic specifies a struct of `BasicAllowance`", + "allOf": [ + { + "$ref": "#/definitions/BasicAllowance" + } + ] + } + }, + "additionalProperties": false + }, + "Grant": { + "type": "object", + "required": [ + "allowance", + "granted_at_height", + "grantee", + "granter" + ], + "properties": { + "allowance": { + "$ref": "#/definitions/Allowance" + }, + "granted_at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "grantee": { + "$ref": "#/definitions/Addr" + }, + "granter": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + }, + "GrantInformation": { + "type": "object", + "required": [ + "expired", + "grant" + ], + "properties": { + "expired": { + "type": "boolean" + }, + "grant": { + "$ref": "#/definitions/Grant" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/nym-pool/schema/raw/response_to_get_locked_tokens.json b/contracts/nym-pool/schema/raw/response_to_get_locked_tokens.json new file mode 100644 index 0000000000..9f0d8aec6b --- /dev/null +++ b/contracts/nym-pool/schema/raw/response_to_get_locked_tokens.json @@ -0,0 +1,50 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "LockedTokensResponse", + "type": "object", + "required": [ + "grantee" + ], + "properties": { + "grantee": { + "$ref": "#/definitions/Addr" + }, + "locked": { + "anyOf": [ + { + "$ref": "#/definitions/Coin" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/nym-pool/schema/raw/response_to_get_locked_tokens_paged.json b/contracts/nym-pool/schema/raw/response_to_get_locked_tokens_paged.json new file mode 100644 index 0000000000..24c8268d44 --- /dev/null +++ b/contracts/nym-pool/schema/raw/response_to_get_locked_tokens_paged.json @@ -0,0 +1,65 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "LockedTokensPagedResponse", + "type": "object", + "required": [ + "locked" + ], + "properties": { + "locked": { + "type": "array", + "items": { + "$ref": "#/definitions/LockedTokens" + } + }, + "start_next_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "LockedTokens": { + "type": "object", + "required": [ + "grantee", + "locked" + ], + "properties": { + "grantee": { + "$ref": "#/definitions/Addr" + }, + "locked": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/nym-pool/schema/raw/response_to_get_total_locked_tokens.json b/contracts/nym-pool/schema/raw/response_to_get_total_locked_tokens.json new file mode 100644 index 0000000000..5d423ece3e --- /dev/null +++ b/contracts/nym-pool/schema/raw/response_to_get_total_locked_tokens.json @@ -0,0 +1,36 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TotalLockedTokensResponse", + "type": "object", + "required": [ + "locked" + ], + "properties": { + "locked": { + "$ref": "#/definitions/Coin" + } + }, + "additionalProperties": false, + "definitions": { + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/nym-pool/src/bin/schema.rs b/contracts/nym-pool/src/bin/schema.rs new file mode 100644 index 0000000000..cf6a1b03dc --- /dev/null +++ b/contracts/nym-pool/src/bin/schema.rs @@ -0,0 +1,14 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_schema::write_api; +use nym_pool_contract_common::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/nym-pool/src/contract.rs b/contracts/nym-pool/src/contract.rs new file mode 100644 index 0000000000..9978d39fe6 --- /dev/null +++ b/contracts/nym-pool/src/contract.rs @@ -0,0 +1,327 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::queries::{ + query_admin, query_available_tokens, query_grant, query_granter, query_granters_paged, + query_grants_paged, query_locked_tokens, query_locked_tokens_paged, query_total_locked_tokens, +}; +use crate::storage::NYM_POOL_STORAGE; +use crate::transactions::{ + try_add_new_granter, try_grant_allowance, try_lock_allowance, try_remove_expired, + try_revoke_grant, try_revoke_granter, try_unlock_allowance, try_update_contract_admin, + try_use_allowance, try_use_locked_allowance, try_withdraw_allowance, + try_withdraw_locked_allowance, +}; +use cosmwasm_std::{ + entry_point, to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response, +}; +use nym_contracts_common::set_build_information; +use nym_pool_contract_common::{ + ExecuteMsg, InstantiateMsg, MigrateMsg, NymPoolContractError, QueryMsg, +}; + +const CONTRACT_NAME: &str = "crate:nym-pool-contract"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[entry_point] +pub fn instantiate( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + set_build_information!(deps.storage)?; + + NYM_POOL_STORAGE.initialise(deps, env, info.sender, &msg.pool_denomination, msg.grants)?; + + Ok(Response::default()) +} + +#[entry_point] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::UpdateAdmin { + admin, + update_granter_set, + } => try_update_contract_admin(deps, env, info, admin, update_granter_set), + ExecuteMsg::GrantAllowance { grantee, allowance } => { + try_grant_allowance(deps, env, info, grantee, *allowance) + } + ExecuteMsg::RevokeAllowance { grantee } => try_revoke_grant(deps, env, info, grantee), + ExecuteMsg::UseAllowance { recipients } => try_use_allowance(deps, env, info, recipients), + ExecuteMsg::WithdrawAllowance { amount } => try_withdraw_allowance(deps, env, info, amount), + ExecuteMsg::LockAllowance { amount } => try_lock_allowance(deps, env, info, amount), + ExecuteMsg::UnlockAllowance { amount } => try_unlock_allowance(deps, env, info, amount), + ExecuteMsg::UseLockedAllowance { recipients } => { + try_use_locked_allowance(deps, env, info, recipients) + } + ExecuteMsg::WithdrawLockedAllowance { amount } => { + try_withdraw_locked_allowance(deps, env, info, amount) + } + ExecuteMsg::AddNewGranter { granter } => try_add_new_granter(deps, env, info, granter), + ExecuteMsg::RevokeGranter { granter } => try_revoke_granter(deps, env, info, granter), + ExecuteMsg::RemoveExpiredGrant { grantee } => try_remove_expired(deps, env, info, grantee), + } +} + +#[entry_point] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> Result { + match msg { + QueryMsg::Admin {} => Ok(to_json_binary(&query_admin(deps)?)?), + QueryMsg::GetAvailableTokens {} => Ok(to_json_binary(&query_available_tokens(deps, env)?)?), + QueryMsg::GetTotalLockedTokens {} => Ok(to_json_binary(&query_total_locked_tokens(deps)?)?), + QueryMsg::GetLockedTokens { grantee } => { + Ok(to_json_binary(&query_locked_tokens(deps, grantee)?)?) + } + QueryMsg::GetLockedTokensPaged { limit, start_after } => Ok(to_json_binary( + &query_locked_tokens_paged(deps, limit, start_after)?, + )?), + QueryMsg::GetGrant { grantee } => Ok(to_json_binary(&query_grant(deps, env, grantee)?)?), + QueryMsg::GetGranter { granter } => Ok(to_json_binary(&query_granter(deps, granter)?)?), + QueryMsg::GetGrantersPaged { limit, start_after } => Ok(to_json_binary( + &query_granters_paged(deps, limit, start_after)?, + )?), + QueryMsg::GetGrantsPaged { limit, start_after } => Ok(to_json_binary( + &query_grants_paged(deps, env, limit, start_after)?, + )?), + } +} + +#[entry_point] +pub fn migrate( + deps: DepsMut, + _env: Env, + _msg: MigrateMsg, +) -> Result { + set_build_information!(deps.storage)?; + cw2::ensure_from_older_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + Ok(Default::default()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(test)] + mod contract_instantiaton { + use super::*; + use crate::storage::NYM_POOL_STORAGE; + use crate::testing::TEST_DENOM; + use cosmwasm_std::testing::{message_info, mock_dependencies, mock_env}; + + #[test] + fn sets_contract_admin_to_the_message_sender() -> anyhow::Result<()> { + let mut deps = mock_dependencies(); + let env = mock_env(); + let init_msg = InstantiateMsg { + pool_denomination: TEST_DENOM.to_string(), + grants: Default::default(), + }; + + let some_sender = deps.api.addr_make("some_sender"); + instantiate( + deps.as_mut(), + env, + message_info(&some_sender, &[]), + init_msg, + )?; + + NYM_POOL_STORAGE + .contract_admin + .assert_admin(deps.as_ref(), &some_sender)?; + + Ok(()) + } + + #[test] + fn sets_the_pool_denomination() -> anyhow::Result<()> { + let mut deps = mock_dependencies(); + let env = mock_env(); + let init_msg = InstantiateMsg { + pool_denomination: "some_denom".to_string(), + grants: Default::default(), + }; + + let some_sender = deps.api.addr_make("some_sender"); + instantiate( + deps.as_mut(), + env, + message_info(&some_sender, &[]), + init_msg, + )?; + + assert_eq!( + NYM_POOL_STORAGE + .pool_denomination + .load(deps.as_ref().storage)?, + "some_denom" + ); + + Ok(()) + } + + #[test] + fn adds_sender_to_set_of_initial_granters() -> anyhow::Result<()> { + let mut deps = mock_dependencies(); + let env = mock_env(); + let init_msg = InstantiateMsg { + pool_denomination: TEST_DENOM.to_string(), + grants: Default::default(), + }; + + let some_sender = deps.api.addr_make("some_sender"); + instantiate( + deps.as_mut(), + env, + message_info(&some_sender, &[]), + init_msg, + )?; + + let granter = query_granter(deps.as_ref(), some_sender.to_string())?; + assert!(granter.information.is_some()); + + Ok(()) + } + + #[cfg(test)] + mod setting_initial_grants { + use super::*; + use crate::testing::deps_with_balance; + use cosmwasm_std::{coin, Order, Storage}; + use nym_pool_contract_common::{Allowance, BasicAllowance, Grant, GranteeAddress}; + use std::collections::HashMap; + + fn all_grants(storage: &dyn Storage) -> HashMap { + NYM_POOL_STORAGE + .grants + .range(storage, None, None, Order::Ascending) + .collect::, _>>() + .unwrap() + } + + #[test] + fn with_empty_map() -> anyhow::Result<()> { + let mut deps = mock_dependencies(); + let env = mock_env(); + let grants = HashMap::new(); + let init_msg = InstantiateMsg { + pool_denomination: TEST_DENOM.to_string(), + grants, + }; + + let some_sender = deps.api.addr_make("some_sender"); + instantiate( + deps.as_mut(), + env, + message_info(&some_sender, &[]), + init_msg, + )?; + + assert!(all_grants(&deps.storage).is_empty()); + Ok(()) + } + + #[test] + fn with_insufficient_tokens() -> anyhow::Result<()> { + // limited grant + let mut deps = mock_dependencies(); + let env = mock_env(); + let mut grants = HashMap::new(); + grants.insert( + deps.api.addr_make("grantee1").to_string(), + Allowance::Basic(BasicAllowance { + spend_limit: Some(coin(100, TEST_DENOM)), + expiration_unix_timestamp: None, + }), + ); + let init_msg = InstantiateMsg { + pool_denomination: TEST_DENOM.to_string(), + grants, + }; + + let some_sender = deps.api.addr_make("some_sender"); + let res = instantiate( + deps.as_mut(), + env, + message_info(&some_sender, &[]), + init_msg, + ); + assert!(res.is_err()); + + // unlimited grant + let mut deps = mock_dependencies(); + let env = mock_env(); + let mut grants = HashMap::new(); + grants.insert( + deps.api.addr_make("grantee1").to_string(), + Allowance::Basic(BasicAllowance::unlimited()), + ); + let init_msg = InstantiateMsg { + pool_denomination: TEST_DENOM.to_string(), + grants, + }; + + let some_sender = deps.api.addr_make("some_sender"); + let res = instantiate( + deps.as_mut(), + env, + message_info(&some_sender, &[]), + init_msg, + ); + assert!(res.is_err()); + + Ok(()) + } + + #[test] + fn with_valid_request() -> anyhow::Result<()> { + let env = mock_env(); + let mut deps = deps_with_balance(&env); + let mut grants = HashMap::new(); + grants.insert( + deps.api.addr_make("grantee1").to_string(), + Allowance::Basic(BasicAllowance { + spend_limit: Some(coin(100, TEST_DENOM)), + expiration_unix_timestamp: None, + }), + ); + grants.insert( + deps.api.addr_make("grantee2").to_string(), + Allowance::Basic(BasicAllowance { + spend_limit: Some(coin(200, TEST_DENOM)), + expiration_unix_timestamp: None, + }), + ); + grants.insert( + deps.api.addr_make("grantee3").to_string(), + Allowance::Basic(BasicAllowance { + spend_limit: Some(coin(300, TEST_DENOM)), + expiration_unix_timestamp: None, + }), + ); + let init_msg = InstantiateMsg { + pool_denomination: TEST_DENOM.to_string(), + grants, + }; + + let some_sender = deps.api.addr_make("some_sender"); + instantiate( + deps.as_mut(), + env, + message_info(&some_sender, &[coin(600, TEST_DENOM)]), + init_msg, + )?; + + assert_eq!(all_grants(&deps.storage).len(), 3); + Ok(()) + } + } + } +} diff --git a/contracts/nym-pool/src/helpers.rs b/contracts/nym-pool/src/helpers.rs new file mode 100644 index 0000000000..e71b9f1148 --- /dev/null +++ b/contracts/nym-pool/src/helpers.rs @@ -0,0 +1,57 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::storage::NYM_POOL_STORAGE; +use cosmwasm_std::{Coin, Storage}; +use nym_pool_contract_common::NymPoolContractError; + +pub fn validate_usage_coin(storage: &dyn Storage, coin: &Coin) -> Result<(), NymPoolContractError> { + let denom = NYM_POOL_STORAGE.pool_denomination.load(storage)?; + + if coin.amount.is_zero() { + return Err(NymPoolContractError::EmptyUsageRequest); + } + + if coin.denom != denom { + return Err(NymPoolContractError::InvalidDenom { + expected: denom, + got: coin.denom.to_string(), + }); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::NymPoolStorage; + use crate::testing::TestSetup; + use cosmwasm_std::coin; + + #[test] + fn validating_coin_usage() -> anyhow::Result<()> { + let test = TestSetup::init(); + let storage = NymPoolStorage::new(); + let denom = storage.pool_denomination.load(test.storage())?; + + // amount has to be non-zero + assert_eq!( + validate_usage_coin(test.storage(), &coin(0, &denom)).unwrap_err(), + NymPoolContractError::EmptyUsageRequest + ); + + // denom has to match the value set in the storage + assert_eq!( + validate_usage_coin(test.storage(), &coin(1000, "bad-denom")).unwrap_err(), + NymPoolContractError::InvalidDenom { + expected: denom.to_string(), + got: "bad-denom".to_string(), + } + ); + + assert!(validate_usage_coin(test.storage(), &coin(1000, denom)).is_ok()); + + Ok(()) + } +} diff --git a/contracts/nym-pool/src/lib.rs b/contracts/nym-pool/src/lib.rs new file mode 100644 index 0000000000..a1aca86201 --- /dev/null +++ b/contracts/nym-pool/src/lib.rs @@ -0,0 +1,17 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +#![warn(clippy::expect_used)] +#![warn(clippy::unwrap_used)] +#![warn(clippy::todo)] +#![warn(clippy::dbg_macro)] + +pub mod contract; +pub mod queued_migrations; +pub mod storage; + +mod helpers; +mod queries; +#[cfg(test)] +pub mod testing; +mod transactions; diff --git a/contracts/nym-pool/src/queries.rs b/contracts/nym-pool/src/queries.rs new file mode 100644 index 0000000000..997d2c5d67 --- /dev/null +++ b/contracts/nym-pool/src/queries.rs @@ -0,0 +1,642 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::storage::{retrieval_limits, NYM_POOL_STORAGE}; +use cosmwasm_std::{Coin, Deps, Env, Order, StdResult}; +use cw_controllers::AdminResponse; +use cw_storage_plus::Bound; +use nym_pool_contract_common::{ + AvailableTokensResponse, GrantInformation, GrantResponse, GranterDetails, GranterResponse, + GrantersPagedResponse, GrantsPagedResponse, LockedTokens, LockedTokensPagedResponse, + LockedTokensResponse, NymPoolContractError, TotalLockedTokensResponse, +}; + +pub fn query_admin(deps: Deps) -> Result { + NYM_POOL_STORAGE + .contract_admin + .query_admin(deps) + .map_err(Into::into) +} + +pub fn query_available_tokens( + deps: Deps, + env: Env, +) -> Result { + Ok(AvailableTokensResponse { + available: NYM_POOL_STORAGE.available_tokens(deps, &env)?, + }) +} + +pub fn query_total_locked_tokens( + deps: Deps, +) -> Result { + let denom = NYM_POOL_STORAGE.pool_denomination.load(deps.storage)?; + let amount = NYM_POOL_STORAGE.locked.total_locked.load(deps.storage)?; + Ok(TotalLockedTokensResponse { + locked: Coin::new(amount, denom), + }) +} + +pub fn query_locked_tokens( + deps: Deps, + grantee: String, +) -> Result { + let grantee = deps.api.addr_validate(&grantee)?; + let denom = NYM_POOL_STORAGE.pool_denomination.load(deps.storage)?; + let amount = NYM_POOL_STORAGE + .locked + .maybe_grantee_locked(deps.storage, &grantee)?; + + Ok(LockedTokensResponse { + locked: amount.map(|amount| Coin::new(amount, denom)), + grantee, + }) +} + +pub fn query_locked_tokens_paged( + deps: Deps, + limit: Option, + start_after: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::LOCKED_TOKENS_DEFAULT_LIMIT) + .min(retrieval_limits::LOCKED_TOKENS_MAX_LIMIT) as usize; + let grantee = start_after + .map(|grantee| deps.api.addr_validate(&grantee)) + .transpose()?; + let denom = NYM_POOL_STORAGE.pool_denomination.load(deps.storage)?; + + let start = grantee.map(Bound::exclusive); + + let locked = NYM_POOL_STORAGE + .locked + .grantees + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|res| { + res.map(|(grantee, amount)| LockedTokens { + grantee, + locked: Coin::new(amount, &denom), + }) + }) + .collect::>>()?; + + let start_next_after = locked.last().map(|locked| locked.grantee.to_string()); + + Ok(LockedTokensPagedResponse { + locked, + start_next_after, + }) +} + +pub fn query_grant( + deps: Deps, + env: Env, + grantee: String, +) -> Result { + let grantee = deps.api.addr_validate(&grantee)?; + let grant = NYM_POOL_STORAGE.try_load_grant(deps, &grantee)?; + + Ok(GrantResponse { + grant: grant.map(|grant| GrantInformation { + expired: grant.allowance.expired(&env), + grant, + }), + grantee, + }) +} + +pub fn query_granter(deps: Deps, granter: String) -> Result { + let granter = deps.api.addr_validate(&granter)?; + + Ok(GranterResponse { + information: NYM_POOL_STORAGE.try_load_granter(deps, &granter)?, + granter, + }) +} + +pub fn query_grants_paged( + deps: Deps, + env: Env, + limit: Option, + start_after: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::GRANTERS_DEFAULT_LIMIT) + .min(retrieval_limits::GRANTERS_MAX_LIMIT) as usize; + let grantee = start_after + .map(|grantee| deps.api.addr_validate(&grantee)) + .transpose()?; + + let start = grantee.map(Bound::exclusive); + + let grants = NYM_POOL_STORAGE + .grants + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|res| { + res.map(|(_, grant)| GrantInformation { + expired: grant.allowance.expired(&env), + grant, + }) + }) + .collect::>>()?; + + let start_next_after = grants.last().map(|info| info.grant.grantee.to_string()); + + Ok(GrantsPagedResponse { + grants, + start_next_after, + }) +} + +pub fn query_granters_paged( + deps: Deps, + limit: Option, + start_after: Option, +) -> Result { + let limit = limit + .unwrap_or(retrieval_limits::GRANTERS_DEFAULT_LIMIT) + .min(retrieval_limits::GRANTERS_MAX_LIMIT) as usize; + let granter = start_after + .map(|granter| deps.api.addr_validate(&granter)) + .transpose()?; + let start = granter.map(Bound::exclusive); + + let granters = NYM_POOL_STORAGE + .granters + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|res| res.map(|(granter, info)| GranterDetails::from((granter, info)))) + .collect::>>()?; + + let start_next_after = granters.last().map(|details| details.granter.to_string()); + + Ok(GrantersPagedResponse { + granters, + start_next_after, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::contract::instantiate; + use crate::testing::{TestSetup, TEST_DENOM}; + use cosmwasm_std::testing::{message_info, mock_dependencies_with_balance, mock_env}; + use cosmwasm_std::{coin, Uint128}; + use nym_pool_contract_common::{Allowance, BasicAllowance, GranterInformation, InstantiateMsg}; + + #[cfg(test)] + mod admin_query { + use super::*; + use crate::testing::TestSetup; + use nym_pool_contract_common::ExecuteMsg; + + #[test] + fn returns_current_admin() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + + let initial_admin = test.admin_unchecked(); + + // initial + let res = query_admin(test.deps())?; + assert_eq!(res.admin, Some(initial_admin.to_string())); + + let new_admin = test.generate_account(); + + // sanity check + assert_ne!(initial_admin, new_admin); + + // after update + test.execute_msg( + initial_admin.clone(), + &ExecuteMsg::UpdateAdmin { + admin: new_admin.to_string(), + update_granter_set: None, + }, + )?; + + let updated_admin = query_admin(test.deps())?; + assert_eq!(updated_admin.admin, Some(new_admin.to_string())); + + Ok(()) + } + } + + #[test] + fn available_tokens_query() { + // no need to test the inner functionalities as this is dealt with in the storage tests + // (i.e. logic to do with calculating diff against locked tokens, etc.) + let env = mock_env(); + let mut deps = mock_dependencies_with_balance(&[coin(100, TEST_DENOM)]); + + let init_msg = InstantiateMsg { + pool_denomination: TEST_DENOM.to_string(), + grants: Default::default(), + }; + + let some_sender = deps.api.addr_make("some_sender"); + instantiate( + deps.as_mut(), + env.clone(), + message_info(&some_sender, &[]), + init_msg, + ) + .unwrap(); + + assert_eq!( + query_available_tokens(deps.as_ref(), env) + .unwrap() + .available, + coin(100, TEST_DENOM) + ); + } + + #[test] + fn total_locked_tokens_query() { + let mut test = TestSetup::init(); + + let locked = query_total_locked_tokens(test.deps()).unwrap().locked; + assert!(locked.amount.is_zero()); + assert_eq!(locked.denom, test.denom()); + + let grantee = test.add_dummy_grant().grantee; + test.lock_allowance(grantee, Uint128::new(1234)); + + let locked = query_total_locked_tokens(test.deps()).unwrap().locked; + assert_eq!(locked.amount, Uint128::new(1234)); + assert_eq!(locked.denom, test.denom()); + } + + #[test] + fn locked_tokens_query() { + let mut test = TestSetup::init(); + + let grantee1 = test.add_dummy_grant().grantee; + test.lock_allowance(grantee1.as_str(), Uint128::new(1234)); + + let grantee2 = test.add_dummy_grant().grantee; + let not_grantee = test.generate_account(); + + let res = query_locked_tokens(test.deps(), grantee1.to_string()).unwrap(); + assert_eq!(res.grantee, grantee1); + assert_eq!(res.locked, Some(coin(1234, TEST_DENOM))); + + let res = query_locked_tokens(test.deps(), grantee2.to_string()).unwrap(); + assert_eq!(res.grantee, grantee2); + assert!(res.locked.is_none()); + + let res = query_locked_tokens(test.deps(), not_grantee.to_string()).unwrap(); + assert_eq!(res.grantee, not_grantee); + assert!(res.locked.is_none()); + } + + #[cfg(test)] + mod locked_tokens_paged_query { + use super::*; + + fn lock_sorted(test: &mut TestSetup, count: usize) -> Vec { + let mut grantees = Vec::new(); + + for _ in 0..count { + let grantee = test.add_dummy_grant().grantee; + test.lock_allowance(grantee.as_str(), Uint128::new(100)); + grantees.push(LockedTokens { + grantee, + locked: coin(100, test.denom()), + }); + } + + grantees.sort_by_key(|g| g.grantee.clone()); + grantees + } + + #[test] + fn obeys_limits() { + let mut test = TestSetup::init(); + let _locked = lock_sorted(&mut test, 1000); + + let limit = 42; + let page1 = query_locked_tokens_paged(test.deps(), Some(limit), None).unwrap(); + assert_eq!(page1.locked.len(), limit as usize); + } + + #[test] + fn has_default_limit() { + let mut test = TestSetup::init(); + let _locked = lock_sorted(&mut test, 1000); + + // query without explicitly setting a limit + let page1 = query_locked_tokens_paged(test.deps(), None, None).unwrap(); + assert_eq!( + page1.locked.len() as u32, + retrieval_limits::LOCKED_TOKENS_DEFAULT_LIMIT + ); + } + + #[test] + fn has_max_limit() { + let mut test = TestSetup::init(); + let _locked = lock_sorted(&mut test, 1000); + + // query with a crazily high limit in an attempt to use too many resources + let crazy_limit = 1000; + let page1 = query_locked_tokens_paged(test.deps(), Some(crazy_limit), None).unwrap(); + + assert_eq!( + page1.locked.len() as u32, + retrieval_limits::LOCKED_TOKENS_MAX_LIMIT + ); + } + + #[test] + fn pagination_works() { + let mut test = TestSetup::init(); + let locked = lock_sorted(&mut test, 1000); + + // first page should return 2 results... + let page1 = query_locked_tokens_paged(test.deps(), Some(2), None).unwrap(); + assert_eq!(page1.locked, locked[..2].to_vec()); + + // if we start after 5th entry, the returned following page should have 6th and onwards + let second = locked[1].clone(); + + let page2 = + query_locked_tokens_paged(test.deps(), Some(3), Some(second.grantee.to_string())) + .unwrap(); + assert_eq!(page2.locked, locked[2..5].to_vec()); + } + } + + #[test] + fn grant_query() { + let mut test = TestSetup::init(); + let env = test.env(); + + // bad address + let bad_address = "not-valid-bech32"; + assert!(query_grant(test.deps(), env.clone(), bad_address.to_string()).is_err()); + + // exists + let grant = test.add_dummy_grant(); + let grantee = grant.grantee.clone(); + + assert_eq!( + query_grant(test.deps(), env.clone(), grantee.to_string()).unwrap(), + GrantResponse { + grantee, + grant: Some(GrantInformation { + grant, + expired: false, + }), + } + ); + + // exists expired + let grantee = test.generate_account(); + let exp = env.block.time.seconds() + 1; + let allowance = Allowance::Basic(BasicAllowance { + spend_limit: None, + expiration_unix_timestamp: Some(exp), + }); + let admin = test.admin_unchecked(); + NYM_POOL_STORAGE + .insert_new_grant(test.deps_mut(), &env, &admin, &grantee, allowance) + .unwrap(); + let grant = NYM_POOL_STORAGE.load_grant(test.deps(), &grantee).unwrap(); + + test.next_block(); + let env = test.env(); + + assert_eq!( + query_grant(test.deps(), env.clone(), grantee.to_string()).unwrap(), + GrantResponse { + grantee, + grant: Some(GrantInformation { + grant, + expired: true, + }), + } + ); + + // doesn't exist + let doesnt_exist = test.generate_account(); + assert_eq!( + query_grant(test.deps(), env.clone(), doesnt_exist.to_string()).unwrap(), + GrantResponse { + grantee: doesnt_exist, + grant: None, + } + ) + } + + #[test] + fn granter_query() { + let mut test = TestSetup::init(); + let admin = test.admin_unchecked(); + let env = test.env(); + + // bad address + let bad_address = "not-valid-bech32"; + assert!(query_granter(test.deps(), bad_address.to_string()).is_err()); + + // exists + let granter = test.generate_account(); + test.add_granter(&granter); + + assert_eq!( + query_granter(test.deps(), granter.to_string()).unwrap(), + GranterResponse { + granter, + information: Some(GranterInformation { + created_by: admin.clone(), + created_at_height: env.block.height, + }), + } + ); + + // (admin is also a granter) + assert_eq!( + query_granter(test.deps(), admin.to_string()).unwrap(), + GranterResponse { + information: Some(GranterInformation { + created_by: admin.clone(), + created_at_height: env.block.height, + }), + granter: admin, + } + ); + + // doesn't exist + let not_granter = test.generate_account(); + assert_eq!( + query_granter(test.deps(), not_granter.to_string()).unwrap(), + GranterResponse { + granter: not_granter, + information: None, + } + ); + } + + #[cfg(test)] + mod granters_paged_query { + use super::*; + + fn granters_sorted(test: &mut TestSetup, count: usize) -> Vec { + let mut granters = Vec::new(); + + for _ in 0..count { + let granter = test.add_dummy_grant().grantee; + test.add_granter(&granter); + granters.push(GranterDetails { + granter, + information: GranterInformation { + created_by: test.admin_unchecked(), + created_at_height: test.env().block.height, + }, + }); + } + + granters.sort_by_key(|g| g.granter.clone()); + granters + } + + #[test] + fn obeys_limits() { + let mut test = TestSetup::init(); + let _granters = granters_sorted(&mut test, 1000); + + let limit = 42; + let page1 = query_granters_paged(test.deps(), Some(limit), None).unwrap(); + assert_eq!(page1.granters.len(), limit as usize); + } + + #[test] + fn has_default_limit() { + let mut test = TestSetup::init(); + let _granters = granters_sorted(&mut test, 1000); + + // query without explicitly setting a limit + let page1 = query_granters_paged(test.deps(), None, None).unwrap(); + assert_eq!( + page1.granters.len() as u32, + retrieval_limits::GRANTERS_DEFAULT_LIMIT + ); + } + + #[test] + fn has_max_limit() { + let mut test = TestSetup::init(); + let _granters = granters_sorted(&mut test, 1000); + + // query with a crazily high limit in an attempt to use too many resources + let crazy_limit = 1000; + let page1 = query_granters_paged(test.deps(), Some(crazy_limit), None).unwrap(); + + assert_eq!( + page1.granters.len() as u32, + retrieval_limits::GRANTERS_MAX_LIMIT + ); + } + + #[test] + fn pagination_works() { + let mut test = TestSetup::init(); + let locked = granters_sorted(&mut test, 1000); + + // first page should return 2 results... + let page1 = query_granters_paged(test.deps(), Some(2), None).unwrap(); + assert_eq!(page1.granters, locked[..2].to_vec()); + + // if we start after 5th entry, the returned following page should have 6th and onwards + let second = locked[1].clone(); + + let page2 = + query_granters_paged(test.deps(), Some(3), Some(second.granter.to_string())) + .unwrap(); + assert_eq!(page2.granters, locked[2..5].to_vec()); + } + } + + #[cfg(test)] + mod grants_paged_query { + use super::*; + + fn grants_sorted(test: &mut TestSetup, count: usize) -> Vec { + let mut grantees = Vec::new(); + + for _ in 0..count { + let grant = test.add_dummy_grant(); + grantees.push(GrantInformation { + grant, + expired: false, + }); + } + + grantees.sort_by_key(|g| g.grant.grantee.clone()); + grantees + } + + #[test] + fn obeys_limits() { + let mut test = TestSetup::init(); + let _grantees = grants_sorted(&mut test, 1000); + + let limit = 42; + let page1 = query_grants_paged(test.deps(), test.env(), Some(limit), None).unwrap(); + assert_eq!(page1.grants.len(), limit as usize); + } + + #[test] + fn has_default_limit() { + let mut test = TestSetup::init(); + let _grantees = grants_sorted(&mut test, 1000); + + // query without explicitly setting a limit + let page1 = query_grants_paged(test.deps(), test.env(), None, None).unwrap(); + assert_eq!( + page1.grants.len() as u32, + retrieval_limits::GRANTS_DEFAULT_LIMIT + ); + } + + #[test] + fn has_max_limit() { + let mut test = TestSetup::init(); + let _grantees = grants_sorted(&mut test, 1000); + + // query with a crazily high limit in an attempt to use too many resources + let crazy_limit = 1000; + let page1 = + query_grants_paged(test.deps(), test.env(), Some(crazy_limit), None).unwrap(); + + assert_eq!( + page1.grants.len() as u32, + retrieval_limits::GRANTS_MAX_LIMIT + ); + } + + #[test] + fn pagination_works() { + let mut test = TestSetup::init(); + let grants = grants_sorted(&mut test, 1000); + + // first page should return 2 results... + let page1 = query_grants_paged(test.deps(), test.env(), Some(2), None).unwrap(); + assert_eq!(page1.grants, grants[..2].to_vec()); + + // if we start after 5th entry, the returned following page should have 6th and onwards + let second = grants[1].clone(); + + let page2 = query_grants_paged( + test.deps(), + test.env(), + Some(3), + Some(second.grant.grantee.to_string()), + ) + .unwrap(); + assert_eq!(page2.grants, grants[2..5].to_vec()); + } + } +} diff --git a/contracts/nym-pool/src/queued_migrations.rs b/contracts/nym-pool/src/queued_migrations.rs new file mode 100644 index 0000000000..7e1e3cacd6 --- /dev/null +++ b/contracts/nym-pool/src/queued_migrations.rs @@ -0,0 +1,2 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 diff --git a/contracts/nym-pool/src/storage.rs b/contracts/nym-pool/src/storage.rs new file mode 100644 index 0000000000..03d55ef941 --- /dev/null +++ b/contracts/nym-pool/src/storage.rs @@ -0,0 +1,2331 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::helpers::validate_usage_coin; +use cosmwasm_std::{coin, Addr, Coin, Deps, DepsMut, Env, Storage, Uint128}; +use cw_controllers::Admin; +use cw_storage_plus::{Item, Map}; +use nym_pool_contract_common::constants::storage_keys; +use nym_pool_contract_common::{ + Allowance, Grant, GranteeAddress, GranterAddress, GranterInformation, NymPoolContractError, +}; +use std::cmp::max; +use std::collections::HashMap; + +pub const NYM_POOL_STORAGE: NymPoolStorage = NymPoolStorage::new(); + +pub struct NymPoolStorage { + pub(crate) contract_admin: Admin, + pub(crate) pool_denomination: Item, + pub(crate) granters: Map, + + // pub(crate) expired: (), + + // unlike the feegrant module, we specifically don't allow multiple grants (from different granters) + // towards the same grantee + pub(crate) grants: Map, + pub(crate) locked: LockedStorage, +} + +impl NymPoolStorage { + #[allow(clippy::new_without_default)] + pub const fn new() -> Self { + NymPoolStorage { + contract_admin: Admin::new(storage_keys::CONTRACT_ADMIN), + pool_denomination: Item::new(storage_keys::POOL_DENOMINATION), + granters: Map::new(storage_keys::GRANTERS), + grants: Map::new(storage_keys::GRANTS), + locked: LockedStorage::new(), + } + } + + pub fn initialise( + &self, + mut deps: DepsMut, + env: Env, + admin: Addr, + pool_denom: &String, + initial_grants: HashMap, + ) -> Result<(), NymPoolContractError> { + // set the denom + self.pool_denomination.save(deps.storage, pool_denom)?; + + // set the contract admin + self.contract_admin + .set(deps.branch(), Some(admin.clone()))?; + + // set the admin to be a whitelisted granter + self.add_new_granter(deps.branch(), &env, &admin, &admin)?; + + // initialise the locked storage (with the total of 0) + self.locked.initialise(deps.branch())?; + + let included_grants = !initial_grants.is_empty(); + + // add all initial grants + let mut required_amount = Uint128::zero(); + for (grantee, allowance) in initial_grants { + let grantee = deps.api.addr_validate(&grantee)?; + if let Some(ref limit) = allowance.basic().spend_limit { + required_amount += limit.amount; + } + self.insert_new_grant(deps.branch(), &env, &admin, &grantee, allowance)?; + } + + // special case: during initialisation, even if we're inserting unlimited grants, + // we have to have _some_ tokens available + if included_grants { + let balance = self.contract_balance(deps.as_ref(), &env)?; + if required_amount > balance.amount || balance.amount.is_zero() { + return Err(NymPoolContractError::InsufficientTokens { + required: coin(max(required_amount.u128(), 1), &balance.denom), + available: balance, + }); + } + } + + Ok(()) + } + + fn contract_balance(&self, deps: Deps, env: &Env) -> Result { + let denom = self.pool_denomination.load(deps.storage)?; + Ok(deps.querier.query_balance(&env.contract.address, denom)?) + } + + fn is_admin(&self, deps: Deps, addr: &Addr) -> Result { + self.contract_admin.is_admin(deps, addr).map_err(Into::into) + } + + fn ensure_is_admin(&self, deps: Deps, addr: &Addr) -> Result<(), NymPoolContractError> { + self.contract_admin + .assert_admin(deps, addr) + .map_err(Into::into) + } + + pub fn try_load_granter( + &self, + deps: Deps, + granter: &GranterAddress, + ) -> Result, NymPoolContractError> { + self.granters + .may_load(deps.storage, granter.clone()) + .map_err(Into::into) + } + + fn is_whitelisted_granter( + &self, + deps: Deps, + addr: &GranterAddress, + ) -> Result { + Ok(self.try_load_granter(deps, addr)?.is_some()) + } + + fn ensure_is_whitelisted_granter( + &self, + deps: Deps, + addr: &GranterAddress, + ) -> Result<(), NymPoolContractError> { + if !self.is_whitelisted_granter(deps, addr)? { + return Err(NymPoolContractError::InvalidGranter { + addr: addr.to_string(), + }); + } + Ok(()) + } + + pub fn add_new_granter( + &self, + deps: DepsMut, + env: &Env, + sender: &Addr, + granter: &GranterAddress, + ) -> Result<(), NymPoolContractError> { + // currently only the admin is permitted to add new granters + self.ensure_is_admin(deps.as_ref(), sender)?; + + if self + .granters + .may_load(deps.storage, granter.clone())? + .is_some() + { + return Err(NymPoolContractError::AlreadyAGranter); + } + + self.granters.save( + deps.storage, + granter.clone(), + &GranterInformation { + created_by: sender.clone(), + created_at_height: env.block.height, + }, + )?; + + Ok(()) + } + + pub fn remove_granter( + &self, + deps: DepsMut, + admin: &Addr, + granter: &GranterAddress, + ) -> Result<(), NymPoolContractError> { + // only admin is permitted to remove granters + self.ensure_is_admin(deps.as_ref(), admin)?; + + // the granter has to be, well, an actual granter + self.ensure_is_whitelisted_granter(deps.as_ref(), granter)?; + + self.granters.remove(deps.storage, granter.clone()); + + Ok(()) + } + + pub fn available_tokens(&self, deps: Deps, env: &Env) -> Result { + let locked = self.locked.total_locked.load(deps.storage)?; + let balance = self.contract_balance(deps, env)?; + + // the amount of available tokens is the current contract balance minus all the locked tokens + let mut available = balance; + available.amount = available.amount.saturating_sub(locked); + Ok(available) + } + + pub fn try_load_grant( + &self, + deps: Deps, + grantee: &GranteeAddress, + ) -> Result, NymPoolContractError> { + self.grants + .may_load(deps.storage, grantee.clone()) + .map_err(Into::into) + } + + pub fn load_grant( + &self, + deps: Deps, + grantee: &GranteeAddress, + ) -> Result { + self.try_load_grant(deps, grantee)? + .ok_or(NymPoolContractError::GrantNotFound { + grantee: grantee.to_string(), + }) + } + + pub fn insert_new_grant( + &self, + deps: DepsMut, + env: &Env, + granter: &GranterAddress, + grantee: &GranteeAddress, + mut allowance: Allowance, + ) -> Result<(), NymPoolContractError> { + // the granter should be permitted to add new grants + self.ensure_is_whitelisted_granter(deps.as_ref(), granter)?; + + // check for existing grant + if let Some(existing_grant) = self.try_load_grant(deps.as_ref(), grantee)? { + return Err(NymPoolContractError::GrantAlreadyExist { + granter: existing_grant.granter.to_string(), + grantee: grantee.to_string(), + created_at_height: existing_grant.granted_at_height, + }); + } + + // the allowance should be well-formed + let expected_denom = self.pool_denomination.load(deps.storage)?; + allowance.validate_new(env, &expected_denom)?; + + // if allowance includes explicit limit, + // it should not be higher than the total remaining tokens + // note: we already verified denomination matched when we validated the allowance + if let Some(ref spend_limit) = allowance.basic().spend_limit { + let available = self.available_tokens(deps.as_ref(), env)?; + if spend_limit.amount > available.amount { + return Err(NymPoolContractError::InsufficientTokens { + available, + required: spend_limit.clone(), + }); + } + } + + // set initial state based on the env + allowance.set_initial_state(env); + + self.grants.save( + deps.storage, + grantee.clone(), + &Grant { + granter: granter.clone(), + grantee: grantee.clone(), + granted_at_height: env.block.height, + allowance, + }, + )?; + + Ok(()) + } + + pub fn try_spend_part_of_grant( + &self, + deps: DepsMut, + env: &Env, + grantee_address: &GranteeAddress, + amount: &Coin, + ) -> Result<(), NymPoolContractError> { + let mut grant = self.load_grant(deps.as_ref(), grantee_address)?; + grant.allowance.try_spend(env, amount)?; + + let locked = self.locked.grantee_locked(deps.storage, grantee_address)?; + + // if we used up all allowance and have no locked tokens, we can just remove the grant from storage + if grant.allowance.is_used_up() && locked.is_zero() { + self.grants.remove(deps.storage, grantee_address.clone()) + } else { + self.grants + .save(deps.storage, grantee_address.clone(), &grant)?; + } + + Ok(()) + } + + pub fn remove_grant( + &self, + deps: DepsMut, + grantee_address: &GranteeAddress, + ) -> Result<(), NymPoolContractError> { + self.grants.remove(deps.storage, grantee_address.clone()); + + // if there are any tokens still locked associated with this grantee, unlock them + if let Some(grantee_locked) = self + .locked + .maybe_grantee_locked(deps.storage, grantee_address)? + { + self.locked.unlock(deps, grantee_address, grantee_locked)?; + } + + Ok(()) + } + + pub fn revoke_grant( + &self, + deps: DepsMut, + grantee_address: &GranteeAddress, + revoker: &Addr, + ) -> Result<(), NymPoolContractError> { + let grant = self.load_grant(deps.as_ref(), grantee_address)?; + let original_granter = grant.granter; + + let is_admin = self.is_admin(deps.as_ref(), revoker)?; + + // grant can only be revoked by the granter who has originally granted it (assuming it's still whitelisted) + // or by the admin + if revoker != original_granter && !is_admin { + // request came from a random sender - neither the original granter nor the current admin + return Err(NymPoolContractError::UnauthorizedGrantRevocation); + } + + // at this point we know the request must have come from either the original granter or contract admin, + // however, if it was the former, we still need to verify whether it's still whitelisted + // (if the granter was removed, it shouldn't have any permissions to modify old grants anymore) + if !is_admin && !self.is_whitelisted_granter(deps.as_ref(), revoker)? { + return Err(NymPoolContractError::UnauthorizedGrantRevocation); + } + + self.remove_grant(deps, grantee_address) + } + + pub fn lock_part_of_allowance( + &self, + mut deps: DepsMut, + env: &Env, + grantee: &GranteeAddress, + amount: Coin, + ) -> Result<(), NymPoolContractError> { + // ensure correct coin has been specified + validate_usage_coin(deps.storage, &amount)?; + + // keep track of the locked coins + self.locked.lock(deps.branch(), grantee, amount.amount)?; + + // attempt to deduct the coins from the allowance + self.try_spend_part_of_grant(deps, env, grantee, &amount)?; + + Ok(()) + } + + pub fn unlock_part_of_allowance( + &self, + deps: DepsMut, + grantee: &GranteeAddress, + amount: &Coin, + ) -> Result<(), NymPoolContractError> { + // ensure correct coin has been specified + validate_usage_coin(deps.storage, amount)?; + + // update the underlying spend limit of the grant + let mut grant = self.load_grant(deps.as_ref(), grantee)?; + // note: this will only increase the basic spend limit and will not change any periodic allowances + grant.allowance.increase_spend_limit(amount.amount); + self.grants.save(deps.storage, grantee.clone(), &grant)?; + + // keep track of the locked coins (also checks whether sufficient tokens are locked, etc.) + self.locked.unlock(deps, grantee, amount.amount) + } +} + +pub(crate) struct LockedStorage { + pub(crate) total_locked: Item, + pub(crate) grantees: Map, +} + +impl LockedStorage { + #[allow(clippy::new_without_default)] + const fn new() -> Self { + LockedStorage { + total_locked: Item::new(storage_keys::TOTAL_LOCKED), + grantees: Map::new(storage_keys::LOCKED_GRANTEES), + } + } + + fn initialise(&self, deps: DepsMut) -> Result<(), NymPoolContractError> { + self.total_locked.save(deps.storage, &Uint128::zero())?; + Ok(()) + } + + pub fn grantee_locked( + &self, + storage: &dyn Storage, + grantee: &GranteeAddress, + ) -> Result { + Ok(self + .maybe_grantee_locked(storage, grantee)? + .unwrap_or_default()) + } + + pub fn maybe_grantee_locked( + &self, + storage: &dyn Storage, + grantee: &GranteeAddress, + ) -> Result, NymPoolContractError> { + Ok(self.grantees.may_load(storage, grantee.clone())?) + } + + /// unconditionally attempts to load specified amount of tokens for the particular grantee + /// it does not validate permissions nor allowances - that's up to the caller + fn lock( + &self, + deps: DepsMut, + grantee: &GranteeAddress, + amount: Uint128, + ) -> Result<(), NymPoolContractError> { + let existing_grantee = self.grantee_locked(deps.storage, grantee)?; + let new_locked_grantee = existing_grantee + amount; + + let existing_total = self.total_locked.load(deps.storage)?; + let new_locked_total = existing_total + amount; + + self.grantees + .save(deps.storage, grantee.clone(), &new_locked_grantee)?; + self.total_locked.save(deps.storage, &new_locked_total)?; + Ok(()) + } + + fn unlock( + &self, + deps: DepsMut, + grantee: &GranteeAddress, + amount: Uint128, + ) -> Result<(), NymPoolContractError> { + let locked_grantee = self.grantee_locked(deps.storage, grantee)?; + let total_locked = self.total_locked.load(deps.storage)?; + + if locked_grantee < amount { + return Err(NymPoolContractError::InsufficientLockedTokens { + grantee: grantee.to_string(), + locked: locked_grantee, + requested: amount, + }); + } + + let updated_grantee = locked_grantee - amount; + + // if the updated value is zero, just remove the map entry + if updated_grantee.is_zero() { + self.grantees.remove(deps.storage, grantee.clone()); + } else { + self.grantees + .save(deps.storage, grantee.clone(), &updated_grantee)?; + } + + // we're specifically not using saturating sub here because that operation should ALWAYS be valid + // if it fails, it means there's a pool inconsistency that has to be resolved + self.total_locked + .save(deps.storage, &(total_locked - amount))?; + + Ok(()) + } +} + +pub mod retrieval_limits { + pub const LOCKED_TOKENS_DEFAULT_LIMIT: u32 = 100; + pub const LOCKED_TOKENS_MAX_LIMIT: u32 = 200; + + pub const GRANTERS_DEFAULT_LIMIT: u32 = 100; + pub const GRANTERS_MAX_LIMIT: u32 = 200; + + pub const GRANTS_DEFAULT_LIMIT: u32 = 100; + pub const GRANTS_MAX_LIMIT: u32 = 200; +} + +#[cfg(test)] +mod tests { + use super::*; + use nym_pool_contract_common::BasicAllowance; + + fn dummy_allowance() -> Allowance { + Allowance::Basic(BasicAllowance::unlimited()) + } + + #[cfg(test)] + mod nympool_storage { + use super::*; + use crate::testing::{TestSetup, TEST_DENOM}; + use cosmwasm_std::testing::{ + mock_dependencies, mock_env, MockApi, MockQuerier, MockStorage, + }; + use cosmwasm_std::{coin, coins, Empty, OwnedDeps}; + use nym_pool_contract_common::BasicAllowance; + + #[cfg(test)] + mod initialisation { + use super::*; + use crate::testing::{deps_with_balance, TEST_DENOM}; + use cosmwasm_std::testing::{mock_dependencies, mock_env}; + use cosmwasm_std::{coin, Order}; + use nym_pool_contract_common::BasicAllowance; + + fn all_grants(storage: &dyn Storage) -> HashMap { + NYM_POOL_STORAGE + .grants + .range(storage, None, None, Order::Ascending) + .collect::, _>>() + .unwrap() + } + + #[test] + fn requires_some_tokens_for_unlimited_initial_grants() -> anyhow::Result<()> { + let storage = NymPoolStorage::new(); + let mut deps = mock_dependencies(); + let env = mock_env(); + let admin = deps.api.addr_make("admin"); + + let mut grants = HashMap::new(); + grants.insert( + deps.api.addr_make("gr1").to_string(), + Allowance::Basic(BasicAllowance::unlimited()), + ); + let res = storage.initialise( + deps.as_mut(), + env.clone(), + admin.clone(), + &TEST_DENOM.to_string(), + grants.clone(), + ); + // we haven't got any tokens - this should have failed + assert!(res.is_err()); + + let address = &env.contract.address; + let mem_storage = MockStorage::default(); + let api = MockApi::default(); + + let querier: MockQuerier = + MockQuerier::new(&[(address.as_str(), coins(1, TEST_DENOM).as_slice())]); + + let mut deps: OwnedDeps<_, _, _, Empty> = OwnedDeps { + storage: mem_storage, + api, + querier, + custom_query_type: Default::default(), + }; + + // while we don't have a lot, we have some tokens which should allow this tx to proceed + let res = storage.initialise( + deps.as_mut(), + env.clone(), + admin.clone(), + &TEST_DENOM.to_string(), + grants.clone(), + ); + assert!(res.is_ok()); + + Ok(()) + } + + #[test] + fn requires_specified_amount_of_tokens_for_bounded_grants() -> anyhow::Result<()> { + fn bounded_allowance() -> Allowance { + Allowance::Basic(BasicAllowance { + spend_limit: Some(coin(100, TEST_DENOM)), + expiration_unix_timestamp: None, + }) + } + + let storage = NymPoolStorage::new(); + let mut deps = mock_dependencies(); + let env = mock_env(); + let admin = deps.api.addr_make("admin"); + + let mut grants = HashMap::new(); + grants.insert(deps.api.addr_make("gr1").to_string(), bounded_allowance()); + grants.insert(deps.api.addr_make("gr2").to_string(), bounded_allowance()); + grants.insert(deps.api.addr_make("gr3").to_string(), bounded_allowance()); + grants.insert(deps.api.addr_make("gr4").to_string(), bounded_allowance()); + let res = storage.initialise( + deps.as_mut(), + env.clone(), + admin.clone(), + &TEST_DENOM.to_string(), + grants.clone(), + ); + // we haven't got any tokens - this should have failed + assert!(res.is_err()); + + let address = &env.contract.address; + let mem_storage = MockStorage::default(); + let api = MockApi::default(); + + let querier: MockQuerier = + MockQuerier::new(&[(address.as_str(), coins(399, TEST_DENOM).as_slice())]); + + let mut deps: OwnedDeps<_, _, _, Empty> = OwnedDeps { + storage: mem_storage, + api, + querier, + custom_query_type: Default::default(), + }; + + // we haven't got enough tokens (we need at least 400) - still a failure! + let res = storage.initialise( + deps.as_mut(), + env.clone(), + admin.clone(), + &TEST_DENOM.to_string(), + grants.clone(), + ); + assert!(res.is_err()); + + // finally, with at least 400, it should work + let mem_storage = MockStorage::default(); + let api = MockApi::default(); + + let querier: MockQuerier = + MockQuerier::new(&[(address.as_str(), coins(400, TEST_DENOM).as_slice())]); + + let mut deps: OwnedDeps<_, _, _, Empty> = OwnedDeps { + storage: mem_storage, + api, + querier, + custom_query_type: Default::default(), + }; + + let res = storage.initialise( + deps.as_mut(), + env.clone(), + admin.clone(), + &TEST_DENOM.to_string(), + grants, + ); + assert!(res.is_ok()); + + Ok(()) + } + + #[test] + fn inserts_all_initial_grants() -> anyhow::Result<()> { + let storage = NymPoolStorage::new(); + let env = mock_env(); + + let mut deps = deps_with_balance(&env); + + let admin = deps.api.addr_make("admin"); + let denom = &TEST_DENOM.to_string(); + + // no grants + let grants = HashMap::new(); + storage.initialise(deps.as_mut(), env.clone(), admin.clone(), denom, grants)?; + assert!(all_grants(&deps.storage).is_empty()); + + // one grant + let mut deps = deps_with_balance(&env); + let mut grants = HashMap::new(); + grants.insert(deps.api.addr_make("gr1").to_string(), dummy_allowance()); + storage.initialise(deps.as_mut(), env.clone(), admin.clone(), denom, grants)?; + let all = all_grants(&deps.storage); + assert_eq!(all.len(), 1); + let grant = all.get(&deps.api.addr_make("gr1")).unwrap(); + assert_eq!(grant.grantee, deps.api.addr_make("gr1")); + assert_eq!(grant.allowance, dummy_allowance()); + + // multiple grants + let mut deps = deps_with_balance(&env); + let mut grants = HashMap::new(); + grants.insert(deps.api.addr_make("gr1").to_string(), dummy_allowance()); + grants.insert(deps.api.addr_make("gr2").to_string(), dummy_allowance()); + grants.insert(deps.api.addr_make("gr3").to_string(), dummy_allowance()); + grants.insert(deps.api.addr_make("gr4").to_string(), dummy_allowance()); + storage.initialise(deps.as_mut(), env.clone(), admin.clone(), denom, grants)?; + let all = all_grants(&deps.storage); + assert_eq!(all.len(), 4); + let grant = all.get(&deps.api.addr_make("gr1")).unwrap(); + assert_eq!(grant.grantee, deps.api.addr_make("gr1")); + let grant = all.get(&deps.api.addr_make("gr3")).unwrap(); + assert_eq!(grant.grantee, deps.api.addr_make("gr3")); + + // fails on invalid grantee address + let mut deps = deps_with_balance(&env); + let mut grants = HashMap::new(); + grants.insert(deps.api.addr_make("gr1").to_string(), dummy_allowance()); + grants.insert("invalid_address".to_string(), dummy_allowance()); + assert!(storage + .initialise(deps.as_mut(), env.clone(), admin.clone(), denom, grants) + .is_err()); + + // fails on invalid allowance + let mut deps = deps_with_balance(&env); + let mut grants = HashMap::new(); + grants.insert( + deps.api.addr_make("gr1").to_string(), + Allowance::Basic(BasicAllowance { + spend_limit: Some(coin(0, TEST_DENOM)), + expiration_unix_timestamp: None, + }), + ); + assert!(storage + .initialise(deps.as_mut(), env.clone(), admin, denom, grants) + .is_err()); + Ok(()) + } + + #[test] + fn sets_pool_denomination() -> anyhow::Result<()> { + let storage = NymPoolStorage::new(); + let mut deps = mock_dependencies(); + let env = mock_env(); + let admin = deps.api.addr_make("admin"); + + storage.initialise( + deps.as_mut(), + env.clone(), + admin.clone(), + &"somedenom".to_string(), + HashMap::new(), + )?; + assert_eq!( + storage.pool_denomination.load(deps.as_ref().storage)?, + "somedenom".to_string() + ); + + let mut deps = mock_dependencies(); + storage.initialise( + deps.as_mut(), + env.clone(), + admin.clone(), + &"anotherdenom".to_string(), + HashMap::new(), + )?; + assert_eq!( + storage.pool_denomination.load(deps.as_ref().storage)?, + "anotherdenom".to_string() + ); + + Ok(()) + } + + #[test] + fn sets_contract_admin() -> anyhow::Result<()> { + let storage = NymPoolStorage::new(); + let mut deps = mock_dependencies(); + let env = mock_env(); + let admin1 = deps.api.addr_make("first-admin"); + let admin2 = deps.api.addr_make("secod-admin"); + + storage.initialise( + deps.as_mut(), + env.clone(), + admin1.clone(), + &TEST_DENOM.to_string(), + HashMap::new(), + )?; + assert!(storage.ensure_is_admin(deps.as_ref(), &admin1).is_ok()); + + let mut deps = mock_dependencies(); + storage.initialise( + deps.as_mut(), + env.clone(), + admin2.clone(), + &TEST_DENOM.to_string(), + HashMap::new(), + )?; + assert!(storage.ensure_is_admin(deps.as_ref(), &admin2).is_ok()); + + Ok(()) + } + + #[test] + fn initialises_locked_storage() -> anyhow::Result<()> { + let storage = NymPoolStorage::new(); + let mut deps = mock_dependencies(); + let env = mock_env(); + let admin = deps.api.addr_make("admin"); + let denom = &TEST_DENOM.to_string(); + + storage.initialise(deps.as_mut(), env, admin, denom, HashMap::new())?; + assert!(storage + .locked + .total_locked + .load(deps.as_ref().storage)? + .is_zero()); + + Ok(()) + } + + #[test] + fn adds_admin_to_the_granters_set() -> anyhow::Result<()> { + let storage = NymPoolStorage::new(); + let mut deps = mock_dependencies(); + let env = mock_env(); + let admin1 = deps.api.addr_make("first-admin"); + let admin2 = deps.api.addr_make("second-admin"); + + storage.initialise( + deps.as_mut(), + env.clone(), + admin1.clone(), + &TEST_DENOM.to_string(), + HashMap::new(), + )?; + assert_eq!( + storage + .granters + .load(&deps.storage, admin1.clone())? + .created_by, + admin1 + ); + + let mut deps = mock_dependencies(); + storage.initialise( + deps.as_mut(), + env.clone(), + admin2.clone(), + &TEST_DENOM.to_string(), + HashMap::new(), + )?; + assert_eq!( + storage + .granters + .load(&deps.storage, admin2.clone())? + .created_by, + admin2 + ); + + Ok(()) + } + } + + #[test] + fn getting_contract_balance() -> anyhow::Result<()> { + // it's a simple as running the bank query against the address set in the current env + // using the pool denom + let env = mock_env(); + let address = &env.contract.address; + + let storage = MockStorage::default(); + let api = MockApi::default(); + + let not_contract = api.addr_make("unrelated-address"); + let pool_denom = TEST_DENOM; + + // set some initial balances + let querier: MockQuerier = MockQuerier::new(&[ + ( + address.as_str(), + vec![coin(1000, pool_denom), coin(2000, "anotherdenom")].as_slice(), + ), + (not_contract.as_str(), coins(1234, pool_denom).as_slice()), + ]); + + let mut deps: OwnedDeps<_, _, _, Empty> = OwnedDeps { + storage, + api, + querier, + custom_query_type: Default::default(), + }; + let storage = NymPoolStorage::new(); + let admin = deps.api.addr_make("admin"); + + // regardless of other denoms and other accounts, the balance query only returns target denom + storage.initialise( + deps.as_mut(), + env.clone(), + admin, + &pool_denom.to_string(), + HashMap::new(), + )?; + assert_eq!( + storage.contract_balance(deps.as_ref(), &env)?, + coin(1000, pool_denom) + ); + + Ok(()) + } + + #[test] + fn checking_for_admin() -> anyhow::Result<()> { + let storage = NymPoolStorage::new(); + let mut deps = mock_dependencies(); + let env = mock_env(); + let admin = deps.api.addr_make("admin"); + let non_admin = deps.api.addr_make("non-admin"); + let denom = &TEST_DENOM.to_string(); + + storage.initialise(deps.as_mut(), env, admin.clone(), denom, HashMap::new())?; + assert!(storage.is_admin(deps.as_ref(), &admin)?); + assert!(!storage.is_admin(deps.as_ref(), &non_admin)?); + + Ok(()) + } + + #[test] + fn ensuring_admin_privileges() -> anyhow::Result<()> { + let storage = NymPoolStorage::new(); + let mut deps = mock_dependencies(); + let env = mock_env(); + let admin = deps.api.addr_make("admin"); + let non_admin = deps.api.addr_make("non-admin"); + let denom = &TEST_DENOM.to_string(); + + storage.initialise(deps.as_mut(), env, admin.clone(), denom, HashMap::new())?; + assert!(storage.ensure_is_admin(deps.as_ref(), &admin).is_ok()); + assert!(storage.ensure_is_admin(deps.as_ref(), &non_admin).is_err()); + + Ok(()) + } + + #[test] + fn loading_granter_information() -> anyhow::Result<()> { + let storage = NymPoolStorage::new(); + let mut test = TestSetup::init(); + + let granter = test.generate_account(); + + // not granter + let info = storage.try_load_granter(test.deps(), &granter)?; + assert!(info.is_none()); + + // granter + let admin = test.admin_unchecked(); + test.add_granter(&granter); + + let info = storage.try_load_granter(test.deps(), &granter)?; + assert_eq!( + info, + Some(GranterInformation { + created_by: admin, + created_at_height: test.env().block.height, + }) + ); + + Ok(()) + } + + #[test] + fn checking_granter_permission() -> anyhow::Result<()> { + let storage = NymPoolStorage::new(); + let mut test = TestSetup::init(); + + let granter = test.generate_account(); + test.add_granter(&granter); + let not_granter = test.generate_account(); + + let deps = test.deps(); + assert!(storage.is_whitelisted_granter(deps, &granter)?); + assert!(!storage.is_whitelisted_granter(deps, ¬_granter)?); + + Ok(()) + } + + #[test] + fn ensuring_granter_permission() -> anyhow::Result<()> { + let storage = NymPoolStorage::new(); + let mut test = TestSetup::init(); + + let granter = test.generate_account(); + test.add_granter(&granter); + let not_granter = test.generate_account(); + + let deps = test.deps(); + assert!(storage + .ensure_is_whitelisted_granter(deps, &granter) + .is_ok()); + assert!(storage + .ensure_is_whitelisted_granter(deps, ¬_granter) + .is_err()); + + Ok(()) + } + + #[test] + fn checking_available_tokens() -> anyhow::Result<()> { + // initialise the contract with some tokens + let env = mock_env(); + let address = &env.contract.address; + + let storage = MockStorage::default(); + let api = MockApi::default(); + let pool_denom = TEST_DENOM; + + // set some initial balances + let querier: MockQuerier = + MockQuerier::new(&[(address.as_str(), coins(1000, pool_denom).as_slice())]); + + let mut deps: OwnedDeps<_, _, _, Empty> = OwnedDeps { + storage, + api, + querier, + custom_query_type: Default::default(), + }; + let storage = NymPoolStorage::new(); + let admin = deps.api.addr_make("admin"); + + storage.initialise( + deps.as_mut(), + env.clone(), + admin, + &pool_denom.to_string(), + HashMap::new(), + )?; + + // if there are no locked tokens, the available equals to the balance + assert_eq!( + storage.available_tokens(deps.as_ref(), &env)?, + coin(1000, pool_denom) + ); + + // however, once we start locking them, it becomes the difference between those + + // some locked + let dummy_grantee = deps.api.addr_make("grantee"); + storage + .locked + .lock(deps.as_mut(), &dummy_grantee, Uint128::new(100))?; + + assert_eq!( + storage.available_tokens(deps.as_ref(), &env)?, + coin(900, pool_denom) + ); + + // all locked + storage + .locked + .lock(deps.as_mut(), &dummy_grantee, Uint128::new(900))?; + assert_eq!( + storage.available_tokens(deps.as_ref(), &env)?, + coin(0, pool_denom) + ); + + // locked beyond balance (to check for overflow errors) + storage + .locked + .lock(deps.as_mut(), &dummy_grantee, Uint128::new(1000000))?; + assert_eq!( + storage.available_tokens(deps.as_ref(), &env)?, + coin(0, pool_denom) + ); + + Ok(()) + } + + #[test] + fn attempting_to_load_grant() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + // doesn't exist... + let grantee = test.generate_account(); + assert_eq!(storage.try_load_grant(test.deps(), &grantee)?, None); + + // exists + test.add_dummy_grant_for(&grantee); + assert_eq!( + storage.try_load_grant(test.deps(), &grantee)?, + Some(Grant { + granter: test.admin_unchecked(), + grantee, + granted_at_height: test.env().block.height, + allowance: Allowance::Basic(BasicAllowance::unlimited()), + }) + ); + Ok(()) + } + + #[test] + fn loading_grant() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + // doesn't exist... + let grantee = test.generate_account(); + assert!(storage.load_grant(test.deps(), &grantee).is_err()); + + // exists + test.add_dummy_grant_for(&grantee); + assert_eq!( + storage.load_grant(test.deps(), &grantee)?, + Grant { + granter: test.admin_unchecked(), + grantee, + granted_at_height: test.env().block.height, + allowance: Allowance::Basic(BasicAllowance::unlimited()), + } + ); + Ok(()) + } + + #[cfg(test)] + mod adding_new_granter { + use super::*; + use cw_controllers::AdminError; + + #[test] + fn can_only_be_performed_by_contract_admin() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + let admin = test.admin_unchecked(); + let not_admin = test.generate_account(); + + let granter = test.generate_account(); + + let (deps, env) = test.deps_mut_env(); + let res = storage + .add_new_granter(deps, &env, ¬_admin, &granter) + .unwrap_err(); + assert_eq!(res, NymPoolContractError::Admin(AdminError::NotAdmin {})); + + let (deps, env) = test.deps_mut_env(); + let res = storage.add_new_granter(deps, &env, &admin, &granter); + assert!(res.is_ok()); + + Ok(()) + } + + #[test] + fn can_only_be_performed_if_account_is_not_already_a_granter() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + let admin = test.admin_unchecked(); + let granter = test.generate_account(); + + // adding it for the first time... + let (deps, env) = test.deps_mut_env(); + storage.add_new_granter(deps, &env, &admin, &granter)?; + + // it's already a granter + let (deps, env) = test.deps_mut_env(); + let res = storage + .add_new_granter(deps, &env, &admin, &granter) + .unwrap_err(); + assert_eq!(res, NymPoolContractError::AlreadyAGranter); + + Ok(()) + } + + #[test] + fn saves_basic_metadata() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + let admin = test.admin_unchecked(); + let granter = test.generate_account(); + + // no metadata + let info = storage.granters.may_load(test.storage(), granter.clone())?; + assert!(info.is_none()); + + let (deps, env) = test.deps_mut_env(); + storage.add_new_granter(deps, &env, &admin, &granter)?; + + let info = storage.granters.may_load(test.storage(), granter.clone())?; + // it was added by the admin at the current height + assert_eq!( + info, + Some(GranterInformation { + created_by: admin.clone(), + created_at_height: env.block.height, + }) + ); + + // after changing admin, new address is set as the creator + let new_admin = test.generate_account(); + // sanity check: + assert_ne!(admin, new_admin); + test.change_admin(&new_admin); + + let new_granter = test.generate_account(); + let (deps, env) = test.deps_mut_env(); + storage.add_new_granter(deps, &env, &new_admin, &new_granter)?; + let info = storage + .granters + .may_load(test.storage(), new_granter.clone())?; + // it was added by the new admin at the current height + assert_eq!( + info, + Some(GranterInformation { + created_by: new_admin.clone(), + created_at_height: env.block.height, + }) + ); + + Ok(()) + } + } + + #[cfg(test)] + mod removing_granter { + use super::*; + + #[test] + fn requires_granter_to_exist() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + let admin = test.admin_unchecked(); + let granter = test.generate_account(); + + assert!(storage + .remove_granter(test.deps_mut(), &admin, &granter) + .is_err()); + + test.add_granter(&granter); + assert!(storage + .remove_granter(test.deps_mut(), &admin, &granter) + .is_ok()); + + Ok(()) + } + + #[test] + fn can_only_be_performed_by_admin() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + let random_address = test.generate_account(); + let granter = test.generate_account(); + let admin = test.admin_unchecked(); + test.add_granter(&granter); + + // can't be removed by the granter itself + assert!(storage + .remove_granter(test.deps_mut(), &granter, &granter) + .is_err()); + + // not by some random address + assert!(storage + .remove_granter(test.deps_mut(), &random_address, &granter) + .is_err()); + + // admin can do it though! + assert!(storage + .remove_granter(test.deps_mut(), &admin, &granter) + .is_ok()); + + test.add_granter(&granter); + let new_admin = test.generate_account(); + test.change_admin(&new_admin); + + // old admin can't do anything : ( + assert!(storage + .remove_granter(test.deps_mut(), &admin, &granter) + .is_err()); + + // but new admin can! + assert!(storage + .remove_granter(test.deps_mut(), &new_admin, &granter) + .is_ok()); + + Ok(()) + } + + #[test] + fn removes_it_from_granter_list() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + let admin = test.admin_unchecked(); + let granter = test.generate_account(); + test.add_granter(&granter); + + assert!(storage + .granters + .may_load(test.storage(), granter.clone())? + .is_some()); + + storage.remove_granter(test.deps_mut(), &admin, &granter)?; + + assert!(storage + .granters + .may_load(test.storage(), granter.clone())? + .is_none()); + Ok(()) + } + } + + #[cfg(test)] + mod adding_new_grant { + use super::*; + use nym_pool_contract_common::ClassicPeriodicAllowance; + + #[test] + fn can_only_be_done_by_whitelisted_granter() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + let not_valid_granter = test.generate_account(); + let granter = test.generate_account(); + test.add_granter(&granter); + + let grantee = test.generate_account(); + + let (deps, env) = test.deps_mut_env(); + let res = storage + .insert_new_grant(deps, &env, ¬_valid_granter, &grantee, dummy_allowance()) + .unwrap_err(); + + assert_eq!( + res, + NymPoolContractError::InvalidGranter { + addr: not_valid_granter.to_string(), + } + ); + + let (deps, env) = test.deps_mut_env(); + let res = + storage.insert_new_grant(deps, &env, &granter, &grantee, dummy_allowance()); + + assert!(res.is_ok()); + Ok(()) + } + + #[test] + fn cant_be_done_if_grant_already_existed() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + let admin = test.admin_unchecked(); + let grantee = test.add_dummy_grant().grantee; + + let (deps, env) = test.deps_mut_env(); + let res = storage + .insert_new_grant(deps, &env, &admin, &grantee, dummy_allowance()) + .unwrap_err(); + + assert!(matches!( + res, + NymPoolContractError::GrantAlreadyExist { .. } + )); + + Ok(()) + } + + #[test] + fn only_accepts_valid_allowances() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + // allowance with 0 limit and wrong denom + let bad_allowance = Allowance::Basic(BasicAllowance { + spend_limit: Some(coin(0, "invalid-denom")), + expiration_unix_timestamp: None, + }); + + let admin = test.admin_unchecked(); + let grantee = test.generate_account(); + + let (deps, env) = test.deps_mut_env(); + let res = storage + .insert_new_grant(deps, &env, &admin, &grantee, bad_allowance) + .unwrap_err(); + + assert!(matches!(res, NymPoolContractError::InvalidDenom { .. })); + + Ok(()) + } + + #[test] + fn explicit_limit_cant_be_larger_than_available_tokens() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + let admin = test.admin_unchecked(); + let grantee = test.generate_account(); + + let available = storage.available_tokens(test.deps(), &test.env())?; + + // just above the available + let mut limit = available.clone(); + limit.amount += Uint128::new(1); + let allowance = Allowance::Basic(BasicAllowance { + spend_limit: Some(limit), + expiration_unix_timestamp: None, + }); + + let (deps, env) = test.deps_mut_env(); + let res = storage + .insert_new_grant(deps, &env, &admin, &grantee, allowance) + .unwrap_err(); + + assert!(matches!( + res, + NymPoolContractError::InsufficientTokens { .. } + )); + + // equal to the available + let allowance = Allowance::Basic(BasicAllowance { + spend_limit: Some(available.clone()), + expiration_unix_timestamp: None, + }); + + let (deps, env) = test.deps_mut_env(); + let res = storage.insert_new_grant(deps, &env, &admin, &grantee, allowance); + assert!(res.is_ok()); + + // and below the available + let mut test = TestSetup::init(); + let mut limit = available.clone(); + limit.amount -= Uint128::new(1); + let allowance = Allowance::Basic(BasicAllowance { + spend_limit: Some(limit), + expiration_unix_timestamp: None, + }); + + let (deps, env) = test.deps_mut_env(); + let res = storage.insert_new_grant(deps, &env, &admin, &grantee, allowance); + assert!(res.is_ok()); + + Ok(()) + } + + #[test] + fn updates_allowances_initial_state_and_saves_it_to_storage() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + let admin = test.admin_unchecked(); + let grantee = test.generate_account(); + + let inner = ClassicPeriodicAllowance { + basic: BasicAllowance::unlimited(), + period_duration_secs: 3600, + period_spend_limit: coin(100, TEST_DENOM), + period_can_spend: None, + period_reset_unix_timestamp: 0, + }; + let allowance = Allowance::ClassicPeriodic(inner.clone()); + + let (deps, env) = test.deps_mut_env(); + let res = storage.insert_new_grant(deps, &env, &admin, &grantee, allowance); + assert!(res.is_ok()); + + let stored_grant = storage.load_grant(test.deps(), &grantee)?; + let mut expected_inner = inner; + expected_inner.period_can_spend = Some(expected_inner.period_spend_limit.clone()); + expected_inner.period_reset_unix_timestamp = + env.block.time.seconds() + expected_inner.period_duration_secs; + let expected = Allowance::ClassicPeriodic(expected_inner); + + assert_eq!(stored_grant.allowance, expected); + assert_eq!(stored_grant.grantee, grantee); + assert_eq!(stored_grant.granter, admin); + assert_eq!(stored_grant.granted_at_height, env.block.height); + + Ok(()) + } + } + + #[cfg(test)] + mod spending_part_of_grant { + use super::*; + + #[test] + fn requires_grant_to_exist() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + let grantee = test.generate_account(); + + let (deps, env) = test.deps_mut_env(); + let res = storage + .try_spend_part_of_grant(deps, &env, &grantee, &coin(100, TEST_DENOM)) + .unwrap_err(); + + assert!(matches!(res, NymPoolContractError::GrantNotFound { .. })); + + test.add_dummy_grant_for(&grantee); + assert!(storage + .try_spend_part_of_grant( + test.deps_mut(), + &env, + &grantee, + &coin(100, TEST_DENOM) + ) + .is_ok()); + Ok(()) + } + + #[test] + fn requires_grant_to_be_spendable() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + let admin = test.admin_unchecked(); + let grantee = test.generate_account(); + + let allowance = Allowance::Basic(BasicAllowance { + spend_limit: Some(coin(100, TEST_DENOM)), + expiration_unix_timestamp: None, + }); + + let (deps, env) = test.deps_mut_env(); + storage.insert_new_grant(deps, &env, &admin, &grantee, allowance)?; + + let res = storage + .try_spend_part_of_grant( + test.deps_mut(), + &env, + &grantee, + &coin(200, TEST_DENOM), + ) + .unwrap_err(); + + assert_eq!(res, NymPoolContractError::SpendingAboveAllowance); + Ok(()) + } + + #[test] + fn updates_stored_grant() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + let admin = test.admin_unchecked(); + let grantee = test.generate_account(); + + let allowance = Allowance::Basic(BasicAllowance { + spend_limit: Some(coin(100, TEST_DENOM)), + expiration_unix_timestamp: None, + }); + let (deps, env) = test.deps_mut_env(); + storage.insert_new_grant(deps, &env, &admin, &grantee, allowance)?; + + storage.try_spend_part_of_grant( + test.deps_mut(), + &env, + &grantee, + &coin(40, TEST_DENOM), + )?; + + let stored_grant = storage.load_grant(test.deps(), &grantee)?; + assert_eq!( + stored_grant.allowance, + Allowance::Basic(BasicAllowance { + spend_limit: Some(coin(60, TEST_DENOM)), + expiration_unix_timestamp: None, + }) + ); + + Ok(()) + } + + #[test] + fn removes_grant_from_storage_if_its_used_up() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + let admin = test.admin_unchecked(); + let grantee1 = test.generate_account(); + let grantee2 = test.generate_account(); + + let allowance = Allowance::Basic(BasicAllowance { + spend_limit: Some(coin(100, TEST_DENOM)), + expiration_unix_timestamp: None, + }); + let (deps, env) = test.deps_mut_env(); + storage.insert_new_grant(deps, &env, &admin, &grantee1, allowance.clone())?; + + let (deps, env) = test.deps_mut_env(); + storage.insert_new_grant(deps, &env, &admin, &grantee2, allowance)?; + + // use whole allowance with no locked tokens + storage.try_spend_part_of_grant( + test.deps_mut(), + &env, + &grantee1, + &coin(100, TEST_DENOM), + )?; + assert!(storage.try_load_grant(test.deps(), &grantee1)?.is_none()); + + // use whole allowance with some locked tokens + test.lock_allowance(grantee2.as_str(), Uint128::new(50)); + storage.try_spend_part_of_grant( + test.deps_mut(), + &env, + &grantee2, + &coin(50, TEST_DENOM), + )?; + + let stored_grant = storage.load_grant(test.deps(), &grantee2)?; + assert_eq!( + stored_grant.allowance, + Allowance::Basic(BasicAllowance { + spend_limit: Some(coin(0, TEST_DENOM)), + expiration_unix_timestamp: None, + }) + ); + + // unlock and attempt to spend again + storage.unlock_part_of_allowance( + test.deps_mut(), + &grantee2, + &coin(50, TEST_DENOM), + )?; + storage.try_spend_part_of_grant( + test.deps_mut(), + &env, + &grantee2, + &coin(50, TEST_DENOM), + )?; + assert!(storage.try_load_grant(test.deps(), &grantee2)?.is_none()); + + Ok(()) + } + } + + #[test] + fn removing_grant() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + let grantee = test.generate_account(); + + // no-op if doesn't exist + assert!(storage.remove_grant(test.deps_mut(), &grantee).is_ok()); + + // removes the actual entry from the map + test.add_dummy_grant_for(&grantee); + + assert!(storage + .grants + .may_load(test.storage(), grantee.clone())? + .is_some()); + + assert!(storage.remove_grant(test.deps_mut(), &grantee).is_ok()); + + assert!(storage + .grants + .may_load(test.storage(), grantee.clone())? + .is_none()); + + // if applicable, unlocks any locked tokens + // (all the details of unlocking are already tested in different unit test(s), + // so it's sufficient to check any of those occurred) + let grantee2 = test.add_dummy_grant().grantee; + test.lock_allowance(grantee2.as_str(), Uint128::new(50)); + + assert!(storage.remove_grant(test.deps_mut(), &grantee2).is_ok()); + + assert!(storage + .locked + .grantees + .may_load(test.storage(), grantee2)? + .is_none()); + assert!(storage.locked.total_locked.load(test.storage())?.is_zero()); + + Ok(()) + } + + #[cfg(test)] + mod revoking_grant { + use super::*; + + #[test] + fn requires_grant_to_exist() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + let admin = test.admin_unchecked(); + let grantee = test.generate_account(); + + assert_eq!( + storage + .revoke_grant(test.deps_mut(), &grantee, &admin) + .unwrap_err(), + NymPoolContractError::GrantNotFound { + grantee: grantee.to_string(), + } + ); + + test.add_dummy_grant_for(&grantee); + assert!(storage + .revoke_grant(test.deps_mut(), &grantee, &admin) + .is_ok()); + + Ok(()) + } + + #[test] + fn can_always_be_called_by_current_admin() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + let grantee = test.add_dummy_grant().grantee; + + // current admin + let admin = test.admin_unchecked(); + assert!(storage + .revoke_grant(test.deps_mut(), &grantee, &admin) + .is_ok()); + + // new admin + let new_admin = test.generate_account(); + let grantee = test.add_dummy_grant().grantee; + test.change_admin(&new_admin); + assert!(storage + .revoke_grant(test.deps_mut(), &grantee, &new_admin) + .is_ok()); + + // old admin + let grantee = test.add_dummy_grant().grantee; + assert_eq!( + storage + .revoke_grant(test.deps_mut(), &grantee, &admin) + .unwrap_err(), + NymPoolContractError::UnauthorizedGrantRevocation + ); + + Ok(()) + } + + #[test] + fn can_be_called_by_original_granter_if_its_still_whitelisted() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + let admin = test.admin_unchecked(); + let granter = test.generate_account(); + let grantee1 = test.generate_account(); + let grantee2 = test.generate_account(); + + test.add_granter(&granter); + let env = test.env(); + storage.insert_new_grant( + test.deps_mut(), + &env, + &granter, + &grantee1, + dummy_allowance(), + )?; + storage.insert_new_grant( + test.deps_mut(), + &env, + &granter, + &grantee2, + dummy_allowance(), + )?; + + // still whitelisted + assert!(storage + .revoke_grant(test.deps_mut(), &grantee1, &granter) + .is_ok()); + + // not whitelisted anymore + storage.remove_granter(test.deps_mut(), &admin, &granter)?; + assert_eq!( + storage + .revoke_grant(test.deps_mut(), &grantee2, &granter) + .unwrap_err(), + NymPoolContractError::UnauthorizedGrantRevocation + ); + + Ok(()) + } + + #[test] + fn removes_the_underlying_grant() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + let admin = test.admin_unchecked(); + let grantee = test.add_dummy_grant().grantee; + + storage.revoke_grant(test.deps_mut(), &grantee, &admin)?; + assert!(storage + .grants + .may_load(test.storage(), grantee.clone())? + .is_none()); + + Ok(()) + } + } + + #[cfg(test)] + mod locking_part_of_allowance { + use super::*; + + #[test] + fn requires_providing_valid_coin() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + let grantee = test.add_dummy_grant().grantee; + + let bad_amount = coin(0, "invalid-denom"); + let good_amount = test.coin(100); + + let env = test.env(); + + assert!(storage + .lock_part_of_allowance(test.deps_mut(), &env, &grantee, bad_amount) + .is_err()); + assert!(storage + .lock_part_of_allowance(test.deps_mut(), &env, &grantee, good_amount) + .is_ok()); + + Ok(()) + } + + #[test] + fn requires_grant_to_exist() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + let grantee = test.generate_account(); + let env = test.env(); + + let amount = test.coin(100); + + // doesn't exist + assert!(storage + .lock_part_of_allowance(test.deps_mut(), &env, &grantee, amount.clone()) + .is_err()); + + // does exist + test.add_dummy_grant_for(&grantee); + assert!(storage + .lock_part_of_allowance(test.deps_mut(), &env, &grantee, amount) + .is_ok()); + Ok(()) + } + + #[test] + fn does_not_allow_locking_more_than_spend_limit() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + let admin = test.admin_unchecked(); + let env = test.env(); + + let allowance = Allowance::Basic(BasicAllowance { + spend_limit: Some(test.coin(100)), + expiration_unix_timestamp: None, + }); + let grantee = test.generate_account(); + storage.insert_new_grant(test.deps_mut(), &env, &admin, &grantee, allowance)?; + + let amount = test.coin(101); + assert!(storage + .lock_part_of_allowance(test.deps_mut(), &env, &grantee, amount) + .is_err()); + + let amount = test.coin(100); + assert!(storage + .lock_part_of_allowance(test.deps_mut(), &env, &grantee, amount) + .is_ok()); + + Ok(()) + } + + #[test] + fn deducts_locked_amount_from_the_allowance() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + let admin = test.admin_unchecked(); + let env = test.env(); + + let allowance = Allowance::Basic(BasicAllowance { + spend_limit: Some(test.coin(100)), + expiration_unix_timestamp: None, + }); + let grantee = test.generate_account(); + storage.insert_new_grant(test.deps_mut(), &env, &admin, &grantee, allowance)?; + + let amount = test.coin(40); + storage.lock_part_of_allowance(test.deps_mut(), &env, &grantee, amount)?; + + let allowance = storage + .grants + .load(test.storage(), grantee.clone())? + .allowance; + assert_eq!(allowance.basic().spend_limit, Some(test.coin(60))); + + // no-op if there's no limit + let grantee = test.generate_account(); + let unlimited = Allowance::Basic(BasicAllowance::unlimited()); + storage.insert_new_grant(test.deps_mut(), &env, &admin, &grantee, unlimited)?; + + let amount = test.coin(40); + storage.lock_part_of_allowance(test.deps_mut(), &env, &grantee, amount)?; + let allowance = storage + .grants + .load(test.storage(), grantee.clone())? + .allowance; + assert_eq!(allowance.basic(), &BasicAllowance::unlimited()); + + Ok(()) + } + + #[test] + fn preserves_grant_even_if_resultant_allowance_is_zero() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + let admin = test.admin_unchecked(); + let env = test.env(); + + let allowance = Allowance::Basic(BasicAllowance { + spend_limit: Some(test.coin(100)), + expiration_unix_timestamp: None, + }); + let grantee = test.generate_account(); + storage.insert_new_grant(test.deps_mut(), &env, &admin, &grantee, allowance)?; + + let amount = test.coin(100); + storage.lock_part_of_allowance(test.deps_mut(), &env, &grantee, amount)?; + + let allowance = storage + .grants + .load(test.storage(), grantee.clone())? + .allowance; + assert_eq!(allowance.basic().spend_limit, Some(test.coin(0))); + + Ok(()) + } + + #[test] + fn updates_internal_locked_counter() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + let env = test.env(); + let grantee = test.add_dummy_grant().grantee; + let amount1 = test.coin(100); + let amount2 = test.coin(200); + + storage.lock_part_of_allowance(test.deps_mut(), &env, &grantee, amount1)?; + assert_eq!( + storage.locked.grantee_locked(test.storage(), &grantee)?, + Uint128::new(100) + ); + assert_eq!( + storage.locked.total_locked.load(test.storage())?, + Uint128::new(100) + ); + + // more locked by same grantee + storage.lock_part_of_allowance(test.deps_mut(), &env, &grantee, amount2)?; + assert_eq!( + storage.locked.grantee_locked(test.storage(), &grantee)?, + Uint128::new(300) + ); + assert_eq!( + storage.locked.total_locked.load(test.storage())?, + Uint128::new(300) + ); + + Ok(()) + } + } + + #[cfg(test)] + mod unlocking_part_of_allowance { + use super::*; + + fn setup_locked_grant(test: &mut TestSetup) -> Addr { + let grantee = test.add_dummy_grant().grantee; + test.lock_allowance(&grantee, Uint128::new(100)); + grantee + } + + #[test] + fn requires_providing_valid_coin() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + let grantee = setup_locked_grant(&mut test); + + let bad_amount = coin(0, "invalid-denom"); + let good_amount = test.coin(100); + + assert!(storage + .unlock_part_of_allowance(test.deps_mut(), &grantee, &bad_amount) + .is_err()); + assert!(storage + .unlock_part_of_allowance(test.deps_mut(), &grantee, &good_amount) + .is_ok()); + + Ok(()) + } + + #[test] + fn does_not_allow_unlocking_more_than_currently_locked() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + let grantee = setup_locked_grant(&mut test); + + let amount = test.coin(101); + assert!(storage + .unlock_part_of_allowance(test.deps_mut(), &grantee, &amount) + .is_err()); + + let amount = test.coin(100); + assert!(storage + .unlock_part_of_allowance(test.deps_mut(), &grantee, &amount) + .is_ok()); + Ok(()) + } + + #[test] + fn requires_grant_to_exist() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + let grantee = test.generate_account(); + + let amount = test.coin(100); + + assert!(storage + .unlock_part_of_allowance(test.deps_mut(), &grantee, &amount) + .is_err()); + test.add_dummy_grant_for(&grantee); + test.lock_allowance(&grantee, Uint128::new(100)); + assert!(storage + .unlock_part_of_allowance(test.deps_mut(), &grantee, &amount) + .is_ok()); + Ok(()) + } + + #[test] + fn requires_having_locked_coins() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + let grantee = test.add_dummy_grant().grantee; + + let amount = test.coin(100); + + assert!(storage + .unlock_part_of_allowance(test.deps_mut(), &grantee, &amount) + .is_err()); + test.lock_allowance(&grantee, Uint128::new(100)); + assert!(storage + .unlock_part_of_allowance(test.deps_mut(), &grantee, &amount) + .is_ok()); + Ok(()) + } + + #[test] + fn increases_internal_grant_spend_limit() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + let admin = test.admin_unchecked(); + let env = test.env(); + + let allowance = Allowance::Basic(BasicAllowance { + spend_limit: Some(test.coin(100)), + expiration_unix_timestamp: None, + }); + let grantee = test.generate_account(); + storage.insert_new_grant(test.deps_mut(), &env, &admin, &grantee, allowance)?; + + let amount = test.coin(40); + storage.lock_part_of_allowance(test.deps_mut(), &env, &grantee, amount)?; + + let amount = test.coin(20); + storage.unlock_part_of_allowance(test.deps_mut(), &grantee, &amount)?; + + let allowance = storage + .grants + .load(test.storage(), grantee.clone())? + .allowance; + assert_eq!(allowance.basic().spend_limit, Some(test.coin(80))); + + // no-op if there's no limit + let grantee = test.generate_account(); + let unlimited = Allowance::Basic(BasicAllowance::unlimited()); + storage.insert_new_grant(test.deps_mut(), &env, &admin, &grantee, unlimited)?; + + let amount = test.coin(40); + storage.lock_part_of_allowance(test.deps_mut(), &env, &grantee, amount)?; + + let amount = test.coin(20); + storage.unlock_part_of_allowance(test.deps_mut(), &grantee, &amount)?; + + let allowance = storage + .grants + .load(test.storage(), grantee.clone())? + .allowance; + assert_eq!(allowance.basic(), &BasicAllowance::unlimited()); + + Ok(()) + } + + #[test] + fn updates_internal_locked_counter() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = NymPoolStorage::new(); + + // 100tokens locked + let grantee = setup_locked_grant(&mut test); + + let amount = test.coin(20); + storage.unlock_part_of_allowance(test.deps_mut(), &grantee, &amount)?; + assert_eq!( + storage.locked.grantee_locked(test.storage(), &grantee)?, + Uint128::new(80) + ); + assert_eq!( + storage.locked.total_locked.load(test.storage())?, + Uint128::new(80) + ); + + let amount = test.coin(80); + storage.unlock_part_of_allowance(test.deps_mut(), &grantee, &amount)?; + assert!(storage + .locked + .grantees + .may_load(test.storage(), grantee)? + .is_none(),); + assert!(storage.locked.total_locked.load(test.storage())?.is_zero()); + + Ok(()) + } + } + } + + #[cfg(test)] + mod locked_storage { + use super::*; + use crate::testing::TestSetup; + use cosmwasm_std::testing::mock_dependencies; + + #[test] + fn is_initialised_with_zero_total_locked() -> anyhow::Result<()> { + let mut deps = mock_dependencies(); + let storage = LockedStorage::new(); + + // by default, when created, the `total_locked` is inaccessible + assert!(storage.total_locked.load(&deps.storage).is_err()); + + storage.initialise(deps.as_mut())?; + // but after proper initialisation, it's correctly set to 0 + assert_eq!(storage.total_locked.load(&deps.storage)?, Uint128::zero()); + + Ok(()) + } + + #[test] + fn getting_grantee_locked() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let grantee = test.generate_account(); + + let storage = LockedStorage::new(); + + // returns zero when there's nothing + assert!(storage + .grantee_locked(test.deps().storage, &grantee)? + .is_zero()); + + // even when a grant is created (but with nothing locked!) + test.add_dummy_grant_for(&grantee); + assert!(storage + .grantee_locked(test.deps().storage, &grantee)? + .is_zero()); + let to_lock = Uint128::new(100); + + // lock some tokens... + test.lock_allowance(&grantee, to_lock); + + // now we're talking! + assert_eq!( + storage.grantee_locked(test.deps().storage, &grantee)?, + to_lock + ); + + Ok(()) + } + + #[test] + fn getting_maybe_grantee_locked() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let grantee = test.generate_account(); + + let storage = LockedStorage::new(); + + // returns None when there's nothing + assert!(storage + .maybe_grantee_locked(test.deps().storage, &grantee)? + .is_none()); + + // even when a grant is created (but with nothing locked!) + test.add_dummy_grant_for(&grantee); + assert!(storage + .maybe_grantee_locked(test.deps().storage, &grantee)? + .is_none()); + let to_lock = Uint128::new(100); + + // lock some tokens... + test.lock_allowance(&grantee, to_lock); + + // now we're talking! + assert_eq!( + storage.maybe_grantee_locked(test.deps().storage, &grantee)?, + Some(to_lock) + ); + + Ok(()) + } + + #[test] + fn locking_tokens() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = LockedStorage::new(); + + let grantee1 = test.generate_account(); + let grantee2 = test.generate_account(); + + let first = Uint128::new(100); + let second = Uint128::new(200); + let third = Uint128::new(500); + + let all = test.full_locked_map(); + assert!(all.is_empty()); + + // fresh one creates entry for grantee with the amount + // and updates the total + let deps = test.deps_mut(); + storage.lock(deps, &grantee1, first)?; + + let deps = test.deps(); + assert_eq!(storage.total_locked.load(deps.storage)?, first); + assert_eq!(storage.grantee_locked(deps.storage, &grantee1)?, first); + + let all = test.full_locked_map(); + assert_eq!(all.len(), 1); + + // another one updates existing entries (and doesn't overwrite them!) + let deps = test.deps_mut(); + storage.lock(deps, &grantee1, second)?; + + let deps = test.deps(); + assert_eq!(storage.total_locked.load(deps.storage)?, first + second); + assert_eq!( + storage.grantee_locked(deps.storage, &grantee1)?, + first + second + ); + + let all = test.full_locked_map(); + assert_eq!(all.len(), 1); + + // if we do it for a new grantee, another entry is created, but the same total is updated + let deps = test.deps_mut(); + storage.lock(deps, &grantee2, third)?; + + let deps = test.deps(); + assert_eq!( + storage.total_locked.load(deps.storage)?, + first + second + third + ); + assert_eq!( + storage.grantee_locked(deps.storage, &grantee1)?, + first + second + ); + assert_eq!(storage.grantee_locked(deps.storage, &grantee2)?, third); + + let all = test.full_locked_map(); + assert_eq!(all.len(), 2); + Ok(()) + } + + #[test] + fn unlocking_tokens() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let storage = LockedStorage::new(); + + let grantee1 = test.generate_account(); + let grantee2 = test.generate_account(); + + test.add_dummy_grant_for(&grantee1); + test.add_dummy_grant_for(&grantee2); + + let first = Uint128::new(100); + let second = Uint128::new(200); + let third = Uint128::new(500); + + let all = test.full_locked_map(); + assert!(all.is_empty()); + + let deps = test.deps_mut(); + + // can't unlock anything if there's nothing locked + assert!(matches!( + storage.unlock(deps, &grantee1, first).unwrap_err(), + NymPoolContractError::InsufficientLockedTokens { .. } + )); + + test.lock_allowance(&grantee1, first); + + // can't unlock more than the total locked amount + let deps = test.deps_mut(); + assert!(matches!( + storage.unlock(deps, &grantee1, first + second).unwrap_err(), + NymPoolContractError::InsufficientLockedTokens { .. } + )); + test.lock_allowance(&grantee1, second); + test.lock_allowance(&grantee2, third); + let all = test.full_locked_map(); + assert_eq!(all.len(), 2); + + // unlocking partial amount correctly updates entries + let deps = test.deps_mut(); + assert!(storage.unlock(deps, &grantee1, first).is_ok()); + + let deps = test.deps_mut(); + assert_eq!(storage.total_locked.load(deps.storage)?, second + third); + assert_eq!(storage.grantee_locked(deps.storage, &grantee1)?, second); + let all = test.full_locked_map(); + assert_eq!(all.len(), 2); + + // unlocking the remaining amount will remove the entry + let deps = test.deps_mut(); + assert!(storage.unlock(deps, &grantee1, second).is_ok()); + let deps = test.deps_mut(); + assert_eq!(storage.total_locked.load(deps.storage)?, third); + assert!(storage.grantee_locked(deps.storage, &grantee1)?.is_zero()); + let all = test.full_locked_map(); + assert_eq!(all.len(), 1); + + // similarly if the full amount is unlocked immediately + let deps = test.deps_mut(); + assert!(storage.unlock(deps, &grantee2, third).is_ok()); + let deps = test.deps_mut(); + assert!(storage.total_locked.load(deps.storage)?.is_zero()); + assert!(storage.grantee_locked(deps.storage, &grantee2)?.is_zero()); + + let all = test.full_locked_map(); + assert!(all.is_empty()); + + Ok(()) + } + } +} diff --git a/contracts/nym-pool/src/testing/mod.rs b/contracts/nym-pool/src/testing/mod.rs new file mode 100644 index 0000000000..701dd953de --- /dev/null +++ b/contracts/nym-pool/src/testing/mod.rs @@ -0,0 +1,311 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::contract; +use crate::contract::{execute, instantiate, migrate, query}; +use crate::storage::NYM_POOL_STORAGE; +use crate::testing::storage::{ContractStorageWrapper, StorageWrapper}; +use cosmwasm_std::testing::{message_info, mock_env, MockApi, MockQuerier, MockStorage}; +use cosmwasm_std::{ + coin, coins, Addr, Coin, ContractInfo, Deps, DepsMut, Empty, Env, MemoryStorage, MessageInfo, + Order, OwnedDeps, Response, StdResult, Storage, Uint128, +}; +use cw_multi_test::{ + next_block, App, AppBuilder, AppResponse, BankKeeper, Contract, ContractWrapper, Executor, +}; +use nym_pool_contract_common::{ + Allowance, BasicAllowance, ExecuteMsg, Grant, InstantiateMsg, NymPoolContractError, QueryMsg, +}; +use rand::{RngCore, SeedableRng}; +use rand_chacha::ChaCha20Rng; +use serde::de::DeserializeOwned; +use std::collections::HashMap; + +mod storage; + +pub fn test_rng() -> ChaCha20Rng { + let dummy_seed = [42u8; 32]; + ChaCha20Rng::from_seed(dummy_seed) +} + +pub fn deps_with_balance(env: &Env) -> OwnedDeps> { + OwnedDeps { + storage: MockStorage::default(), + api: MockApi::default(), + querier: MockQuerier::::new(&[( + env.contract.address.as_str(), + coins(100000000000, TEST_DENOM).as_slice(), + )]), + custom_query_type: Default::default(), + } +} + +pub const TEST_DENOM: &str = "unym"; + +pub struct TestSetup { + pub app: App, + pub rng: ChaCha20Rng, + pub contract_address: Addr, + pub master_address: Addr, + pub(crate) storage: ContractStorageWrapper, +} + +pub fn contract() -> Box> { + let contract = ContractWrapper::new(execute, instantiate, query).with_migrate(migrate); + Box::new(contract) +} + +impl TestSetup { + pub fn init() -> TestSetup { + let storage = StorageWrapper::new(); + + let api = MockApi::default().with_prefix("n"); + let master_address = api.addr_make("master-owner"); + + let mut app = AppBuilder::new() + .with_api(api) + .with_storage(storage.clone()) + .build(|router, _api, storage| { + router + .bank + .init_balance( + storage, + &master_address, + coins(1000000000000000, TEST_DENOM), + ) + .unwrap() + }); + let code_id = app.store_code(contract()); + let contract_address = app + .instantiate_contract( + code_id, + master_address.clone(), + &InstantiateMsg { + pool_denomination: TEST_DENOM.to_string(), + grants: Default::default(), + }, + &[], + "nym-pool-contract", + Some(master_address.to_string()), + ) + .unwrap(); + + // send some tokens to the contract + app.send_tokens( + master_address.clone(), + contract_address.clone(), + &[coin(100000000, TEST_DENOM)], + ) + .unwrap(); + + TestSetup { + app, + rng: test_rng(), + storage: storage.contract_storage_wrapper(&contract_address), + contract_address, + master_address, + } + } + + pub fn set_contract_balance(&mut self, balance: Coin) { + let contract_address = &self.contract_address; + self.app + .router() + .bank + .init_balance( + &mut self.storage.inner_storage(), + contract_address, + vec![balance], + ) + .unwrap(); + } + + pub fn deps(&self) -> Deps<'_> { + Deps { + storage: &self.storage, + api: self.app.api(), + querier: self.app.wrap(), + } + } + + pub fn deps_mut(&mut self) -> DepsMut<'_> { + DepsMut { + storage: &mut self.storage, + api: self.app.api(), + querier: self.app.wrap(), + } + } + + pub fn deps_mut_env(&mut self) -> (DepsMut<'_>, Env) { + let env = self.env().clone(); + (self.deps_mut(), env) + } + + pub fn storage(&self) -> &dyn Storage { + &self.storage + } + + pub fn storage_mut(&mut self) -> &mut dyn Storage { + &mut self.storage + } + + pub fn env(&self) -> Env { + Env { + block: self.app.block_info(), + contract: ContractInfo { + address: self.contract_address.clone(), + }, + ..mock_env() + } + } + + pub fn next_block(&mut self) { + self.app.update_block(next_block) + } + + pub fn execute_raw( + &mut self, + sender: Addr, + message: ExecuteMsg, + ) -> Result { + self.execute_raw_with_balance(sender, &[], message) + } + + pub fn execute_raw_with_balance( + &mut self, + sender: Addr, + coins: &[Coin], + message: ExecuteMsg, + ) -> Result { + let env = self.env(); + let info = message_info(&sender, coins); + contract::execute(self.deps_mut(), env, info, message) + } + + pub fn execute_msg( + &mut self, + sender: Addr, + message: &ExecuteMsg, + ) -> anyhow::Result { + self.execute_msg_with_balance(sender, &[], message) + } + + pub fn execute_msg_with_balance( + &mut self, + sender: Addr, + coins: &[Coin], + message: &ExecuteMsg, + ) -> anyhow::Result { + self.app + .execute_contract(sender, self.contract_address.clone(), message, coins) + } + + pub fn query(&self, message: &QueryMsg) -> StdResult { + self.app + .wrap() + .query_wasm_smart(self.contract_address.as_str(), message) + } + + pub fn generate_account(&mut self) -> Addr { + self.app + .api() + .addr_make(&format!("foomp{}", self.rng.next_u64())) + } + + pub fn admin_unchecked(&self) -> Addr { + NYM_POOL_STORAGE + .contract_admin + .get(self.deps()) + .unwrap() + .unwrap() + } + + pub fn change_admin(&mut self, new_admin: &Addr) { + self.execute_msg( + self.admin_unchecked(), + &ExecuteMsg::UpdateAdmin { + admin: new_admin.to_string(), + update_granter_set: Some(true), + }, + ) + .unwrap(); + } + + pub fn admin_msg(&self) -> MessageInfo { + message_info(&self.admin_unchecked(), &[]) + } + + pub fn denom(&self) -> String { + NYM_POOL_STORAGE + .pool_denomination + .load(self.storage()) + .unwrap() + } + + pub fn coin(&self, amount: u128) -> Coin { + coin(amount, self.denom()) + } + + pub fn coins(&self, amount: u128) -> Vec { + coins(amount, self.denom()) + } + + #[track_caller] + pub fn add_dummy_grant(&mut self) -> Grant { + let grantee = self.generate_account(); + self.add_dummy_grant_for(&grantee) + } + + #[track_caller] + pub fn add_dummy_grant_for(&mut self, grantee: impl Into) -> Grant { + let grantee = Addr::unchecked(grantee); + let granter = self.admin_unchecked(); + let env = self.env(); + NYM_POOL_STORAGE + .insert_new_grant( + self.deps_mut(), + &env, + &granter, + &grantee, + Allowance::Basic(BasicAllowance::unlimited()), + ) + .unwrap(); + + NYM_POOL_STORAGE.load_grant(self.deps(), &grantee).unwrap() + } + + #[track_caller] + pub fn lock_allowance(&mut self, grantee: impl Into, amount: impl Into) { + let denom = NYM_POOL_STORAGE + .pool_denomination + .load(self.deps().storage) + .unwrap(); + + self.execute_msg( + Addr::unchecked(grantee), + &ExecuteMsg::LockAllowance { + amount: coin(amount.into().u128(), denom), + }, + ) + .unwrap(); + } + + #[track_caller] + pub fn full_locked_map(&self) -> HashMap { + NYM_POOL_STORAGE + .locked + .grantees + .range(self.deps().storage, None, None, Order::Ascending) + .collect::, _>>() + .unwrap() + } + + #[track_caller] + pub fn add_granter(&mut self, granter: &Addr) { + let env = self.env(); + let admin = self.admin_unchecked(); + NYM_POOL_STORAGE + .add_new_granter(self.deps_mut(), &env, &admin, granter) + .unwrap(); + } +} diff --git a/contracts/nym-pool/src/testing/storage.rs b/contracts/nym-pool/src/testing/storage.rs new file mode 100644 index 0000000000..14ba3e955a --- /dev/null +++ b/contracts/nym-pool/src/testing/storage.rs @@ -0,0 +1,168 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_std::storage_keys::to_length_prefixed_nested; +use cosmwasm_std::testing::MockStorage; +use cosmwasm_std::{Addr, MemoryStorage, Order, Record, Storage}; +use std::cell::RefCell; +use std::rc::Rc; + +#[derive(Debug, Clone)] +pub struct StorageWrapper(Rc>); + +impl StorageWrapper { + pub(super) fn contract_storage_wrapper(&self, contract: &Addr) -> ContractStorageWrapper { + ContractStorageWrapper { + address: contract.clone(), + inner: self.clone(), + } + } + + pub(super) fn new() -> Self { + StorageWrapper(Rc::new(RefCell::new(MockStorage::new()))) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct ContractStorageWrapper { + address: Addr, + inner: StorageWrapper, +} + +impl ContractStorageWrapper { + pub fn inner_storage(&self) -> StorageWrapper { + self.inner.clone() + } +} + +impl Storage for StorageWrapper { + fn get(&self, key: &[u8]) -> Option> { + self.0.borrow().get(key) + } + + fn range<'a>( + &'a self, + start: Option<&[u8]>, + end: Option<&[u8]>, + order: Order, + ) -> Box + 'a> { + // hehe, that's nasty + let vals = self.0.borrow().range(start, end, order).collect::>(); + Box::new(vals.into_iter()) + } + + fn set(&mut self, key: &[u8], value: &[u8]) { + self.0.borrow_mut().set(key, value); + } + + fn remove(&mut self, key: &[u8]) { + self.0.borrow_mut().remove(key); + } +} + +impl ContractStorageWrapper { + fn contract_namespace(&self) -> Vec { + let mut name = b"contract_data/".to_vec(); + name.extend_from_slice(self.address.as_bytes()); + name + } + + fn prefix(&self) -> Vec { + to_length_prefixed_nested(&[b"wasm", &self.contract_namespace()]) + } + + #[inline] + fn trim(namespace: &[u8], key: &[u8]) -> Vec { + key[namespace.len()..].to_vec() + } + + /// Returns a new vec of same length and last byte incremented by one + /// If last bytes are 255, we handle overflow up the chain. + /// If all bytes are 255, this returns wrong data - but that is never possible as a namespace + fn namespace_upper_bound(input: &[u8]) -> Vec { + let mut copy = input.to_vec(); + // zero out all trailing 255, increment first that is not such + for i in (0..input.len()).rev() { + if copy[i] == 255 { + copy[i] = 0; + } else { + copy[i] += 1; + break; + } + } + copy + } + + #[inline] + fn concat(namespace: &[u8], key: &[u8]) -> Vec { + let mut k = namespace.to_vec(); + k.extend_from_slice(key); + k + } + + fn get_with_prefix(storage: &dyn Storage, namespace: &[u8], key: &[u8]) -> Option> { + storage.get(&Self::concat(namespace, key)) + } + + fn set_with_prefix(storage: &mut dyn Storage, namespace: &[u8], key: &[u8], value: &[u8]) { + storage.set(&Self::concat(namespace, key), value); + } + + fn remove_with_prefix(storage: &mut dyn Storage, namespace: &[u8], key: &[u8]) { + storage.remove(&Self::concat(namespace, key)); + } + + fn range_with_prefix<'a>( + storage: &'a dyn Storage, + namespace: &[u8], + start: Option<&[u8]>, + end: Option<&[u8]>, + order: Order, + ) -> Box + 'a> { + // prepare start, end with prefix + let start = match start { + Some(s) => Self::concat(namespace, s), + None => namespace.to_vec(), + }; + let end = match end { + Some(e) => Self::concat(namespace, e), + // end is updating last byte by one + None => Self::namespace_upper_bound(namespace), + }; + + // get iterator from storage + let base_iterator = storage.range(Some(&start), Some(&end), order); + + // make a copy for the closure to handle lifetimes safely + let prefix = namespace.to_vec(); + let mapped = base_iterator.map(move |(k, v)| (Self::trim(&prefix, &k), v)); + Box::new(mapped) + } +} + +impl Storage for ContractStorageWrapper { + fn get(&self, key: &[u8]) -> Option> { + let prefix = self.prefix(); + Self::get_with_prefix(&self.inner, &prefix, key) + } + + fn range<'a>( + &'a self, + start: Option<&[u8]>, + end: Option<&[u8]>, + order: Order, + ) -> Box + 'a> { + let prefix = self.prefix(); + Self::range_with_prefix(&self.inner, &prefix, start, end, order) + } + + fn set(&mut self, key: &[u8], value: &[u8]) { + let prefix = self.prefix(); + Self::set_with_prefix(&mut self.inner, &prefix, key, value); + } + + fn remove(&mut self, key: &[u8]) { + let prefix = self.prefix(); + Self::remove_with_prefix(&mut self.inner, &prefix, key); + } +} diff --git a/contracts/nym-pool/src/transactions.rs b/contracts/nym-pool/src/transactions.rs new file mode 100644 index 0000000000..59181bd07b --- /dev/null +++ b/contracts/nym-pool/src/transactions.rs @@ -0,0 +1,1597 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::helpers::validate_usage_coin; +use crate::storage::NYM_POOL_STORAGE; +use cosmwasm_std::{BankMsg, Coin, CosmosMsg, DepsMut, Env, MessageInfo, Response, Uint128}; +use nym_pool_contract_common::{Allowance, NymPoolContractError, TransferRecipient}; + +pub fn try_update_contract_admin( + mut deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + new_admin: String, + update_granter_set: Option, +) -> Result { + let new_admin = deps.api.addr_validate(&new_admin)?; + let old_admin = info.sender.clone(); + + if let Some(true) = update_granter_set { + // remove current/old admin from the granter set, if present + NYM_POOL_STORAGE + .granters + .remove(deps.storage, old_admin.clone()); + + // insert new admin into the granter set + NYM_POOL_STORAGE.add_new_granter(deps.branch(), &env, &old_admin, &new_admin)?; + } + + let res = NYM_POOL_STORAGE + .contract_admin + .execute_update_admin(deps, info, Some(new_admin))?; + + Ok(res) +} + +pub fn try_grant_allowance( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + grantee: String, + allowance: Allowance, +) -> Result { + let grantee = deps.api.addr_validate(&grantee)?; + + NYM_POOL_STORAGE.insert_new_grant(deps, &env, &info.sender, &grantee, allowance)?; + + // TODO: emit events + Ok(Response::new()) +} + +pub fn try_revoke_grant( + deps: DepsMut<'_>, + _env: Env, + info: MessageInfo, + grantee: String, +) -> Result { + let grantee = deps.api.addr_validate(&grantee)?; + + NYM_POOL_STORAGE.revoke_grant(deps, &grantee, &info.sender)?; + + // TODO: emit events + Ok(Response::new()) +} + +pub fn try_use_allowance( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + recipients: Vec, +) -> Result { + let denom = NYM_POOL_STORAGE.pool_denomination.load(deps.storage)?; + + if recipients.is_empty() { + return Err(NymPoolContractError::EmptyUsageRequest); + } + + let mut amount = Uint128::zero(); + let mut messages = Vec::new(); + for recipient in recipients { + validate_usage_coin(deps.storage, &recipient.amount)?; + + amount += recipient.amount.amount; + messages.push(CosmosMsg::Bank(BankMsg::Send { + to_address: deps.api.addr_validate(&recipient.recipient)?.to_string(), + amount: vec![recipient.amount], + })) + } + + let available = NYM_POOL_STORAGE.available_tokens(deps.as_ref(), &env)?; + // even if the contract has sufficient amount of tokens (which would be implicit from BankMsg not failing) + // the locked ones take priority + if available.amount < amount { + return Err(NymPoolContractError::InsufficientTokens { + available, + required: Coin { amount, denom }, + }); + } + + NYM_POOL_STORAGE.try_spend_part_of_grant(deps, &env, &info.sender, &Coin { amount, denom })?; + + // TODO: emit events + Ok(Response::new().add_messages(messages)) +} + +pub fn try_withdraw_allowance( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + amount: Coin, +) -> Result { + validate_usage_coin(deps.storage, &amount)?; + + let available = NYM_POOL_STORAGE.available_tokens(deps.as_ref(), &env)?; + + // even if the contract has sufficient amount of tokens (which would be implicit from BankMsg not failing) + // the locked ones take priority + if available.amount < amount.amount { + return Err(NymPoolContractError::InsufficientTokens { + available, + required: amount, + }); + } + + NYM_POOL_STORAGE.try_spend_part_of_grant(deps, &env, &info.sender, &amount)?; + + // TODO: emit events + // TODO2: after migrating common to cw2.2 use `send_tokens` from `ResponseExt` trait + Ok(Response::new().add_message(CosmosMsg::Bank(BankMsg::Send { + to_address: info.sender.to_string(), + amount: vec![amount], + }))) +} + +pub fn try_lock_allowance( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + amount: Coin, +) -> Result { + NYM_POOL_STORAGE.lock_part_of_allowance(deps, &env, &info.sender, amount)?; + + // TODO: emit events + Ok(Response::new()) +} + +pub fn try_unlock_allowance( + deps: DepsMut<'_>, + _env: Env, + info: MessageInfo, + amount: Coin, +) -> Result { + NYM_POOL_STORAGE.unlock_part_of_allowance(deps, &info.sender, &amount)?; + + // TODO: emit events + Ok(Response::new()) +} + +pub fn try_use_locked_allowance( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + recipients: Vec, +) -> Result { + let mut amount = Uint128::zero(); + let mut messages = Vec::new(); + for recipient in recipients { + validate_usage_coin(deps.storage, &recipient.amount)?; + + amount += recipient.amount.amount; + messages.push(CosmosMsg::Bank(BankMsg::Send { + to_address: deps.api.addr_validate(&recipient.recipient)?.to_string(), + amount: vec![recipient.amount], + })) + } + + // if the grant has already expired, locked coins can no longer be used, + // ideally, they'd be immediately unlocked here, but we need to revert the transaction + let grant = NYM_POOL_STORAGE.load_grant(deps.as_ref(), &info.sender)?; + if grant.allowance.expired(&env) { + return Err(NymPoolContractError::GrantExpired); + } + + let denom = NYM_POOL_STORAGE.pool_denomination.load(deps.storage)?; + + // we remove those coins from the locked pool before transferring them to the specified account + NYM_POOL_STORAGE.unlock_part_of_allowance(deps, &info.sender, &Coin { amount, denom })?; + + // TODO: emit events + Ok(Response::new().add_messages(messages)) +} + +pub fn try_withdraw_locked_allowance( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + amount: Coin, +) -> Result { + // if the grant has already expired, locked coins can no longer be used, + // ideally, they'd be immediately unlocked here, but we need to revert the transaction + let grant = NYM_POOL_STORAGE.load_grant(deps.as_ref(), &info.sender)?; + if grant.allowance.expired(&env) { + return Err(NymPoolContractError::GrantExpired); + } + + // we remove those coins from the locked pool before transferring them to the specified account + NYM_POOL_STORAGE.unlock_part_of_allowance(deps, &info.sender, &amount)?; + + // TODO: emit events + // TODO2: after migrating common to cw2.2 use `send_tokens` from `ResponseExt` trait + Ok(Response::new().add_message(CosmosMsg::Bank(BankMsg::Send { + to_address: info.sender.to_string(), + amount: vec![amount], + }))) +} + +pub fn try_add_new_granter( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + granter: String, +) -> Result { + let granter = deps.api.addr_validate(&granter)?; + NYM_POOL_STORAGE.add_new_granter(deps, &env, &info.sender, &granter)?; + + // TODO: emit events + Ok(Response::new()) +} + +pub fn try_revoke_granter( + deps: DepsMut<'_>, + _env: Env, + info: MessageInfo, + granter: String, +) -> Result { + let granter = deps.api.addr_validate(&granter)?; + NYM_POOL_STORAGE.remove_granter(deps, &info.sender, &granter)?; + + // TODO: emit events + Ok(Response::new()) +} + +// can be called by anyone, because expired grants are unusable anyway +pub fn try_remove_expired( + deps: DepsMut<'_>, + env: Env, + _info: MessageInfo, + grantee: String, +) -> Result { + let grantee = deps.api.addr_validate(&grantee)?; + let grant = NYM_POOL_STORAGE.load_grant(deps.as_ref(), &grantee)?; + + if !grant.allowance.expired(&env) { + return Err(NymPoolContractError::GrantNotExpired); + } + + NYM_POOL_STORAGE.remove_grant(deps, &grantee)?; + + // TODO: emit events + Ok(Response::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::TestSetup; + use nym_pool_contract_common::ExecuteMsg; + + #[cfg(test)] + mod updating_contract_admin { + use super::*; + use crate::testing::TestSetup; + use cosmwasm_std::{Deps, Order}; + use cw_controllers::AdminError; + use nym_pool_contract_common::{ExecuteMsg, GranterAddress, GranterInformation}; + use std::collections::HashMap; + + #[test] + fn can_only_be_performed_by_current_admin() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + + let random_acc = test.generate_account(); + let new_admin = test.generate_account(); + let res = test + .execute_raw( + random_acc, + ExecuteMsg::UpdateAdmin { + admin: new_admin.to_string(), + update_granter_set: None, + }, + ) + .unwrap_err(); + + assert_eq!(res, NymPoolContractError::Admin(AdminError::NotAdmin {})); + + let actual_admin = test.admin_unchecked(); + let res = test.execute_raw( + actual_admin.clone(), + ExecuteMsg::UpdateAdmin { + admin: new_admin.to_string(), + update_granter_set: None, + }, + ); + assert!(res.is_ok()); + + let updated_admin = test.admin_unchecked(); + assert_eq!(new_admin, updated_admin); + + Ok(()) + } + + #[test] + fn requires_providing_valid_address() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + + let bad_account = "definitely-not-valid-account"; + let res = test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::UpdateAdmin { + admin: bad_account.to_string(), + update_granter_set: None, + }, + ); + + assert!(res.is_err()); + + let empty_account = ""; + let res = test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::UpdateAdmin { + admin: empty_account.to_string(), + update_granter_set: None, + }, + ); + + assert!(res.is_err()); + + Ok(()) + } + + #[test] + fn updates_granter_set_if_specified() { + fn granters(deps: Deps) -> HashMap { + NYM_POOL_STORAGE + .granters + .range(deps.storage, None, None, Order::Ascending) + .map(|res| res.unwrap()) + .collect() + } + + let mut test = TestSetup::init(); + let current_admin = test.admin_unchecked(); + let new_admin = test.generate_account(); + + let old_granters = granters(test.deps()); + + // no change to the granter set + let res = test.execute_raw( + current_admin.clone(), + ExecuteMsg::UpdateAdmin { + admin: new_admin.to_string(), + update_granter_set: Some(false), + }, + ); + assert!(res.is_ok()); + let new_granters = granters(test.deps()); + assert_eq!(old_granters, new_granters); + + // + // + // + + let mut test = TestSetup::init(); + let current_admin = test.admin_unchecked(); + let new_admin = test.generate_account(); + let old_granters = granters(test.deps()); + + let res = test.execute_raw( + current_admin.clone(), + ExecuteMsg::UpdateAdmin { + admin: new_admin.to_string(), + update_granter_set: Some(true), + }, + ); + assert!(res.is_ok()); + let new_granters = granters(test.deps()); + assert_ne!(old_granters, new_granters); + assert!(old_granters.contains_key(¤t_admin)); + assert!(new_granters.contains_key(&new_admin)); + } + } + + #[cfg(test)] + mod granting_allowance { + use super::*; + use crate::testing::TestSetup; + use cosmwasm_std::StdError; + use nym_pool_contract_common::BasicAllowance; + + #[test] + fn requires_providing_valid_grantee_address() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + + let env = test.env(); + let admin = test.admin_msg(); + let dummy_grant = Allowance::Basic(BasicAllowance::unlimited()); + + assert!(matches!( + try_grant_allowance( + test.deps_mut(), + env.clone(), + admin.clone(), + "not-a-valid-address".to_string(), + dummy_grant.clone() + ) + .unwrap_err(), + NymPoolContractError::StdErr(StdError::GenericErr { msg, .. }) if msg == "Error decoding bech32" + )); + + let valid_address = test.generate_account(); + assert!(try_grant_allowance( + test.deps_mut(), + env.clone(), + admin.clone(), + valid_address.to_string(), + dummy_grant + ) + .is_ok()); + + Ok(()) + } + } + + #[cfg(test)] + mod revoking_allowance { + use super::*; + use crate::testing::TestSetup; + use cosmwasm_std::StdError; + + #[test] + fn requires_providing_valid_grantee_address() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + + let env = test.env(); + let admin = test.admin_msg(); + let grant = test.add_dummy_grant(); + + assert!(matches!( + try_revoke_grant( + test.deps_mut(), + env.clone(), + admin.clone(), + "not-a-valid-address".to_string() + ) + .unwrap_err(), + NymPoolContractError::StdErr(StdError::GenericErr { msg, .. }) if msg == "Error decoding bech32" + )); + + // use a valid address + // note the different error + let valid_address = test.generate_account(); + assert_eq!( + try_revoke_grant( + test.deps_mut(), + env.clone(), + admin.clone(), + valid_address.to_string() + ) + .unwrap_err(), + NymPoolContractError::GrantNotFound { + grantee: valid_address.to_string() + } + ); + + // for sanity’s sake check with an existing grant + assert!(try_revoke_grant( + test.deps_mut(), + env.clone(), + admin.clone(), + grant.grantee.to_string() + ) + .is_ok()); + + Ok(()) + } + } + + #[cfg(test)] + mod using_allowance { + use super::*; + use crate::testing::TestSetup; + use nym_pool_contract_common::{BasicAllowance, ExecuteMsg}; + + #[test] + fn requires_at_least_a_single_coin_receiver() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let grantee = test.add_dummy_grant().grantee; + + let res = test.execute_raw(grantee, ExecuteMsg::UseAllowance { recipients: vec![] }); + assert_eq!(res.unwrap_err(), NymPoolContractError::EmptyUsageRequest); + + Ok(()) + } + + #[test] + fn requires_valid_coin_for_each_receiver() -> anyhow::Result<()> { + // 1 bad receiver + let mut test = TestSetup::init(); + let grantee = test.add_dummy_grant().grantee; + + let res = test.execute_raw( + grantee, + ExecuteMsg::UseAllowance { + recipients: vec![TransferRecipient { + recipient: "invalid-address".to_string(), + amount: test.coin(1234), + }], + }, + ); + assert!(res.is_err()); + + // 3 receivers, one invalid + let mut test = TestSetup::init(); + let grantee = test.add_dummy_grant().grantee; + + let addr1 = test.generate_account(); + let addr2 = test.generate_account(); + let addr3 = test.generate_account(); + let res = test.execute_raw( + grantee, + ExecuteMsg::UseAllowance { + recipients: vec![ + TransferRecipient { + recipient: addr1.to_string(), + amount: test.coin(1234), + }, + TransferRecipient { + recipient: addr2.to_string(), + amount: test.coin(0), + }, + TransferRecipient { + recipient: addr3.to_string(), + amount: test.coin(1234), + }, + ], + }, + ); + assert!(res.is_err()); + + // all fine + let mut test = TestSetup::init(); + let grantee = test.add_dummy_grant().grantee; + + let res = test.execute_raw( + grantee, + ExecuteMsg::UseAllowance { + recipients: vec![ + TransferRecipient { + recipient: addr1.to_string(), + amount: test.coin(1234), + }, + TransferRecipient { + recipient: addr2.to_string(), + amount: test.coin(1), + }, + TransferRecipient { + recipient: addr3.to_string(), + amount: test.coin(1234), + }, + ], + }, + ); + assert!(res.is_ok()); + + Ok(()) + } + + #[test] + fn requires_the_total_to_be_available_for_spending() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let recipient = test.generate_account(); + + // contract balance < required + let grantee = test.add_dummy_grant().grantee; + test.set_contract_balance(test.coin(100)); + let res = test.execute_raw( + grantee.clone(), + ExecuteMsg::UseAllowance { + recipients: vec![ + TransferRecipient { + recipient: recipient.to_string(), + amount: test.coin(50), + }, + TransferRecipient { + recipient: recipient.to_string(), + amount: test.coin(51), + }, + ], + }, + ); + assert_eq!( + NymPoolContractError::InsufficientTokens { + available: test.coin(100), + required: test.coin(101) + }, + res.unwrap_err() + ); + + // contract balance == required + let res = test.execute_raw( + grantee, + ExecuteMsg::UseAllowance { + recipients: vec![ + TransferRecipient { + recipient: recipient.to_string(), + amount: test.coin(50), + }, + TransferRecipient { + recipient: recipient.to_string(), + amount: test.coin(50), + }, + ], + }, + ); + assert!(res.is_ok()); + + // contract balance > required + let grantee = test.add_dummy_grant().grantee; + test.set_contract_balance(test.coin(100)); + let res = test.execute_raw( + grantee, + ExecuteMsg::UseAllowance { + recipients: vec![TransferRecipient { + recipient: recipient.to_string(), + amount: test.coin(50), + }], + }, + ); + assert!(res.is_ok()); + + // contract balance > required BUT (contract balance - locked) < required + let grantee = test.add_dummy_grant().grantee; + test.set_contract_balance(test.coin(100)); + test.lock_allowance(&grantee, Uint128::new(40)); + let another_grantee = test.add_dummy_grant().grantee; + + let res = test.execute_raw( + another_grantee, + ExecuteMsg::UseAllowance { + recipients: vec![TransferRecipient { + recipient: recipient.to_string(), + amount: test.coin(61), + }], + }, + ); + assert_eq!( + NymPoolContractError::InsufficientTokens { + available: test.coin(60), + required: test.coin(61) + }, + res.unwrap_err() + ); + + Ok(()) + } + + #[test] + fn requires_the_total_to_be_within_spend_limit() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let allowance = Allowance::Basic(BasicAllowance { + spend_limit: Some(test.coin(100)), + expiration_unix_timestamp: None, + }); + let grantee = test.generate_account(); + let env = test.env(); + let admin = test.admin_unchecked(); + NYM_POOL_STORAGE.insert_new_grant( + test.deps_mut(), + &env, + &admin, + &grantee, + allowance, + )?; + + let recipient = test.generate_account(); + let res = test.execute_raw( + grantee.clone(), + ExecuteMsg::UseAllowance { + recipients: vec![TransferRecipient { + recipient: recipient.to_string(), + amount: test.coin(101), + }], + }, + ); + assert_eq!( + NymPoolContractError::SpendingAboveAllowance, + res.unwrap_err() + ); + + let res = test.execute_raw( + grantee, + ExecuteMsg::UseAllowance { + recipients: vec![TransferRecipient { + recipient: recipient.to_string(), + amount: test.coin(100), + }], + }, + ); + assert!(res.is_ok()); + + Ok(()) + } + + #[test] + fn attaches_appropriate_bank_message_for_each_receiver() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + + let grantee = test.add_dummy_grant().grantee; + + let recipient1 = test.generate_account(); + let recipient2 = test.generate_account(); + let recipient3 = test.generate_account(); + + let mut res = test.execute_raw( + grantee.clone(), + ExecuteMsg::UseAllowance { + recipients: vec![ + TransferRecipient { + recipient: recipient1.to_string(), + amount: test.coin(100), + }, + TransferRecipient { + recipient: recipient2.to_string(), + amount: test.coin(200), + }, + TransferRecipient { + recipient: recipient3.to_string(), + amount: test.coin(300), + }, + ], + }, + )?; + + // last + let CosmosMsg::Bank(BankMsg::Send { to_address, amount }) = + res.messages.pop().unwrap().msg + else { + panic!("invalid message") + }; + assert_eq!(to_address, recipient3.to_string()); + assert_eq!(amount, test.coins(300)); + + // second + let CosmosMsg::Bank(BankMsg::Send { to_address, amount }) = + res.messages.pop().unwrap().msg + else { + panic!("invalid message") + }; + assert_eq!(to_address, recipient2.to_string()); + assert_eq!(amount, test.coins(200)); + + // first + let CosmosMsg::Bank(BankMsg::Send { to_address, amount }) = + res.messages.pop().unwrap().msg + else { + panic!("invalid message") + }; + assert_eq!(to_address, recipient1.to_string()); + assert_eq!(amount, test.coins(100)); + + assert!(res.messages.is_empty()); + + Ok(()) + } + + #[test] + fn requires_grant_to_not_be_expired() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let env = test.env(); + let allowance = Allowance::Basic(BasicAllowance { + spend_limit: None, + expiration_unix_timestamp: Some(env.block.time.seconds() + 1), + }); + let grantee = test.generate_account(); + let admin = test.admin_unchecked(); + NYM_POOL_STORAGE.insert_new_grant( + test.deps_mut(), + &env, + &admin, + &grantee, + allowance, + )?; + test.next_block(); + + let recipient = test.generate_account(); + let res = test.execute_raw( + grantee.clone(), + ExecuteMsg::UseAllowance { + recipients: vec![TransferRecipient { + recipient: recipient.to_string(), + amount: test.coin(100), + }], + }, + ); + assert_eq!(NymPoolContractError::GrantExpired, res.unwrap_err()); + + Ok(()) + } + } + + #[cfg(test)] + mod withdrawing_from_allowance { + use super::*; + use crate::testing::TestSetup; + use cosmwasm_std::coin; + use nym_pool_contract_common::{BasicAllowance, ExecuteMsg}; + + #[test] + fn requires_valid_coin() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let grantee = test.add_dummy_grant().grantee; + + let res = test.execute_raw( + grantee.clone(), + ExecuteMsg::WithdrawAllowance { + amount: coin(1234, "wtf-denom"), + }, + ); + assert!(res.is_err()); + + let res = test.execute_raw( + grantee.clone(), + ExecuteMsg::WithdrawAllowance { + amount: test.coin(0), + }, + ); + assert!(res.is_err()); + + let res = test.execute_raw( + grantee.clone(), + ExecuteMsg::WithdrawAllowance { + amount: test.coin(123), + }, + ); + assert!(res.is_ok()); + + Ok(()) + } + + #[test] + fn requires_the_amount_to_be_available_for_spending() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + + // contract balance < required + let grantee = test.add_dummy_grant().grantee; + test.set_contract_balance(test.coin(100)); + let res = test.execute_raw( + grantee.clone(), + ExecuteMsg::WithdrawAllowance { + amount: test.coin(101), + }, + ); + assert_eq!( + NymPoolContractError::InsufficientTokens { + available: test.coin(100), + required: test.coin(101) + }, + res.unwrap_err() + ); + + // contract balance == required + let res = test.execute_raw( + grantee, + ExecuteMsg::WithdrawAllowance { + amount: test.coin(100), + }, + ); + assert!(res.is_ok()); + + // contract balance > required + let grantee = test.add_dummy_grant().grantee; + test.set_contract_balance(test.coin(100)); + let res = test.execute_raw( + grantee, + ExecuteMsg::WithdrawAllowance { + amount: test.coin(50), + }, + ); + assert!(res.is_ok()); + + // contract balance > required BUT (contract balance - locked) < required + let grantee = test.add_dummy_grant().grantee; + test.set_contract_balance(test.coin(100)); + test.lock_allowance(&grantee, Uint128::new(40)); + let another_grantee = test.add_dummy_grant().grantee; + + let res = test.execute_raw( + another_grantee, + ExecuteMsg::WithdrawAllowance { + amount: test.coin(61), + }, + ); + assert_eq!( + NymPoolContractError::InsufficientTokens { + available: test.coin(60), + required: test.coin(61) + }, + res.unwrap_err() + ); + + Ok(()) + } + + #[test] + fn requires_the_amount_to_be_within_spend_limit() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let allowance = Allowance::Basic(BasicAllowance { + spend_limit: Some(test.coin(100)), + expiration_unix_timestamp: None, + }); + let grantee = test.generate_account(); + let env = test.env(); + let admin = test.admin_unchecked(); + NYM_POOL_STORAGE.insert_new_grant( + test.deps_mut(), + &env, + &admin, + &grantee, + allowance, + )?; + + let res = test.execute_raw( + grantee.clone(), + ExecuteMsg::WithdrawAllowance { + amount: test.coin(101), + }, + ); + assert_eq!( + NymPoolContractError::SpendingAboveAllowance, + res.unwrap_err() + ); + + let res = test.execute_raw( + grantee, + ExecuteMsg::WithdrawAllowance { + amount: test.coin(100), + }, + ); + assert!(res.is_ok()); + + Ok(()) + } + + #[test] + fn attaches_appropriate_bank_message() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + + let grantee = test.add_dummy_grant().grantee; + + let mut res = test.execute_raw( + grantee.clone(), + ExecuteMsg::WithdrawAllowance { + amount: test.coin(100), + }, + )?; + + let CosmosMsg::Bank(BankMsg::Send { to_address, amount }) = + res.messages.pop().unwrap().msg + else { + panic!("invalid message") + }; + assert_eq!(to_address, grantee.to_string()); + assert_eq!(amount, test.coins(100)); + + assert!(res.messages.is_empty()); + + Ok(()) + } + + #[test] + fn requires_grant_to_not_be_expired() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let env = test.env(); + let allowance = Allowance::Basic(BasicAllowance { + spend_limit: None, + expiration_unix_timestamp: Some(env.block.time.seconds() + 1), + }); + let grantee = test.generate_account(); + let env = test.env(); + let admin = test.admin_unchecked(); + NYM_POOL_STORAGE.insert_new_grant( + test.deps_mut(), + &env, + &admin, + &grantee, + allowance, + )?; + test.next_block(); + + let res = test.execute_raw( + grantee.clone(), + ExecuteMsg::WithdrawAllowance { + amount: test.coin(101), + }, + ); + assert_eq!(NymPoolContractError::GrantExpired, res.unwrap_err()); + + Ok(()) + } + } + + #[test] + fn locking_allowance() -> anyhow::Result<()> { + // internals got tested in storage tests, so this is mostly about checking events (TODO) + let mut test = TestSetup::init(); + let grantee = test.add_dummy_grant().grantee; + + let res = test.execute_raw( + grantee.clone(), + ExecuteMsg::LockAllowance { + amount: test.coin(100), + }, + ); + assert!(res.is_ok()); + + assert_eq!( + NYM_POOL_STORAGE + .locked + .grantee_locked(test.storage(), &grantee)?, + Uint128::new(100) + ); + + Ok(()) + } + + #[test] + fn unlocking_allowance() -> anyhow::Result<()> { + // internals got tested in storage tests, so this is mostly about checking events (TODO) + let mut test = TestSetup::init(); + let grantee = test.add_dummy_grant().grantee; + test.lock_allowance(&grantee, Uint128::new(100)); + + let res = test.execute_raw( + grantee.clone(), + ExecuteMsg::UnlockAllowance { + amount: test.coin(40), + }, + ); + assert!(res.is_ok()); + + assert_eq!( + NYM_POOL_STORAGE + .locked + .grantee_locked(test.storage(), &grantee)?, + Uint128::new(60) + ); + + Ok(()) + } + + #[cfg(test)] + mod using_locked_allowance { + use super::*; + use nym_pool_contract_common::BasicAllowance; + + #[test] + fn requires_at_least_a_single_coin_receiver() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let grantee = test.add_dummy_grant().grantee; + + let res = test.execute_raw( + grantee, + ExecuteMsg::UseLockedAllowance { recipients: vec![] }, + ); + assert_eq!(res.unwrap_err(), NymPoolContractError::EmptyUsageRequest); + + Ok(()) + } + + #[test] + fn requires_valid_coin_for_each_receiver() -> anyhow::Result<()> { + // 1 bad receiver + let mut test = TestSetup::init(); + let grantee = test.add_dummy_grant().grantee; + test.lock_allowance(&grantee, Uint128::new(10000)); + + let res = test.execute_raw( + grantee, + ExecuteMsg::UseLockedAllowance { + recipients: vec![TransferRecipient { + recipient: "invalid-address".to_string(), + amount: test.coin(1234), + }], + }, + ); + assert!(res.is_err()); + + // 3 receivers, one invalid + let mut test = TestSetup::init(); + let grantee = test.add_dummy_grant().grantee; + test.lock_allowance(&grantee, Uint128::new(10000)); + + let addr1 = test.generate_account(); + let addr2 = test.generate_account(); + let addr3 = test.generate_account(); + let res = test.execute_raw( + grantee, + ExecuteMsg::UseLockedAllowance { + recipients: vec![ + TransferRecipient { + recipient: addr1.to_string(), + amount: test.coin(1234), + }, + TransferRecipient { + recipient: addr2.to_string(), + amount: test.coin(0), + }, + TransferRecipient { + recipient: addr3.to_string(), + amount: test.coin(1234), + }, + ], + }, + ); + assert!(res.is_err()); + + // all fine + let mut test = TestSetup::init(); + let grantee = test.add_dummy_grant().grantee; + test.lock_allowance(&grantee, Uint128::new(10000)); + + let res = test.execute_raw( + grantee, + ExecuteMsg::UseLockedAllowance { + recipients: vec![ + TransferRecipient { + recipient: addr1.to_string(), + amount: test.coin(1234), + }, + TransferRecipient { + recipient: addr2.to_string(), + amount: test.coin(1), + }, + TransferRecipient { + recipient: addr3.to_string(), + amount: test.coin(1234), + }, + ], + }, + ); + assert!(res.is_ok()); + + Ok(()) + } + + #[test] + fn requires_the_total_to_be_locked_by_grantee() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let grantee = test.add_dummy_grant().grantee; + test.lock_allowance(&grantee, Uint128::new(100)); + + let recipient = test.generate_account(); + let res = test.execute_raw( + grantee.clone(), + ExecuteMsg::UseLockedAllowance { + recipients: vec![TransferRecipient { + recipient: recipient.to_string(), + amount: test.coin(101), + }], + }, + ); + assert_eq!( + NymPoolContractError::InsufficientLockedTokens { + grantee: grantee.to_string(), + locked: Uint128::new(100), + requested: Uint128::new(101), + }, + res.unwrap_err() + ); + + let res = test.execute_raw( + grantee, + ExecuteMsg::UseLockedAllowance { + recipients: vec![TransferRecipient { + recipient: recipient.to_string(), + amount: test.coin(100), + }], + }, + ); + assert!(res.is_ok()); + + Ok(()) + } + + #[test] + fn attaches_appropriate_bank_message_for_each_receiver() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + + let grantee = test.add_dummy_grant().grantee; + test.lock_allowance(&grantee, Uint128::new(10000)); + + let recipient1 = test.generate_account(); + let recipient2 = test.generate_account(); + let recipient3 = test.generate_account(); + + let mut res = test.execute_raw( + grantee.clone(), + ExecuteMsg::UseLockedAllowance { + recipients: vec![ + TransferRecipient { + recipient: recipient1.to_string(), + amount: test.coin(100), + }, + TransferRecipient { + recipient: recipient2.to_string(), + amount: test.coin(200), + }, + TransferRecipient { + recipient: recipient3.to_string(), + amount: test.coin(300), + }, + ], + }, + )?; + + // last + let CosmosMsg::Bank(BankMsg::Send { to_address, amount }) = + res.messages.pop().unwrap().msg + else { + panic!("invalid message") + }; + assert_eq!(to_address, recipient3.to_string()); + assert_eq!(amount, test.coins(300)); + + // second + let CosmosMsg::Bank(BankMsg::Send { to_address, amount }) = + res.messages.pop().unwrap().msg + else { + panic!("invalid message") + }; + assert_eq!(to_address, recipient2.to_string()); + assert_eq!(amount, test.coins(200)); + + // first + let CosmosMsg::Bank(BankMsg::Send { to_address, amount }) = + res.messages.pop().unwrap().msg + else { + panic!("invalid message") + }; + assert_eq!(to_address, recipient1.to_string()); + assert_eq!(amount, test.coins(100)); + + assert!(res.messages.is_empty()); + + Ok(()) + } + + #[test] + fn requires_grant_to_not_be_expired() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let env = test.env(); + let allowance = Allowance::Basic(BasicAllowance { + spend_limit: None, + expiration_unix_timestamp: Some(env.block.time.seconds() + 1), + }); + let grantee = test.generate_account(); + let admin = test.admin_unchecked(); + NYM_POOL_STORAGE.insert_new_grant( + test.deps_mut(), + &env, + &admin, + &grantee, + allowance, + )?; + test.lock_allowance(&grantee, Uint128::new(10000)); + test.next_block(); + + let recipient = test.generate_account(); + let res = test.execute_raw( + grantee.clone(), + ExecuteMsg::UseLockedAllowance { + recipients: vec![TransferRecipient { + recipient: recipient.to_string(), + amount: test.coin(100), + }], + }, + ); + assert_eq!(NymPoolContractError::GrantExpired, res.unwrap_err()); + + Ok(()) + } + } + + #[cfg(test)] + mod withdrawing_from_locked_allowance { + use super::*; + use cosmwasm_std::coin; + use nym_pool_contract_common::BasicAllowance; + + #[test] + fn requires_valid_coin() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let grantee = test.add_dummy_grant().grantee; + test.lock_allowance(&grantee, Uint128::new(10000)); + + let res = test.execute_raw( + grantee.clone(), + ExecuteMsg::WithdrawLockedAllowance { + amount: coin(1234, "wtf-denom"), + }, + ); + assert!(res.is_err()); + + let res = test.execute_raw( + grantee.clone(), + ExecuteMsg::WithdrawLockedAllowance { + amount: test.coin(0), + }, + ); + assert!(res.is_err()); + + let res = test.execute_raw( + grantee.clone(), + ExecuteMsg::WithdrawLockedAllowance { + amount: test.coin(123), + }, + ); + assert!(res.is_ok()); + + Ok(()) + } + + #[test] + fn attaches_appropriate_bank_message() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + + let grantee = test.add_dummy_grant().grantee; + test.lock_allowance(&grantee, Uint128::new(10000)); + + let mut res = test.execute_raw( + grantee.clone(), + ExecuteMsg::WithdrawLockedAllowance { + amount: test.coin(100), + }, + )?; + + let CosmosMsg::Bank(BankMsg::Send { to_address, amount }) = + res.messages.pop().unwrap().msg + else { + panic!("invalid message") + }; + assert_eq!(to_address, grantee.to_string()); + assert_eq!(amount, test.coins(100)); + + assert!(res.messages.is_empty()); + + Ok(()) + } + + #[test] + fn requires_grant_to_not_be_expired() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let env = test.env(); + let allowance = Allowance::Basic(BasicAllowance { + spend_limit: None, + expiration_unix_timestamp: Some(env.block.time.seconds() + 1), + }); + let grantee = test.generate_account(); + let env = test.env(); + let admin = test.admin_unchecked(); + NYM_POOL_STORAGE.insert_new_grant( + test.deps_mut(), + &env, + &admin, + &grantee, + allowance, + )?; + test.lock_allowance(&grantee, Uint128::new(10000)); + + test.next_block(); + + let res = test.execute_raw( + grantee.clone(), + ExecuteMsg::WithdrawLockedAllowance { + amount: test.coin(101), + }, + ); + assert_eq!(NymPoolContractError::GrantExpired, res.unwrap_err()); + + Ok(()) + } + + #[test] + fn requires_the_amount_to_be_locked_by_grantee() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let grantee = test.add_dummy_grant().grantee; + test.lock_allowance(&grantee, Uint128::new(100)); + + let res = test.execute_raw( + grantee.clone(), + ExecuteMsg::WithdrawLockedAllowance { + amount: test.coin(101), + }, + ); + assert_eq!( + NymPoolContractError::InsufficientLockedTokens { + grantee: grantee.to_string(), + locked: Uint128::new(100), + requested: Uint128::new(101), + }, + res.unwrap_err() + ); + + let res = test.execute_raw( + grantee, + ExecuteMsg::WithdrawLockedAllowance { + amount: test.coin(100), + }, + ); + assert!(res.is_ok()); + + Ok(()) + } + } + + #[test] + fn adding_new_granter() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let bad_address = "foomp"; + let good_address = test.generate_account(); + + // requires valid address + let res = test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::AddNewGranter { + granter: bad_address.to_string(), + }, + ); + assert!(res.is_err()); + + let res = test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::AddNewGranter { + granter: good_address.to_string(), + }, + ); + assert!(res.is_ok()); + + // introduces new granter + assert!(NYM_POOL_STORAGE + .granters + .may_load(test.storage(), good_address)? + .is_some()); + + Ok(()) + } + + #[test] + fn revoking_granter() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let bad_address = "foomp"; + let good_address = test.generate_account(); + let granter_address = test.generate_account(); + test.add_granter(&granter_address); + + // requires valid address + let res = test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::RevokeGranter { + granter: bad_address.to_string(), + }, + ); + assert!(res.is_err()); + + // requires an actual granter + let res = test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::RevokeGranter { + granter: good_address.to_string(), + }, + ); + assert!(res.is_err()); + + // revokes the granter + let res = test.execute_raw( + test.admin_unchecked(), + ExecuteMsg::RevokeGranter { + granter: granter_address.to_string(), + }, + ); + assert!(res.is_ok()); + + assert!(NYM_POOL_STORAGE + .granters + .may_load(test.storage(), granter_address)? + .is_none()); + + Ok(()) + } + + #[cfg(test)] + mod removing_expired { + use super::*; + use nym_pool_contract_common::{BasicAllowance, GranteeAddress}; + + fn setup_with_expired_grant() -> (TestSetup, GranteeAddress) { + let mut test = TestSetup::init(); + let env = test.env(); + let allowance = Allowance::Basic(BasicAllowance { + spend_limit: None, + expiration_unix_timestamp: Some(env.block.time.seconds() + 1), + }); + let grantee = test.generate_account(); + let admin = test.admin_unchecked(); + NYM_POOL_STORAGE + .insert_new_grant(test.deps_mut(), &env, &admin, &grantee, allowance) + .unwrap(); + test.next_block(); + (test, grantee) + } + + #[test] + fn requires_valid_grantee_address() -> anyhow::Result<()> { + let (mut test, grantee) = setup_with_expired_grant(); + let sender = test.generate_account(); + let res = test.execute_raw( + sender.clone(), + ExecuteMsg::RemoveExpiredGrant { + grantee: "bad grantee".to_string(), + }, + ); + assert!(res.is_err()); + + let res = test.execute_raw( + sender, + ExecuteMsg::RemoveExpiredGrant { + grantee: grantee.to_string(), + }, + ); + assert!(res.is_ok()); + + Ok(()) + } + + #[test] + fn requires_grant_to_actually_exist_and_be_expired() -> anyhow::Result<()> { + let mut test = TestSetup::init(); + let sender = test.generate_account(); + let grantee = test.add_dummy_grant().grantee; + let not_grantee = test.generate_account(); + + // doesn't exist + let res = test.execute_raw( + sender.clone(), + ExecuteMsg::RemoveExpiredGrant { + grantee: not_grantee.to_string(), + }, + ); + assert!(res.is_err()); + + // exists but not expired + let res = test.execute_raw( + sender.clone(), + ExecuteMsg::RemoveExpiredGrant { + grantee: grantee.to_string(), + }, + ); + assert!(res.is_err()); + + Ok(()) + } + + #[test] + fn removes_the_grant() -> anyhow::Result<()> { + let (mut test, grantee) = setup_with_expired_grant(); + let sender = test.generate_account(); + + assert!(NYM_POOL_STORAGE + .grants + .may_load(test.storage(), grantee.clone())? + .is_some()); + + test.execute_raw( + sender.clone(), + ExecuteMsg::RemoveExpiredGrant { + grantee: grantee.to_string(), + }, + )?; + + assert!(NYM_POOL_STORAGE + .grants + .may_load(test.storage(), grantee)? + .is_none()); + + Ok(()) + } + } +}