diff --git a/Cargo.lock b/Cargo.lock index 6565c11db..77cc7bf80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1103,7 +1103,10 @@ dependencies = [ "cw2 1.1.2", "cw20 1.1.2", "cw20-base 1.1.2", + "dao-proposal-single", "dao-testing", + "dao-voting 2.3.0", + "dao-voting-token-staked", "serde", "thiserror", "wynd-utils", diff --git a/ci/integration-tests/src/tests/cw_vesting_test.rs b/ci/integration-tests/src/tests/cw_vesting_test.rs index 7d2d74292..5efd92bd6 100644 --- a/ci/integration-tests/src/tests/cw_vesting_test.rs +++ b/ci/integration-tests/src/tests/cw_vesting_test.rs @@ -69,6 +69,7 @@ fn test_cw_vesting_staking(chain: &mut Chain) { total: Uint128::new(100_000_000), denom: cw_vesting::UncheckedDenom::Native("ujunox".to_string()), + dao_staking: None, schedule: Schedule::SaturatingLinear, start_time: None, vesting_duration_seconds: 10, diff --git a/contracts/external/cw-payroll-factory/schema/cw-payroll-factory.json b/contracts/external/cw-payroll-factory/schema/cw-payroll-factory.json index 890437b2a..93b7904f3 100644 --- a/contracts/external/cw-payroll-factory/schema/cw-payroll-factory.json +++ b/contracts/external/cw-payroll-factory/schema/cw-payroll-factory.json @@ -182,6 +182,21 @@ }, "additionalProperties": false }, + "DaoStakingLimits": { + "type": "object", + "required": [ + "staking_contract_allowlist" + ], + "properties": { + "staking_contract_allowlist": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, "Expiration": { "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "oneOf": [ @@ -241,6 +256,17 @@ "vesting_duration_seconds" ], "properties": { + "dao_staking": { + "description": "TODO support limits for native staked tokens as well? Optionally enabling this vesting contract to stake in token DAOs. Set to None if vesting a native token.", + "anyOf": [ + { + "$ref": "#/definitions/DaoStakingLimits" + }, + { + "type": "null" + } + ] + }, "denom": { "description": "The type and denom of token being vested.", "allOf": [ diff --git a/contracts/external/cw-payroll-factory/src/tests.rs b/contracts/external/cw-payroll-factory/src/tests.rs index 1feffd463..e6cab528a 100644 --- a/contracts/external/cw-payroll-factory/src/tests.rs +++ b/contracts/external/cw-payroll-factory/src/tests.rs @@ -100,6 +100,7 @@ pub fn test_instantiate_native_payroll_contract() { vesting_duration_seconds: 200, unbonding_duration_seconds: 2592000, // 30 days start_time: None, + dao_staking: None, }, label: "Payroll".to_string(), }; @@ -275,6 +276,7 @@ pub fn test_instantiate_cw20_payroll_contract() { vesting_duration_seconds: 200, unbonding_duration_seconds: 2592000, // 30 days start_time: None, + dao_staking: None, }; // Attempting to call InstantiatePayrollContract directly with cw20 fails @@ -397,6 +399,7 @@ fn test_instantiate_wrong_ownership_native() { vesting_duration_seconds: 200, unbonding_duration_seconds: 2592000, // 30 days start_time: None, + dao_staking: None, }, label: "vesting".to_string(), }, @@ -481,6 +484,7 @@ fn test_update_vesting_code_id() { vesting_duration_seconds: 200, unbonding_duration_seconds: 2592000, // 30 days start_time: None, + dao_staking: None, }, label: "Payroll".to_string(), }; @@ -571,6 +575,7 @@ pub fn test_inconsistent_cw20_amount() { vesting_duration_seconds: 200, unbonding_duration_seconds: 2592000, // 30 days start_time: None, + dao_staking: None, }; let err: ContractError = app .execute_contract( diff --git a/contracts/external/cw-vesting/Cargo.toml b/contracts/external/cw-vesting/Cargo.toml index 039d37d49..9ba1a0b43 100644 --- a/contracts/external/cw-vesting/Cargo.toml +++ b/contracts/external/cw-vesting/Cargo.toml @@ -28,6 +28,9 @@ cw-utils = { workspace = true } cw-wormhole = { workspace = true } cw2 = { workspace = true } cw20 = { workspace = true } +dao-proposal-single = { workspace = true, features = ["library"] } +dao-voting = { workspace = true } +dao-voting-token-staked = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } wynd-utils = { workspace = true } diff --git a/contracts/external/cw-vesting/schema/cw-vesting.json b/contracts/external/cw-vesting/schema/cw-vesting.json index daefea9a5..1978456da 100644 --- a/contracts/external/cw-vesting/schema/cw-vesting.json +++ b/contracts/external/cw-vesting/schema/cw-vesting.json @@ -16,6 +16,17 @@ "vesting_duration_seconds" ], "properties": { + "dao_staking": { + "description": "Optionally enabling this vesting contract to stake in token DAOs. Set to None if vesting a native staking token.", + "anyOf": [ + { + "$ref": "#/definitions/DaoStakingLimits" + }, + { + "type": "null" + } + ] + }, "denom": { "description": "The type and denom of token being vested.", "allOf": [ @@ -88,6 +99,21 @@ }, "additionalProperties": false, "definitions": { + "DaoStakingLimits": { + "type": "object", + "required": [ + "staking_contract_allowlist" + ], + "properties": { + "staking_contract_allowlist": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, "Schedule": { "oneOf": [ { @@ -194,7 +220,7 @@ "additionalProperties": false }, { - "description": "Distribute vested tokens to the vest receiver. Anyone may call this method.", + "description": "TODO we need something for things like airdrops? Should we have a general execute method that can be used for this? Distribute vested tokens to the vest receiver. Anyone may call this method.", "type": "object", "required": [ "distribute" @@ -446,6 +472,19 @@ }, "additionalProperties": false }, + { + "description": "Actions related to staking and voting in DAOs", + "type": "object", + "required": [ + "dao_actions" + ], + "properties": { + "dao_actions": { + "$ref": "#/definitions/DaoActionsMsg" + } + }, + "additionalProperties": false + }, { "description": "Update the contract's ownership. The `action` to be provided can be either to propose transferring ownership to an account, accept a pending ownership transfer, or renounce the ownership permanently.", "type": "object", @@ -537,6 +576,120 @@ }, "additionalProperties": false }, + "DaoActionsMsg": { + "oneOf": [ + { + "description": "Stake to a DAO", + "type": "object", + "required": [ + "stake" + ], + "properties": { + "stake": { + "type": "object", + "required": [ + "amount", + "staking_contract" + ], + "properties": { + "amount": { + "description": "The amount to stake.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "staking_contract": { + "description": "The staking contract address", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Unstake from a DAO", + "type": "object", + "required": [ + "unstake" + ], + "properties": { + "unstake": { + "type": "object", + "required": [ + "amount", + "staking_contract" + ], + "properties": { + "amount": { + "description": "The amount to unstake.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "staking_contract": { + "description": "The staking contract address", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Vote on single choice proposal", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "proposal_module", + "vote" + ], + "properties": { + "proposal_id": { + "description": "The ID of the proposal to vote on.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal_module": { + "description": "The address of the proposal module you are voting on.", + "type": "string" + }, + "rationale": { + "description": "An optional rationale for why this vote was cast. This can be updated, set, or removed later by the address casting the vote.", + "type": [ + "string", + "null" + ] + }, + "vote": { + "description": "The senders position on the proposal.", + "allOf": [ + { + "$ref": "#/definitions/Vote" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, "Expiration": { "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", "oneOf": [ @@ -599,6 +752,31 @@ "Uint64": { "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 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 `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", "type": "string" + }, + "Vote": { + "oneOf": [ + { + "description": "Marks support for the proposal.", + "type": "string", + "enum": [ + "yes" + ] + }, + { + "description": "Marks opposition to the proposal.", + "type": "string", + "enum": [ + "no" + ] + }, + { + "description": "Marks participation but does not count towards the ratio of support / opposed.", + "type": "string", + "enum": [ + "abstain" + ] + } + ] } } }, diff --git a/contracts/external/cw-vesting/src/contract.rs b/contracts/external/cw-vesting/src/contract.rs index 264a062e0..c7f565d7f 100644 --- a/contracts/external/cw-vesting/src/contract.rs +++ b/contracts/external/cw-vesting/src/contract.rs @@ -3,17 +3,18 @@ use cosmwasm_std::entry_point; use cosmwasm_std::{ from_json, to_json_binary, Binary, Coin, CosmosMsg, DelegationResponse, Deps, DepsMut, DistributionMsg, Env, MessageInfo, Response, StakingMsg, StakingQuery, StdResult, Timestamp, - Uint128, + Uint128, WasmMsg, }; use cw2::set_contract_version; use cw20::Cw20ReceiveMsg; use cw_denom::CheckedDenom; use cw_ownable::OwnershipError; use cw_utils::{must_pay, nonpayable}; +use dao_voting::voting::Vote; use crate::error::ContractError; -use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg, ReceiveMsg}; -use crate::state::{PAYMENT, UNBONDING_DURATION_SECONDS}; +use crate::msg::{DaoActionsMsg, ExecuteMsg, InstantiateMsg, QueryMsg, ReceiveMsg}; +use crate::state::{DAO_STAKING_LIMITS, PAYMENT, UNBONDING_DURATION_SECONDS}; use crate::vesting::{Status, VestInit}; const CONTRACT_NAME: &str = "crates.io:cw-vesting"; @@ -68,12 +69,22 @@ pub fn instantiate( // payment receiver so that when they stake vested tokens // they receive the rewards. if denom.as_str() == deps.querier.query_bonded_denom()? { + // Check if dao_staking is enabled. Throw an error if it is. + if let Some(_) = msg.dao_staking { + return Err(ContractError::DaoStakingNotSupported {}); + } + Some(CosmosMsg::Distribution( DistributionMsg::SetWithdrawAddress { address: vest.recipient.to_string(), }, )) } else { + // Check if dao_staking is enabled, and save dao_staking limits if it is. + if let Some(dao_staking) = msg.dao_staking { + DAO_STAKING_LIMITS.save(deps.storage, &dao_staking)?; + } + None } } @@ -125,6 +136,30 @@ pub fn execute( amount, during_unbonding, } => execute_register_slash(deps, env, info, validator, time, amount, during_unbonding), + ExecuteMsg::DaoActions(action) => match action { + DaoActionsMsg::Stake { + amount, + staking_contract, + } => execute_dao_stake(deps, env, info, amount, staking_contract), + DaoActionsMsg::Unstake { + amount, + staking_contract, + } => execute_dao_unstake(deps, env, info, amount, staking_contract), + DaoActionsMsg::Vote { + proposal_module, + proposal_id, + vote, + rationale, + } => execute_dao_vote( + deps, + env, + info, + proposal_module, + proposal_id, + vote, + rationale, + ), + }, } } @@ -443,6 +478,128 @@ pub fn execute_register_slash( } } +/// Stake tokens in a DAO +pub fn execute_dao_stake( + deps: DepsMut, + _env: Env, + info: MessageInfo, + amount: Uint128, + staking_contract: String, +) -> Result { + // Validate staking contract address + deps.api.addr_validate(&staking_contract)?; + + // Load vest and check status, only recipients can stake + let vest = PAYMENT.get_vest(deps.storage)?; + match vest.status { + Status::Unfunded => return Err(ContractError::NotFunded), + Status::Funded => { + if info.sender != vest.recipient { + return Err(ContractError::NotReceiver); + } + } + Status::Canceled { .. } => return Err(ContractError::Cancelled), + } + + // Validate staking contract is on the allowist + // Otherwise staking might be abused to get tokens out of this contract + let dao_staking = DAO_STAKING_LIMITS.load(deps.storage)?; + if !dao_staking + .staking_contract_allowlist + .contains(&staking_contract) + { + return Err(ContractError::NotOnAllowlist); + } + + // TODO limitations on how much can be staked? + + // Construct stake message + let msg = WasmMsg::Execute { + contract_addr: staking_contract.clone(), + msg: to_json_binary(&dao_voting_token_staked::msg::ExecuteMsg::Stake {})?, + funds: vec![Coin { + denom: vest.denom.to_string(), + amount: amount, + }], + }; + + Ok(Response::default() + .add_message(msg) + .add_attribute("method", "execute_dao_stake") + .add_attribute("staking_contract", staking_contract) + .add_attribute("amount", amount)) +} + +/// Unstake tokens in a DAO +pub fn execute_dao_unstake( + deps: DepsMut, + _env: Env, + info: MessageInfo, + amount: Uint128, + staking_contract: String, +) -> Result { + // Validate staking contract address + deps.api.addr_validate(&staking_contract)?; + + // Load vest and check status, only recipients can unstake + let vest = PAYMENT.get_vest(deps.storage)?; + if info.sender != vest.recipient { + return Err(ContractError::NotReceiver); + }; + + // Construct unstake message + let msg = WasmMsg::Execute { + contract_addr: staking_contract.clone(), + msg: to_json_binary(&dao_voting_token_staked::msg::ExecuteMsg::Unstake { amount })?, + funds: vec![], + }; + + Ok(Response::default() + .add_message(msg) + .add_attribute("method", "execute_dao_unstake") + .add_attribute("staking_contract", staking_contract) + .add_attribute("amount", amount)) +} + +/// Vote in a DAO +pub fn execute_dao_vote( + deps: DepsMut, + _env: Env, + info: MessageInfo, + proposal_module: String, + proposal_id: u64, + vote: Vote, + rationale: Option, +) -> Result { + // Validate proposal module contract address + deps.api.addr_validate(&proposal_module)?; + + // Check sender is the recipient of the vesting contract + let vest = PAYMENT.get_vest(deps.storage)?; + if info.sender != vest.recipient { + return Err(ContractError::NotReceiver); + }; + + // Construct voting message + let msg = WasmMsg::Execute { + contract_addr: proposal_module.clone(), + msg: to_json_binary(&dao_proposal_single::msg::ExecuteMsg::Vote { + proposal_id, + vote, + rationale: rationale.clone(), + })?, + funds: vec![], + }; + + Ok(Response::default() + .add_message(msg) + .add_attribute("method", "execute_dao_vote") + .add_attribute("proposal_module", proposal_module) + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("vote", vote.to_string()) + .add_attribute("rationale", rationale.unwrap_or_default())) +} + #[cfg_attr(not(feature = "library"), entry_point)] pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { match msg { diff --git a/contracts/external/cw-vesting/src/error.rs b/contracts/external/cw-vesting/src/error.rs index c29989fbc..53b455a7f 100644 --- a/contracts/external/cw-vesting/src/error.rs +++ b/contracts/external/cw-vesting/src/error.rs @@ -43,6 +43,14 @@ pub enum ContractError { #[error("payment is cancelled")] Cancelled, + // TODO better error message / name + #[error("DAO staking is not supported for the native staking token")] + DaoStakingNotSupported, + + // TODO better error message / name + #[error("staking contract is not on dao staking allowlist")] + NotOnAllowlist, + #[error("payment is not cancelled")] NotCancelled, diff --git a/contracts/external/cw-vesting/src/msg.rs b/contracts/external/cw-vesting/src/msg.rs index db6c7119f..af6e196f6 100644 --- a/contracts/external/cw-vesting/src/msg.rs +++ b/contracts/external/cw-vesting/src/msg.rs @@ -4,8 +4,9 @@ use cw20::Cw20ReceiveMsg; use cw_denom::UncheckedDenom; use cw_ownable::cw_ownable_execute; use cw_stake_tracker::StakeTrackerQuery; +use dao_voting::voting::Vote; -use crate::vesting::Schedule; +use crate::{state::DaoStakingLimits, vesting::Schedule}; #[cw_serde] pub struct InstantiateMsg { @@ -26,6 +27,12 @@ pub struct InstantiateMsg { /// The type and denom of token being vested. pub denom: UncheckedDenom, + // TODO maybe rename this... right now it's just an allowlist of staking contracts + // if DAO staking is enabled, this contract will be allowed to stake with these contracts. + /// Optionally enabling this vesting contract to stake in token DAOs. + /// Set to None if vesting a native staking token. + pub dao_staking: Option, + /// The vesting schedule, can be either `SaturatingLinear` vesting /// (which vests evenly over time), or `PiecewiseLinear` which can /// represent a more complicated vesting schedule. @@ -69,6 +76,8 @@ pub enum ExecuteMsg { /// Anyone may call this method so long as the contract has not /// yet been funded. Receive(Cw20ReceiveMsg), + /// TODO we need something for things like airdrops? Should we have a general + /// execute method that can be used for this? /// Distribute vested tokens to the vest receiver. Anyone may call /// this method. Distribute { @@ -179,6 +188,52 @@ pub enum ExecuteMsg { /// the common case where the slash impacted bonding tokens. during_unbonding: bool, }, + /// Actions related to staking and voting in DAOs + DaoActions(DaoActionsMsg), +} + +#[cw_serde] +pub enum DaoActionsMsg { + /// Stake to a DAO + Stake { + /// The staking contract address + staking_contract: String, + /// The amount to stake. + amount: Uint128, + }, + /// Unstake from a DAO + Unstake { + /// The staking contract address + staking_contract: String, + /// The amount to unstake. + amount: Uint128, + }, + /// Vote on single choice proposal + Vote { + /// The address of the proposal module you are voting on. + proposal_module: String, + /// The ID of the proposal to vote on. + proposal_id: u64, + /// The senders position on the proposal. + vote: Vote, + /// An optional rationale for why this vote was cast. This can + /// be updated, set, or removed later by the address casting + /// the vote. + rationale: Option, + }, + // // TODO support multiple choice voting and proposals + // /// Vote on multiple choice proposal + // VoteMultipleChoice { + // /// The proposal id to vote on. + // proposal_id: u64, + // /// The vote options. + // options: Vec, + // }, + // TODO update dao_staking config + // UpdateConfig { + // /// Contracts the vesting contract is allowed to stake with. + // staking_contract_allowlist: Option>, + // }, } #[cw_serde] diff --git a/contracts/external/cw-vesting/src/state.rs b/contracts/external/cw-vesting/src/state.rs index ea691bbab..adbd7b34e 100644 --- a/contracts/external/cw-vesting/src/state.rs +++ b/contracts/external/cw-vesting/src/state.rs @@ -1,6 +1,17 @@ +use cosmwasm_schema::cw_serde; use cw_storage_plus::Item; use crate::vesting::Payment; +#[cw_serde] +pub struct DaoStakingLimits { + // TODO max limits need to be kept track of + // The maximum amount of tokens that can be staked by this contract. + // pub max: Option, + // Staking contracts this contract is allowed to stake with + pub staking_contract_allowlist: Vec, +} + +pub const DAO_STAKING_LIMITS: Item = Item::new("dao_staking_limits"); pub const PAYMENT: Payment = Payment::new("vesting", "staked", "validator", "cardinality"); pub const UNBONDING_DURATION_SECONDS: Item = Item::new("ubs"); diff --git a/contracts/external/cw-vesting/src/suite_tests/suite.rs b/contracts/external/cw-vesting/src/suite_tests/suite.rs index 401aeb8f3..4c816b659 100644 --- a/contracts/external/cw-vesting/src/suite_tests/suite.rs +++ b/contracts/external/cw-vesting/src/suite_tests/suite.rs @@ -29,6 +29,7 @@ impl Default for SuiteBuilder { Self { instantiate: InstantiateMsg { + dao_staking: None, owner: Some("owner".to_string()), recipient: "recipient".to_string(), title: "title".to_string(), diff --git a/contracts/external/cw-vesting/src/tests.rs b/contracts/external/cw-vesting/src/tests.rs index 9a55807b5..15b3dc603 100644 --- a/contracts/external/cw-vesting/src/tests.rs +++ b/contracts/external/cw-vesting/src/tests.rs @@ -128,6 +128,7 @@ pub fn setup_contracts(app: &mut App) -> (Addr, u64, u64) { impl Default for InstantiateMsg { fn default() -> Self { Self { + dao_staking: None, owner: Some(OWNER.to_string()), recipient: BOB.to_string(), title: "title".to_string(),