From a4ed3294b4ebd0f00898ae26ab6ddf8fbdb74707 Mon Sep 17 00:00:00 2001 From: Hein Dauven Date: Wed, 27 May 2026 16:29:30 +0200 Subject: [PATCH] standards: add access control and multisig --- Makefile | 7 +- standards/Cargo.lock | 28 +- standards/Cargo.toml | 1 + .../src/access/access_control.rs | 221 ++++++ .../src/access/events.rs | 91 +++ .../dusk-contract-standards/src/access/mod.rs | 18 + .../src/access/ownable.rs | 252 +++++++ .../src/access/owner_set.rs | 202 ++++++ .../src/access/pausable.rs | 51 ++ .../src/governance/mod.rs | 24 + .../src/governance/multisig.rs | 266 ++++++++ .../src/governance/multisig_controller.rs | 632 ++++++++++++++++++ standards/dusk-contract-standards/src/lib.rs | 2 + .../examples/multisig_controller/Cargo.toml | 29 + .../examples/multisig_controller/Makefile | 34 + .../examples/multisig_controller/src/lib.rs | 506 ++++++++++++++ 16 files changed, 2360 insertions(+), 4 deletions(-) create mode 100644 standards/dusk-contract-standards/src/access/access_control.rs create mode 100644 standards/dusk-contract-standards/src/access/events.rs create mode 100644 standards/dusk-contract-standards/src/access/mod.rs create mode 100644 standards/dusk-contract-standards/src/access/ownable.rs create mode 100644 standards/dusk-contract-standards/src/access/owner_set.rs create mode 100644 standards/dusk-contract-standards/src/access/pausable.rs create mode 100644 standards/dusk-contract-standards/src/governance/mod.rs create mode 100644 standards/dusk-contract-standards/src/governance/multisig.rs create mode 100644 standards/dusk-contract-standards/src/governance/multisig_controller.rs create mode 100644 standards/examples/multisig_controller/Cargo.toml create mode 100644 standards/examples/multisig_controller/Makefile create mode 100644 standards/examples/multisig_controller/src/lib.rs diff --git a/Makefile b/Makefile index 5a64b15..fce4220 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -STANDARDS_EXAMPLES := -STANDARDS_WASM_CONTRACTS := +STANDARDS_EXAMPLES := standards/examples/multisig_controller +STANDARDS_WASM_CONTRACTS := $(STANDARDS_EXAMPLES) LEGACY_SUBDIRS := tests/alice tests/bob tests/charlie genesis/transfer genesis/stake tests/host_fn STANDARDS_PROPTEST_CASES ?= 8192 STANDARDS_PROPTEST_MAX_SHRINK_ITERS ?= 16384 @@ -29,10 +29,11 @@ standards-test: ## Test the Dusk standards crate without the Dusk compiler bundl $(MAKE) -C standards/dusk-contract-standards test standards-wasm: ## Build standards reference contracts without the Dusk compiler bundle - @true + $(MAKE) $(STANDARDS_WASM_CONTRACTS) MAKECMDGOALS=wasm standards-clippy: ## Run standards clippy without the Dusk compiler bundle $(MAKE) -C standards/dusk-contract-standards clippy + $(MAKE) $(STANDARDS_WASM_CONTRACTS) MAKECMDGOALS=clippy standards-ci: standards-fmt standards-check standards-clippy standards-test standards-wasm ## Run regular standards CI checks diff --git a/standards/Cargo.lock b/standards/Cargo.lock index f719994..7d3391a 100644 --- a/standards/Cargo.lock +++ b/standards/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler" @@ -808,6 +808,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "dlmalloc" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad5208a115eaba24916f7456929832e310a81518c641f93fee4f89aa93aa3675" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "dusk-bls12_381" version = "0.14.2" @@ -1659,6 +1670,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "multisig-controller" +version = "0.1.0" +dependencies = [ + "bytecheck", + "dusk-contract-standards", + "dusk-core", + "dusk-data-driver", + "dusk-forge", + "rkyv", + "serde", + "serde_json", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1849,6 +1874,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df8d2d3f5deb052f61c65d01d892c4babe604cd884cdd2fcef7f6da4392b476" dependencies = [ "bytecheck", + "dlmalloc", "hex", "rkyv", "serde", diff --git a/standards/Cargo.toml b/standards/Cargo.toml index f71c3a6..394205c 100644 --- a/standards/Cargo.toml +++ b/standards/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "dusk-contract-standards", + "examples/multisig_controller", ] resolver = "2" diff --git a/standards/dusk-contract-standards/src/access/access_control.rs b/standards/dusk-contract-standards/src/access/access_control.rs new file mode 100644 index 0000000..0e37a4c --- /dev/null +++ b/standards/dusk-contract-standards/src/access/access_control.rs @@ -0,0 +1,221 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Role-based access control. + +use alloc::collections::{BTreeMap, BTreeSet}; + +use crate::auth::{ + ActionEnvelope, AuthorizationManager, Authorizer, SignedAuthorization, +}; +use crate::core::{error, CallContext, Principal}; + +/// 32-byte role id. +pub type Role = [u8; 32]; + +/// Default admin role. +pub const DEFAULT_ADMIN_ROLE: Role = [0u8; 32]; + +#[derive(Clone, Debug)] +struct RoleData { + members: BTreeSet, + admin_role: Role, +} + +impl RoleData { + fn new(admin_role: Role) -> Self { + Self { + members: BTreeSet::new(), + admin_role, + } + } +} + +/// Role-based access-control module. +#[derive(Clone, Debug, Default)] +pub struct AccessControl { + roles: BTreeMap, +} + +impl AccessControl { + /// Creates an empty access-control module. + pub const fn new() -> Self { + Self { + roles: BTreeMap::new(), + } + } + + /// Bootstraps the default admin role. + pub fn init_admin(&mut self, admin: Principal) { + if admin.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + if self.roles.contains_key(&DEFAULT_ADMIN_ROLE) { + panic!("{}", error::ALREADY_INITIALIZED); + } + self.roles + .entry(DEFAULT_ADMIN_ROLE) + .or_insert_with(|| RoleData::new(DEFAULT_ADMIN_ROLE)) + .members + .insert(admin); + } + + /// Returns true when `account` has `role`. + pub fn has_role(&self, role: Role, account: Principal) -> bool { + self.roles + .get(&role) + .map(|data| data.members.contains(&account)) + .unwrap_or(false) + } + + /// Panics unless `account` has `role`. + pub fn assert_role(&self, role: Role, account: Principal) { + if !self.has_role(role, account) { + panic!("{}", error::UNAUTHORIZED); + } + } + + /// Authorizes any principal with `role` through runtime context or signed + /// authorization. + pub fn authorize_role( + &self, + role: Role, + authorizations: &mut AuthorizationManager, + context: CallContext, + authorization: Option<&SignedAuthorization>, + now: u64, + ) -> Principal { + let mut authorizer = Authorizer::new(authorizations, context, now); + self.authorize_role_with(role, &mut authorizer, authorization) + } + + /// Authorizes any principal with `role` using a reusable call authorizer. + pub fn authorize_role_with( + &self, + role: Role, + authorizer: &mut Authorizer<'_>, + authorization: Option<&SignedAuthorization>, + ) -> Principal { + if let Some(principal) = authorizer.observed_principal() { + if self.has_role(role, principal) { + return principal; + } + } + let Some(authorization) = authorization else { + panic!("{}", error::UNAUTHORIZED); + }; + authorizer.require_unbound_signed_if(authorization, |principal| { + self.has_role(role, principal) + }) + } + + /// Authorizes any principal with `role` through runtime context or an + /// action-bound signed authorization. + pub fn authorize_role_action( + &self, + role: Role, + authorizations: &mut AuthorizationManager, + context: CallContext, + authorization: Option<&SignedAuthorization>, + envelope: ActionEnvelope, + now: u64, + ) -> Principal { + let mut authorizer = Authorizer::new(authorizations, context, now); + self.authorize_role_action_with( + role, + &mut authorizer, + authorization, + envelope, + ) + } + + /// Authorizes any principal with `role` using a reusable call authorizer + /// and exact call envelope for signed fallbacks. + pub fn authorize_role_action_with( + &self, + role: Role, + authorizer: &mut Authorizer<'_>, + authorization: Option<&SignedAuthorization>, + envelope: ActionEnvelope, + ) -> Principal { + if let Some(principal) = authorizer.observed_principal() { + if self.has_role(role, principal) { + return principal; + } + } + let Some(authorization) = authorization else { + panic!("{}", error::UNAUTHORIZED); + }; + authorizer.require_signed_action_if( + authorization, + envelope, + |principal| self.has_role(role, principal), + ) + } + + /// Returns a role's admin role. + pub fn get_role_admin(&self, role: Role) -> Role { + self.roles + .get(&role) + .map(|data| data.admin_role) + .unwrap_or(DEFAULT_ADMIN_ROLE) + } + + /// Sets a role's admin role. Caller must have the current admin role. + pub fn set_role_admin( + &mut self, + caller: Principal, + role: Role, + admin_role: Role, + ) { + let current_admin = self.get_role_admin(role); + self.assert_role(current_admin, caller); + self.roles + .entry(role) + .or_insert_with(|| RoleData::new(DEFAULT_ADMIN_ROLE)) + .admin_role = admin_role; + } + + /// Grants `role` to `account`. Caller must have the role admin. + pub fn grant_role( + &mut self, + caller: Principal, + role: Role, + account: Principal, + ) { + if account.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + let admin = self.get_role_admin(role); + self.assert_role(admin, caller); + self.roles + .entry(role) + .or_insert_with(|| RoleData::new(DEFAULT_ADMIN_ROLE)) + .members + .insert(account); + } + + /// Revokes `role` from `account`. Caller must have the role admin. + pub fn revoke_role( + &mut self, + caller: Principal, + role: Role, + account: Principal, + ) { + let admin = self.get_role_admin(role); + self.assert_role(admin, caller); + if let Some(data) = self.roles.get_mut(&role) { + data.members.remove(&account); + } + } + + /// Renounces `role` from `caller`. + pub fn renounce_role(&mut self, role: Role, caller: Principal) { + if let Some(data) = self.roles.get_mut(&role) { + data.members.remove(&caller); + } + } +} diff --git a/standards/dusk-contract-standards/src/access/events.rs b/standards/dusk-contract-standards/src/access/events.rs new file mode 100644 index 0000000..670b1c1 --- /dev/null +++ b/standards/dusk-contract-standards/src/access/events.rs @@ -0,0 +1,91 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Access-control and emergency-stop event payloads. + +use bytecheck::CheckBytes; +use rkyv::{Archive, Deserialize, Serialize}; + +use crate::core::Principal; + +use super::Role; + +/// Pause event topic. +pub const PAUSED_TOPIC: &str = "access/paused"; +/// Unpause event topic. +pub const UNPAUSED_TOPIC: &str = "access/unpaused"; +/// Role-grant event topic. +pub const ROLE_GRANTED_TOPIC: &str = "access/role_granted"; +/// Role-revoke event topic. +pub const ROLE_REVOKED_TOPIC: &str = "access/role_revoked"; + +/// Pause event. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct Paused { + /// Principal that authorized the pause. + pub account: Principal, +} + +/// Unpause event. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct Unpaused { + /// Principal that authorized the unpause. + pub account: Principal, +} + +/// Role-grant event. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct RoleGranted { + /// Role id. + pub role: Role, + /// Principal receiving the role. + pub account: Principal, + /// Principal that authorized the grant. + pub sender: Principal, +} + +/// Role-revoke event. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct RoleRevoked { + /// Role id. + pub role: Role, + /// Principal losing the role. + pub account: Principal, + /// Principal that authorized the revoke. + pub sender: Principal, +} + +impl dusk_forge::ContractEvent for Paused { + const TOPICS: &'static [&'static str] = &[PAUSED_TOPIC]; +} + +impl dusk_forge::ContractEvent for Unpaused { + const TOPICS: &'static [&'static str] = &[UNPAUSED_TOPIC]; +} + +impl dusk_forge::ContractEvent for RoleGranted { + const TOPICS: &'static [&'static str] = &[ROLE_GRANTED_TOPIC]; +} + +impl dusk_forge::ContractEvent for RoleRevoked { + const TOPICS: &'static [&'static str] = &[ROLE_REVOKED_TOPIC]; +} diff --git a/standards/dusk-contract-standards/src/access/mod.rs b/standards/dusk-contract-standards/src/access/mod.rs new file mode 100644 index 0000000..7d11135 --- /dev/null +++ b/standards/dusk-contract-standards/src/access/mod.rs @@ -0,0 +1,18 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Access-control modules. + +pub mod access_control; +pub mod events; +pub mod ownable; +pub mod owner_set; +pub mod pausable; + +pub use access_control::{AccessControl, Role, DEFAULT_ADMIN_ROLE}; +pub use ownable::{Ownable, Ownable2Step}; +pub use owner_set::OwnerSet; +pub use pausable::Pausable; diff --git a/standards/dusk-contract-standards/src/access/ownable.rs b/standards/dusk-contract-standards/src/access/ownable.rs new file mode 100644 index 0000000..0c67de6 --- /dev/null +++ b/standards/dusk-contract-standards/src/access/ownable.rs @@ -0,0 +1,252 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Ownership primitives. + +use crate::auth::{ + ActionEnvelope, AuthorizationManager, Authorizer, SignedAuthorization, +}; +use crate::core::{error, CallContext, Principal}; + +/// Single-owner access control. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Ownable { + owner: Option, +} + +impl Ownable { + /// Creates an uninitialized owner module. + pub const fn new() -> Self { + Self { owner: None } + } + + /// Initializes ownership. + pub fn init(&mut self, owner: Principal) { + if self.owner.is_some() { + panic!("{}", error::ALREADY_INITIALIZED); + } + if owner.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + self.owner = Some(owner); + } + + /// Returns the current owner. + pub const fn owner(&self) -> Option { + self.owner + } + + /// Returns true when `caller` is the owner. + pub fn is_owner(&self, caller: Principal) -> bool { + self.owner == Some(caller) + } + + /// Panics unless `caller` is the owner. + pub fn assert_owner(&self, caller: Principal) { + if !self.is_owner(caller) { + panic!("{}", error::UNAUTHORIZED); + } + } + + /// Authorizes the owner through runtime context or signed authorization. + pub fn authorize_owner( + &self, + authorizations: &mut AuthorizationManager, + context: CallContext, + authorization: Option<&SignedAuthorization>, + now: u64, + ) -> Principal { + let mut authorizer = Authorizer::new(authorizations, context, now); + self.authorize_owner_with(&mut authorizer, authorization) + } + + /// Authorizes the owner with a reusable call authorizer. + pub fn authorize_owner_with( + &self, + authorizer: &mut Authorizer<'_>, + authorization: Option<&SignedAuthorization>, + ) -> Principal { + let owner = self + .owner + .unwrap_or_else(|| panic!("{}", error::NOT_INITIALIZED)); + authorizer.require_principal_unbound(owner, authorization) + } + + /// Authorizes the owner through runtime context or an action-bound signed + /// authorization. + pub fn authorize_owner_action( + &self, + authorizations: &mut AuthorizationManager, + context: CallContext, + authorization: Option<&SignedAuthorization>, + envelope: ActionEnvelope, + now: u64, + ) -> Principal { + let mut authorizer = Authorizer::new(authorizations, context, now); + self.authorize_owner_action_with( + &mut authorizer, + authorization, + envelope, + ) + } + + /// Authorizes the owner with a reusable call authorizer and exact call + /// envelope for signed fallbacks. + pub fn authorize_owner_action_with( + &self, + authorizer: &mut Authorizer<'_>, + authorization: Option<&SignedAuthorization>, + envelope: ActionEnvelope, + ) -> Principal { + let owner = self + .owner + .unwrap_or_else(|| panic!("{}", error::NOT_INITIALIZED)); + authorizer.require_principal_action(owner, authorization, envelope) + } + + /// Transfers ownership. + pub fn transfer_ownership( + &mut self, + caller: Principal, + new_owner: Principal, + ) { + self.assert_owner(caller); + if new_owner.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + self.owner = Some(new_owner); + } + + /// Renounces ownership. + pub fn renounce_ownership(&mut self, caller: Principal) { + self.assert_owner(caller); + self.owner = None; + } +} + +/// Two-step ownership transfer. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Ownable2Step { + ownable: Ownable, + pending_owner: Option, +} + +impl Ownable2Step { + /// Creates an uninitialized module. + pub const fn new() -> Self { + Self { + ownable: Ownable::new(), + pending_owner: None, + } + } + + /// Initializes ownership. + pub fn init(&mut self, owner: Principal) { + self.ownable.init(owner); + } + + /// Returns the current owner. + pub const fn owner(&self) -> Option { + self.ownable.owner() + } + + /// Returns the pending owner. + pub const fn pending_owner(&self) -> Option { + self.pending_owner + } + + /// Panics unless `caller` is the owner. + pub fn assert_owner(&self, caller: Principal) { + self.ownable.assert_owner(caller); + } + + /// Authorizes the owner through runtime context or signed authorization. + pub fn authorize_owner( + &self, + authorizations: &mut AuthorizationManager, + context: CallContext, + authorization: Option<&SignedAuthorization>, + now: u64, + ) -> Principal { + self.ownable.authorize_owner( + authorizations, + context, + authorization, + now, + ) + } + + /// Authorizes the owner with a reusable call authorizer. + pub fn authorize_owner_with( + &self, + authorizer: &mut Authorizer<'_>, + authorization: Option<&SignedAuthorization>, + ) -> Principal { + self.ownable.authorize_owner_with(authorizer, authorization) + } + + /// Authorizes the owner through runtime context or an action-bound signed + /// authorization. + pub fn authorize_owner_action( + &self, + authorizations: &mut AuthorizationManager, + context: CallContext, + authorization: Option<&SignedAuthorization>, + envelope: ActionEnvelope, + now: u64, + ) -> Principal { + self.ownable.authorize_owner_action( + authorizations, + context, + authorization, + envelope, + now, + ) + } + + /// Authorizes the owner with a reusable call authorizer and exact call + /// envelope for signed fallbacks. + pub fn authorize_owner_action_with( + &self, + authorizer: &mut Authorizer<'_>, + authorization: Option<&SignedAuthorization>, + envelope: ActionEnvelope, + ) -> Principal { + self.ownable.authorize_owner_action_with( + authorizer, + authorization, + envelope, + ) + } + + /// Starts ownership transfer. + pub fn transfer_ownership( + &mut self, + caller: Principal, + new_owner: Principal, + ) { + self.ownable.assert_owner(caller); + if new_owner.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + self.pending_owner = Some(new_owner); + } + + /// Accepts ownership transfer. + pub fn accept_ownership(&mut self, caller: Principal) { + if self.pending_owner != Some(caller) { + panic!("{}", error::UNAUTHORIZED); + } + self.ownable.owner = Some(caller); + self.pending_owner = None; + } + + /// Renounces ownership. + pub fn renounce_ownership(&mut self, caller: Principal) { + self.ownable.renounce_ownership(caller); + self.pending_owner = None; + } +} diff --git a/standards/dusk-contract-standards/src/access/owner_set.rs b/standards/dusk-contract-standards/src/access/owner_set.rs new file mode 100644 index 0000000..f93e5f1 --- /dev/null +++ b/standards/dusk-contract-standards/src/access/owner_set.rs @@ -0,0 +1,202 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Multi-principal ownership. + +use alloc::collections::BTreeSet; +use alloc::vec::Vec; + +use crate::auth::{ + ActionEnvelope, AuthorizationManager, Authorizer, SignedAuthorization, +}; +use crate::core::{error, CallContext, Principal, PrincipalKind}; + +/// Owner registry for contracts that need Moonlight, Phoenix, and contract +/// identities to coexist in one authorization policy. +#[derive(Clone, Debug, Default)] +pub struct OwnerSet { + owners: BTreeSet, +} + +impl OwnerSet { + /// Creates an empty owner set. + pub const fn new() -> Self { + Self { + owners: BTreeSet::new(), + } + } + + /// Initializes with at least one owner. + pub fn init(&mut self, owners: impl IntoIterator) { + if !self.owners.is_empty() { + panic!("{}", error::ALREADY_INITIALIZED); + } + let mut next = BTreeSet::new(); + for owner in owners { + if owner.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + next.insert(owner); + } + if next.is_empty() { + panic!("{}", error::INVALID_OWNER); + } + self.owners = next; + } + + /// Returns true when `principal` is an owner. + pub fn is_owner(&self, principal: Principal) -> bool { + self.owners.contains(&principal) + } + + /// Panics unless `principal` is an owner. + pub fn assert_owner(&self, principal: Principal) { + if !self.is_owner(principal) { + panic!("{}", error::UNAUTHORIZED); + } + } + + /// Authorizes any owner through runtime context or signed authorization. + pub fn authorize_owner( + &self, + authorizations: &mut AuthorizationManager, + context: CallContext, + authorization: Option<&SignedAuthorization>, + now: u64, + ) -> Principal { + let mut authorizer = Authorizer::new(authorizations, context, now); + self.authorize_owner_with(&mut authorizer, authorization) + } + + /// Authorizes any owner with a reusable call authorizer. + pub fn authorize_owner_with( + &self, + authorizer: &mut Authorizer<'_>, + authorization: Option<&SignedAuthorization>, + ) -> Principal { + if let Some(principal) = authorizer.observed_principal() { + if self.is_owner(principal) { + return principal; + } + } + let Some(authorization) = authorization else { + panic!("{}", error::UNAUTHORIZED); + }; + authorizer.require_unbound_signed_if(authorization, |principal| { + self.is_owner(principal) + }) + } + + /// Authorizes any owner through runtime context or an action-bound signed + /// authorization. + pub fn authorize_owner_action( + &self, + authorizations: &mut AuthorizationManager, + context: CallContext, + authorization: Option<&SignedAuthorization>, + envelope: ActionEnvelope, + now: u64, + ) -> Principal { + let mut authorizer = Authorizer::new(authorizations, context, now); + self.authorize_owner_action_with( + &mut authorizer, + authorization, + envelope, + ) + } + + /// Authorizes any owner with a reusable call authorizer and exact call + /// envelope for signed fallbacks. + pub fn authorize_owner_action_with( + &self, + authorizer: &mut Authorizer<'_>, + authorization: Option<&SignedAuthorization>, + envelope: ActionEnvelope, + ) -> Principal { + if let Some(principal) = authorizer.observed_principal() { + if self.is_owner(principal) { + return principal; + } + } + let Some(authorization) = authorization else { + panic!("{}", error::UNAUTHORIZED); + }; + authorizer.require_signed_action_if( + authorization, + envelope, + |principal| self.is_owner(principal), + ) + } + + /// Returns all owners in stable order. + pub fn owners(&self) -> Vec { + self.owners.iter().copied().collect() + } + + /// Returns the number of owners. + pub fn len(&self) -> usize { + self.owners.len() + } + + /// Returns true when no owners are configured. + pub fn is_empty(&self) -> bool { + self.owners.is_empty() + } + + /// Counts owners of a given principal kind. + pub fn count_kind(&self, kind: PrincipalKind) -> usize { + self.owners + .iter() + .filter(|principal| principal.kind() == kind) + .count() + } + + /// Adds an owner. Caller must already be an owner. + pub fn add_owner(&mut self, caller: Principal, new_owner: Principal) { + self.assert_owner(caller); + self.add_initial_owner(new_owner); + } + + /// Removes an owner. The last owner cannot be removed. + pub fn remove_owner(&mut self, caller: Principal, owner: Principal) { + self.assert_owner(caller); + if !self.owners.contains(&owner) { + panic!("{}", error::INVALID_OWNER); + } + if self.owners.len() == 1 { + panic!("{}", error::INVALID_OWNER); + } + self.owners.remove(&owner); + } + + /// Replaces one owner with another. + pub fn replace_owner( + &mut self, + caller: Principal, + old_owner: Principal, + new_owner: Principal, + ) { + self.assert_owner(caller); + if new_owner.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + if !self.owners.contains(&old_owner) { + panic!("{}", error::INVALID_OWNER); + } + if old_owner != new_owner && self.owners.contains(&new_owner) { + panic!("{}", error::INVALID_OWNER); + } + self.owners.remove(&old_owner); + self.owners.insert(new_owner); + } + + fn add_initial_owner(&mut self, owner: Principal) { + if owner.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + self.owners.insert(owner); + } +} diff --git a/standards/dusk-contract-standards/src/access/pausable.rs b/standards/dusk-contract-standards/src/access/pausable.rs new file mode 100644 index 0000000..5aa2412 --- /dev/null +++ b/standards/dusk-contract-standards/src/access/pausable.rs @@ -0,0 +1,51 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Emergency stop primitive. + +use crate::core::error; + +/// Pausable module. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Pausable { + paused: bool, +} + +impl Pausable { + /// Creates an unpaused module. + pub const fn new() -> Self { + Self { paused: false } + } + + /// Returns whether the module is paused. + pub const fn paused(&self) -> bool { + self.paused + } + + /// Panics when paused. + pub fn assert_not_paused(&self) { + if self.paused { + panic!("{}", error::UNAUTHORIZED); + } + } + + /// Panics when not paused. + pub fn assert_paused(&self) { + if !self.paused { + panic!("{}", error::UNAUTHORIZED); + } + } + + /// Pauses. + pub fn pause(&mut self) { + self.paused = true; + } + + /// Unpauses. + pub fn unpause(&mut self) { + self.paused = false; + } +} diff --git a/standards/dusk-contract-standards/src/governance/mod.rs b/standards/dusk-contract-standards/src/governance/mod.rs new file mode 100644 index 0000000..08a5c3d --- /dev/null +++ b/standards/dusk-contract-standards/src/governance/mod.rs @@ -0,0 +1,24 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Governance primitives. + +pub mod multisig; +pub mod multisig_controller; + +pub use multisig::{ + MultisigApprovals, MultisigConfig, Threshold, ThresholdMultisig, +}; +pub use multisig_controller::{ + MultisigAuthorityUpdated, MultisigController, MultisigControllerConfig, + MultisigControllerOutcome, MultisigControllerStatus, + MultisigOperationCancelled, MultisigOperationConfirmed, + MultisigOperationExecuted, MultisigOperationId, MultisigOperationProposed, + MultisigPendingOperation, MultisigTarget, MultisigTimeLimitsUpdated, + MULTISIG_AUTHORITY_UPDATED_TOPIC, MULTISIG_OPERATION_CANCELLED_TOPIC, + MULTISIG_OPERATION_CONFIRMED_TOPIC, MULTISIG_OPERATION_EXECUTED_TOPIC, + MULTISIG_OPERATION_PROPOSED_TOPIC, MULTISIG_TIME_LIMITS_UPDATED_TOPIC, +}; diff --git a/standards/dusk-contract-standards/src/governance/multisig.rs b/standards/dusk-contract-standards/src/governance/multisig.rs new file mode 100644 index 0000000..6ba2f88 --- /dev/null +++ b/standards/dusk-contract-standards/src/governance/multisig.rs @@ -0,0 +1,266 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Threshold multisig authorization for Dusk principals. + +use alloc::collections::BTreeSet; +use alloc::vec::Vec; + +use bytecheck::CheckBytes; +use rkyv::{Archive, Deserialize, Serialize}; + +use crate::auth::{ActionEnvelope, AuthorizationManager, SignedAuthorization}; +use crate::core::{error, CallContext, Principal, PrincipalKind}; + +/// Owner threshold type used by multisig policies. +pub type Threshold = u16; + +/// Initialization config for a threshold multisig policy. +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct MultisigConfig { + /// Unique non-zero owners. + pub owners: Vec, + /// Required number of distinct owners. + pub threshold: Threshold, +} + +/// Signed approvals submitted with a multisig-gated call. +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct MultisigApprovals { + /// Signed Moonlight/Phoenix approvals for the exact call envelope. + pub approvals: Vec, +} + +/// Dusk-native threshold multisig authorization policy. +/// +/// The primitive is intentionally an action authorizer, not an EVM-style +/// arbitrary calldata executor. Composing contracts bind approvals to their own +/// `ActionEnvelope`, then execute the local state transition after this module +/// returns a threshold of distinct owner signers. +#[derive(Clone, Debug, Default)] +pub struct ThresholdMultisig { + owners: BTreeSet, + threshold: Threshold, +} + +impl ThresholdMultisig { + /// Creates an uninitialized multisig policy. + pub const fn new() -> Self { + Self { + owners: BTreeSet::new(), + threshold: 0, + } + } + + /// Initializes the policy with unique owners and a non-zero threshold. + pub fn init(&mut self, config: MultisigConfig) { + if self.threshold != 0 || !self.owners.is_empty() { + panic!("{}", error::ALREADY_INITIALIZED); + } + let owners = collect_owners(config.owners); + validate_threshold(config.threshold, owners.len()); + self.owners = owners; + self.threshold = config.threshold; + } + + /// Returns all owners in stable order. + pub fn owners(&self) -> Vec { + self.owners.iter().copied().collect() + } + + /// Returns the number of owners. + pub fn len(&self) -> usize { + self.owners.len() + } + + /// Returns true when there are no owners configured. + pub fn is_empty(&self) -> bool { + self.owners.is_empty() + } + + /// Returns the current threshold. + pub const fn threshold(&self) -> Threshold { + self.threshold + } + + /// Returns true when `principal` is an owner. + pub fn is_owner(&self, principal: Principal) -> bool { + self.owners.contains(&principal) + } + + /// Verifies that the observed caller plus supplied signed approvals satisfy + /// the threshold for a concrete call envelope. + /// + /// The method verifies every signed approval and all policy constraints + /// before consuming any nonce/replay state. Rejected threshold, duplicate, + /// unauthorized, expired, wrong-envelope, or bad-signature cases are + /// therefore atomic with respect to signer nonces. + pub fn authorize_action( + &self, + authorizations: &mut AuthorizationManager, + context: CallContext, + approvals: &[SignedAuthorization], + envelope: ActionEnvelope, + now: u64, + ) -> Vec { + let signers = self.verify_action( + authorizations, + context, + approvals, + envelope, + now, + ); + for approval in approvals { + authorizations.consume_verified(approval); + } + signers + } + + /// Verifies that the observed caller plus supplied signed approvals satisfy + /// the threshold for a concrete call envelope without consuming nonce or + /// replay state. + pub fn verify_action( + &self, + authorizations: &AuthorizationManager, + context: CallContext, + approvals: &[SignedAuthorization], + envelope: ActionEnvelope, + now: u64, + ) -> Vec { + self.assert_initialized(); + + let mut signers = BTreeSet::new(); + if let Some(principal) = context.principal { + match principal.kind() { + PrincipalKind::Moonlight | PrincipalKind::Contract => { + if self.is_owner(principal) { + signers.insert(principal); + } + } + PrincipalKind::Phoenix => {} + } + } + + for approval in approvals { + let principal = + authorizations.verify_signed_action(approval, envelope, now); + if !self.is_owner(principal) || !signers.insert(principal) { + panic!("{}", error::UNAUTHORIZED); + } + } + + self.assert_threshold_count(signers.len()); + signers.iter().copied().collect() + } + + /// Panics unless `signers` are a distinct threshold of current owners. + pub fn assert_quorum(&self, signers: &[Principal]) { + self.assert_initialized(); + let mut unique = BTreeSet::new(); + for signer in signers { + if !self.is_owner(*signer) || !unique.insert(*signer) { + panic!("{}", error::UNAUTHORIZED); + } + } + self.assert_threshold_count(unique.len()); + } + + /// Adds an owner after a threshold of current owners has authorized it. + pub fn add_owner(&mut self, signers: &[Principal], owner: Principal) { + self.assert_quorum(signers); + if owner.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + if !self.owners.insert(owner) { + panic!("{}", error::INVALID_OWNER); + } + } + + /// Removes an owner and sets the threshold that should apply after removal. + pub fn remove_owner( + &mut self, + signers: &[Principal], + owner: Principal, + new_threshold: Threshold, + ) { + self.assert_quorum(signers); + let mut owners = self.owners.clone(); + if !owners.remove(&owner) { + panic!("{}", error::INVALID_OWNER); + } + validate_threshold(new_threshold, owners.len()); + self.owners = owners; + self.threshold = new_threshold; + } + + /// Replaces one owner with another after threshold authorization. + pub fn replace_owner( + &mut self, + signers: &[Principal], + old_owner: Principal, + new_owner: Principal, + ) { + self.assert_quorum(signers); + if new_owner.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + let mut owners = self.owners.clone(); + if !owners.remove(&old_owner) || !owners.insert(new_owner) { + panic!("{}", error::INVALID_OWNER); + } + self.owners = owners; + } + + /// Changes the threshold after a threshold of current owners has authorized + /// it. + pub fn set_threshold( + &mut self, + signers: &[Principal], + threshold: Threshold, + ) { + self.assert_quorum(signers); + validate_threshold(threshold, self.owners.len()); + self.threshold = threshold; + } + + fn assert_initialized(&self) { + if self.threshold == 0 || self.owners.is_empty() { + panic!("{}", error::NOT_INITIALIZED); + } + } + + fn assert_threshold_count(&self, count: usize) { + if count < usize::from(self.threshold) { + panic!("{}", error::UNAUTHORIZED); + } + } +} + +fn collect_owners(owners: Vec) -> BTreeSet { + let mut next = BTreeSet::new(); + for owner in owners { + if owner.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + if !next.insert(owner) { + panic!("{}", error::INVALID_OWNER); + } + } + if next.is_empty() { + panic!("{}", error::INVALID_OWNER); + } + next +} + +fn validate_threshold(threshold: Threshold, owner_count: usize) { + if threshold == 0 || usize::from(threshold) > owner_count { + panic!("{}", error::INVALID_OWNER); + } +} diff --git a/standards/dusk-contract-standards/src/governance/multisig_controller.rs b/standards/dusk-contract-standards/src/governance/multisig_controller.rs new file mode 100644 index 0000000..688c902 --- /dev/null +++ b/standards/dusk-contract-standards/src/governance/multisig_controller.rs @@ -0,0 +1,632 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Standalone multisig controller state machine. + +use alloc::collections::BTreeMap; +use alloc::string::String; +use alloc::vec; +use alloc::vec::Vec; +use core::mem; + +use bytecheck::CheckBytes; +use dusk_core::transfer::data::ContractCall; +use rkyv::{Archive, Deserialize, Serialize}; + +use crate::auth::{ActionEnvelope, AuthorizationManager, SignedAuthorization}; +use crate::core::{error, CallContext, Principal}; +use crate::governance::{MultisigConfig, Threshold, ThresholdMultisig}; + +/// Multisig operation id. +pub type MultisigOperationId = [u8; 32]; + +/// Event topic emitted when an operation is proposed. +pub const MULTISIG_OPERATION_PROPOSED_TOPIC: &str = + "multisig/operation_proposed"; +/// Event topic emitted when an operation is confirmed. +pub const MULTISIG_OPERATION_CONFIRMED_TOPIC: &str = + "multisig/operation_confirmed"; +/// Event topic emitted when an operation execution is attempted. +pub const MULTISIG_OPERATION_EXECUTED_TOPIC: &str = + "multisig/operation_executed"; +/// Event topic emitted when a pending operation is cancelled. +pub const MULTISIG_OPERATION_CANCELLED_TOPIC: &str = + "multisig/operation_cancelled"; +/// Event topic emitted when owner authority changes. +pub const MULTISIG_AUTHORITY_UPDATED_TOPIC: &str = "multisig/authority_updated"; +/// Event topic emitted when proposal/replay timing changes. +pub const MULTISIG_TIME_LIMITS_UPDATED_TOPIC: &str = + "multisig/time_limits_updated"; + +/// Target call controlled by the multisig. +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct MultisigTarget { + /// Call executed by the controller after quorum. + pub call: ContractCall, + /// Salt used to intentionally repeat the same logical call. + pub salt: [u8; 32], +} + +/// Initialization config for a standalone multisig controller. +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct MultisigControllerConfig { + /// Unique non-zero owner principals. + pub owners: Vec, + /// Required distinct confirmations. + pub threshold: Threshold, + /// Number of blocks/heights a proposal remains confirmable. + pub proposal_ttl: u64, + /// Number of blocks/heights an executed operation id remains tombstoned. + pub tombstone_ttl: u64, +} + +/// Pending operation metadata. +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct MultisigPendingOperation { + /// Target call. + pub target: MultisigTarget, + /// Distinct owner confirmations in stable insertion order. + pub confirmations: Vec, + /// Last block/height at which confirmations are accepted. + pub deadline: u64, +} + +impl MultisigPendingOperation { + /// Returns true when `principal` has already confirmed. + pub fn confirmed_by(&self, principal: Principal) -> bool { + self.confirmations.contains(&principal) + } +} + +/// Lifecycle status for a proposal/confirmation call. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub enum MultisigControllerStatus { + /// A new operation was created. + Proposed, + /// An existing operation received one confirmation. + Confirmed, + /// The operation reached threshold and should be executed by the wrapper. + Ready, +} + +/// Result of proposing or confirming an operation. +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct MultisigControllerOutcome { + /// Operation id. + pub id: MultisigOperationId, + /// Owner principal that authorized this confirmation. + pub authorizer: Principal, + /// Lifecycle status. + pub status: MultisigControllerStatus, + /// Confirmation count after this call. + pub confirmations: u16, + /// Current threshold. + pub threshold: Threshold, + /// Operation deadline. + pub deadline: u64, + /// Operation to execute when status is + /// [`MultisigControllerStatus::Ready`]. + pub ready_operation: Option, +} + +/// Event payload for proposal creation. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct MultisigOperationProposed { + /// Operation id. + pub id: MultisigOperationId, + /// Owner that proposed the operation. + pub authorizer: Principal, + /// Confirmation count after proposal. + pub confirmations: u16, + /// Required confirmation threshold. + pub threshold: Threshold, + /// Operation deadline. + pub deadline: u64, +} + +/// Event payload for operation confirmation. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct MultisigOperationConfirmed { + /// Operation id. + pub id: MultisigOperationId, + /// Owner that confirmed the operation. + pub authorizer: Principal, + /// Confirmation count after confirmation. + pub confirmations: u16, + /// Required confirmation threshold. + pub threshold: Threshold, + /// Operation deadline. + pub deadline: u64, +} + +/// Event payload for operation execution attempts. +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct MultisigOperationExecuted { + /// Operation id. + pub id: MultisigOperationId, + /// Whether the target call returned successfully. + pub success: bool, + /// Raw return data when the target call succeeds. + pub return_data: Vec, + /// Error string when the target call fails. + pub error: Option, +} + +/// Event payload for threshold-cancelled operations. +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct MultisigOperationCancelled { + /// Operation id. + pub id: MultisigOperationId, + /// Owners that authorized cancellation. + pub signers: Vec, +} + +/// Event payload for authority changes. +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct MultisigAuthorityUpdated { + /// Previous owners. + pub previous_owners: Vec, + /// Previous threshold. + pub previous_threshold: Threshold, + /// New owners. + pub owners: Vec, + /// New threshold. + pub threshold: Threshold, + /// Pending operations removed because the authority changed. + pub removed_operations: Vec, +} + +/// Event payload for proposal/replay timing changes. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct MultisigTimeLimitsUpdated { + /// Previous proposal TTL. + pub previous_proposal_ttl: u64, + /// Previous tombstone TTL. + pub previous_tombstone_ttl: u64, + /// New proposal TTL. + pub proposal_ttl: u64, + /// New tombstone TTL. + pub tombstone_ttl: u64, +} + +impl dusk_forge::ContractEvent for MultisigOperationProposed { + const TOPICS: &'static [&'static str] = + &[MULTISIG_OPERATION_PROPOSED_TOPIC]; +} + +impl dusk_forge::ContractEvent for MultisigOperationConfirmed { + const TOPICS: &'static [&'static str] = + &[MULTISIG_OPERATION_CONFIRMED_TOPIC]; +} + +impl dusk_forge::ContractEvent for MultisigOperationExecuted { + const TOPICS: &'static [&'static str] = + &[MULTISIG_OPERATION_EXECUTED_TOPIC]; +} + +impl dusk_forge::ContractEvent for MultisigOperationCancelled { + const TOPICS: &'static [&'static str] = + &[MULTISIG_OPERATION_CANCELLED_TOPIC]; +} + +impl dusk_forge::ContractEvent for MultisigAuthorityUpdated { + const TOPICS: &'static [&'static str] = &[MULTISIG_AUTHORITY_UPDATED_TOPIC]; +} + +impl dusk_forge::ContractEvent for MultisigTimeLimitsUpdated { + const TOPICS: &'static [&'static str] = + &[MULTISIG_TIME_LIMITS_UPDATED_TOPIC]; +} + +/// Standalone multisig controller primitive. +/// +/// This state machine tracks operation proposals and confirmations. It does +/// not call target contracts itself; Forge wrappers perform the runtime call +/// after this primitive returns a ready operation. +#[derive(Clone, Debug)] +pub struct MultisigController { + policy: ThresholdMultisig, + proposal_ttl: u64, + tombstone_ttl: u64, + proposals: BTreeMap, + tombstones: BTreeMap, +} + +impl MultisigController { + /// Creates an uninitialized controller. + pub const fn new() -> Self { + Self { + policy: ThresholdMultisig::new(), + proposal_ttl: 0, + tombstone_ttl: 0, + proposals: BTreeMap::new(), + tombstones: BTreeMap::new(), + } + } + + /// Initializes the controller. + pub fn init(&mut self, config: MultisigControllerConfig) { + if self.is_initialized() { + panic!("{}", error::ALREADY_INITIALIZED); + } + validate_time_limits(config.proposal_ttl, config.tombstone_ttl); + self.policy.init(MultisigConfig { + owners: config.owners, + threshold: config.threshold, + }); + self.proposal_ttl = config.proposal_ttl; + self.tombstone_ttl = config.tombstone_ttl; + } + + /// Returns true when the controller is initialized. + pub fn is_initialized(&self) -> bool { + !self.policy.is_empty() && self.policy.threshold() != 0 + } + + /// Returns owners in stable order. + pub fn owners(&self) -> Vec { + self.policy.owners() + } + + /// Returns the current threshold. + pub const fn threshold(&self) -> Threshold { + self.policy.threshold() + } + + /// Returns the proposal TTL. + pub const fn proposal_ttl(&self) -> u64 { + self.proposal_ttl + } + + /// Returns the tombstone TTL. + pub const fn tombstone_ttl(&self) -> u64 { + self.tombstone_ttl + } + + /// Returns true when `principal` is an owner. + pub fn is_owner(&self, principal: Principal) -> bool { + self.policy.is_owner(principal) + } + + /// Returns a pending proposal. + pub fn proposal( + &self, + id: MultisigOperationId, + ) -> Option { + self.proposals.get(&id).cloned() + } + + /// Returns a tombstone expiry. + pub fn tombstone_expiry(&self, id: MultisigOperationId) -> Option { + self.tombstones.get(&id).copied() + } + + /// Verifies an action-bound threshold of owner approvals. + pub fn authorize_action( + &self, + authorizations: &mut AuthorizationManager, + context: CallContext, + approvals: &[SignedAuthorization], + envelope: ActionEnvelope, + now: u64, + ) -> Vec { + self.policy.authorize_action( + authorizations, + context, + approvals, + envelope, + now, + ) + } + + /// Verifies an action-bound threshold of owner approvals without consuming + /// nonce or replay state. + pub fn verify_action( + &self, + authorizations: &AuthorizationManager, + context: CallContext, + approvals: &[SignedAuthorization], + envelope: ActionEnvelope, + now: u64, + ) -> Vec { + self.policy.verify_action( + authorizations, + context, + approvals, + envelope, + now, + ) + } + + /// Proposes an operation and records the first confirmation. + pub fn propose( + &mut self, + id: MultisigOperationId, + target: MultisigTarget, + authorizer: Principal, + now: u64, + ) -> MultisigControllerOutcome { + self.assert_initialized(); + self.assert_owner(authorizer); + validate_target(&target); + self.prune(now); + self.assert_not_tombstoned(id); + + if self.proposals.contains_key(&id) { + return self.confirm_pending(id, authorizer, now); + } + + let deadline = + now.checked_add(self.proposal_ttl).expect(error::OVERFLOW); + self.proposals.insert( + id, + MultisigPendingOperation { + target, + confirmations: vec![authorizer], + deadline, + }, + ); + + self.finish_if_ready( + id, + authorizer, + MultisigControllerStatus::Proposed, + deadline, + now, + ) + } + + /// Confirms a pending operation. + pub fn confirm( + &mut self, + id: MultisigOperationId, + authorizer: Principal, + now: u64, + ) -> MultisigControllerOutcome { + self.assert_initialized(); + self.assert_owner(authorizer); + self.prune(now); + self.assert_not_tombstoned(id); + self.confirm_pending(id, authorizer, now) + } + + /// Cancels a pending operation after a current owner quorum authorizes it. + pub fn cancel( + &mut self, + id: MultisigOperationId, + signers: &[Principal], + now: u64, + ) -> MultisigOperationCancelled { + self.assert_initialized(); + self.policy.assert_quorum(signers); + self.prune(now); + if self.proposals.remove(&id).is_none() { + panic!("{}", error::OPERATION_UNKNOWN); + } + MultisigOperationCancelled { + id, + signers: signers.to_vec(), + } + } + + /// Updates owners and threshold after a current owner quorum authorizes it. + /// + /// Pending operations are removed when an owner is removed or the threshold + /// changes, because old confirmations may no longer represent the new + /// authority. + pub fn update_authority( + &mut self, + signers: &[Principal], + owners: Vec, + threshold: Threshold, + ) -> MultisigAuthorityUpdated { + self.assert_initialized(); + self.policy.assert_quorum(signers); + + let previous_owners = self.owners(); + let previous_threshold = self.threshold(); + let mut next = ThresholdMultisig::new(); + next.init(MultisigConfig { owners, threshold }); + let next_owners = next.owners(); + + let removed_owner = + previous_owners.iter().any(|owner| !next.is_owner(*owner)); + let threshold_changed = previous_threshold != next.threshold(); + let removed_operations = if removed_owner || threshold_changed { + self.clear_pending() + } else { + Vec::new() + }; + + self.policy = next; + + MultisigAuthorityUpdated { + previous_owners, + previous_threshold, + owners: next_owners, + threshold, + removed_operations, + } + } + + /// Updates time limits after a current owner quorum authorizes it. + pub fn set_time_limits( + &mut self, + signers: &[Principal], + proposal_ttl: u64, + tombstone_ttl: u64, + ) -> MultisigTimeLimitsUpdated { + self.assert_initialized(); + self.policy.assert_quorum(signers); + validate_time_limits(proposal_ttl, tombstone_ttl); + + let event = MultisigTimeLimitsUpdated { + previous_proposal_ttl: self.proposal_ttl, + previous_tombstone_ttl: self.tombstone_ttl, + proposal_ttl, + tombstone_ttl, + }; + self.proposal_ttl = proposal_ttl; + self.tombstone_ttl = tombstone_ttl; + event + } + + fn confirm_pending( + &mut self, + id: MultisigOperationId, + authorizer: Principal, + now: u64, + ) -> MultisigControllerOutcome { + let deadline = { + let operation = self + .proposals + .get_mut(&id) + .unwrap_or_else(|| panic!("{}", error::OPERATION_UNKNOWN)); + if now > operation.deadline { + panic!("{}", error::DELAY_NOT_ELAPSED); + } + if operation.confirmed_by(authorizer) { + panic!("{}", error::UNAUTHORIZED); + } + operation.confirmations.push(authorizer); + operation.deadline + }; + + self.finish_if_ready( + id, + authorizer, + MultisigControllerStatus::Confirmed, + deadline, + now, + ) + } + + fn finish_if_ready( + &mut self, + id: MultisigOperationId, + authorizer: Principal, + status: MultisigControllerStatus, + deadline: u64, + now: u64, + ) -> MultisigControllerOutcome { + let confirmations = self + .proposals + .get(&id) + .unwrap_or_else(|| panic!("{}", error::OPERATION_UNKNOWN)) + .confirmations + .len(); + let mut status = status; + let ready_operation = + if confirmations >= usize::from(self.policy.threshold()) { + status = MultisigControllerStatus::Ready; + let operation = self + .proposals + .remove(&id) + .expect("ready operation must be pending"); + self.tombstones.insert( + id, + now.checked_add(self.tombstone_ttl).expect(error::OVERFLOW), + ); + Some(operation) + } else { + None + }; + + MultisigControllerOutcome { + id, + authorizer, + status, + confirmations: confirmations as u16, + threshold: self.policy.threshold(), + deadline, + ready_operation, + } + } + + fn assert_initialized(&self) { + if !self.is_initialized() { + panic!("{}", error::NOT_INITIALIZED); + } + } + + fn assert_owner(&self, authorizer: Principal) { + if !self.policy.is_owner(authorizer) { + panic!("{}", error::UNAUTHORIZED); + } + } + + fn assert_not_tombstoned(&self, id: MultisigOperationId) { + if self.tombstones.contains_key(&id) { + panic!("{}", error::REPLAY); + } + } + + fn prune(&mut self, now: u64) { + self.proposals + .retain(|_, operation| now <= operation.deadline); + self.tombstones.retain(|_, expiry| now <= *expiry); + } + + fn clear_pending(&mut self) -> Vec { + let proposals = mem::take(&mut self.proposals); + proposals.into_keys().collect() + } +} + +impl Default for MultisigController { + fn default() -> Self { + Self::new() + } +} + +fn validate_time_limits(proposal_ttl: u64, tombstone_ttl: u64) { + if proposal_ttl == 0 || tombstone_ttl == 0 { + panic!("{}", error::INVALID_OPERATION); + } +} + +fn validate_target(target: &MultisigTarget) { + if target + .call + .contract + .to_bytes() + .iter() + .all(|byte| *byte == 0) + || target.call.fn_name.is_empty() + { + panic!("{}", error::INVALID_OPERATION); + } +} diff --git a/standards/dusk-contract-standards/src/lib.rs b/standards/dusk-contract-standards/src/lib.rs index 5f625e7..10ab2ac 100644 --- a/standards/dusk-contract-standards/src/lib.rs +++ b/standards/dusk-contract-standards/src/lib.rs @@ -35,6 +35,8 @@ use serde_with as _; #[cfg(feature = "serde")] use time as _; +pub mod access; pub mod auth; pub mod core; +pub mod governance; pub mod security; diff --git a/standards/examples/multisig_controller/Cargo.toml b/standards/examples/multisig_controller/Cargo.toml new file mode 100644 index 0000000..3ee38a2 --- /dev/null +++ b/standards/examples/multisig_controller/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "multisig-controller" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[target.'cfg(target_family = "wasm")'.dependencies] +dusk-core = "=1.6.0" +dusk-data-driver = { version = "=0.3.2-alpha.1", optional = true } +dusk-forge = "=0.3.0" +dusk-contract-standards = { path = "../../dusk-contract-standards" } +bytecheck = { workspace = true } +rkyv = { workspace = true, features = ["size_32", "alloc", "validation"] } +serde = { version = "1", default-features = false, features = ["derive", "alloc"], optional = true } +serde_json = { version = "1", default-features = false, features = ["alloc"], optional = true } + +[features] +contract = ["dusk-core/abi-dlmalloc", "dusk-contract-standards/contract"] +serde = ["dep:serde", "dusk-contract-standards/serde"] +data-driver = [ + "serde", + "dusk-core/serde", + "dep:dusk-data-driver", + "dusk-data-driver/wasm-export", + "dep:serde_json", +] +data-driver-js = ["data-driver", "dusk-data-driver/alloc"] diff --git a/standards/examples/multisig_controller/Makefile b/standards/examples/multisig_controller/Makefile new file mode 100644 index 0000000..ba0153a --- /dev/null +++ b/standards/examples/multisig_controller/Makefile @@ -0,0 +1,34 @@ +TARGET_DIR ?= ../../../target +DD_TARGET_DIR ?= ../../../target/data-driver + +all: wasm wasm-dd clippy + +wasm: + @RUSTFLAGS="$(RUSTFLAGS) --remap-path-prefix $(HOME)= -C link-args=-zstack-size=65536" \ + CARGO_TARGET_DIR=$(TARGET_DIR) \ + cargo build \ + --release \ + --color=always \ + -Z build-std=core,alloc \ + --target wasm32-unknown-unknown \ + --features contract \ + -p multisig-controller + +test: + @cargo test -p dusk-contract-standards + +wasm-dd: + @CARGO_TARGET_DIR=$(DD_TARGET_DIR) \ + cargo build \ + --release \ + --color=always \ + --target wasm32-unknown-unknown \ + --features data-driver-js \ + -p multisig-controller + +clippy: + @cargo clippy -p multisig-controller -Z build-std=core,alloc --release --target wasm32-unknown-unknown --features contract -- -D warnings + +doc: + +.PHONY: all wasm wasm-dd test clippy doc diff --git a/standards/examples/multisig_controller/src/lib.rs b/standards/examples/multisig_controller/src/lib.rs new file mode 100644 index 0000000..2007cb8 --- /dev/null +++ b/standards/examples/multisig_controller/src/lib.rs @@ -0,0 +1,506 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Forge reference multisig controller for Dusk standards contracts. + +#![no_std] +#![cfg(target_family = "wasm")] + +extern crate alloc; +extern crate self as multisig_controller_types; + +use alloc::vec::Vec; +use bytecheck::CheckBytes; +use dusk_contract_standards::auth::SignedAuthorization; +use dusk_contract_standards::core::{NonceDomain, Principal}; +use dusk_contract_standards::governance::{ + MultisigApprovals, MultisigControllerConfig, MultisigOperationId, + MultisigTarget, Threshold, +}; +use rkyv::{Archive, Deserialize, Serialize}; + +pub const MULTISIG_CONTROLLER_DOMAIN: NonceDomain = [41u8; 32]; +pub const PROPOSE_ACTION: [u8; 32] = [42u8; 32]; +pub const CONFIRM_ACTION: [u8; 32] = [43u8; 32]; +pub const CANCEL_ACTION: [u8; 32] = [44u8; 32]; +pub const UPDATE_AUTHORITY_ACTION: [u8; 32] = [45u8; 32]; +pub const SET_TIME_LIMITS_ACTION: [u8; 32] = [46u8; 32]; + +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct Init { + pub config: MultisigControllerConfig, +} + +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct Propose { + pub target: MultisigTarget, + pub authorization: Option, +} + +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct Confirm { + pub id: MultisigOperationId, + pub authorization: Option, +} + +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct Cancel { + pub id: MultisigOperationId, + pub approvals: MultisigApprovals, +} + +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct UpdateAuthority { + pub owners: Vec, + pub threshold: Threshold, + pub approvals: MultisigApprovals, +} + +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct SetTimeLimits { + pub proposal_ttl: u64, + pub tombstone_ttl: u64, + pub approvals: MultisigApprovals, +} + +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct NonceQuery { + pub principal: Principal, + pub domain: NonceDomain, +} + +#[dusk_forge::contract(events = [ + MultisigOperationProposed, + MultisigOperationConfirmed, + MultisigOperationExecuted, + MultisigOperationCancelled, + MultisigAuthorityUpdated, + MultisigTimeLimitsUpdated, +])] +mod multisig_controller { + use alloc::format; + use alloc::vec::Vec; + + use dusk_contract_standards::auth::{ + ActionEnvelope, AuthorizationManager, SignedAuthorization, + }; + use dusk_contract_standards::core::{ + error, CallContext, Principal, PrincipalKind, + }; + use dusk_contract_standards::governance::{ + MultisigAuthorityUpdated, MultisigController, + MultisigControllerOutcome, MultisigControllerStatus, + MultisigOperationCancelled, MultisigOperationConfirmed, + MultisigOperationExecuted, MultisigOperationId, + MultisigOperationProposed, MultisigPendingOperation, MultisigTarget, + MultisigTimeLimitsUpdated, Threshold, MULTISIG_AUTHORITY_UPDATED_TOPIC, + MULTISIG_OPERATION_CANCELLED_TOPIC, MULTISIG_OPERATION_CONFIRMED_TOPIC, + MULTISIG_OPERATION_EXECUTED_TOPIC, MULTISIG_OPERATION_PROPOSED_TOPIC, + MULTISIG_TIME_LIMITS_UPDATED_TOPIC, + }; + use dusk_core::abi; + + use multisig_controller_types::{ + Cancel, Confirm, Init, NonceQuery, Propose, SetTimeLimits, + UpdateAuthority, CANCEL_ACTION, CONFIRM_ACTION, + MULTISIG_CONTROLLER_DOMAIN, PROPOSE_ACTION, SET_TIME_LIMITS_ACTION, + UPDATE_AUTHORITY_ACTION, + }; + + pub struct MultisigControllerContract { + controller: MultisigController, + authorizations: AuthorizationManager, + initialized: bool, + } + + impl MultisigControllerContract { + pub const fn new() -> Self { + Self { + controller: MultisigController::new(), + authorizations: AuthorizationManager::new(), + initialized: false, + } + } + pub fn init(&mut self, args: Init) { + if self.initialized { + panic!("{}", error::ALREADY_INITIALIZED); + } + self.controller.init(args.config); + self.initialized = true; + } + + pub fn owners(&self) -> Vec { + self.controller.owners() + } + + pub fn threshold(&self) -> Threshold { + self.controller.threshold() + } + + pub fn proposal_ttl(&self) -> u64 { + self.controller.proposal_ttl() + } + + pub fn tombstone_ttl(&self) -> u64 { + self.controller.tombstone_ttl() + } + + pub fn nonce(&self, args: NonceQuery) -> u64 { + self.authorizations.nonce(args.principal, args.domain) + } + + pub fn operation_id( + &self, + target: MultisigTarget, + ) -> MultisigOperationId { + operation_id(&target) + } + + pub fn proposal( + &self, + id: MultisigOperationId, + ) -> Option { + self.controller.proposal(id) + } + + pub fn tombstone_expiry(&self, id: MultisigOperationId) -> Option { + self.controller.tombstone_expiry(id) + } + + pub fn propose(&mut self, args: Propose) -> Vec { + self.assert_initialized(); + let Propose { + target, + authorization, + } = args; + let id = operation_id(&target); + let authorizer = + self.verify_owner(PROPOSE_ACTION, id, authorization.as_ref()); + let outcome = + self.controller.propose(id, target, authorizer, now()); + self.consume_authorization(authorization.as_ref()); + match outcome.status { + MultisigControllerStatus::Proposed => abi::emit( + MULTISIG_OPERATION_PROPOSED_TOPIC, + MultisigOperationProposed { + id: outcome.id, + authorizer: outcome.authorizer, + confirmations: outcome.confirmations, + threshold: outcome.threshold, + deadline: outcome.deadline, + }, + ), + MultisigControllerStatus::Confirmed + | MultisigControllerStatus::Ready => abi::emit( + MULTISIG_OPERATION_CONFIRMED_TOPIC, + MultisigOperationConfirmed { + id: outcome.id, + authorizer: outcome.authorizer, + confirmations: outcome.confirmations, + threshold: outcome.threshold, + deadline: outcome.deadline, + }, + ), + } + let execution_id = outcome.id; + let (return_data, execution) = self.execute_if_ready(outcome); + if let Some((success, error)) = execution { + abi::emit( + MULTISIG_OPERATION_EXECUTED_TOPIC, + MultisigOperationExecuted { + id: execution_id, + success, + return_data: return_data.clone(), + error, + }, + ); + } + return_data + } + + pub fn confirm(&mut self, args: Confirm) -> Vec { + self.assert_initialized(); + let authorizer = self.verify_owner( + CONFIRM_ACTION, + args.id, + args.authorization.as_ref(), + ); + let outcome = self.controller.confirm(args.id, authorizer, now()); + self.consume_authorization(args.authorization.as_ref()); + match outcome.status { + MultisigControllerStatus::Proposed => abi::emit( + MULTISIG_OPERATION_PROPOSED_TOPIC, + MultisigOperationProposed { + id: outcome.id, + authorizer: outcome.authorizer, + confirmations: outcome.confirmations, + threshold: outcome.threshold, + deadline: outcome.deadline, + }, + ), + MultisigControllerStatus::Confirmed + | MultisigControllerStatus::Ready => abi::emit( + MULTISIG_OPERATION_CONFIRMED_TOPIC, + MultisigOperationConfirmed { + id: outcome.id, + authorizer: outcome.authorizer, + confirmations: outcome.confirmations, + threshold: outcome.threshold, + deadline: outcome.deadline, + }, + ), + } + let execution_id = outcome.id; + let (return_data, execution) = self.execute_if_ready(outcome); + if let Some((success, error)) = execution { + abi::emit( + MULTISIG_OPERATION_EXECUTED_TOPIC, + MultisigOperationExecuted { + id: execution_id, + success, + return_data: return_data.clone(), + error, + }, + ); + } + return_data + } + + pub fn cancel(&mut self, args: Cancel) { + self.assert_initialized(); + let signers = self.verify_quorum( + CANCEL_ACTION, + args.id, + &args.approvals.approvals, + ); + let event: MultisigOperationCancelled = + self.controller.cancel(args.id, &signers, now()); + self.consume_approvals(&args.approvals.approvals); + abi::emit( + MULTISIG_OPERATION_CANCELLED_TOPIC, + MultisigOperationCancelled { + id: event.id, + signers: event.signers, + }, + ); + } + + pub fn update_authority(&mut self, args: UpdateAuthority) { + self.assert_initialized(); + let payload_hash = + authority_payload_hash(&args.owners, args.threshold); + let signers = self.verify_quorum( + UPDATE_AUTHORITY_ACTION, + payload_hash, + &args.approvals.approvals, + ); + let event: MultisigAuthorityUpdated = self + .controller + .update_authority(&signers, args.owners, args.threshold); + self.consume_approvals(&args.approvals.approvals); + abi::emit( + MULTISIG_AUTHORITY_UPDATED_TOPIC, + MultisigAuthorityUpdated { + previous_owners: event.previous_owners, + previous_threshold: event.previous_threshold, + owners: event.owners, + threshold: event.threshold, + removed_operations: event.removed_operations, + }, + ); + } + + pub fn set_time_limits(&mut self, args: SetTimeLimits) { + self.assert_initialized(); + let payload_hash = + time_limits_payload_hash(args.proposal_ttl, args.tombstone_ttl); + let signers = self.verify_quorum( + SET_TIME_LIMITS_ACTION, + payload_hash, + &args.approvals.approvals, + ); + let event: MultisigTimeLimitsUpdated = + self.controller.set_time_limits( + &signers, + args.proposal_ttl, + args.tombstone_ttl, + ); + self.consume_approvals(&args.approvals.approvals); + abi::emit( + MULTISIG_TIME_LIMITS_UPDATED_TOPIC, + MultisigTimeLimitsUpdated { + previous_proposal_ttl: event.previous_proposal_ttl, + previous_tombstone_ttl: event.previous_tombstone_ttl, + proposal_ttl: event.proposal_ttl, + tombstone_ttl: event.tombstone_ttl, + }, + ); + } + + fn verify_owner( + &self, + action_id: [u8; 32], + payload_hash: [u8; 32], + authorization: Option<&SignedAuthorization>, + ) -> Principal { + if let Some(principal) = CallContext::current().principal { + match principal.kind() { + PrincipalKind::Moonlight | PrincipalKind::Contract => { + if self.controller.is_owner(principal) { + return principal; + } + } + PrincipalKind::Phoenix => {} + } + } + + let Some(authorization) = authorization else { + panic!("{}", error::UNAUTHORIZED); + }; + let envelope = ActionEnvelope::for_current_chain( + abi::self_id(), + MULTISIG_CONTROLLER_DOMAIN, + action_id, + payload_hash, + ); + let principal = self.authorizations.verify_signed_action( + authorization, + envelope, + now(), + ); + if !self.controller.is_owner(principal) { + panic!("{}", error::UNAUTHORIZED); + } + principal + } + + fn verify_quorum( + &self, + action_id: [u8; 32], + payload_hash: [u8; 32], + approvals: &[SignedAuthorization], + ) -> Vec { + self.controller.verify_action( + &self.authorizations, + CallContext::current(), + approvals, + ActionEnvelope::for_current_chain( + abi::self_id(), + MULTISIG_CONTROLLER_DOMAIN, + action_id, + payload_hash, + ), + now(), + ) + } + + fn consume_authorization( + &mut self, + authorization: Option<&SignedAuthorization>, + ) { + if let Some(authorization) = authorization { + self.authorizations.consume_verified(authorization); + } + } + + fn consume_approvals(&mut self, approvals: &[SignedAuthorization]) { + for approval in approvals { + self.authorizations.consume_verified(approval); + } + } + + fn execute_if_ready( + &mut self, + outcome: MultisigControllerOutcome, + ) -> (Vec, Option<(bool, Option)>) { + let Some(operation) = outcome.ready_operation else { + return (Vec::new(), None); + }; + + let call = &operation.target.call; + let (success, return_data, error) = match abi::call_raw( + call.contract, + &call.fn_name, + &call.fn_args, + ) { + Ok(data) => (true, data, None), + Err(error) => (false, Vec::new(), Some(format!("{error}"))), + }; + (return_data, Some((success, error))) + } + + fn assert_initialized(&self) { + if !self.initialized { + panic!("{}", error::NOT_INITIALIZED); + } + } + } + + impl Default for MultisigControllerContract { + fn default() -> Self { + Self::new() + } + } + + fn operation_id(target: &MultisigTarget) -> MultisigOperationId { + let mut bytes = + Vec::from(&b"dusk-contract-standards/multisig/op/v1"[..]); + bytes.push(abi::chain_id()); + bytes.extend_from_slice(&abi::self_id().to_bytes()); + bytes.extend_from_slice(&target.call.to_var_bytes()); + bytes.extend_from_slice(&target.salt); + abi::keccak256(bytes) + } + + fn authority_payload_hash( + owners: &[Principal], + threshold: Threshold, + ) -> [u8; 32] { + let mut bytes = Vec::from(&b"multisig.authority"[..]); + bytes.extend_from_slice(&(owners.len() as u32).to_be_bytes()); + for owner in owners { + push_principal(&mut bytes, *owner); + } + bytes.extend_from_slice(&threshold.to_be_bytes()); + abi::keccak256(bytes) + } + + fn time_limits_payload_hash( + proposal_ttl: u64, + tombstone_ttl: u64, + ) -> [u8; 32] { + let mut bytes = Vec::from(&b"multisig.time_limits"[..]); + bytes.extend_from_slice(&proposal_ttl.to_be_bytes()); + bytes.extend_from_slice(&tombstone_ttl.to_be_bytes()); + abi::keccak256(bytes) + } + + fn push_principal(bytes: &mut Vec, principal: Principal) { + let principal = principal.to_bytes(); + bytes.extend_from_slice(&(principal.len() as u16).to_be_bytes()); + bytes.extend_from_slice(&principal); + } + + fn now() -> u64 { + abi::block_height() + } +}