From 8d0ec4f761bc3c1966518fc496698f406d67989e Mon Sep 17 00:00:00 2001 From: thounyy Date: Tue, 26 Nov 2024 17:33:38 +0100 Subject: [PATCH 1/2] feat: create DAO config --- packages/config/Move.lock | 2 +- packages/config/sources/dao.move | 745 +++++++++++++++++++++++++ packages/config/sources/math.move | 86 +++ packages/config/sources/multisig.move | 29 +- packages/protocol/sources/account.move | 2 +- 5 files changed, 848 insertions(+), 16 deletions(-) create mode 100644 packages/config/sources/dao.move create mode 100644 packages/config/sources/math.move diff --git a/packages/config/Move.lock b/packages/config/Move.lock index a14e0c0..f05eef8 100644 --- a/packages/config/Move.lock +++ b/packages/config/Move.lock @@ -2,7 +2,7 @@ [move] version = 3 -manifest_digest = "E80C15FFF6552C8398D76F688BFA8F25EE83864DD377601CD631EF924B94444B" +manifest_digest = "CF1AC7EE55C714A3DE422DC45FB66BD9979E0D9208A835C5EAAB7D79BCD02A03" deps_digest = "060AD7E57DFB13104F21BE5F5C3759D03F0553FC3229247D9A7A6B45F50D03A3" dependencies = [ { id = "AccountExtensions", name = "AccountExtensions" }, diff --git a/packages/config/sources/dao.move b/packages/config/sources/dao.move new file mode 100644 index 0000000..ac7e4a2 --- /dev/null +++ b/packages/config/sources/dao.move @@ -0,0 +1,745 @@ + +module account_config::dao; + +// === Imports === + +use std::{ + string::String, + type_name::{Self, TypeName}, +}; +use sui::{ + vec_set::{Self, VecSet}, + table::{Self, Table}, + clock::Clock, + vec_map::{Self, VecMap}, + coin::Coin, +}; +use account_extensions::extensions::Extensions; +use account_protocol::{ + account::{Self, Account}, + proposals::Expired, + executable::Executable, + auth::{Self, Auth}, + issuer::Issuer, +}; +use account_config::{ + version, + user::User, + math, +}; + +// === Constants === + +const MUL: u64 = 1_000_000_000; +// acts as a dynamic enum for the voting rule +const LINEAR: u8 = 1; +const QUADRATIC: u8 = 2; + +// === Errors === + +#[error] +const EMemberNotFound: vector = b"No member for this address"; +#[error] +const ECallerIsNotMember: vector = b"Caller is not member"; +#[error] +const ERoleNotFound: vector = b"Role not found"; +#[error] +const EThresholdNotReached: vector = b"Threshold not reached"; +#[error] +const EAlreadyApproved: vector = b"Proposal is already approved by the caller"; +#[error] +const ENotApproved: vector = b"Caller has not approved"; +#[error] +const ENotUnstaked: vector = b"Start cooldown before destroying voting power"; +#[error] +const EProposalNotActive: vector = b"Proposal is not open to vote"; +#[error] +const EInvalidAccount: vector = b"Invalid dao account for this staked asset"; +#[error] +const EInvalidVotingRule: vector = b"Voting rule doesn't exist"; +#[error] +const EInvalidAnswer: vector = b"Answer must be yes, no or abstain"; +#[error] +const EThresholdNull: vector = b"Threshold must be greater than 0"; +#[error] +const EMembersNotSameLength: vector = b"Members and roles vectors are not the same length"; +#[error] +const ERolesNotSameLength: vector = b"The role vectors are not the same length"; +#[error] +const ERoleNotAdded: vector = b"Role not added so member cannot have it"; +#[error] +const EThresholdTooHigh: vector = b"Threshold is too high"; + +// === Structs === + +/// [PROPOSAL] modifies the rules of the dao account +public struct ConfigDaoProposal() has drop; + +/// [ACTION] wraps a Dao struct into an action +public struct ConfigDaoAction has store { + config: Dao, +} + +/// Parent struct protecting the config +public struct Dao has copy, drop, store { + // members and associated data + members: vector, + // role name with role threshold, works like a multisig, allows for "working groups" + roles: vector, + // object type allowed for voting + asset_type: TypeName, + // cooldown when unstaking, voting power decreases linearly over time + staking_cooldown: u64, + // type of voting mechanism, u8 so we can add more in the future + voting_rule: u8, + // maximum voting power that can be used in a single vote + max_voting_power: u64, + // minimum number of votes needed to pass a proposal (can be 0 if not important) + minimum_votes: u64, + // global voting threshold between (0, 1e9], If 50% votes needed, then should be > 500_000_000 + voting_quorum: u64, +} + +/// Child struct for managing and displaying members with roles +public struct Member has copy, drop, store { + // address of the member + addr: address, + // roles that have been attributed + roles: VecSet, +} + +/// Child struct representing a role with a name and its threshold +public struct Role has copy, drop, store { + // role name: witness + optional name + name: String, + // threshold for the role + threshold: u64, +} + +/// Outcome field for the Proposals, voters are holders of the asset +/// Proposal is validated when role threshold is reached or dao rules are met +/// Must be validated before destruction +public struct Votes has store { + // members with the role that approved the proposal + role_approvals: u64, + // who has approved the proposal + approved: VecSet
, + // voting start time + start_time: u64, + // voting end time + end_time: u64, + // who has approved the proposal => (answer, voting_power) + voted: Table, + // results of the votes, answer => total_voting_power + results: VecMap, +} + +/// Struct for storing the answer and voting power of a voter +public struct Voted(String, u64) has copy, drop, store; + +/// Soul bound object wrapping the staked assets used for voting in a specific dao +/// Staked assets cannot be retrieved during the voting period +public struct Vote has key, store { + id: UID, + // id of the dao account + dao_id: ID, + // Proposal.actions.id if VotingPower is linked to a proposal + proposal_key: String, + // answer chosen for the vote + answer: String, + // timestamp when the vote ends and when this object can be unpacked + vote_end: u64, + // staked assets with metadata + assets: vector>, +} + +/// Staked asset, can be unstaked after the vote ends, according to the DAO cooldown +public struct Staked has key, store { + id: UID, + // id of the dao account + dao_id: ID, + // value of the staked asset (Coin.value if Coin or 1 if Object) + value: u64, + // unstaking time, if none then staked + unstaked: Option, + // staked asset + asset: Asset, +} + +// === [ACCOUNT] Public functions === + +/// Init and returns a new Account object +public fun new_account( + extensions: &Extensions, + name: String, + staking_cooldown: u64, + voting_rule: u8, + max_voting_power: u64, + voting_quorum: u64, + minimum_votes: u64, + ctx: &mut TxContext, +): Account { + let config = Dao { + members: vector[Member { + addr: ctx.sender(), + roles: vec_set::empty() + }], + asset_type: type_name::get(), + staking_cooldown, + voting_rule, + max_voting_power, + voting_quorum, + minimum_votes, + roles: vector[], + }; + + account::new(extensions, name, config, ctx) +} + +/// Authenticates the caller for a given role or globally +public fun authenticate( + extensions: &Extensions, + account: &Account, + role: String, // can be empty + ctx: &TxContext +): Auth { + account.config().assert_is_member(ctx); + if (!role.is_empty()) assert!(account.config().member(ctx.sender()).has_role(role), ERoleNotFound); + + auth::new(extensions, role, account.addr(), version::current()) +} + +/// Creates a new outcome to initiate a proposal +public fun empty_outcome( + account: &Account, + start_time: u64, + end_time: u64, + ctx: &mut TxContext +): Votes { + account.config().assert_is_member(ctx); // TODO: who can create a proposal? + + Votes { + role_approvals: 0, + approved: vec_set::empty(), + start_time, + end_time, + voted: table::new(ctx), + results: vec_map::from_keys_values( + vector[b"yes".to_string(), b"no".to_string(), b"abstain".to_string()], + vector[0, 0, 0], + ), + } +} + +public fun new_vote( + account: &mut Account, + proposal_key: String, + ctx: &mut TxContext +): Vote { + Vote { + id: object::new(ctx), + dao_id: object::id(account), + proposal_key, + answer: b"".to_string(), + vote_end: account.proposal(proposal_key).outcome().end_time, + assets: vector[], + } +} + +/// Stakes a coin and calculates the voting power +public fun stake_coin( + account: &mut Account, + coin: Coin, + ctx: &mut TxContext +): Staked> { + Staked { + id: object::new(ctx), + dao_id: object::id(account), + value: coin.value(), + unstaked: option::none(), + asset: coin, + } +} + +/// Stakes the asset and calculates the voting power +public fun stake_object( + account: &mut Account, + asset: Asset, + ctx: &mut TxContext +): Staked { + Staked { + id: object::new(ctx), + dao_id: object::id(account), + value: 1, + unstaked: option::none(), + asset, + } +} + +/// Starts cooldown for the staked asset +public fun unstake( + staked: &mut Staked, + clock: &Clock, +) { + staked.unstaked = option::some(clock.timestamp_ms()); +} + +/// Retrieves the staked asset after cooldown +public fun claim( + staked: Staked, + account: &mut Account, + clock: &Clock, +): Asset { + let Staked { id, dao_id, mut unstaked, asset, .. } = staked; + id.delete(); + + assert!(dao_id == object::id(account), EInvalidAccount); + assert!(unstaked.is_some(), ENotUnstaked); + assert!(clock.timestamp_ms() > account.config().staking_cooldown + unstaked.extract(), ENotUnstaked); + + asset +} + +/// Can be done while vote is open +public fun add_staked_to_vote( + vote: &mut Vote, + staked: Staked, +) { + vote.assets.push_back(staked); +} + +/// Can be done after vote is closed +public fun remove_staked_from_vote( + vote: &mut Vote, + idx: u64, +): Staked { + vote.assets.swap_remove(idx) +} + +public fun vote( + vote: &mut Vote, + account: &mut Account, + key: String, + answer: String, + clock: &Clock, +) { + assert!( + clock.timestamp_ms() > account.proposal(key).outcome().start_time && + clock.timestamp_ms() < account.proposal(key).outcome().end_time, + EProposalNotActive + ); + assert!( + answer == b"yes".to_string() || answer == b"no".to_string() || answer == b"abstain".to_string(), + EInvalidAnswer + ); // could change in the future + + let power = vote.get_voting_power(account, clock); + vote.answer = answer; + + account.proposals().all_idx(key).do!(|idx| { + let outcome_mut = account.proposal_mut(idx, version::current()).outcome_mut(); + // if already voted, remove previous vote to update it + if (outcome_mut.voted.contains(vote.addr())) { + let (prev_answer, prev_power) = outcome_mut.voted(vote.addr()); + *outcome_mut.results.get_mut(&prev_answer) = *outcome_mut.results.get_mut(&prev_answer) - prev_power; + }; + + outcome_mut.voted.add(vote.addr(), Voted(answer, power)); // throws if already approved + *outcome_mut.results.get_mut(&answer) = *outcome_mut.results.get_mut(&answer) + power; + }); +} + +/// Members with the role of the proposal can approve the proposal and bypass the vote +/// We assert that all Proposals with the same key have the same outcome state +/// Approves all proposals with the same key +public fun approve_proposal( + account: &mut Account, + key: String, + ctx: &TxContext +) { + assert!( + !account.proposal(key).outcome().approved.contains(&ctx.sender()), + EAlreadyApproved + ); + + let role = account.proposal(key).issuer().full_role(); + let member = account.config().member(ctx.sender()); + assert!(member.has_role(role), ERoleNotFound); + + account.proposals().all_idx(key).do!(|idx| { + let outcome_mut = account.proposal_mut(idx, version::current()).outcome_mut(); + outcome_mut.approved.insert(ctx.sender()); // throws if already approved + outcome_mut.role_approvals = outcome_mut.role_approvals + 1; + }); +} + +/// We assert that all Proposals with the same key have the same outcome state +/// Approves all proposals with the same key +public fun disapprove_proposal( + account: &mut Account, + key: String, + ctx: &TxContext +) { + assert!( + account.proposal(key).outcome().approved.contains(&ctx.sender()), + ENotApproved + ); + + let role = account.proposal(key).issuer().full_role(); + let member = account.config().member(ctx.sender()); + assert!(member.has_role(role), ERoleNotFound); + + account.proposals().all_idx(key).do!(|idx| { + let outcome_mut = account.proposal_mut(idx, version::current()).outcome_mut(); + outcome_mut.approved.remove(&ctx.sender()); // throws if already approved + outcome_mut.role_approvals = if (outcome_mut.role_approvals == 0) 0 else outcome_mut.role_approvals - 1; + }); +} + +/// Returns an executable if the number of signers is >= (global || role) threshold +/// Anyone can execute a proposal, this allows to automate the execution of proposals +public fun execute_proposal( + account: &mut Account, + key: String, + clock: &Clock, +): Executable { + let (executable, outcome) = account.execute_proposal(key, clock, version::current()); + outcome.validate(account.config(), executable.issuer()); + + executable +} + +public fun delete_proposal( + account: &mut Account, + key: String, + clock: &Clock, +): Expired { + account.delete_proposal(key, version::current(), clock) +} + +/// Actions must have been removed and deleted before calling this function +public fun delete_expired_outcome( + expired: Expired +) { + let Votes { voted, .. } = expired.remove_expired_outcome(); + voted.drop(); +} + +// User functions + +/// Inserts account_id in User, aborts if already joined +public fun join(user: &mut User, account: &mut Account) { + user.add_account(account.addr(), b"dao".to_string()); +} + +/// Removes account_id from User, aborts if not joined +public fun leave(user: &mut User, account: &mut Account) { + user.remove_account(account.addr(), b"dao".to_string()); +} + +// === [PROPOSAL] Public functions === + +/// No actions are defined as changing the config isn't supposed to be composable for security reasons + +// step 1: propose to modify account rules (everything touching weights) +// threshold has to be valid (reachable and different from 0 for global) +public fun propose_config_dao( + auth: Auth, + account: &mut Account, + outcome: Votes, + key: String, + description: String, + execution_time: u64, + expiration_time: u64, + // members & roles + member_addresses: vector
, + member_roles: vector>, + role_names: vector, + role_thresholds: vector, + // dao rules + asset_type: TypeName, + staking_cooldown: u64, + voting_rule: u8, + max_voting_power: u64, + minimum_votes: u64, + voting_quorum: u64, + ctx: &mut TxContext +) { + // verify new rules are valid + verify_new_rules(member_addresses, member_roles, role_names, role_thresholds); + + let mut proposal = account.create_proposal( + auth, + outcome, + version::current(), + ConfigDaoProposal(), + b"".to_string(), + key, + description, + execution_time, + expiration_time, + ctx + ); + // must modify members before modifying thresholds to ensure they are reachable + + let mut config = Dao { + members: vector[], + roles: vector[], + asset_type, + staking_cooldown, + voting_rule, + max_voting_power, + minimum_votes, + voting_quorum + }; + + member_addresses.zip_do!(member_roles, |addr, role| { + config.members.push_back(Member { + addr, + roles: vec_set::from_keys(role), + }); + }); + + role_names.zip_do!(role_thresholds, |role, threshold| { + config.roles.push_back(Role { name: role, threshold }); + }); + + proposal.add_action(ConfigDaoAction { config }, ConfigDaoProposal()); + account.add_proposal(proposal, version::current(), ConfigDaoProposal()); +} + +// step 2: multiple members have to approve the proposal (account::approve_proposal) + +// step 3: execute the action and modify Account Multisig +public fun execute_config_dao( + mut executable: Executable, + account: &mut Account, +) { + let ConfigDaoAction { config } = executable.action(account.addr(), version::current(), ConfigDaoProposal()); + *account.config_mut(version::current()) = config; + executable.destroy(version::current(), ConfigDaoProposal()); +} + +public fun delete_expired_config_dao(expired: &mut Expired) { + let action = expired.remove_expired_action(); + let ConfigDaoAction { .. } = action; +} + +// === Accessors === + +public fun addr(vote: &Vote): address { + object::id(vote).to_address() +} + +public fun addresses(dao: &Dao): vector
{ + dao.members.map_ref!(|member| member.addr) +} + +public fun member(dao: &Dao, addr: address): Member { + let idx = dao.get_member_idx(addr); + dao.members[idx] +} + +public fun member_mut(dao: &mut Dao, addr: address): &mut Member { + let idx = dao.get_member_idx(addr); + &mut dao.members[idx] +} + +public fun get_member_idx(dao: &Dao, addr: address): u64 { + let opt = dao.members.find_index!(|member| member.addr == addr); + assert!(opt.is_some(), EMemberNotFound); + opt.destroy_some() +} + +public fun is_member(dao: &Dao, addr: address): bool { + dao.members.any!(|member| member.addr == addr) +} + +public fun assert_is_member(dao: &Dao, ctx: &TxContext) { + assert!(dao.is_member(ctx.sender()), ECallerIsNotMember); +} + +// member functions +public fun roles(member: &Member): vector { + *member.roles.keys() +} + +public fun has_role(member: &Member, role: String): bool { + member.roles.contains(&role) +} + +// roles functions +public fun get_role_threshold(dao: &Dao, name: String): u64 { + let idx = dao.get_role_idx(name); + dao.roles[idx].threshold +} + +public fun get_role_idx(dao: &Dao, name: String): u64 { + let opt = dao.roles.find_index!(|role| role.name == name); + assert!(opt.is_some(), ERoleNotFound); + opt.destroy_some() +} + +public fun role_exists(dao: &Dao, name: String): bool { + dao.roles.any!(|role| role.name == name) +} + +public fun asset_type(dao: &Dao): TypeName { + dao.asset_type +} + +public fun is_coin(dao: &Dao): bool { + let addr = dao.asset_type.get_address(); + let module_name = dao.asset_type.get_module(); + + let str_bytes = dao.asset_type.into_string().as_bytes(); + let mut struct_name = vector[]; + 4u64.do!(|i| { + struct_name.push_back(str_bytes[i + 72]); // starts at 0x2::coin:: + }); + + addr == @0x0000000000000000000000000000000000000000000000000000000000000002.to_ascii_string() && + module_name == b"coin".to_ascii_string() && + struct_name == b"Coin" +} + +// outcome functions +public fun start_time(outcome: &Votes): u64 { + outcome.start_time +} + +public fun end_time(outcome: &Votes): u64 { + outcome.end_time +} + +public fun voted(outcome: &Votes, vote: address): (String, u64) { + let voted = outcome.voted.borrow(vote); + (voted.0, voted.1) +} + +public fun results(outcome: &Votes): &VecMap { + &outcome.results +} + +// === Private functions === + +fun verify_new_rules( + member_addresses: vector
, + member_roles: vector>, + role_names: vector, + role_thresholds: vector, +) { + assert!(member_addresses.length() == member_roles.length(), EMembersNotSameLength); + assert!(role_names.length() == role_thresholds.length(), ERolesNotSameLength); + assert!(!role_thresholds.any!(|threshold| threshold == 0), EThresholdNull); + + let mut weights_for_role: VecMap = vec_map::from_keys_values(role_names, vector::tabulate!(role_names.length(), |_| 0)); + member_roles.do!(|roles| { + roles.do!(|role| { + *weights_for_role.get_mut(&role) = *weights_for_role.get_mut(&role) + 1; + }); + }); + + while (!weights_for_role.is_empty()) { + let (role, weight) = weights_for_role.pop(); + let (role_exists, idx) = role_names.index_of(&role); + assert!(role_exists, ERoleNotAdded); + assert!(weight >= role_thresholds[idx], EThresholdTooHigh); + }; +} + +fun validate( + outcome: Votes, + dao: &Dao, + issuer: &Issuer, +) { + let Votes { voted, role_approvals, results, .. } = outcome; + voted.drop(); + + let role = issuer.full_role(); + let total_votes = results[&b"yes".to_string()] + results[&b"no".to_string()]; + + assert!( + (dao.role_exists(role) && role_approvals >= dao.get_role_threshold(role)) || + total_votes >= dao.minimum_votes && results[&b"yes".to_string()] * MUL / total_votes >= dao.voting_quorum, + EThresholdNotReached + ); +} + +/// Returns the voting multiplier depending on the cooldown [0, 1e9] +fun get_voting_power( + vote: &Vote, + account: &Account, + clock: &Clock, +): u64 { + assert!(vote.dao_id == object::id(account), EInvalidAccount); + + let mut total = 0; + vote.assets.do_ref!(|staked| { + let multiplier = if (staked.unstaked.is_none()) { + MUL + } else { + let time_passed = clock.timestamp_ms() - *staked.unstaked.borrow(); + if (time_passed > account.config().staking_cooldown) 0 else + (account.config().staking_cooldown - time_passed) * MUL / account.config().staking_cooldown + }; + + total = total + staked.value * multiplier; + }); + + let mut voting_power = total / MUL; + + if (account.config().voting_rule == LINEAR) { + // do nothing + } else if (account.config().voting_rule == QUADRATIC) { + voting_power = math::sqrt_down(total as u256) as u64 / MUL + } else { + abort EInvalidVotingRule + }; // can add other voting rules in the future + + voting_power +} + +// === Test functions === + +#[test_only] +public fun add_member( + dao: &mut Dao, + addr: address, +) { + dao.members.push_back(Member { addr, roles: vec_set::empty() }); +} + +#[test_only] +public fun remove_member( + dao: &mut Dao, + addr: address, +) { + let idx = dao.get_member_idx(addr); + dao.members.remove(idx); +} + +#[test_only] +public fun add_role_to_multisig( + dao: &mut Dao, + name: String, + threshold: u64, +) { + dao.roles.push_back(Role { name, threshold }); +} + +#[test_only] +public fun add_role_to_member( + member: &mut Member, + role: String, +) { + member.roles.insert(role); +} + +#[test_only] +public fun remove_role_from_member( + member: &mut Member, + role: String, +) { + member.roles.remove(&role); +} \ No newline at end of file diff --git a/packages/config/sources/math.move b/packages/config/sources/math.move new file mode 100644 index 0000000..6e5e1bb --- /dev/null +++ b/packages/config/sources/math.move @@ -0,0 +1,86 @@ +/// Copied from https://github.com/interest-protocol/suitears + +module account_config::math { + /* + * @notice Returns the log2(x) rounding down. + * + * @param x The operand. + * @return u256. Log2(x). + */ + public fun log2_down(mut x: u256): u8 { + let mut result = 0; + if (x >> 128 > 0) { + x = x >> 128; + result = result + 128; + }; + + if (x >> 64 > 0) { + x = x >> 64; + result = result + 64; + }; + + if (x >> 32 > 0) { + x = x >> 32; + result = result + 32; + }; + + if (x >> 16 > 0) { + x = x >> 16; + result = result + 16; + }; + + if (x >> 8 > 0) { + x = x >> 8; + result = result + 8; + }; + + if (x >> 4 > 0) { + x = x >> 4; + result = result + 4; + }; + + if (x >> 2 > 0) { + x = x >> 2; + result = result + 2; + }; + + if (x >> 1 > 0) result = result + 1; + + result + } + + /* + * @notice It returns the lowest number. + * + * @param x The first operand. + * @param y The second operand. + * @return u256. The lowest number. + */ + public fun min(x: u256, y: u256): u256 { + if (x < y) x else y + } + + /* + * @notice Returns the square root of a number. If the number is not a perfect square, the x is rounded down. + * + * @dev Inspired by Henry S. Warren, Jr.'s "Hacker's Delight" (Chapter 11). + * + * @param x The operand. + * @return u256. The square root of x rounding down. + */ + public fun sqrt_down(x: u256): u256 { + if (x == 0) return 0; + + let mut result = 1 << ((log2_down(x) >> 1) as u8); + + result = (result + x / result) >> 1; + result = (result + x / result) >> 1; + result = (result + x / result) >> 1; + result = (result + x / result) >> 1; + result = (result + x / result) >> 1; + result = (result + x / result) >> 1; + result = (result + x / result) >> 1; + + min(result, x / result) + } +} \ No newline at end of file diff --git a/packages/config/sources/multisig.move b/packages/config/sources/multisig.move index a099d56..e9fdd65 100644 --- a/packages/config/sources/multisig.move +++ b/packages/config/sources/multisig.move @@ -71,6 +71,7 @@ public struct Multisig has copy, drop, store { /// Child struct for managing and displaying members public struct Member has copy, drop, store { + // address of the member addr: address, // voting power of the member weight: u64, @@ -103,7 +104,7 @@ public struct Invite has key { account_addr: address, } -// === Public functions === +// === [ACCOUNT] Public functions === // TODO: use built-in feature // public fun init_display(otw: MULTISIG, ctx: &mut TxContext) { @@ -155,6 +156,19 @@ public fun new_account( account::new(extensions, name, config, ctx) } +/// Authenticates the caller for a given role or globally +public fun authenticate( + extensions: &Extensions, + account: &Account, + role: String, // can be empty + ctx: &TxContext +): Auth { + account.config().assert_is_member(ctx); + if (!role.is_empty()) assert!(account.config().member(ctx.sender()).has_role(role), ERoleNotFound); + + auth::new(extensions, role, account.addr(), version::current()) +} + /// Creates a new outcome to initiate a proposal public fun empty_outcome( account: &Account, @@ -169,19 +183,6 @@ public fun empty_outcome( } } -/// Authenticates the caller for a given role or globally -public fun authenticate( - extensions: &Extensions, - account: &Account, - role: String, // can be empty - ctx: &TxContext -): Auth { - account.config().assert_is_member(ctx); - if (!role.is_empty()) assert!(account.config().member(ctx.sender()).has_role(role), ERoleNotFound); - - auth::new(extensions, role, account.addr(), version::current()) -} - /// We assert that all Proposals with the same key have the same outcome state /// Approves all proposals with the same key public fun approve_proposal( diff --git a/packages/protocol/sources/account.move b/packages/protocol/sources/account.move index 8ba49ff..7c23d9b 100644 --- a/packages/protocol/sources/account.move +++ b/packages/protocol/sources/account.move @@ -158,7 +158,7 @@ public fun execute_proposal( /// Removes a proposal if it has expired /// Needs to delete each action in the bag within their own module -public fun delete_proposal( +public fun delete_proposal( account: &mut Account, key: String, version: TypeName, From 8aa025c16965b38b2ffc437ffcc599d25f3415fc Mon Sep 17 00:00:00 2001 From: thounyy Date: Tue, 26 Nov 2024 17:48:33 +0100 Subject: [PATCH 2/2] docs: add comments for the dao --- packages/config/sources/dao.move | 49 +++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/config/sources/dao.move b/packages/config/sources/dao.move index ac7e4a2..4e1cf8a 100644 --- a/packages/config/sources/dao.move +++ b/packages/config/sources/dao.move @@ -1,3 +1,21 @@ +/// This module defines the DAO configuration and Votes proposal logic for account.tech. +/// Proposals can be executed once the role threshold is reached (similar to multisig) or if the DAO rules are met. +/// +/// The DAO can be configured with: +/// - a specific asset type for voting +/// - a cooldown for unstaking (this will decrease the voting power linearly over time) +/// - a voting rule (linear or quadratic, more can be added in the future) +/// - a maximum voting power that can be used in a single vote +/// - a minimum number of votes needed to pass a proposal (can be 0) +/// - a global voting threshold between (0, 1e9], If 50% votes needed, then should be > 500_000_000 +/// +/// Participants have to stake their assets to construct a Vote object. +/// They can stake their assets at any time, but they will have to wait for the cooldown period to pass before they can unstake them. +/// Staked assets can be pushed into a Vote object, to vote on a proposal. This object can be unpacked once the vote ends. +/// New assets can be added during vote, and vote can be changed. +/// +/// Alternatively, roles can be added to the DAO with a specific threshold, then roles can be assigned to members +/// Members with the role can approve the proposals which can be executed once the role threshold is reached module account_config::dao; @@ -69,6 +87,8 @@ const ERolesNotSameLength: vector = b"The role vectors are not the same leng const ERoleNotAdded: vector = b"Role not added so member cannot have it"; #[error] const EThresholdTooHigh: vector = b"Threshold is too high"; +#[error] +const EAlreadyUnstaked: vector = b"Cooldown already started"; // === Structs === @@ -89,7 +109,7 @@ public struct Dao has copy, drop, store { // object type allowed for voting asset_type: TypeName, // cooldown when unstaking, voting power decreases linearly over time - staking_cooldown: u64, + unstaking_cooldown: u64, // type of voting mechanism, u8 so we can add more in the future voting_rule: u8, // maximum voting power that can be used in a single vote @@ -134,16 +154,16 @@ public struct Votes has store { results: VecMap, } -/// Struct for storing the answer and voting power of a voter +/// Tuple struct for storing the answer and voting power of a voter public struct Voted(String, u64) has copy, drop, store; -/// Soul bound object wrapping the staked assets used for voting in a specific dao +/// Object wrapping the staked assets used for voting in a specific dao /// Staked assets cannot be retrieved during the voting period public struct Vote has key, store { id: UID, // id of the dao account dao_id: ID, - // Proposal.actions.id if VotingPower is linked to a proposal + // the proposal voted on proposal_key: String, // answer chosen for the vote answer: String, @@ -172,7 +192,7 @@ public struct Staked has key, store { public fun new_account( extensions: &Extensions, name: String, - staking_cooldown: u64, + unstaking_cooldown: u64, voting_rule: u8, max_voting_power: u64, voting_quorum: u64, @@ -185,7 +205,7 @@ public fun new_account( roles: vec_set::empty() }], asset_type: type_name::get(), - staking_cooldown, + unstaking_cooldown, voting_rule, max_voting_power, voting_quorum, @@ -196,6 +216,7 @@ public fun new_account( account::new(extensions, name, config, ctx) } +// TODO: who can create a proposal? /// Authenticates the caller for a given role or globally public fun authenticate( extensions: &Extensions, @@ -246,7 +267,7 @@ public fun new_vote( } } -/// Stakes a coin and calculates the voting power +/// Stakes a coin and get its value public fun stake_coin( account: &mut Account, coin: Coin, @@ -261,7 +282,7 @@ public fun stake_coin( } } -/// Stakes the asset and calculates the voting power +/// Stakes the asset and adds 1 as value public fun stake_object( account: &mut Account, asset: Asset, @@ -281,6 +302,7 @@ public fun unstake( staked: &mut Staked, clock: &Clock, ) { + assert!(staked.unstaked.is_none(), EAlreadyUnstaked); staked.unstaked = option::some(clock.timestamp_ms()); } @@ -295,7 +317,7 @@ public fun claim( assert!(dao_id == object::id(account), EInvalidAccount); assert!(unstaked.is_some(), ENotUnstaked); - assert!(clock.timestamp_ms() > account.config().staking_cooldown + unstaked.extract(), ENotUnstaked); + assert!(clock.timestamp_ms() > account.config().unstaking_cooldown + unstaked.extract(), ENotUnstaked); asset } @@ -334,6 +356,7 @@ public fun vote( ); // could change in the future let power = vote.get_voting_power(account, clock); + let power = math::min(power as u256, account.config().max_voting_power as u256) as u64; vote.answer = answer; account.proposals().all_idx(key).do!(|idx| { @@ -458,7 +481,7 @@ public fun propose_config_dao( role_thresholds: vector, // dao rules asset_type: TypeName, - staking_cooldown: u64, + unstaking_cooldown: u64, voting_rule: u8, max_voting_power: u64, minimum_votes: u64, @@ -486,7 +509,7 @@ public fun propose_config_dao( members: vector[], roles: vector[], asset_type, - staking_cooldown, + unstaking_cooldown, voting_rule, max_voting_power, minimum_votes, @@ -680,8 +703,8 @@ fun get_voting_power( MUL } else { let time_passed = clock.timestamp_ms() - *staked.unstaked.borrow(); - if (time_passed > account.config().staking_cooldown) 0 else - (account.config().staking_cooldown - time_passed) * MUL / account.config().staking_cooldown + if (time_passed > account.config().unstaking_cooldown) 0 else + (account.config().unstaking_cooldown - time_passed) * MUL / account.config().unstaking_cooldown }; total = total + staked.value * multiplier;