diff --git a/Makefile b/Makefile index fce4220..86550ab 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -STANDARDS_EXAMPLES := standards/examples/multisig_controller +STANDARDS_EXAMPLES := standards/examples/drc20_roles_pausable 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 diff --git a/standards/Cargo.lock b/standards/Cargo.lock index 7d3391a..1fd62a0 100644 --- a/standards/Cargo.lock +++ b/standards/Cargo.lock @@ -819,6 +819,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "drc20-roles-pausable" +version = "0.1.0" +dependencies = [ + "bytecheck", + "dusk-contract-standards", + "dusk-core", + "dusk-data-driver", + "dusk-forge", + "rkyv", + "serde", + "serde_json", +] + [[package]] name = "dusk-bls12_381" version = "0.14.2" diff --git a/standards/Cargo.toml b/standards/Cargo.toml index 394205c..ae5b987 100644 --- a/standards/Cargo.toml +++ b/standards/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "dusk-contract-standards", + "examples/drc20_roles_pausable", "examples/multisig_controller", ] diff --git a/standards/dusk-contract-standards/src/lib.rs b/standards/dusk-contract-standards/src/lib.rs index 10ab2ac..dc509da 100644 --- a/standards/dusk-contract-standards/src/lib.rs +++ b/standards/dusk-contract-standards/src/lib.rs @@ -40,3 +40,4 @@ pub mod auth; pub mod core; pub mod governance; pub mod security; +pub mod token; diff --git a/standards/dusk-contract-standards/src/token/drc20/events.rs b/standards/dusk-contract-standards/src/token/drc20/events.rs new file mode 100644 index 0000000..909852c --- /dev/null +++ b/standards/dusk-contract-standards/src/token/drc20/events.rs @@ -0,0 +1,55 @@ +// 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. + +//! DRC20 event payloads. + +use bytecheck::CheckBytes; +use rkyv::{Archive, Deserialize, Serialize}; + +use crate::core::Principal; + +/// Transfer event topic. +pub const TRANSFER_TOPIC: &str = "drc20/transfer"; +/// Approval event topic. +pub const APPROVAL_TOPIC: &str = "drc20/approval"; + +/// Transfer 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 Transfer { + /// Sender. + pub from: Principal, + /// Recipient. + pub to: Principal, + /// Amount. + pub amount: u64, +} + +/// Approval 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 Approval { + /// Owner. + pub owner: Principal, + /// Spender. + pub spender: Principal, + /// Amount. + pub amount: u64, +} + +impl dusk_forge::ContractEvent for Transfer { + const TOPICS: &'static [&'static str] = &[TRANSFER_TOPIC]; +} + +impl dusk_forge::ContractEvent for Approval { + const TOPICS: &'static [&'static str] = &[APPROVAL_TOPIC]; +} diff --git a/standards/dusk-contract-standards/src/token/drc20/extensions.rs b/standards/dusk-contract-standards/src/token/drc20/extensions.rs new file mode 100644 index 0000000..078e470 --- /dev/null +++ b/standards/dusk-contract-standards/src/token/drc20/extensions.rs @@ -0,0 +1,272 @@ +// 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. + +//! Optional DRC20 policy helpers. + +use alloc::collections::BTreeMap; +use alloc::vec::Vec; + +use bytecheck::CheckBytes; +use rkyv::{Archive, Deserialize, Serialize}; + +use crate::core::{error, Principal}; + +/// Supply cap policy. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct SupplyCap { + cap: u64, +} + +impl SupplyCap { + /// Creates a supply cap. + pub const fn new(cap: u64) -> Self { + Self { cap } + } + + /// Returns the cap. + pub const fn cap(&self) -> u64 { + self.cap + } + + /// Sets a new cap. The new cap must not be below current supply. + pub fn set_cap(&mut self, current_supply: u64, cap: u64) { + if cap < current_supply { + panic!("DRC20: cap below current supply"); + } + self.cap = cap; + } + + /// Returns remaining mintable supply. + pub const fn remaining(&self, current_supply: u64) -> u64 { + self.cap.saturating_sub(current_supply) + } + + /// Panics unless minting `amount` keeps supply within cap. + pub fn assert_mint(&self, current_supply: u64, amount: u64) { + let next = current_supply.checked_add(amount).expect(error::OVERFLOW); + if next > self.cap { + panic!("DRC20: cap exceeded"); + } + } +} + +/// Vote or accounting checkpoint. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct Checkpoint { + /// Block height, timestamp, epoch, or app-selected timepoint. + pub key: u64, + /// Value at that timepoint. + pub value: u64, +} + +/// Ordered checkpoint trace. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Checkpoints { + checkpoints: Vec, +} + +impl Checkpoints { + /// Creates an empty trace. + pub const fn new() -> Self { + Self { + checkpoints: Vec::new(), + } + } + + /// Returns all checkpoints. + pub fn entries(&self) -> &[Checkpoint] { + &self.checkpoints + } + + /// Returns latest value or zero. + pub fn latest(&self) -> u64 { + self.checkpoints + .last() + .map(|checkpoint| checkpoint.value) + .unwrap_or(0) + } + + /// Writes a checkpoint. + pub fn push(&mut self, key: u64, value: u64) { + if let Some(last) = self.checkpoints.last_mut() { + if key < last.key { + panic!("Checkpoints: non-monotonic key"); + } + if key == last.key { + last.value = value; + return; + } + } + self.checkpoints.push(Checkpoint { key, value }); + } + + fn assert_can_push(&self, key: u64) { + if let Some(last) = self.checkpoints.last() { + if key < last.key { + panic!("Checkpoints: non-monotonic key"); + } + } + } + + /// Returns value at or before `key`. + pub fn get_at(&self, key: u64) -> u64 { + let mut low = 0usize; + let mut high = self.checkpoints.len(); + while low < high { + let mid = (low + high) / 2; + if self.checkpoints[mid].key <= key { + low = mid + 1; + } else { + high = mid; + } + } + if low == 0 { + 0 + } else { + self.checkpoints[low - 1].value + } + } +} + +/// DRC20 voting-unit checkpoint store. +/// +/// Composing contracts call `move_units` after mint, burn, and transfer hooks +/// when they want historical vote/accounting queries. +#[derive(Clone, Debug, Default)] +pub struct VotingUnits { + accounts: BTreeMap, + total_supply: Checkpoints, +} + +impl VotingUnits { + /// Creates empty voting-unit state. + pub const fn new() -> Self { + Self { + accounts: BTreeMap::new(), + total_supply: Checkpoints::new(), + } + } + + /// Returns latest account units. + pub fn latest_votes(&self, account: Principal) -> u64 { + self.accounts + .get(&account) + .map(Checkpoints::latest) + .unwrap_or(0) + } + + /// Returns historical account units. + pub fn past_votes(&self, account: Principal, timepoint: u64) -> u64 { + self.accounts + .get(&account) + .map(|trace| trace.get_at(timepoint)) + .unwrap_or(0) + } + + /// Returns latest total units. + pub fn latest_total_supply(&self) -> u64 { + self.total_supply.latest() + } + + /// Returns historical total units. + pub fn past_total_supply(&self, timepoint: u64) -> u64 { + self.total_supply.get_at(timepoint) + } + + /// Writes an account checkpoint directly. + pub fn write_votes( + &mut self, + account: Principal, + timepoint: u64, + value: u64, + ) { + self.accounts + .entry(account) + .or_default() + .push(timepoint, value); + } + + /// Moves units between accounts, minting from `None` or burning to `None`. + pub fn move_units( + &mut self, + from: Option, + to: Option, + amount: u64, + timepoint: u64, + ) { + if amount == 0 || from == to { + return; + } + + let from_next = if let Some(from) = from { + let current = self.latest_votes(from); + if current < amount { + panic!("VotingUnits: insufficient units"); + } + Some((from, current - amount)) + } else { + None + }; + + let to_next = if let Some(to) = to { + let current = self.latest_votes(to); + let next = current.checked_add(amount).expect(error::OVERFLOW); + Some((to, next)) + } else { + None + }; + + let total_next = match (from, to) { + (None, Some(_)) => Some( + self.latest_total_supply() + .checked_add(amount) + .expect(error::OVERFLOW), + ), + (Some(_), None) => { + let current = self.latest_total_supply(); + if current < amount { + panic!("VotingUnits: total supply underflow"); + } + Some(current - amount) + } + _ => None, + }; + + if let Some((from, _)) = from_next { + self.assert_can_write_votes(from, timepoint); + } + if let Some((to, _)) = to_next { + self.assert_can_write_votes(to, timepoint); + } + if total_next.is_some() { + self.total_supply.assert_can_push(timepoint); + } + + if let Some((from, value)) = from_next { + self.write_votes(from, timepoint, value); + } + if let Some((to, value)) = to_next { + self.write_votes(to, timepoint, value); + } + if let Some(value) = total_next { + self.total_supply.push(timepoint, value); + } + } + + fn assert_can_write_votes(&self, account: Principal, timepoint: u64) { + if let Some(trace) = self.accounts.get(&account) { + trace.assert_can_push(timepoint); + } + } +} diff --git a/standards/dusk-contract-standards/src/token/drc20/mod.rs b/standards/dusk-contract-standards/src/token/drc20/mod.rs new file mode 100644 index 0000000..bd633e2 --- /dev/null +++ b/standards/dusk-contract-standards/src/token/drc20/mod.rs @@ -0,0 +1,350 @@ +// 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. + +//! DRC20 fungible token primitive. + +pub mod events; +pub mod extensions; +pub mod types; + +use alloc::collections::BTreeMap; +use alloc::string::String; +use alloc::vec::Vec; + +use crate::core::{error, Principal}; +use events::{Approval, Transfer}; +pub use extensions::{Checkpoint, Checkpoints, SupplyCap, VotingUnits}; +pub use types::{ + Allowance, ApproveCall, BalanceOf, DecreaseAllowanceCall, + IncreaseAllowanceCall, Init, InitBalance, SignedApproveCall, TransferCall, + TransferFromCall, +}; + +/// Reserved zero principal. +pub const ZERO_PRINCIPAL: Principal = + Principal::Contract(dusk_core::abi::ContractId::from_bytes([0u8; 32])); + +/// DRC20 token state and logic. +#[derive(Clone, Debug)] +pub struct Drc20 { + initialized: bool, + name: String, + symbol: String, + decimals: u8, + balances: BTreeMap, + allowances: BTreeMap<(Principal, Principal), u64>, + supply: u64, +} + +impl Drc20 { + /// Creates empty token state. + pub const fn new() -> Self { + Self { + initialized: false, + name: String::new(), + symbol: String::new(), + decimals: 0, + balances: BTreeMap::new(), + allowances: BTreeMap::new(), + supply: 0, + } + } + + /// Initializes token metadata and initial supply. + pub fn init(&mut self, args: Init) -> Vec { + if self.initialized { + panic!("{}", error::ALREADY_INITIALIZED); + } + let mut initial_supply = 0u64; + for entry in &args.initial_balances { + if entry.account.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + initial_supply = initial_supply + .checked_add(entry.amount) + .expect(error::OVERFLOW); + } + + self.initialized = true; + self.name = args.name; + self.symbol = args.symbol; + self.decimals = args.decimals; + + let mut events = Vec::new(); + for entry in args.initial_balances { + if entry.amount == 0 { + continue; + } + self.mint_internal(entry.account, entry.amount); + events.push(Transfer { + from: ZERO_PRINCIPAL, + to: entry.account, + amount: entry.amount, + }); + } + events + } + + /// Token name. + pub fn name(&self) -> String { + self.assert_initialized(); + self.name.clone() + } + + /// Token symbol. + pub fn symbol(&self) -> String { + self.assert_initialized(); + self.symbol.clone() + } + + /// Token decimals. + pub fn decimals(&self) -> u8 { + self.assert_initialized(); + self.decimals + } + + /// Total supply. + pub fn total_supply(&self) -> u64 { + self.assert_initialized(); + self.supply + } + + /// Balance of `account`. + pub fn balance_of(&self, args: BalanceOf) -> u64 { + self.assert_initialized(); + self.balances.get(&args.account).copied().unwrap_or(0) + } + + /// Allowance from owner to spender. + pub fn allowance(&self, args: Allowance) -> u64 { + self.assert_initialized(); + self.allowances + .get(&(args.owner, args.spender)) + .copied() + .unwrap_or(0) + } + + /// Transfers from caller to recipient. + pub fn transfer( + &mut self, + caller: Principal, + args: TransferCall, + ) -> Transfer { + self.assert_initialized(); + self.transfer_internal(caller, args.to, args.amount) + } + + /// Approves spender. + pub fn approve( + &mut self, + caller: Principal, + args: ApproveCall, + ) -> Approval { + self.approve_for(caller, args) + } + + /// Approves spender for an explicitly authorized owner. + pub fn approve_for( + &mut self, + owner: Principal, + args: ApproveCall, + ) -> Approval { + self.assert_initialized(); + if owner.is_zero() || args.spender.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + self.allowances.insert((owner, args.spender), args.amount); + Approval { + owner, + spender: args.spender, + amount: args.amount, + } + } + + /// Increases spender allowance. + pub fn increase_allowance( + &mut self, + caller: Principal, + args: IncreaseAllowanceCall, + ) -> Approval { + self.assert_initialized(); + if caller.is_zero() || args.spender.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + let current = self.allowance(Allowance { + owner: caller, + spender: args.spender, + }); + let amount = current + .checked_add(args.added_amount) + .expect(error::OVERFLOW); + self.allowances.insert((caller, args.spender), amount); + Approval { + owner: caller, + spender: args.spender, + amount, + } + } + + /// Decreases spender allowance. + pub fn decrease_allowance( + &mut self, + caller: Principal, + args: DecreaseAllowanceCall, + ) -> Approval { + self.assert_initialized(); + if caller.is_zero() || args.spender.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + let current = self.allowance(Allowance { + owner: caller, + spender: args.spender, + }); + if current < args.subtracted_amount { + panic!("DRC20: allowance below zero"); + } + let amount = current - args.subtracted_amount; + self.allowances.insert((caller, args.spender), amount); + Approval { + owner: caller, + spender: args.spender, + amount, + } + } + + /// Transfers using allowance. + pub fn transfer_from( + &mut self, + caller: Principal, + args: TransferFromCall, + ) -> Transfer { + self.assert_initialized(); + if caller.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + self.assert_can_transfer(args.owner, args.to, args.amount); + let current = self.allowance(Allowance { + owner: args.owner, + spender: caller, + }); + if current < args.amount { + panic!("DRC20: allowance too low"); + } + self.allowances + .insert((args.owner, caller), current - args.amount); + self.transfer_internal(args.owner, args.to, args.amount) + } + + /// Mints tokens. Access control belongs in the composing contract. + pub fn mint(&mut self, to: Principal, amount: u64) -> Transfer { + self.assert_initialized(); + self.mint_internal(to, amount); + Transfer { + from: ZERO_PRINCIPAL, + to, + amount, + } + } + + /// Burns caller tokens. + pub fn burn(&mut self, from: Principal, amount: u64) -> Transfer { + self.assert_initialized(); + self.burn_internal(from, amount); + Transfer { + from, + to: ZERO_PRINCIPAL, + amount, + } + } + + fn assert_initialized(&self) { + if !self.initialized { + panic!("{}", error::NOT_INITIALIZED); + } + } + + fn mint_internal(&mut self, to: Principal, amount: u64) { + if to.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + let current = self.balances.get(&to).copied().unwrap_or(0); + let next = current.checked_add(amount).expect(error::OVERFLOW); + let supply = self.supply.checked_add(amount).expect(error::OVERFLOW); + if next == 0 { + self.balances.remove(&to); + } else { + self.balances.insert(to, next); + } + self.supply = supply; + } + + fn burn_internal(&mut self, from: Principal, amount: u64) { + if from.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + let current = self.balances.get(&from).copied().unwrap_or(0); + if current < amount { + panic!("DRC20: balance too low"); + } + let supply = self.supply.checked_sub(amount).expect(error::UNDERFLOW); + if amount != 0 { + let next = current - amount; + if next == 0 { + self.balances.remove(&from); + } else { + self.balances.insert(from, next); + } + self.supply = supply; + } + } + + fn transfer_internal( + &mut self, + from: Principal, + to: Principal, + amount: u64, + ) -> Transfer { + self.assert_can_transfer(from, to, amount); + if amount != 0 { + if from == to { + return Transfer { from, to, amount }; + } + let current = self.balances.get(&from).copied().unwrap_or(0); + let next = current - amount; + if next == 0 { + self.balances.remove(&from); + } else { + self.balances.insert(from, next); + } + let to_balance = self.balances.get(&to).copied().unwrap_or(0); + self.balances.insert(to, to_balance + amount); + } + Transfer { from, to, amount } + } + + fn assert_can_transfer(&self, from: Principal, to: Principal, amount: u64) { + if from.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + if to.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + let current = self.balances.get(&from).copied().unwrap_or(0); + if current < amount { + panic!("DRC20: balance too low"); + } + if from != to { + let to_balance = self.balances.get(&to).copied().unwrap_or(0); + to_balance.checked_add(amount).expect(error::OVERFLOW); + } + } +} + +impl Default for Drc20 { + fn default() -> Self { + Self::new() + } +} diff --git a/standards/dusk-contract-standards/src/token/drc20/types.rs b/standards/dusk-contract-standards/src/token/drc20/types.rs new file mode 100644 index 0000000..4b54c80 --- /dev/null +++ b/standards/dusk-contract-standards/src/token/drc20/types.rs @@ -0,0 +1,150 @@ +// 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. + +//! DRC20 rkyv-compatible call types. + +use alloc::string::String; +use alloc::vec::Vec; + +use bytecheck::CheckBytes; +use rkyv::{Archive, Deserialize, Serialize}; + +use crate::auth::SignedAuthorization; +use crate::core::Principal; + +/// One initial balance entry. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct InitBalance { + /// Recipient. + pub account: Principal, + /// Amount. + pub amount: u64, +} + +/// Initialization input. +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct Init { + /// Name. + pub name: String, + /// Symbol. + pub symbol: String, + /// Decimals. + pub decimals: u8, + /// Initial balances. + pub initial_balances: Vec, +} + +/// Balance query. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct BalanceOf { + /// Account. + pub account: Principal, +} + +/// Allowance query. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct Allowance { + /// Owner. + pub owner: Principal, + /// Spender. + pub spender: Principal, +} + +/// Transfer call. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct TransferCall { + /// Recipient. + pub to: Principal, + /// Amount. + pub amount: u64, +} + +/// Approve call. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct ApproveCall { + /// Spender. + pub spender: Principal, + /// Amount. + pub amount: u64, +} + +/// Increase allowance call. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct IncreaseAllowanceCall { + /// Spender. + pub spender: Principal, + /// Added amount. + pub added_amount: u64, +} + +/// Decrease allowance call. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct DecreaseAllowanceCall { + /// Spender. + pub spender: Principal, + /// Subtracted amount. + pub subtracted_amount: u64, +} + +/// Transfer from call. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct TransferFromCall { + /// Owner. + pub owner: Principal, + /// Recipient. + pub to: Principal, + /// Amount. + pub amount: u64, +} + +/// Signed approval call for Moonlight/Phoenix owner authorization. +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct SignedApproveCall { + /// Allowance owner. Must match the signed action principal. + pub owner: Principal, + /// Spender. + pub spender: Principal, + /// Allowance amount. + pub amount: u64, + /// Replay-protected authorization for this approval payload. + pub authorization: SignedAuthorization, +} diff --git a/standards/dusk-contract-standards/src/token/mod.rs b/standards/dusk-contract-standards/src/token/mod.rs new file mode 100644 index 0000000..6b6642a --- /dev/null +++ b/standards/dusk-contract-standards/src/token/mod.rs @@ -0,0 +1,9 @@ +// 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. + +//! Dusk token primitives. + +pub mod drc20; diff --git a/standards/examples/drc20_roles_pausable/Cargo.toml b/standards/examples/drc20_roles_pausable/Cargo.toml new file mode 100644 index 0000000..bb9a648 --- /dev/null +++ b/standards/examples/drc20_roles_pausable/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "drc20-roles-pausable" +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/drc20_roles_pausable/Makefile b/standards/examples/drc20_roles_pausable/Makefile new file mode 100644 index 0000000..3357eb7 --- /dev/null +++ b/standards/examples/drc20_roles_pausable/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 drc20-roles-pausable + +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 drc20-roles-pausable + +clippy: + @cargo clippy -p drc20-roles-pausable -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/drc20_roles_pausable/src/lib.rs b/standards/examples/drc20_roles_pausable/src/lib.rs new file mode 100644 index 0000000..e30ef9b --- /dev/null +++ b/standards/examples/drc20_roles_pausable/src/lib.rs @@ -0,0 +1,557 @@ +// 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. + +//! Example DRC20 composed from reusable Dusk contract standards modules. + +#![no_std] +#![cfg(target_family = "wasm")] + +extern crate alloc; +extern crate self as drc20_roles_pausable_types; + +use bytecheck::CheckBytes; +use dusk_contract_standards::access::Role; +use dusk_contract_standards::auth::SignedAuthorization; +use dusk_contract_standards::core::{NonceDomain, Principal}; +use dusk_contract_standards::token::drc20::Init as TokenInit; +use rkyv::{Archive, Deserialize, Serialize}; + +pub const TOKEN_ADMIN_DOMAIN: NonceDomain = [11u8; 32]; +pub const SIGNED_APPROVE_DOMAIN: NonceDomain = [12u8; 32]; +pub const MINT_ACTION: [u8; 32] = [13u8; 32]; +pub const PAUSE_ACTION: [u8; 32] = [14u8; 32]; +pub const UNPAUSE_ACTION: [u8; 32] = [15u8; 32]; +pub const GRANT_ROLE_ACTION: [u8; 32] = [16u8; 32]; +pub const REVOKE_ROLE_ACTION: [u8; 32] = [17u8; 32]; +pub const SIGNED_APPROVE_ACTION: [u8; 32] = [18u8; 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 admin: Principal, + pub token: TokenInit, + /// Optional max supply. A value of zero means uncapped. + pub cap: u64, +} + +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct MintCall { + pub to: Principal, + pub amount: u64, + pub authorization: Option, +} + +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct RoleQuery { + pub role: Role, + pub account: Principal, +} + +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct RoleCall { + pub role: Role, + pub account: Principal, + 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 AdminCall { + pub authorization: Option, +} + +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct VotesQuery { + pub account: Principal, + pub timepoint: u64, +} + +#[dusk_forge::contract(events = [ + Drc20Transfer, + Drc20Approval, + Paused, + Unpaused, + RoleGranted, + RoleRevoked, +])] +mod drc20_roles_pausable { + use alloc::string::String; + use alloc::vec::Vec; + + use drc20_roles_pausable_types::{ + AdminCall, Init, MintCall, RoleCall, RoleQuery, VotesQuery, + GRANT_ROLE_ACTION, MINT_ACTION, PAUSE_ACTION, REVOKE_ROLE_ACTION, + SIGNED_APPROVE_ACTION, SIGNED_APPROVE_DOMAIN, TOKEN_ADMIN_DOMAIN, + UNPAUSE_ACTION, + }; + use dusk_contract_standards::access::events::{ + Paused, RoleGranted, RoleRevoked, Unpaused, PAUSED_TOPIC, + ROLE_GRANTED_TOPIC, ROLE_REVOKED_TOPIC, UNPAUSED_TOPIC, + }; + use dusk_contract_standards::access::{AccessControl, Pausable, Role}; + use dusk_contract_standards::auth::{ + ActionEnvelope, AuthorizationManager, SignedAuthorization, + }; + use dusk_contract_standards::core::{ + error, CallContext, NonceQuery, Principal, + }; + use dusk_contract_standards::security::ReentrancyGuard; + use dusk_contract_standards::token::drc20::events::{ + Approval as Drc20Approval, Transfer as Drc20Transfer, APPROVAL_TOPIC, + TRANSFER_TOPIC, + }; + use dusk_contract_standards::token::drc20::{ + Allowance, ApproveCall, BalanceOf, DecreaseAllowanceCall, Drc20, + IncreaseAllowanceCall, SignedApproveCall, SupplyCap, TransferCall, + TransferFromCall, VotingUnits, + }; + use dusk_core::abi; + + const MINTER_ROLE: Role = [1u8; 32]; + const PAUSER_ROLE: Role = [2u8; 32]; + + pub struct Drc20RolesPausable { + token: Drc20, + access: AccessControl, + authorizations: AuthorizationManager, + pausable: Pausable, + guard: ReentrancyGuard, + cap: Option, + votes: VotingUnits, + } + + impl Drc20RolesPausable { + pub const fn new() -> Self { + Self { + token: Drc20::new(), + access: AccessControl::new(), + authorizations: AuthorizationManager::new(), + pausable: Pausable::new(), + guard: ReentrancyGuard::new(), + cap: None, + votes: VotingUnits::new(), + } + } + pub fn init(&mut self, args: Init) { + if args.admin.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + let mut initial_supply = 0u64; + for balance in &args.token.initial_balances { + if balance.account.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + initial_supply = initial_supply + .checked_add(balance.amount) + .expect(error::OVERFLOW); + } + if args.cap != 0 { + SupplyCap::new(args.cap).assert_mint(0, initial_supply); + } + + let initial_balances = args.token.initial_balances.clone(); + let events = self.token.init(args.token); + self.access.init_admin(args.admin); + self.access.grant_role(args.admin, MINTER_ROLE, args.admin); + self.access.grant_role(args.admin, PAUSER_ROLE, args.admin); + for event in events { + Self::emit_transfer(event); + } + if args.cap != 0 { + let cap = SupplyCap::new(args.cap); + self.cap = Some(cap); + } + for balance in initial_balances { + self.votes.move_units( + None, + Some(balance.account), + balance.amount, + now(), + ); + } + } + + pub fn name(&self) -> String { + self.token.name() + } + + pub fn symbol(&self) -> String { + self.token.symbol() + } + + pub fn decimals(&self) -> u8 { + self.token.decimals() + } + + pub fn total_supply(&self) -> u64 { + self.token.total_supply() + } + + pub fn balance_of(&self, args: BalanceOf) -> u64 { + self.token.balance_of(args) + } + + pub fn allowance(&self, args: Allowance) -> u64 { + self.token.allowance(args) + } + + pub fn nonce(&self, args: NonceQuery) -> u64 { + self.authorizations.nonce(args.principal, args.domain) + } + + pub fn cap(&self) -> u64 { + self.cap.map(|cap| cap.cap()).unwrap_or(0) + } + + pub fn remaining_mintable(&self) -> u64 { + self.cap + .map(|cap| cap.remaining(self.token.total_supply())) + .unwrap_or(u64::MAX) + } + + pub fn latest_votes(&self, account: Principal) -> u64 { + self.votes.latest_votes(account) + } + + pub fn past_votes(&self, args: VotesQuery) -> u64 { + self.votes.past_votes(args.account, args.timepoint) + } + + pub fn latest_total_votes(&self) -> u64 { + self.votes.latest_total_supply() + } + + pub fn past_total_supply(&self, timepoint: u64) -> u64 { + self.votes.past_total_supply(timepoint) + } + + pub fn has_role(&self, args: RoleQuery) -> bool { + self.access.has_role(args.role, args.account) + } + pub fn transfer(&mut self, args: TransferCall) { + self.pausable.assert_not_paused(); + let caller = caller(); + let event = { + let guard = &mut self.guard; + let token = &mut self.token; + let votes = &mut self.votes; + guard.run(|| { + let event = token.transfer(caller, args); + votes.move_units( + Some(event.from), + Some(event.to), + event.amount, + now(), + ); + event + }) + }; + Self::emit_transfer(event); + } + pub fn approve(&mut self, args: ApproveCall) { + let caller = caller(); + let event = self.token.approve(caller, args); + Self::emit_approval(event); + } + pub fn approve_by_authorization(&mut self, args: SignedApproveCall) { + let principal = self.authorizations.authorize_signed_action( + &args.authorization, + ActionEnvelope::for_current_chain( + abi::self_id(), + SIGNED_APPROVE_DOMAIN, + SIGNED_APPROVE_ACTION, + approve_payload_hash(args.owner, args.spender, args.amount), + ), + now(), + ); + if principal != args.owner { + panic!("{}", error::UNAUTHORIZED); + } + let event = self.token.approve_for( + args.owner, + ApproveCall { + spender: args.spender, + amount: args.amount, + }, + ); + Self::emit_approval(event); + } + pub fn increase_allowance(&mut self, args: IncreaseAllowanceCall) { + let caller = caller(); + let event = self.token.increase_allowance(caller, args); + Self::emit_approval(event); + } + pub fn decrease_allowance(&mut self, args: DecreaseAllowanceCall) { + let caller = caller(); + let event = self.token.decrease_allowance(caller, args); + Self::emit_approval(event); + } + pub fn transfer_from(&mut self, args: TransferFromCall) { + self.pausable.assert_not_paused(); + let caller = caller(); + let event = { + let guard = &mut self.guard; + let token = &mut self.token; + let votes = &mut self.votes; + guard.run(|| { + let event = token.transfer_from(caller, args); + votes.move_units( + Some(event.from), + Some(event.to), + event.amount, + now(), + ); + event + }) + }; + Self::emit_transfer(event); + } + pub fn mint(&mut self, args: MintCall) { + self.pausable.assert_not_paused(); + if args.to.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + if let Some(cap) = self.cap { + cap.assert_mint(self.token.total_supply(), args.amount); + } + self.authorize_role_action( + MINTER_ROLE, + args.authorization.as_ref(), + ActionEnvelope::for_current_chain( + abi::self_id(), + TOKEN_ADMIN_DOMAIN, + MINT_ACTION, + mint_payload_hash(args.to, args.amount), + ), + ); + let event = self.token.mint(args.to, args.amount); + self.votes + .move_units(None, Some(event.to), event.amount, now()); + Self::emit_transfer(event); + } + pub fn burn(&mut self, amount: u64) { + self.pausable.assert_not_paused(); + let caller = caller(); + let event = self.token.burn(caller, amount); + self.votes + .move_units(Some(event.from), None, event.amount, now()); + Self::emit_transfer(event); + } + pub fn pause(&mut self, args: AdminCall) { + self.pausable.assert_not_paused(); + let caller = self.authorize_role_action( + PAUSER_ROLE, + args.authorization.as_ref(), + ActionEnvelope::for_current_chain( + abi::self_id(), + TOKEN_ADMIN_DOMAIN, + PAUSE_ACTION, + empty_payload_hash(b"drc20.pause"), + ), + ); + self.pausable.pause(); + Self::emit_paused(caller); + } + pub fn unpause(&mut self, args: AdminCall) { + self.pausable.assert_paused(); + let caller = self.authorize_role_action( + PAUSER_ROLE, + args.authorization.as_ref(), + ActionEnvelope::for_current_chain( + abi::self_id(), + TOKEN_ADMIN_DOMAIN, + UNPAUSE_ACTION, + empty_payload_hash(b"drc20.unpause"), + ), + ); + self.pausable.unpause(); + Self::emit_unpaused(caller); + } + + pub fn paused(&self) -> bool { + self.pausable.paused() + } + pub fn grant_role(&mut self, args: RoleCall) { + if args.account.is_zero() { + panic!("{}", error::ZERO_PRINCIPAL); + } + let admin = self.access.get_role_admin(args.role); + let caller = self.authorize_role_action( + admin, + args.authorization.as_ref(), + ActionEnvelope::for_current_chain( + abi::self_id(), + TOKEN_ADMIN_DOMAIN, + GRANT_ROLE_ACTION, + role_payload_hash(args.role, args.account), + ), + ); + let had_role = self.access.has_role(args.role, args.account); + self.access.grant_role(caller, args.role, args.account); + if !had_role { + Self::emit_role_granted(args.role, args.account, caller); + } + } + pub fn revoke_role(&mut self, args: RoleCall) { + let admin = self.access.get_role_admin(args.role); + let caller = self.authorize_role_action( + admin, + args.authorization.as_ref(), + ActionEnvelope::for_current_chain( + abi::self_id(), + TOKEN_ADMIN_DOMAIN, + REVOKE_ROLE_ACTION, + role_payload_hash(args.role, args.account), + ), + ); + let had_role = self.access.has_role(args.role, args.account); + self.access.revoke_role(caller, args.role, args.account); + if had_role { + Self::emit_role_revoked(args.role, args.account, caller); + } + } + + fn authorize_role_action( + &mut self, + role: Role, + authorization: Option<&SignedAuthorization>, + envelope: ActionEnvelope, + ) -> Principal { + self.access.authorize_role_action( + role, + &mut self.authorizations, + CallContext::current(), + authorization, + envelope, + now(), + ) + } + + fn emit_transfer(event: Drc20Transfer) { + abi::emit( + TRANSFER_TOPIC, + Drc20Transfer { + from: event.from, + to: event.to, + amount: event.amount, + }, + ); + } + + fn emit_approval(event: Drc20Approval) { + abi::emit( + APPROVAL_TOPIC, + Drc20Approval { + owner: event.owner, + spender: event.spender, + amount: event.amount, + }, + ); + } + + fn emit_paused(account: Principal) { + abi::emit(PAUSED_TOPIC, Paused { account }); + } + + fn emit_unpaused(account: Principal) { + abi::emit(UNPAUSED_TOPIC, Unpaused { account }); + } + + fn emit_role_granted( + role: Role, + account: Principal, + sender: Principal, + ) { + abi::emit( + ROLE_GRANTED_TOPIC, + RoleGranted { + role, + account, + sender, + }, + ); + } + + fn emit_role_revoked( + role: Role, + account: Principal, + sender: Principal, + ) { + abi::emit( + ROLE_REVOKED_TOPIC, + RoleRevoked { + role, + account, + sender, + }, + ); + } + } + + impl Default for Drc20RolesPausable { + fn default() -> Self { + Self::new() + } + } + + fn caller() -> Principal { + CallContext::current().require_principal(error::UNAUTHORIZED) + } + + fn empty_payload_hash(tag: &[u8]) -> [u8; 32] { + abi::keccak256(Vec::from(tag)) + } + + fn mint_payload_hash(to: Principal, amount: u64) -> [u8; 32] { + let mut bytes = Vec::from(&b"drc20.mint"[..]); + push_principal(&mut bytes, to); + bytes.extend_from_slice(&amount.to_be_bytes()); + abi::keccak256(bytes) + } + + fn approve_payload_hash( + owner: Principal, + spender: Principal, + amount: u64, + ) -> [u8; 32] { + let mut bytes = Vec::from(&b"drc20.approve"[..]); + push_principal(&mut bytes, owner); + push_principal(&mut bytes, spender); + bytes.extend_from_slice(&amount.to_be_bytes()); + abi::keccak256(bytes) + } + + fn role_payload_hash(role: Role, account: Principal) -> [u8; 32] { + let mut bytes = Vec::from(&b"drc20.role"[..]); + bytes.extend_from_slice(&role); + push_principal(&mut bytes, account); + 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() + } +}