use std::collections::{HashMap, VecDeque}; use ext_fungible_token::ext_fungible_token; use near_sdk::json_types::{Base64VecU8, U64, U128}; use near_sdk::{AccountId, Gas, NearToken, PromiseOrValue, log, utils}; use crate::action_log::ProposalLog; use crate::policy::UserInfo; use crate::types::{ Action, Config, GAS_FOR_FT_TRANSFER, OLD_BASE_TOKEN, ONE_YOCTO_NEAR, OldAccountId, convert_old_to_new_token, }; use crate::upgrade::{upgrade_remote, upgrade_using_factory}; use crate::*; /// Status of a proposal. #[derive(Clone, PartialEq, Debug)] #[near(serializers=[borsh, json])] pub enum ProposalStatus { InProgress, /// If quorum voted yes, this proposal is successfully approved. Approved, /// If quorum voted no, this proposal is rejected. Bond is returned. Rejected, /// If quorum voted to remove (e.g. spam), this proposal is rejected and bond is not returned. /// Interfaces shouldn't show removed proposals. Removed, /// Expired after period of time. Expired, /// If proposal was moved to Hub or somewhere else. Moved, /// If proposal has failed when finalizing. Allowed to re-finalize again to either expire or approved. Failed, } /// Function call arguments. #[derive(PartialEq, Clone)] #[near(serializers=[borsh, json])] #[cfg_attr(not(target_arch = "wasm32"), derive(Debug))] #[serde(deny_unknown_fields)] pub struct ActionCall { method_name: String, args: Base64VecU8, deposit: NearToken, gas: Gas, } /// Function call arguments. #[derive(PartialEq, Clone)] #[near(serializers=[borsh, json])] #[cfg_attr(not(target_arch = "wasm32"), derive(Debug))] #[serde(deny_unknown_fields)] pub struct PolicyParameters { pub proposal_bond: Option, pub proposal_period: Option, pub bounty_bond: Option, pub bounty_forgiveness_period: Option, } /// Kinds of proposals, doing different action. #[derive(PartialEq, Clone)] #[near(serializers=[borsh, json])] #[cfg_attr(not(target_arch = "wasm32"), derive(Debug))] #[serde(deny_unknown_fields)] pub enum ProposalKind { /// Change the DAO config. ChangeConfig { config: Config }, /// Change the full policy. ChangePolicy { policy: VersionedPolicy }, /// Add member to given role in the policy. This is short cut to updating the whole policy. AddMemberToRole { member_id: AccountId, role: String }, /// Remove member to given role in the policy. This is short cut to updating the whole policy. RemoveMemberFromRole { member_id: AccountId, role: String }, /// Calls `receiver_id` with list of method names in a single promise. /// Allows this contract to execute any arbitrary set of actions in other contracts. FunctionCall { receiver_id: AccountId, actions: Vec, }, /// Upgrade this contract with given hash from blob store. UpgradeSelf { hash: Base58CryptoHash }, /// Upgrade another contract, by calling method with the code from given hash from blob store. UpgradeRemote { receiver_id: AccountId, method_name: String, hash: Base58CryptoHash, }, /// Transfers given amount of `token_id` from this DAO to `receiver_id`. /// If `msg` is not None, calls `ft_transfer_call` with given `msg`. Fails if this base token. /// For `ft_transfer` and `ft_transfer_call` `memo` is the `description` of the proposal. Transfer { /// Can be "" for $NEAR or a valid account id. token_id: OldAccountId, receiver_id: AccountId, amount: U128, msg: Option, }, /// Sets staking contract. Can only be proposed if staking contract is not set yet. SetStakingContract { staking_id: AccountId }, /// Add new bounty. AddBounty { bounty: Bounty }, /// Indicates that given bounty is done by given user. BountyDone { bounty_id: u64, receiver_id: AccountId, }, /// Just a signaling vote, with no execution. Vote, /// Change information about factory and auto update. FactoryInfoUpdate { factory_info: FactoryInfo }, /// Add new role to the policy. If the role already exists, update it. This is short cut to updating the whole policy. ChangePolicyAddOrUpdateRole { role: RolePermission }, /// Remove role from the policy. This is short cut to updating the whole policy. ChangePolicyRemoveRole { role: String }, /// Update the default vote policy from the policy. This is short cut to updating the whole policy. ChangePolicyUpdateDefaultVotePolicy { vote_policy: VotePolicy }, /// Update the parameters from the policy. This is short cut to updating the whole policy. ChangePolicyUpdateParameters { parameters: PolicyParameters }, } impl ProposalKind { /// Returns label of policy for given type of proposal. pub fn to_policy_label(&self) -> &str { match self { ProposalKind::ChangeConfig { .. } => "config", ProposalKind::ChangePolicy { .. } => "policy", ProposalKind::AddMemberToRole { .. } => "add_member_to_role", ProposalKind::RemoveMemberFromRole { .. } => "remove_member_from_role", ProposalKind::FunctionCall { .. } => "call", ProposalKind::UpgradeSelf { .. } => "upgrade_self", ProposalKind::UpgradeRemote { .. } => "upgrade_remote", ProposalKind::Transfer { .. } => "transfer", ProposalKind::SetStakingContract { .. } => "set_vote_token", ProposalKind::AddBounty { .. } => "add_bounty", ProposalKind::BountyDone { .. } => "bounty_done", ProposalKind::Vote => "vote", ProposalKind::FactoryInfoUpdate { .. } => "factory_info_update", ProposalKind::ChangePolicyAddOrUpdateRole { .. } => "policy_add_or_update_role", ProposalKind::ChangePolicyRemoveRole { .. } => "policy_remove_role", ProposalKind::ChangePolicyUpdateDefaultVotePolicy { .. } => { "policy_update_default_vote_policy" } ProposalKind::ChangePolicyUpdateParameters { .. } => "policy_update_parameters", } } } /// Votes recorded in the proposal. #[derive(Clone, Debug)] #[near(serializers=[borsh(use_discriminant=true),json])] pub enum Vote { Approve = 0x0, Reject = 0x1, Remove = 0x2, } impl From for Vote { fn from(action: Action) -> Self { match action { Action::VoteApprove => Vote::Approve, Action::VoteReject => Vote::Reject, Action::VoteRemove => Vote::Remove, _ => unreachable!(), } } } /// Proposal that are sent to this DAO. #[near(serializers=[borsh, json])] #[cfg_attr(not(target_arch = "wasm32"), derive(Debug))] #[derive(Clone)] #[serde(deny_unknown_fields)] pub struct ProposalV1 { /// Original proposer. pub proposer: AccountId, /// Description of this proposal. pub description: String, /// Kind of proposal with relevant information. pub kind: ProposalKind, /// Current status of the proposal. pub status: ProposalStatus, /// Count of votes per role per decision: yes / no / spam. pub vote_counts: HashMap, /// Map of who voted and how. pub votes: HashMap, /// Submission time (for voting period). pub submission_time: U64, } #[near(serializers=[borsh, json])] #[cfg_attr(not(target_arch = "wasm32"), derive(Debug))] #[derive(Clone)] pub struct Proposal { /// Original proposer. pub proposer: AccountId, /// Description of this proposal. pub description: String, /// Kind of proposal with relevant information. pub kind: ProposalKind, /// Current status of the proposal. pub status: ProposalStatus, /// Count of votes per role per decision: yes / no / spam. pub vote_counts: HashMap, /// Map of who voted and how. pub votes: HashMap, /// Submission time (for voting period). pub submission_time: U64, /// Last actions log pub last_actions_log: VecDeque, } impl From for Proposal { fn from(v1: ProposalV1) -> Self { Proposal { proposer: v1.proposer.clone(), description: v1.description.clone(), kind: v1.kind.clone(), status: v1.status.clone(), vote_counts: v1.vote_counts.clone(), votes: v1.votes.clone(), submission_time: v1.submission_time, last_actions_log: VecDeque::new(), } } } #[derive(Clone)] #[near(serializers=[borsh])] #[cfg_attr(not(target_arch = "wasm32"), derive(Debug))] pub enum VersionedProposal { V1(ProposalV1), Latest(Proposal), } impl From for ProposalV1 { fn from(v: VersionedProposal) -> Self { match v { VersionedProposal::V1(p) => p, _ => unimplemented!(), } } } impl From for Proposal { fn from(v: VersionedProposal) -> Self { match v { VersionedProposal::V1(p) => p.into(), VersionedProposal::Latest(p) => p, } } } impl Proposal { pub fn update_votes( &mut self, account_id: &AccountId, roles: &[String], vote: Vote, policy: &Policy, user_weight: Balance, ) { for role in roles { let amount = if policy.is_token_weighted(role, &self.kind.to_policy_label().to_string()) { user_weight } else { 1 }; let defaults = [U128::from(0); 3]; let vote_counted = self.vote_counts.entry(role.clone()).or_insert(defaults)[vote.clone() as usize].0 + amount; self.vote_counts .entry(role.clone()) .and_modify(|votes| votes[vote.clone() as usize] = vote_counted.into()); } assert!( self.votes.insert(account_id.clone(), vote).is_none(), "ERR_ALREADY_VOTED" ); } } #[near(serializers=[json])] pub struct ProposalInput { /// Description of this proposal. pub description: String, /// Kind of proposal with relevant information. pub kind: ProposalKind, } impl From for VersionedProposal { fn from(input: ProposalInput) -> Self { VersionedProposal::Latest(Proposal { proposer: env::predecessor_account_id(), description: input.description, kind: input.kind, status: ProposalStatus::InProgress, vote_counts: HashMap::default(), votes: HashMap::default(), submission_time: U64::from(env::block_timestamp()), last_actions_log: VecDeque::new(), }) } } impl Contract { /// Execute payout of given token to given user. pub(crate) fn internal_payout( &mut self, token_id: &Option, receiver_id: &AccountId, amount: Balance, memo: String, msg: Option, ) -> PromiseOrValue<()> { if token_id.is_none() { Promise::new(receiver_id.clone()) .transfer(NearToken::from_yoctonear(amount)) .into() } else { if let Some(msg) = msg { ext_fungible_token::ext(token_id.as_ref().unwrap().clone()) .with_attached_deposit(ONE_YOCTO_NEAR) .with_static_gas(GAS_FOR_FT_TRANSFER) .ft_transfer_call(receiver_id.clone(), U128(amount), Some(memo), msg) } else { ext_fungible_token::ext(token_id.as_ref().unwrap().clone()) .with_attached_deposit(ONE_YOCTO_NEAR) .with_static_gas(GAS_FOR_FT_TRANSFER) .ft_transfer(receiver_id.clone(), U128(amount), Some(memo)) } .into() } } fn internal_return_bonds(&mut self, policy: &Policy, proposal: &Proposal) -> Promise { if let ProposalKind::BountyDone { .. } = &proposal.kind { self.locked_amount = self.locked_amount.saturating_sub(policy.bounty_bond); Promise::new(proposal.proposer.clone()) .transfer(policy.bounty_bond) .detach(); } self.locked_amount = self.locked_amount.saturating_sub(policy.proposal_bond); Promise::new(proposal.proposer.clone()).transfer(policy.proposal_bond) } /// Executes given proposal and updates the contract's state. fn internal_execute_proposal( &mut self, policy: &Policy, proposal: &Proposal, proposal_id: u64, ) -> PromiseOrValue<()> { let result = match &proposal.kind { ProposalKind::ChangeConfig { config } => { self.config.set(config); PromiseOrValue::Value(()) } ProposalKind::ChangePolicy { policy } => { self.policy.set(policy); PromiseOrValue::Value(()) } ProposalKind::AddMemberToRole { member_id, role } => { let mut new_policy = policy.clone(); new_policy.add_member_to_role(role, member_id); self.policy.set(&VersionedPolicy::Current(new_policy)); PromiseOrValue::Value(()) } ProposalKind::RemoveMemberFromRole { member_id, role } => { let mut new_policy = policy.clone(); new_policy.remove_member_from_role(role, member_id); self.policy.set(&VersionedPolicy::Current(new_policy)); PromiseOrValue::Value(()) } ProposalKind::FunctionCall { receiver_id, actions, } => { let mut promise = Promise::new(receiver_id.clone()); for action in actions { promise = promise.function_call( action.method_name.clone(), action.args.clone(), action.deposit, action.gas, ) } promise.into() } ProposalKind::UpgradeSelf { hash } => { upgrade_using_factory(hash); PromiseOrValue::Value(()) } ProposalKind::UpgradeRemote { receiver_id, method_name, hash, } => { upgrade_remote(receiver_id, method_name, &hash.into()); PromiseOrValue::Value(()) } ProposalKind::Transfer { token_id, receiver_id, amount, msg, } => self.internal_payout( &convert_old_to_new_token(token_id), receiver_id, amount.0, proposal.description.clone(), msg.clone(), ), ProposalKind::SetStakingContract { staking_id } => { assert!(self.staking_id.is_none(), "ERR_INVALID_STAKING_CHANGE"); self.staking_id = Some(staking_id.clone()); PromiseOrValue::Value(()) } ProposalKind::AddBounty { bounty } => { self.internal_add_bounty(bounty); PromiseOrValue::Value(()) } ProposalKind::BountyDone { bounty_id, receiver_id, } => self.internal_execute_bounty_payout(*bounty_id, receiver_id, true), ProposalKind::Vote => PromiseOrValue::Value(()), ProposalKind::FactoryInfoUpdate { factory_info } => { internal_set_factory_info(factory_info); PromiseOrValue::Value(()) } ProposalKind::ChangePolicyAddOrUpdateRole { role } => { let mut new_policy = policy.clone(); new_policy.add_or_update_role(role); self.policy.set(&VersionedPolicy::Current(new_policy)); PromiseOrValue::Value(()) } ProposalKind::ChangePolicyRemoveRole { role } => { let mut new_policy = policy.clone(); new_policy.remove_role(role); self.policy.set(&VersionedPolicy::Current(new_policy)); PromiseOrValue::Value(()) } ProposalKind::ChangePolicyUpdateDefaultVotePolicy { vote_policy } => { let mut new_policy = policy.clone(); new_policy.update_default_vote_policy(vote_policy); self.policy.set(&VersionedPolicy::Current(new_policy)); PromiseOrValue::Value(()) } ProposalKind::ChangePolicyUpdateParameters { parameters } => { let mut new_policy = policy.clone(); new_policy.update_parameters(parameters); self.policy.set(&VersionedPolicy::Current(new_policy)); PromiseOrValue::Value(()) } }; match result { PromiseOrValue::Promise(promise) => promise .then( Self::ext(env::current_account_id()) .with_static_gas(GAS_FOR_FT_TRANSFER) .on_proposal_callback(proposal_id), ) .into(), PromiseOrValue::Value(()) => self.internal_return_bonds(policy, proposal).into(), } } pub(crate) fn internal_callback_proposal_success( &mut self, proposal: &mut Proposal, ) -> PromiseOrValue<()> { let policy = self.policy.get().unwrap().to_policy(); if let ProposalKind::BountyDone { bounty_id, .. } = proposal.kind { let mut bounty: Bounty = self.bounties.get(&bounty_id).expect("ERR_NO_BOUNTY").into(); if bounty.times == 0 { self.bounties.remove(&bounty_id); } else { bounty.times -= 1; self.bounties .insert(&bounty_id, &VersionedBounty::Default(bounty)); } } proposal.status = ProposalStatus::Approved; self.internal_return_bonds(&policy, proposal).into() } pub(crate) fn internal_callback_proposal_fail( &mut self, proposal: &mut Proposal, ) -> PromiseOrValue<()> { proposal.status = ProposalStatus::Failed; PromiseOrValue::Value(()) } /// Process rejecting proposal. fn internal_reject_proposal( &mut self, policy: &Policy, proposal: &Proposal, return_bonds: bool, ) -> PromiseOrValue<()> { if return_bonds { // Return bond to the proposer. self.internal_return_bonds(policy, proposal).detach(); } let proposal_data = proposal; match &proposal_data.kind { ProposalKind::BountyDone { bounty_id, receiver_id, } => self.internal_execute_bounty_payout(*bounty_id, receiver_id, false), _ => PromiseOrValue::Value(()), } } pub(crate) fn internal_user_info(&self) -> UserInfo { let account_id = env::predecessor_account_id(); UserInfo { amount: self.get_user_weight(&account_id), account_id, } } } #[near] impl Contract { /// Add proposal to this DAO. #[payable] pub fn add_proposal(&mut self, proposal: ProposalInput) -> u64 { // 0. validate bond attached. // TODO: consider bond in the token of this DAO. let policy = self.policy.get().unwrap().to_policy(); assert_eq!( env::attached_deposit(), policy.proposal_bond, "ERR_MIN_BOND" ); // 1. Validate proposal. match &proposal.kind { ProposalKind::ChangePolicy { policy } => match policy { VersionedPolicy::Current(_) => {} _ => panic!("ERR_INVALID_POLICY"), }, ProposalKind::Transfer { token_id, msg, .. } => { assert!( token_id != OLD_BASE_TOKEN || msg.is_none(), "ERR_BASE_TOKEN_NO_MSG" ); } ProposalKind::SetStakingContract { .. } => assert!( self.staking_id.is_none(), "ERR_STAKING_CONTRACT_CANT_CHANGE" ), // TODO: add more verifications. _ => {} }; // 2. Check permission of caller to add this type of proposal. assert!( policy .can_execute_action( self.internal_user_info(), &proposal.kind, &Action::AddProposal ) .1, "ERR_PERMISSION_DENIED" ); // 3. Actually add proposal to the current list of proposals. let id = self.last_proposal_id; // 4. Log proposal creation let mut proposal = VersionedProposal::from(proposal).into(); self.internal_log_action(id, Action::AddProposal, &mut proposal); self.proposals .insert(&id, &VersionedProposal::Latest(proposal)); self.last_proposal_id += 1; self.locked_amount = self.locked_amount.saturating_add(env::attached_deposit()); id } /// Act on given proposal by id, if permissions allow. /// Memo is logged but not stored in the state. Can be used to leave notes or explain the action. #[deny_unknown_arguments] pub fn act_proposal( &mut self, id: u64, action: Action, proposal: ProposalKind, memo: Option, ) { let input_proposal_kind = proposal; // Covert proposal to the latest version let mut proposal: Proposal = self.proposals.get(&id).expect("ERR_NO_PROPOSAL").into(); // Log the action self.internal_log_action(id, action.clone(), &mut proposal); let policy = self.policy.get().unwrap().to_policy(); // Check permissions for the given action. let (roles, allowed) = policy.can_execute_action(self.internal_user_info(), &proposal.kind, &action); assert!(allowed, "ERR_PERMISSION_DENIED"); let sender_id = env::predecessor_account_id(); // Verify propolsal kind assert!(proposal.kind == input_proposal_kind, "ERR_WRONG_KIND"); // Update proposal given action. Returns true if should be updated in storage. let update = match action { Action::AddProposal => env::panic_str("ERR_WRONG_ACTION"), Action::RemoveProposal => { self.proposals.remove(&id); false } Action::VoteApprove | Action::VoteReject | Action::VoteRemove => { assert!( matches!(proposal.status, ProposalStatus::InProgress), "ERR_PROPOSAL_NOT_READY_FOR_VOTE" ); proposal.update_votes( &sender_id, &roles, Vote::from(action), &policy, self.get_user_weight(&sender_id), ); // Updates proposal status with new votes using the policy. proposal.status = policy.proposal_status(&proposal, roles, self.total_delegation_amount); if proposal.status == ProposalStatus::Approved { self.internal_execute_proposal(&policy, &proposal, id) .detach(); true } else if proposal.status == ProposalStatus::Removed { self.internal_reject_proposal(&policy, &proposal, false) .detach(); self.proposals.remove(&id); false } else if proposal.status == ProposalStatus::Rejected { self.internal_reject_proposal(&policy, &proposal, true) .detach(); true } else { // Still in progress or expired. true } } // There are two cases when proposal must be finalized manually: expired or failed. // In case of failed, we just recompute the status and if it still approved, we re-execute the proposal. // In case of expired, we reject the proposal and return the bond. // Corner cases: // - if proposal expired during the failed state - it will be marked as expired. // - if the number of votes in the group has changed (new members has been added) - // the proposal can loose it's approved state. In this case new proposal needs to be made, this one can only expire. Action::Finalize => { proposal.status = policy.proposal_status( &proposal, policy.roles.iter().map(|r| r.name.clone()).collect(), self.total_delegation_amount, ); match proposal.status { ProposalStatus::Approved => { self.internal_execute_proposal(&policy, &proposal, id) .detach(); } ProposalStatus::Expired => { self.internal_reject_proposal(&policy, &proposal, true) .detach(); } _ => { env::panic_str("ERR_PROPOSAL_NOT_EXPIRED_OR_FAILED"); } } true } Action::MoveToHub => false, }; if update { self.proposals .insert(&id, &VersionedProposal::Latest(proposal)); } if let Some(memo) = memo { log!("Memo: {}", memo); } } /// Receiving callback after the proposal has been finalized. /// If successful, returns bond money to the proposal originator. /// If the proposal execution failed (funds didn't transfer or function call failure), /// move proposal to "Failed" state. #[private] pub fn on_proposal_callback(&mut self, proposal_id: u64) -> PromiseOrValue<()> { let mut proposal: Proposal = self .proposals .get(&proposal_id) .expect("ERR_NO_PROPOSAL") .into(); assert_eq!( env::promise_results_count(), 1, "ERR_UNEXPECTED_CALLBACK_PROMISES" ); let result = if utils::is_promise_success() { self.internal_callback_proposal_success(&mut proposal) } else { self.internal_callback_proposal_fail(&mut proposal) }; self.proposals .insert(&proposal_id, &VersionedProposal::Latest(proposal)); result } }