diff --git a/Cargo.lock b/Cargo.lock index 7ab222337..686d217f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2129,6 +2129,39 @@ dependencies = [ "thiserror", ] +[[package]] +name = "dao-voting-onft-staked" +version = "2.4.2" +dependencies = [ + "anyhow", + "chrono", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers 1.1.2", + "cw-hooks", + "cw-multi-test", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw2 1.1.2", + "cw721-controllers", + "dao-dao-macros", + "dao-hooks", + "dao-interface", + "dao-proposal-hook-counter", + "dao-proposal-single", + "dao-test-custom-factory", + "dao-testing", + "dao-voting 2.4.2", + "osmosis-std", + "osmosis-std-derive", + "osmosis-test-tube", + "prost 0.12.3", + "prost-types 0.12.3", + "schemars", + "serde", + "thiserror", +] + [[package]] name = "dao-voting-token-staked" version = "2.4.2" diff --git a/Cargo.toml b/Cargo.toml index 95915acee..3d24d2e53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ overflow-checks = true [workspace.dependencies] anyhow = { version = "1.0" } assert_matches = "1.5" +chrono = { version = "0.4.27", default-features = false } cosm-orc = { version = "4.0" } cosm-tome = "0.2" cosmos-sdk-proto = "0.19" diff --git a/contracts/voting/dao-voting-cw721-roles/src/error.rs b/contracts/voting/dao-voting-cw721-roles/src/error.rs index 2fa498222..62c33b0c6 100644 --- a/contracts/voting/dao-voting-cw721-roles/src/error.rs +++ b/contracts/voting/dao-voting-cw721-roles/src/error.rs @@ -15,7 +15,7 @@ pub enum ContractError { #[error("New cw721-roles contract must be instantiated with at least one NFT")] NoInitialNfts {}, - #[error("Only the owner of this contract my execute this message")] + #[error("Only the owner of this contract may execute this message")] NotOwner {}, #[error("Got a submessage reply with unknown id: {id}")] diff --git a/contracts/voting/dao-voting-cw721-staked/src/error.rs b/contracts/voting/dao-voting-cw721-staked/src/error.rs index 287c7a509..df9526bd2 100644 --- a/contracts/voting/dao-voting-cw721-staked/src/error.rs +++ b/contracts/voting/dao-voting-cw721-staked/src/error.rs @@ -38,7 +38,7 @@ pub enum ContractError { #[error("Nothing to claim")] NothingToClaim {}, - #[error("Only the owner of this contract my execute this message")] + #[error("Only the owner of this contract may execute this message")] NotOwner {}, #[error("Can not unstake that which you have not staked (unstaking {token_id})")] diff --git a/contracts/voting/dao-voting-onft-staked/Cargo.toml b/contracts/voting/dao-voting-onft-staked/Cargo.toml new file mode 100644 index 000000000..99e0326fe --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "dao-voting-onft-staked" +authors = [ + "CypherApe cypherape@protonmail.com", + "Jake Hartnell", + "ekez", + "noah ", +] +description = "A DAO DAO voting module based on staked x/onft tokens." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] +# use test tube feature to enable test-tube integration tests, for example +# cargo test --features "test-tube" +test-tube = [] +# when writing tests you may wish to enable test-tube as a default feature +# default = ["test-tube"] + +[dependencies] +chrono = { workspace = true } +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw-controllers = { workspace = true } +cw-hooks = { workspace = true } +cw721-controllers = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +dao-dao-macros = { workspace = true } +dao-hooks = { workspace = true } +dao-interface = { workspace = true } +dao-voting = { workspace = true } +osmosis-std = { workspace = true } +osmosis-std-derive = { workspace = true } +prost = { workspace = true } +prost-types = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +cw-multi-test = { workspace = true } +dao-proposal-single = { workspace = true } +dao-proposal-hook-counter = { workspace = true } +dao-test-custom-factory = { workspace = true } +dao-testing = { workspace = true, features = ["test-tube"] } +osmosis-test-tube = { workspace = true } diff --git a/contracts/voting/dao-voting-onft-staked/README.md b/contracts/voting/dao-voting-onft-staked/README.md new file mode 100644 index 000000000..f71bf8853 --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/README.md @@ -0,0 +1,43 @@ +# `dao-voting-onft-staked` + +This is a basic implementation of an NFT staking contract that supports +OmniFlix's NFT standard: +[x/onft](https://github.com/OmniFlix/omniflixhub/tree/main/x/onft). + +Staked tokens can be unbonded with a configurable unbonding period. Staked balances can be queried at any arbitrary height by external contracts. This contract implements the interface needed to be a DAO DAO [voting module](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-Contracts-Design#the-voting-module). + +### Stake process + +Unlike the base cw721 smart contract, the x/onft SDK module doesn't support +executing a smart contract on NFT transfer, so the stake process is broken up +into three steps: + +1. The sender calls `PrepareStake` to inform this staking contract of the NFTs + that are about to be staked. This will succeed only if the sender currently + owns the NFT(s). +2. The sender then transfers the NFT(s) to the staking contract. +3. The sender calls `ConfirmStake` on this staking contract which confirms the + NFTs were transferred to it and registers the stake. + +In case this process is interrupted, or executed incorrectly (e.g. the sender +accidentally transfers an NFT to the staking contract without first preparing +it), there is also a `CancelStake` action to help recover NFTs. If called by: + +- the original stake preparer, the preparation will be canceled, and the NFT(s) + will be sent back if the staking contract owns them. +- the current NFT(s) owner, the preparation will be canceled, if any. +- the DAO, the preparation will be canceled (if any exists), and the NFT(s) will + be sent to the specified recipient (if the staking contract owns them). + +The recipient field only applies when the sender is the DAO. In the other cases, +the NFT(s) will always be sent back to the sender. Note: if the NFTs were sent +to the staking contract, but no stake was prepared, only the DAO will be able to +correct this and send them somewhere. + +The `PrepareStake` step overrides any previous `PrepareStake` calls as long as +the new sender owns the NFT(s) and the first stake was never confirmed (which +should be impossible if someone else now owns the NFT(s)). Thus there is no +combination of messages or steps where someone can stake nor prevent stake when +it would otherwise be valid. A stake is only ever confirmed if it was prepared +and transferred by the same address confirming, and the DAO can always recover +an NFT that accidentally skipped the preparation step. diff --git a/contracts/voting/dao-voting-onft-staked/examples/schema.rs b/contracts/voting/dao-voting-onft-staked/examples/schema.rs new file mode 100644 index 000000000..45e321363 --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/examples/schema.rs @@ -0,0 +1,10 @@ +use cosmwasm_schema::write_api; +use dao_voting_onft_staked::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + } +} diff --git a/contracts/voting/dao-voting-onft-staked/src/contract.rs b/contracts/voting/dao-voting-onft-staked/src/contract.rs new file mode 100644 index 000000000..c851573db --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/src/contract.rs @@ -0,0 +1,687 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_json_binary, Addr, Binary, CosmosMsg, Deps, DepsMut, Empty, Env, MessageInfo, Response, + StdResult, SubMsg, Uint128, Uint256, +}; +use cw2::{get_contract_version, set_contract_version, ContractVersion}; +use cw_storage_plus::Bound; +use cw_utils::Duration; +use dao_hooks::nft_stake::{stake_nft_hook_msgs, unstake_nft_hook_msgs}; +use dao_interface::voting::IsActiveResponse; +use dao_voting::duration::validate_duration; +use dao_voting::threshold::{ + assert_valid_absolute_count_threshold, assert_valid_percentage_threshold, ActiveThreshold, + ActiveThresholdResponse, +}; + +use crate::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, OnftCollection, QueryMsg}; +use crate::omniflix::{get_onft_transfer_msg, query_onft_owner, query_onft_supply}; +use crate::state::{ + register_staked_nfts, register_unstaked_nfts, Config, ACTIVE_THRESHOLD, CONFIG, DAO, HOOKS, + MAX_CLAIMS, NFT_BALANCES, NFT_CLAIMS, PREPARED_ONFTS, STAKED_NFTS_PER_OWNER, TOTAL_STAKED_NFTS, +}; +use crate::ContractError; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-voting-onft-staked"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +// We multiply by this when calculating needed power for being active +// when using active threshold with percent +const PRECISION_FACTOR: u128 = 10u128.pow(9); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result, ContractError> { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + DAO.save(deps.storage, &info.sender)?; + + // Validate unstaking duration + validate_duration(msg.unstaking_duration)?; + + // Validate active threshold if configured + if let Some(active_threshold) = msg.active_threshold.as_ref() { + match active_threshold { + ActiveThreshold::Percentage { percent } => { + assert_valid_percentage_threshold(*percent)?; + } + ActiveThreshold::AbsoluteCount { count } => { + // Check absolute count is less than the supply of NFTs for + // existing NFT collection. + + let OnftCollection::Existing { ref id } = msg.onft_collection; + let nft_supply = query_onft_supply(deps.as_ref(), id)?; + + // Check the absolute count is less than the supply of NFTs and + // greater than zero. + assert_valid_absolute_count_threshold(*count, Uint128::new(nft_supply.into()))?; + } + } + ACTIVE_THRESHOLD.save(deps.storage, active_threshold)?; + } + + TOTAL_STAKED_NFTS.save(deps.storage, &Uint128::zero(), env.block.height)?; + + match msg.onft_collection { + OnftCollection::Existing { id } => { + let config = Config { + onft_collection_id: id.clone(), + unstaking_duration: msg.unstaking_duration, + }; + CONFIG.save(deps.storage, &config)?; + + Ok(Response::default() + .add_attribute("method", "instantiate") + .add_attribute("onft_collection_id", id)) + } + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result, ContractError> { + match msg { + ExecuteMsg::PrepareStake { token_ids } => execute_prepare_stake(deps, info, token_ids), + ExecuteMsg::ConfirmStake { token_ids } => execute_confirm_stake(deps, env, info, token_ids), + ExecuteMsg::CancelStake { + token_ids, + recipient, + } => execute_cancel_stake(deps, env, info, token_ids, recipient), + ExecuteMsg::Unstake { token_ids } => execute_unstake(deps, env, info, token_ids), + ExecuteMsg::ClaimNfts {} => execute_claim_nfts(deps, env, info), + ExecuteMsg::UpdateConfig { duration } => execute_update_config(info, deps, duration), + ExecuteMsg::AddHook { addr } => execute_add_hook(deps, info, addr), + ExecuteMsg::RemoveHook { addr } => execute_remove_hook(deps, info, addr), + ExecuteMsg::UpdateActiveThreshold { new_threshold } => { + execute_update_active_threshold(deps, env, info, new_threshold) + } + } +} + +pub fn execute_prepare_stake( + deps: DepsMut, + info: MessageInfo, + token_ids: Vec, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + // verify sender owns all the tokens + let owns_all = token_ids + .iter() + .map(|token_id| -> StdResult { + let owner = query_onft_owner(deps.as_ref(), &config.onft_collection_id, token_id)?; + + Ok(owner == info.sender) + }) + .collect::>>()? + .into_iter() + .all(|b| b); + + if !owns_all { + return Err(ContractError::OnlyOwnerCanPrepareStake {}); + } + + // save and override prepared ONFTS, readying them to be transferred and + // staked + for token_id in &token_ids { + PREPARED_ONFTS.save(deps.storage, token_id.to_string(), &info.sender)?; + } + + Ok(Response::default() + .add_attribute("action", "prepare_stake") + .add_attribute("preparer", info.sender.to_string()) + .add_attribute("token_ids", token_ids.join(","))) +} + +pub fn execute_confirm_stake( + deps: DepsMut, + env: Env, + info: MessageInfo, + token_ids: Vec, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + // verify sender prepared and transferred all the tokens + let sender_prepared_all = token_ids + .iter() + .map(|token_id| -> StdResult { + // check if sender prepared + let prepared = PREPARED_ONFTS + .may_load(deps.storage, token_id.to_string())? + .map_or(false, |preparer| preparer == info.sender); + + // check that NFT was transferred to this contract + let owner = query_onft_owner(deps.as_ref(), &config.onft_collection_id, token_id)?; + + Ok(prepared && owner == env.contract.address) + }) + .collect::>>()? + .into_iter() + .all(|b| b); + + if !sender_prepared_all { + return Err(ContractError::StakeMustBePrepared {}); + } + + register_staked_nfts(deps.storage, env.block.height, &info.sender, &token_ids)?; + + let hook_msgs = token_ids + .iter() + .map(|token_id| { + stake_nft_hook_msgs(HOOKS, deps.storage, info.sender.clone(), token_id.clone()) + }) + .collect::>>>()? + .into_iter() + .flatten() + .collect::>(); + + Ok(Response::default() + .add_submessages(hook_msgs) + .add_attribute("action", "stake") + .add_attribute("from", info.sender) + .add_attribute("token_ids", token_ids.join(","))) +} + +/// CancelStake serves as an undo function in case an NFT or stake gets into a +/// bad state, either because the stake process was never completed, or because +/// someone sent an NFT to the staking contract without preparing the stake +/// first. +/// +/// If called by: +/// - the original stake preparer, the preparation will be canceled, and the +/// NFT(s) will be sent back if the staking contract owns them. +/// - the current NFT(s) owner, the preparation will be canceled, if any. +/// - the DAO, the preparation will be canceled (if any exists), and the NFT(s) +/// will be sent to the specified recipient (if the staking contract owns +/// them). +/// +/// The recipient field only applies when the sender is the DAO. In the other +/// cases, the NFT(s) will always be sent back to the sender. Note: if the NFTs +/// were sent to the staking contract, but no stake was prepared, only the DAO +/// will be able to correct this and send them somewhere. +pub fn execute_cancel_stake( + deps: DepsMut, + env: Env, + info: MessageInfo, + token_ids: Vec, + recipient: Option, +) -> Result { + let dao = DAO.load(deps.storage)?; + let config = CONFIG.load(deps.storage)?; + + // get preparers and owners of NFTs + let token_ids_with_owners_and_preparers = token_ids + .iter() + .map(|token_id| { + let preparer = PREPARED_ONFTS.may_load(deps.storage, token_id.clone())?; + + let owner = query_onft_owner(deps.as_ref(), &config.onft_collection_id, token_id)?; + + Ok((token_id, owner, preparer)) + }) + .collect::)>>>()?; + + let mut transfer_msgs: Vec = vec![]; + + // If DAO, cancel preparations (if any) and send NFTs to the specified + // recipient. + if info.sender == dao { + for (token_id, owner, _) in token_ids_with_owners_and_preparers { + // cancel preparation + PREPARED_ONFTS.remove(deps.storage, token_id.to_string()); + + // if this contract owns the NFT, send it to the recipient. + if owner == env.contract.address { + if let Some(recipient) = recipient.clone() { + transfer_msgs.push(get_onft_transfer_msg( + &config.onft_collection_id, + token_id, + env.contract.address.as_str(), + &recipient, + )); + } else { + return Err(ContractError::NoRecipient {}); + } + } + } + } else { + for (token_id, owner, preparer) in token_ids_with_owners_and_preparers { + let is_preparer = preparer != Some(info.sender.clone()); + // only owner or preparer can cancel stake + if info.sender != owner && !is_preparer { + return Err(ContractError::NotPreparerNorOwner {}); + } + + // cancel preparation + PREPARED_ONFTS.remove(deps.storage, token_id.to_string()); + + // if owner is this staking contract, send it back to the preparer, + // who must also be the sender (but let's force unwrap the preparer + // just to make sure) + if owner == env.contract.address { + transfer_msgs.push(get_onft_transfer_msg( + &config.onft_collection_id, + token_id, + env.contract.address.as_str(), + preparer.unwrap().as_ref(), + )); + } + } + } + + Ok(Response::default() + .add_messages(transfer_msgs) + .add_attribute("action", "cancel_stake") + .add_attribute("sender", info.sender) + .add_attribute("token_ids", token_ids.join(",")) + .add_attribute("recipient", recipient.unwrap_or_default())) +} + +pub fn execute_unstake( + deps: DepsMut, + env: Env, + info: MessageInfo, + token_ids: Vec, +) -> Result { + if token_ids.is_empty() { + return Err(ContractError::ZeroUnstake {}); + } + + register_unstaked_nfts(deps.storage, env.block.height, &info.sender, &token_ids)?; + + // Provided that the backing cw721 contract is non-malicious: + // + // 1. no token that has been staked may be staked again before + // first being unstaked. + // + // Provided that the other methods on this contract are functional: + // + // 2. there will never exist a pending claim for a token that is + // unstaked. + // 3. (6) => claims may only be created for tokens that are staked. + // 4. (1) && (2) && (3) => there will never be a staked NFT for + // which there is also a pending claim. + // + // (aside: the requirement on (1) for (4) may be confusing. it is + // needed because if a token could be staked more than once, a + // token could be staked, moved into the claims queue, and then + // staked again, in which case the token is both staked and has a + // pending claim.) + // + // If we reach this point in execution, `register_unstaked_nfts` + // has not errored and thus: + // + // 5. token_ids contains no duplicate values. + // 6. all NFTs in token_ids were staked by `info.sender` + // 7. (4) && (6) => none of the tokens in token_ids are in the + // claims queue for `info.sender` + // + // (5) && (7) are the invariants for calling `create_nft_claims` + // so if we reach this point in execution, we may safely create + // claims. + + let hook_msgs = + unstake_nft_hook_msgs(HOOKS, deps.storage, info.sender.clone(), token_ids.clone())?; + + let config = CONFIG.load(deps.storage)?; + match config.unstaking_duration { + None => { + let return_messages = token_ids + .into_iter() + .map(|token_id| -> CosmosMsg { + get_onft_transfer_msg( + &config.onft_collection_id, + &token_id, + env.contract.address.as_str(), + info.sender.as_str(), + ) + }) + .collect::>(); + + Ok(Response::default() + .add_messages(return_messages) + .add_submessages(hook_msgs) + .add_attribute("action", "unstake") + .add_attribute("from", info.sender) + .add_attribute("claim_duration", "None")) + } + + Some(duration) => { + let outstanding_claims = NFT_CLAIMS + .query_claims(deps.as_ref(), &info.sender)? + .nft_claims; + if outstanding_claims.len() + token_ids.len() > MAX_CLAIMS as usize { + return Err(ContractError::TooManyClaims {}); + } + + // Out of gas here is fine - just try again with fewer + // tokens. + NFT_CLAIMS.create_nft_claims( + deps.storage, + &info.sender, + token_ids, + duration.after(&env.block), + )?; + + Ok(Response::default() + .add_attribute("action", "unstake") + .add_submessages(hook_msgs) + .add_attribute("from", info.sender) + .add_attribute("claim_duration", format!("{duration}"))) + } + } +} + +pub fn execute_claim_nfts( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result { + let nfts = NFT_CLAIMS.claim_nfts(deps.storage, &info.sender, &env.block)?; + if nfts.is_empty() { + return Err(ContractError::NothingToClaim {}); + } + + let config = CONFIG.load(deps.storage)?; + + let msgs = nfts + .into_iter() + .map(|nft| -> CosmosMsg { + get_onft_transfer_msg( + &config.onft_collection_id, + &nft, + env.contract.address.as_str(), + info.sender.as_str(), + ) + }) + .collect::>(); + + Ok(Response::default() + .add_messages(msgs) + .add_attribute("action", "claim_nfts") + .add_attribute("from", info.sender)) +} + +pub fn execute_update_config( + info: MessageInfo, + deps: DepsMut, + duration: Option, +) -> Result { + let mut config: Config = CONFIG.load(deps.storage)?; + let dao = DAO.load(deps.storage)?; + + // Only the DAO can update the config. + if info.sender != dao { + return Err(ContractError::Unauthorized {}); + } + + // Validate unstaking duration + validate_duration(duration)?; + + config.unstaking_duration = duration; + CONFIG.save(deps.storage, &config)?; + + Ok(Response::default() + .add_attribute("action", "update_config") + .add_attribute( + "unstaking_duration", + config + .unstaking_duration + .map(|d| d.to_string()) + .unwrap_or_else(|| "none".to_string()), + )) +} + +pub fn execute_add_hook( + deps: DepsMut, + info: MessageInfo, + addr: String, +) -> Result { + let dao = DAO.load(deps.storage)?; + + // Only the DAO can add a hook + if info.sender != dao { + return Err(ContractError::Unauthorized {}); + } + + let hook = deps.api.addr_validate(&addr)?; + HOOKS.add_hook(deps.storage, hook)?; + + Ok(Response::default() + .add_attribute("action", "add_hook") + .add_attribute("hook", addr)) +} + +pub fn execute_remove_hook( + deps: DepsMut, + info: MessageInfo, + addr: String, +) -> Result { + let dao = DAO.load(deps.storage)?; + + // Only the DAO can remove a hook + if info.sender != dao { + return Err(ContractError::Unauthorized {}); + } + + let hook = deps.api.addr_validate(&addr)?; + HOOKS.remove_hook(deps.storage, hook)?; + + Ok(Response::default() + .add_attribute("action", "remove_hook") + .add_attribute("hook", addr)) +} + +pub fn execute_update_active_threshold( + deps: DepsMut, + _env: Env, + info: MessageInfo, + new_active_threshold: Option, +) -> Result { + let dao = DAO.load(deps.storage)?; + if info.sender != dao { + return Err(ContractError::Unauthorized {}); + } + + let config = CONFIG.load(deps.storage)?; + if let Some(active_threshold) = new_active_threshold { + match active_threshold { + ActiveThreshold::Percentage { percent } => { + assert_valid_percentage_threshold(percent)?; + } + ActiveThreshold::AbsoluteCount { count } => { + let nft_supply = query_onft_supply(deps.as_ref(), &config.onft_collection_id)?; + assert_valid_absolute_count_threshold(count, Uint128::new(nft_supply.into()))?; + } + } + ACTIVE_THRESHOLD.save(deps.storage, &active_threshold)?; + } else { + ACTIVE_THRESHOLD.remove(deps.storage); + } + + Ok(Response::new().add_attribute("action", "update_active_threshold")) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::ActiveThreshold {} => query_active_threshold(deps), + QueryMsg::Config {} => query_config(deps), + QueryMsg::Dao {} => query_dao(deps), + QueryMsg::Info {} => query_info(deps), + QueryMsg::IsActive {} => query_is_active(deps, env), + QueryMsg::NftClaims { address } => query_nft_claims(deps, address), + QueryMsg::Hooks {} => query_hooks(deps), + QueryMsg::StakedNfts { + address, + start_after, + limit, + } => query_staked_nfts(deps, address, start_after, limit), + QueryMsg::TotalPowerAtHeight { height } => query_total_power_at_height(deps, env, height), + QueryMsg::VotingPowerAtHeight { address, height } => { + query_voting_power_at_height(deps, env, address, height) + } + } +} + +pub fn query_active_threshold(deps: Deps) -> StdResult { + to_json_binary(&ActiveThresholdResponse { + active_threshold: ACTIVE_THRESHOLD.may_load(deps.storage)?, + }) +} + +pub fn query_is_active(deps: Deps, env: Env) -> StdResult { + let threshold = ACTIVE_THRESHOLD.may_load(deps.storage)?; + if let Some(threshold) = threshold { + let config = CONFIG.load(deps.storage)?; + let staked_nfts = TOTAL_STAKED_NFTS + .may_load_at_height(deps.storage, env.block.height)? + .unwrap_or_default(); + let total_nfts = query_onft_supply(deps, &config.onft_collection_id)?; + + match threshold { + ActiveThreshold::AbsoluteCount { count } => to_json_binary(&IsActiveResponse { + active: staked_nfts >= count, + }), + ActiveThreshold::Percentage { percent } => { + // Check if there are any staked NFTs + if staked_nfts.is_zero() { + return to_json_binary(&IsActiveResponse { active: false }); + } + + // percent is bounded between [0, 100]. decimal + // represents percents in u128 terms as p * + // 10^15. this bounds percent between [0, 10^17]. + // + // total_potential_power is bounded between [0, 2^64] + // as it tracks the count of NFT tokens which has + // a max supply of 2^64. + // + // with our precision factor being 10^9: + // + // total_nfts <= 2^64 * 10^9 <= 2^256 + // + // so we're good to put that in a u256. + // + // multiply_ratio promotes to a u512 under the hood, + // so it won't overflow, multiplying by a percent less + // than 100 is gonna make something the same size or + // smaller, applied + 10^9 <= 2^128 * 10^9 + 10^9 <= + // 2^256, so the top of the round won't overflow, and + // rounding is rounding down, so the whole thing can + // be safely unwrapped at the end of the day thank you + // for coming to my ted talk. + let total_nfts_count = Uint128::from(total_nfts).full_mul(PRECISION_FACTOR); + + // under the hood decimals are `atomics / 10^decimal_places`. + // cosmwasm doesn't give us a Decimal * Uint256 + // implementation so we take the decimal apart and + // multiply by the fraction. + let applied = total_nfts_count.multiply_ratio( + percent.atomics(), + Uint256::from(10u64).pow(percent.decimal_places()), + ); + let rounded = (applied + Uint256::from(PRECISION_FACTOR) - Uint256::from(1u128)) + / Uint256::from(PRECISION_FACTOR); + let count: Uint128 = rounded.try_into().unwrap(); + + // staked_nfts >= total_nfts * percent + to_json_binary(&IsActiveResponse { + active: staked_nfts >= count, + }) + } + } + } else { + to_json_binary(&IsActiveResponse { active: true }) + } +} + +pub fn query_voting_power_at_height( + deps: Deps, + env: Env, + address: String, + height: Option, +) -> StdResult { + let address = deps.api.addr_validate(&address)?; + let height = height.unwrap_or(env.block.height); + let power = NFT_BALANCES + .may_load_at_height(deps.storage, &address, height)? + .unwrap_or_default(); + to_json_binary(&dao_interface::voting::VotingPowerAtHeightResponse { power, height }) +} + +pub fn query_total_power_at_height(deps: Deps, env: Env, height: Option) -> StdResult { + let height = height.unwrap_or(env.block.height); + let power = TOTAL_STAKED_NFTS + .may_load_at_height(deps.storage, height)? + .unwrap_or_default(); + to_json_binary(&dao_interface::voting::TotalPowerAtHeightResponse { power, height }) +} + +pub fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + to_json_binary(&config) +} + +pub fn query_dao(deps: Deps) -> StdResult { + let dao = DAO.load(deps.storage)?; + to_json_binary(&dao) +} + +pub fn query_nft_claims(deps: Deps, address: String) -> StdResult { + to_json_binary(&NFT_CLAIMS.query_claims(deps, &deps.api.addr_validate(&address)?)?) +} + +pub fn query_hooks(deps: Deps) -> StdResult { + to_json_binary(&HOOKS.query_hooks(deps)?) +} + +pub fn query_info(deps: Deps) -> StdResult { + let info = cw2::get_contract_version(deps.storage)?; + to_json_binary(&dao_interface::voting::InfoResponse { info }) +} + +pub fn query_staked_nfts( + deps: Deps, + address: String, + start_after: Option, + limit: Option, +) -> StdResult { + let prefix = deps.api.addr_validate(&address)?; + let prefix = STAKED_NFTS_PER_OWNER.prefix(&prefix); + + let start_after = start_after.as_deref().map(Bound::exclusive); + let range = prefix.keys( + deps.storage, + start_after, + None, + cosmwasm_std::Order::Ascending, + ); + let range: StdResult> = match limit { + Some(l) => range.take(l as usize).collect(), + None => range.collect(), + }; + to_json_binary(&range?) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + let storage_version: ContractVersion = get_contract_version(deps.storage)?; + + // Only migrate if newer + if storage_version.version.as_str() < CONTRACT_VERSION { + // Set contract to version to latest + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + } + + Ok(Response::new().add_attribute("action", "migrate")) +} diff --git a/contracts/voting/dao-voting-onft-staked/src/error.rs b/contracts/voting/dao-voting-onft-staked/src/error.rs new file mode 100644 index 000000000..f67dff0bd --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/src/error.rs @@ -0,0 +1,70 @@ +use cosmwasm_std::{Addr, StdError}; +use cw_utils::ParseReplyError; +use dao_voting::threshold::ActiveThresholdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error(transparent)] + Std(#[from] StdError), + + #[error(transparent)] + ActiveThresholdError(#[from] ActiveThresholdError), + + #[error(transparent)] + HookError(#[from] cw_hooks::HookError), + + #[error(transparent)] + ParseReplyError(#[from] ParseReplyError), + + #[error(transparent)] + UnstakingDurationError(#[from] dao_voting::duration::UnstakingDurationError), + + #[error("Can not stake that which has already been staked")] + AlreadyStaked {}, + + #[error("Invalid token. Got ({received}), expected ({expected})")] + InvalidToken { received: Addr, expected: Addr }, + + #[error("Error instantiating NFT contract")] + NftInstantiateError {}, + + #[error("New NFT contract must be instantiated with at least one NFT")] + NoInitialNfts {}, + + #[error("Factory contract did not implment the required NftFactoryCallback interface")] + NoFactoryCallback {}, + + #[error("Nothing to claim")] + NothingToClaim {}, + + #[error("Only an NFT's owner can prepare it to be staked")] + OnlyOwnerCanPrepareStake {}, + + #[error("NFTs must be prepared and transferred before they can be staked")] + StakeMustBePrepared {}, + + #[error("Recipient must be set when the DAO is cancelling a stake")] + NoRecipient {}, + + #[error("Only the owner or preparer can cancel a prepared stake")] + NotPreparerNorOwner {}, + + #[error("Can not unstake that which you have not staked (unstaking {token_id})")] + NotStaked { token_id: String }, + + #[error("Too many outstanding claims. Claim some tokens before unstaking more.")] + TooManyClaims {}, + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Got a submessage reply with unknown id: {id}")] + UnknownReplyId { id: u64 }, + + #[error("Factory message must serialize to WasmMsg::Execute")] + UnsupportedFactoryMsg {}, + + #[error("Can't unstake zero NFTs.")] + ZeroUnstake {}, +} diff --git a/contracts/voting/dao-voting-onft-staked/src/lib.rs b/contracts/voting/dao-voting-onft-staked/src/lib.rs new file mode 100644 index 000000000..e22b7d020 --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/src/lib.rs @@ -0,0 +1,13 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod msg; +mod omniflix; +pub mod state; +use osmosis_std::shim; + +// #[cfg(test)] +// mod testing; + +pub use crate::error::ContractError; diff --git a/contracts/voting/dao-voting-onft-staked/src/msg.rs b/contracts/voting/dao-voting-onft-staked/src/msg.rs new file mode 100644 index 000000000..44d759e7b --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/src/msg.rs @@ -0,0 +1,118 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cw_utils::Duration; +use dao_dao_macros::{active_query, voting_module_query}; +use dao_voting::threshold::{ActiveThreshold, ActiveThresholdResponse}; + +#[cw_serde] +#[allow(clippy::large_enum_variant)] +pub enum OnftCollection { + /// Uses an existing x/onft denom/collection. + Existing { + /// ID of an already created x/onft denom/collection. + id: String, + }, +} + +#[cw_serde] +pub struct InstantiateMsg { + /// ONFT collection that will be staked. + pub onft_collection: OnftCollection, + /// Amount of time between unstaking and tokens being available. To unstake + /// with no delay, leave as `None`. + pub unstaking_duration: Option, + /// The number or percentage of tokens that must be staked for the DAO to be + /// active + pub active_threshold: Option, +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Step 1/3 of the NFT staking process. x/onft doesn't support executing a + /// smart contract on NFT transfer like cw721s do, so the stake process is + /// broken up: + /// 1. The sender calls `PrepareStake` to inform this staking contract of + /// the NFTs that are about to be staked. This will succeed only if the + /// sender currently owns the NFT(s). + /// 2. The sender then transfers the NFT(s) to the staking contract. + /// 3. The sender calls `ConfirmStake` on this staking contract which + /// confirms the NFTs were transferred to it and registers the stake. + /// + /// PrepareStake overrides any previous PrepareStake calls, as long as the + /// sender owns the NFT(s). + PrepareStake { token_ids: Vec }, + /// Step 3/3 of the NFT staking process. x/onft doesn't support executing a + /// smart contract on NFT transfer like cw721s do, so the stake process is + /// broken up: + /// 1. The sender calls `PrepareStake` to inform this staking contract of + /// the NFTs that are about to be staked. This will succeed only if the + /// sender currently owns the NFT(s). + /// 2. The sender then transfers the NFT(s) to the staking contract. + /// 3. The sender calls `ConfirmStake` on this staking contract which + /// confirms the NFTs were transferred to it and registers the stake. + ConfirmStake { token_ids: Vec }, + /// CancelStake serves as an undo function in case an NFT or stake gets into + /// a bad state, either because the stake process was never completed, or + /// because someone sent an NFT to the staking contract without preparing + /// the stake first. + /// + /// If called by: + /// - the original stake preparer, the preparation will be canceled, and the + /// NFT(s) will be sent back if the staking contract owns them. + /// - the current NFT(s) owner, the preparation will be canceled, if any. + /// - the DAO, the preparation will be canceled (if any exists), and the + /// NFT(s) will be sent to the specified recipient (if the staking + /// contract owns them). + /// + /// The recipient field only applies when the sender is the DAO. In the + /// other cases, the NFT(s) will always be sent back to the sender. Note: if + /// the NFTs were sent to the staking contract, but no stake was prepared, + /// only the DAO will be able to correct this and send them somewhere. + CancelStake { + token_ids: Vec, + recipient: Option, + }, + /// Unstakes the specified token_ids on behalf of the sender. token_ids must + /// have unique values and have non-zero length. + Unstake { token_ids: Vec }, + /// Claim NFTs that have been unstaked for the specified duration. + ClaimNfts {}, + /// Updates the contract configuration, namely unstaking duration. Only + /// callable by the DAO that initialized this voting contract. + UpdateConfig { duration: Option }, + /// Adds a hook which is called on staking / unstaking events. Only callable + /// by the DAO that initialized this voting contract. + AddHook { addr: String }, + /// Removes a hook which is called on staking / unstaking events. Only + /// callable by the DAO that initialized this voting contract. + RemoveHook { addr: String }, + /// Sets the active threshold to a new value. Only callable by the DAO that + /// initialized this voting contract. + UpdateActiveThreshold { + new_threshold: Option, + }, +} + +#[active_query] +#[voting_module_query] +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(crate::state::Config)] + Config {}, + #[returns(::cw721_controllers::NftClaimsResponse)] + NftClaims { address: String }, + #[returns(::cw_controllers::HooksResponse)] + Hooks {}, + // List the staked NFTs for a given address. + #[returns(Vec)] + StakedNfts { + address: String, + start_after: Option, + limit: Option, + }, + #[returns(ActiveThresholdResponse)] + ActiveThreshold {}, +} + +#[cw_serde] +pub struct MigrateMsg {} diff --git a/contracts/voting/dao-voting-onft-staked/src/omniflix.rs b/contracts/voting/dao-voting-onft-staked/src/omniflix.rs new file mode 100644 index 000000000..58a137d0a --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/src/omniflix.rs @@ -0,0 +1,293 @@ +use cosmwasm_std::{to_json_binary, CosmosMsg, Deps, QueryRequest, StdError, StdResult}; +use osmosis_std_derive::CosmwasmExt; +use std::convert::{TryFrom, TryInto}; + +use ::serde::{Deserialize, Deserializer, Serialize, Serializer}; +use chrono::{DateTime, NaiveDateTime, Utc}; +use serde::de; +use serde::de::Visitor; + +use std::fmt; +use std::str::FromStr; + +// see https://github.com/OmniFlix/omniflixhub/blob/main/proto/OmniFlix/onft/v1beta1 + +/// ONFT info +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, +)] +pub struct Onft { + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, + #[prost(message, tag = "2")] + pub metadata: ::core::option::Option, + #[prost(string, tag = "3")] + pub data: ::prost::alloc::string::String, + #[prost(string, tag = "4")] + pub owner: ::prost::alloc::string::String, + #[prost(bool, tag = "5")] + pub transferable: bool, + #[prost(bool, tag = "6")] + pub extensible: bool, + #[prost(message, tag = "7")] + pub created_at: ::core::option::Option, + #[prost(bool, tag = "8")] + pub nsfw: bool, + #[prost(string, tag = "9")] + pub royalty_share: ::prost::alloc::string::String, +} + +/// ONFT metadata +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, +)] +pub struct Metadata { + #[prost(string, tag = "1")] + pub name: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub description: ::prost::alloc::string::String, + #[prost(string, tag = "3")] + pub media_uri: ::prost::alloc::string::String, + #[prost(string, tag = "4")] + pub preview_uri: ::prost::alloc::string::String, + #[prost(string, tag = "5")] + pub uri_hash: ::prost::alloc::string::String, +} + +/// QueryONFTRequest requests the info for a single ONFT. +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + CosmwasmExt, +)] +#[proto_message(type_url = "/omniflix.onft.v1beta1.QueryONFTRequest")] +pub struct QueryONFTRequest { + #[prost(string, tag = "1")] + pub denom_id: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub id: ::prost::alloc::string::String, +} + +/// QueryONFTResponse returns the info for a single ONFT. +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + CosmwasmExt, +)] +#[proto_message(type_url = "/omniflix.onft.v1beta1.QueryONFTResponse")] +pub struct QueryONFTResponse { + #[prost(message, tag = "1")] + pub onft: ::core::option::Option, +} + +/// QuerySupplyRequest requests the supply of the denom. +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + CosmwasmExt, +)] +#[proto_message(type_url = "/omniflix.onft.v1beta1.QuerySupplyRequest")] +pub struct QuerySupplyRequest { + #[prost(string, tag = "1")] + pub denom_id: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub owner: ::prost::alloc::string::String, +} + +/// QuerySupplyResponse returns the supply of the denom. +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + CosmwasmExt, +)] +#[proto_message(type_url = "/omniflix.onft.v1beta1.QuerySupplyResponse")] +pub struct QuerySupplyResponse { + #[prost(uint64, tag = "1")] + pub amount: u64, +} + +/// MsgTransferONFT transfers an ONFT. +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + CosmwasmExt, +)] +#[proto_message(type_url = "/omniflix.onft.v1beta1.MsgTransferONFT")] +pub struct MsgTransferONFT { + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub denom_id: ::prost::alloc::string::String, + #[prost(string, tag = "3")] + pub sender: ::prost::alloc::string::String, + #[prost(string, tag = "4")] + pub recipient: ::prost::alloc::string::String, +} + +/// MsgTransferONFTResponse is the return type of MsgTransferONFT. +#[derive( + Clone, + PartialEq, + Eq, + ::prost::Message, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + CosmwasmExt, +)] +#[proto_message(type_url = "/omniflix.onft.v1beta1.MsgTransferONFTResponse")] +pub struct MsgTransferONFTResponse {} + +#[derive(Clone, PartialEq, Eq, ::prost::Message, schemars::JsonSchema)] +pub struct Timestamp { + /// Represents seconds of UTC time since Unix epoch + /// 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to + /// 9999-12-31T23:59:59Z inclusive. + #[prost(int64, tag = "1")] + pub seconds: i64, + /// Non-negative fractions of a second at nanosecond resolution. Negative + /// second values with fractions must still have non-negative nanos values + /// that count forward in time. Must be from 0 to 999,999,999 + /// inclusive. + #[prost(int32, tag = "2")] + pub nanos: i32, +} + +impl Serialize for Timestamp { + fn serialize(&self, serializer: S) -> Result<::Ok, ::Error> + where + S: Serializer, + { + let mut ts = prost_types::Timestamp { + seconds: self.seconds, + nanos: self.nanos, + }; + ts.normalize(); + let dt = NaiveDateTime::from_timestamp_opt(ts.seconds, ts.nanos as u32) + .expect("invalid or out-of-range datetime"); + let dt: DateTime = DateTime::from_naive_utc_and_offset(dt, Utc); + serializer.serialize_str(format!("{:?}", dt).as_str()) + } +} + +impl<'de> Deserialize<'de> for Timestamp { + fn deserialize(deserializer: D) -> Result>::Error> + where + D: Deserializer<'de>, + { + struct TimestampVisitor; + + impl<'de> Visitor<'de> for TimestampVisitor { + type Value = Timestamp; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("Timestamp in RFC3339 format") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + let utc: DateTime = chrono::DateTime::from_str(value).map_err(|err| { + serde::de::Error::custom(format!( + "Failed to parse {} as datetime: {:?}", + value, err + )) + })?; + let ts = Timestamp::from(utc); + Ok(ts) + } + } + deserializer.deserialize_str(TimestampVisitor) + } +} + +impl From> for Timestamp { + fn from(dt: DateTime) -> Self { + Timestamp { + seconds: dt.timestamp(), + nanos: dt.timestamp_subsec_nanos() as i32, + } + } +} + +pub fn query_onft_owner(deps: Deps, denom_id: &str, token_id: &str) -> StdResult { + let res: QueryONFTResponse = deps.querier.query(&QueryRequest::Stargate { + path: "/omniflix.onft.v1beta1.Query/ONFT".to_string(), + data: to_json_binary(&QueryONFTRequest { + denom_id: denom_id.to_string(), + id: token_id.to_string(), + })?, + })?; + + let owner = res + .onft + .ok_or(StdError::generic_err("ONFT not found"))? + .owner; + + Ok(owner) +} + +pub fn query_onft_supply(deps: Deps, id: &str) -> StdResult { + let res: QuerySupplyResponse = deps.querier.query(&QueryRequest::Stargate { + path: "/omniflix.onft.v1beta1.Query/Supply".to_string(), + data: to_json_binary(&QuerySupplyRequest { + denom_id: id.to_string(), + owner: "".to_string(), + })?, + })?; + + Ok(res.amount) +} + +pub fn get_onft_transfer_msg( + denom_id: &str, + token_id: &str, + sender: &str, + recipient: &str, +) -> CosmosMsg { + MsgTransferONFT { + denom_id: denom_id.to_string(), + id: token_id.to_string(), + sender: sender.to_string(), + recipient: recipient.to_string(), + } + .into() +} diff --git a/contracts/voting/dao-voting-onft-staked/src/state.rs b/contracts/voting/dao-voting-onft-staked/src/state.rs new file mode 100644 index 000000000..0e85822ab --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/src/state.rs @@ -0,0 +1,118 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Empty, StdError, StdResult, Storage, Uint128}; +use cw721_controllers::NftClaims; +use cw_hooks::Hooks; +use cw_storage_plus::{Item, Map, SnapshotItem, SnapshotMap, Strategy}; +use cw_utils::Duration; +use dao_voting::threshold::ActiveThreshold; + +use crate::ContractError; + +#[cw_serde] +pub struct Config { + pub onft_collection_id: String, + pub unstaking_duration: Option, +} + +pub const ACTIVE_THRESHOLD: Item = Item::new("active_threshold"); +pub const CONFIG: Item = Item::new("config"); +pub const DAO: Item = Item::new("dao"); + +/// NFTs prepared to be staked. The owner must prepare the NFT before +/// transferring and staking so the contract can verify them as the rightful +/// owner before staking. Since ONFT transfer actions cannot include a message +/// to execute on transfer, we can't verify who sent an ONFT, so we have to +/// prepare it first. Once a stake is confirmed, the prepared stake is removed. +/// +/// Map token ID to validated preparer. +pub const PREPARED_ONFTS: Map = Map::new("po"); + +/// The set of NFTs currently staked by each address. The existence of +/// an `(address, token_id)` pair implies that `address` has staked +/// `token_id`. +pub const STAKED_NFTS_PER_OWNER: Map<(&Addr, &str), Empty> = Map::new("snpw"); +/// The number of NFTs staked by an address as a function of block +/// height. +pub const NFT_BALANCES: SnapshotMap<&Addr, Uint128> = SnapshotMap::new( + "nb", + "nb__checkpoints", + "nb__changelog", + Strategy::EveryBlock, +); +/// The number of NFTs staked with this contract as a function of +/// block height. +pub const TOTAL_STAKED_NFTS: SnapshotItem = SnapshotItem::new( + "tsn", + "tsn__checkpoints", + "tsn__changelog", + Strategy::EveryBlock, +); + +/// The maximum number of claims that may be outstanding. +pub const MAX_CLAIMS: u64 = 70; +pub const NFT_CLAIMS: NftClaims = NftClaims::new("nft_claims"); + +// Hooks to contracts that will receive staking and unstaking +// messages. +pub const HOOKS: Hooks = Hooks::new("hooks"); + +pub fn register_staked_nfts( + storage: &mut dyn Storage, + height: u64, + staker: &Addr, + token_ids: &Vec, +) -> StdResult<()> { + let count = token_ids.len() as u128; + let add_count = |prev: Option| -> StdResult { + prev.unwrap_or_default() + .checked_add(Uint128::new(count)) + .map_err(StdError::overflow) + }; + + for token_id in token_ids { + PREPARED_ONFTS.remove(storage, token_id.to_string()); + STAKED_NFTS_PER_OWNER.save(storage, (staker, token_id), &Empty::default())?; + } + + NFT_BALANCES.update(storage, staker, height, add_count)?; + TOTAL_STAKED_NFTS + .update(storage, height, add_count) + .map(|_| ()) +} + +/// Registers the unstaking of TOKEN_IDs in storage. Errors if: +/// +/// 1. `token_ids` is non-unique. +/// 2. a NFT being staked has not previously been staked. +pub fn register_unstaked_nfts( + storage: &mut dyn Storage, + height: u64, + staker: &Addr, + token_ids: &[String], +) -> Result<(), ContractError> { + let subtractor = |amount: u128| { + move |prev: Option| -> StdResult { + prev.expect("unstaking that which was not staked") + .checked_sub(Uint128::new(amount)) + .map_err(StdError::overflow) + } + }; + + for token in token_ids { + let key = (staker, token.as_str()); + if STAKED_NFTS_PER_OWNER.has(storage, key) { + STAKED_NFTS_PER_OWNER.remove(storage, key); + } else { + return Err(ContractError::NotStaked { + token_id: token.clone(), + }); + } + } + + // invariant: token_ids has unique values. for loop asserts this. + + let sub_n = subtractor(token_ids.len() as u128); + TOTAL_STAKED_NFTS.update(storage, height, sub_n)?; + NFT_BALANCES.update(storage, staker, height, sub_n)?; + Ok(()) +}