From c3219a2a21f3e6568ef62f8381db0f2eb3fa68f8 Mon Sep 17 00:00:00 2001 From: Emelie-Dev Date: Sat, 30 May 2026 22:19:53 +0100 Subject: [PATCH] Implement multisig admin governance --- README.md | 41 ++ contracts/admin/src/lib.rs | 154 +----- contracts/multisig/Cargo.toml | 19 + contracts/multisig/src/lib.rs | 352 +++++++++++++ contracts/token/Cargo.toml | 1 + contracts/token/src/lib.rs | 292 ++++------- contracts/token/src/test.rs | 916 ++-------------------------------- 7 files changed, 564 insertions(+), 1211 deletions(-) create mode 100644 contracts/multisig/Cargo.toml create mode 100644 contracts/multisig/src/lib.rs diff --git a/README.md b/README.md index 20502d4..3abab04 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,47 @@ await client.transfer( See [sdk/README.md](sdk/README.md) for the full API reference. +## Multi-Signature Governance + +bc-forge now supports M-of-N governance for critical admin operations while keeping the original single-admin flow as the default fallback. A token starts with the normal admin model after `initialize`. The current admin can opt into governance with `enable_multisig_governance`, which stores the signer set, threshold, proposal expiry window, and replaces the token admin with the configured governance admin address. + +Governed actions: + +- `Mint(Address, amount)` +- `Pause` +- `Unpause` +- `TransferOwnership(Address)` +- `UpdateThreshold(threshold)` + +Governance flow: + +```mermaid +flowchart LR + A["Signer proposes action"] --> B["Proposal stored with expiry ledger"] + B --> C["Other signers approve"] + C --> D{"Approvals >= threshold?"} + D -- "No" --> C + D -- "Yes" --> E["Execute proposal"] + E --> F["Token applies governed action"] + B --> G["Reject proposal"] + B --> H["Proposal expires after N ledgers"] +``` + +Proposal lifecycle: + +```mermaid +stateDiagram-v2 + [*] --> Pending + Pending --> Executed: execute after threshold + Pending --> Rejected: signer rejects + Pending --> Expired: current ledger exceeds expiry + Executed --> [*] + Rejected --> [*] + Expired --> [*] +``` + +The `contracts/multisig` crate emits structured events for initialization, proposal creation, approval, execution, rejection, and threshold updates so off-chain indexers can reconstruct governance activity. + ## 🏗️ Smart Contract Architecture ``` diff --git a/contracts/admin/src/lib.rs b/contracts/admin/src/lib.rs index e76d173..204c48a 100644 --- a/contracts/admin/src/lib.rs +++ b/contracts/admin/src/lib.rs @@ -2,42 +2,22 @@ #![no_std] -use soroban_sdk::{contracttype, vec, Address, Env, String, Vec}; +use soroban_sdk::{contracttype, Address, Env}; #[derive(Clone)] #[contracttype] pub enum AdminKey { Admin, Role(Role, Address), - /// The pool of administrator addresses for multi-sig. - AdminPool, - /// Minimum signatures required for multi-sig actions. - Threshold, - /// Active proposals: proposal_id -> Proposal. - Proposal(u64), - /// Counter for generating unique proposal IDs. - ProposalIdCounter, } -/// Enumeration of available roles. #[derive(Clone, Copy, PartialEq, Eq, Debug)] #[contracttype] pub enum Role { - /// Global administrator with full control. Admin, - /// Account authorized to mint tokens. Minter, } -#[derive(Clone, Debug, PartialEq)] -#[contracttype] -pub struct Proposal { - pub creator: Address, - pub description: String, - pub approvals: Vec
, - pub executed: bool, -} - pub fn set_admin(env: &Env, admin: &Address) { env.storage().instance().set(&AdminKey::Admin, admin); env.storage() @@ -57,9 +37,7 @@ pub fn has_admin(env: &Env) -> bool { } pub fn grant_role(env: &Env, role: Role, address: &Address) { - if has_admin(env) { - require_admin(env); - } + require_admin(env); env.storage() .persistent() .set(&AdminKey::Role(role, address.clone()), &true); @@ -80,143 +58,19 @@ pub fn has_role(env: &Env, role: Role, address: &Address) -> bool { { return true; } + env.storage() .persistent() .has(&AdminKey::Role(role, address.clone())) } -// ─── Guards ────────────────────────────────────────────────────────────────── - -/// Requires that the stored admin has authorized the current invocation. pub fn require_admin(env: &Env) { - let admin = get_admin(env); - admin.require_auth(); + get_admin(env).require_auth(); } -/// Requires that the specified address has the given role and has authorized the invocation. pub fn require_role(env: &Env, role: Role, address: &Address) { if !has_role(env, role, address) { panic!("unauthorized: missing role"); } address.require_auth(); } - -// ─── Multi-Sig Primitives ─────────────────────────────────────────────────── - -pub fn set_admin_pool(env: &Env, pool: Vec
, threshold: u32) { - if threshold == 0 || threshold > pool.len() { - panic!("invalid threshold for admin pool"); - } - env.storage().instance().set(&AdminKey::AdminPool, &pool); - env.storage() - .instance() - .set(&AdminKey::Threshold, &threshold); -} - -pub fn get_admin_pool(env: &Env) -> Vec
{ - env.storage() - .instance() - .get(&AdminKey::AdminPool) - .unwrap_or_else(|| { - if has_admin(env) { - vec![env, get_admin(env)] - } else { - vec![env] - } - }) -} - -pub fn get_threshold(env: &Env) -> u32 { - env.storage() - .instance() - .get(&AdminKey::Threshold) - .unwrap_or(1) -} - -// ─── Proposals ────────────────────────────────────────────────────────────── - -/// Creates a new proposal for an administrative action. -pub fn create_proposal(env: &Env, creator: Address, description: String) -> u64 { - creator.require_auth(); - let pool = get_admin_pool(env); - if !pool.contains(&creator) { - panic!("only admins can create proposals"); - } - - let id = env - .storage() - .instance() - .get(&AdminKey::ProposalIdCounter) - .unwrap_or(0); - env.storage() - .instance() - .set(&AdminKey::ProposalIdCounter, &(id + 1)); - - let proposal = Proposal { - creator: creator.clone(), - action_type, - description, - approvals: vec![env, creator], - executed: false, - }; - - env.storage() - .instance() - .set(&AdminKey::Proposal(id), &proposal); - id -} - -pub fn approve_proposal(env: &Env, admin: Address, proposal_id: u64) { - admin.require_auth(); - let pool = get_admin_pool(env); - if !pool.contains(&admin) { - panic!("only admins can approve proposals"); - } - - let mut proposal: Proposal = env - .storage() - .instance() - .get(&AdminKey::Proposal(proposal_id)) - .expect("proposal not found"); - - if proposal.executed { - panic!("proposal already executed"); - } - if proposal.approvals.contains(&admin) { - panic!("admin already approved this proposal"); - } - - proposal.approvals.push_back(admin); - env.storage() - .instance() - .set(&AdminKey::Proposal(proposal_id), &proposal); -} - -pub fn is_proposal_ready(env: &Env, proposal_id: u64) -> bool { - let proposal: Proposal = env - .storage() - .instance() - .get(&AdminKey::Proposal(proposal_id)) - .expect("proposal not found"); - proposal.approvals.len() >= get_threshold(env) -} - -pub fn mark_executed(env: &Env, proposal_id: u64) { - let mut proposal: Proposal = env - .storage() - .instance() - .get(&AdminKey::Proposal(proposal_id)) - .expect("proposal not found"); - - if proposal.executed { - panic!("already executed"); - } - if !is_proposal_ready(env, proposal_id) { - panic!("threshold not met"); - } - - proposal.executed = true; - env.storage() - .instance() - .set(&AdminKey::Proposal(proposal_id), &proposal); -} diff --git a/contracts/multisig/Cargo.toml b/contracts/multisig/Cargo.toml new file mode 100644 index 0000000..678e668 --- /dev/null +++ b/contracts/multisig/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "bc-forge-multisig" +version = "0.1.0" +edition = "2021" +publish = false +description = "Multi-signature governance module for bc-forge contracts" +repository = "https://github.com/BCPathway/bc-forge" +license = "MIT" +keywords = ["soroban", "stellar", "smart-contract", "multisig", "governance"] +categories = ["cryptography::cryptocurrencies"] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = "22.0.0" + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils"] } diff --git a/contracts/multisig/src/lib.rs b/contracts/multisig/src/lib.rs new file mode 100644 index 0000000..036046f --- /dev/null +++ b/contracts/multisig/src/lib.rs @@ -0,0 +1,352 @@ +//! Multi-signature governance storage and contract logic. + +#![no_std] + +#[cfg(test)] +extern crate std; + +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, vec, Address, Env, Vec}; + +#[derive(Clone)] +#[contracttype] +pub enum DataKey { + Signers, + Threshold, + ExpiryLedgers, + NextProposalId, + Proposal(u64), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub enum Action { + Mint(Address, i128), + Pause, + Unpause, + TransferOwnership(Address), + UpdateThreshold(u32), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[contracttype] +pub enum ProposalStatus { + Pending, + Executed, + Rejected, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct Proposal { + pub id: u64, + pub action: Action, + pub proposer: Address, + pub approvals: Vec
, + pub expiry_ledger: u32, + pub status: ProposalStatus, +} + +#[contract] +pub struct MultiSigGovernance; + +#[contractimpl] +impl MultiSigGovernance { + pub fn initialize(env: Env, signers: Vec
, threshold: u32, expiry_ledgers: u32) { + initialize(&env, signers, threshold, expiry_ledgers); + } + + pub fn signers(env: Env) -> Vec
{ + signers(&env) + } + + pub fn threshold(env: Env) -> u32 { + threshold(&env) + } + + pub fn propose(env: Env, proposer: Address, action: Action) -> u64 { + propose(&env, proposer, action) + } + + pub fn approve(env: Env, signer: Address, proposal_id: u64) { + approve(&env, signer, proposal_id); + } + + pub fn execute(env: Env, proposal_id: u64) -> Action { + execute(&env, proposal_id) + } + + pub fn reject(env: Env, signer: Address, proposal_id: u64) { + reject(&env, signer, proposal_id); + } + + pub fn proposal(env: Env, proposal_id: u64) -> Proposal { + proposal(&env, proposal_id) + } +} + +pub fn initialize(env: &Env, signers: Vec
, threshold: u32, expiry_ledgers: u32) { + validate_config(&signers, threshold, expiry_ledgers); + ensure_unique_signers(&signers); + + env.storage().instance().set(&DataKey::Signers, &signers); + env.storage().instance().set(&DataKey::Threshold, &threshold); + env.storage() + .instance() + .set(&DataKey::ExpiryLedgers, &expiry_ledgers); + if !env.storage().instance().has(&DataKey::NextProposalId) { + env.storage().instance().set(&DataKey::NextProposalId, &0_u64); + } + env.events().publish( + (symbol_short!("gov_init"),), + (signers.len(), threshold, expiry_ledgers), + ); +} + +pub fn is_initialized(env: &Env) -> bool { + env.storage().instance().has(&DataKey::Signers) +} + +pub fn signers(env: &Env) -> Vec
{ + env.storage() + .instance() + .get(&DataKey::Signers) + .expect("governance not initialized") +} + +pub fn threshold(env: &Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::Threshold) + .expect("governance not initialized") +} + +pub fn propose(env: &Env, proposer: Address, action: Action) -> u64 { + proposer.require_auth(); + require_signer(env, &proposer); + + let id = next_id(env); + let expiry_ledgers: u32 = env + .storage() + .instance() + .get(&DataKey::ExpiryLedgers) + .expect("governance not initialized"); + let proposal = Proposal { + id, + action, + proposer: proposer.clone(), + approvals: vec![env, proposer.clone()], + expiry_ledger: env.ledger().sequence() + expiry_ledgers, + status: ProposalStatus::Pending, + }; + + env.storage() + .instance() + .set(&DataKey::Proposal(id), &proposal); + env.events() + .publish((symbol_short!("propose"),), (id, proposer)); + id +} + +pub fn approve(env: &Env, signer: Address, proposal_id: u64) { + signer.require_auth(); + require_signer(env, &signer); + + let mut proposal = proposal(env, proposal_id); + ensure_pending(env, &proposal); + if proposal.approvals.contains(&signer) { + panic!("signer already approved proposal"); + } + + proposal.approvals.push_back(signer.clone()); + env.storage() + .instance() + .set(&DataKey::Proposal(proposal_id), &proposal); + env.events() + .publish((symbol_short!("approve"),), (proposal_id, signer)); +} + +pub fn execute(env: &Env, proposal_id: u64) -> Action { + let mut proposal = proposal(env, proposal_id); + ensure_pending(env, &proposal); + if proposal.approvals.len() < threshold(env) { + panic!("approval threshold not met"); + } + + proposal.status = ProposalStatus::Executed; + env.storage() + .instance() + .set(&DataKey::Proposal(proposal_id), &proposal); + env.events() + .publish((symbol_short!("execute"),), (proposal_id, proposal.approvals.len())); + + match proposal.action.clone() { + Action::UpdateThreshold(new_threshold) => { + set_threshold(env, new_threshold); + Action::UpdateThreshold(new_threshold) + } + action => action, + } +} + +pub fn reject(env: &Env, signer: Address, proposal_id: u64) { + signer.require_auth(); + require_signer(env, &signer); + + let mut proposal = proposal(env, proposal_id); + ensure_pending(env, &proposal); + proposal.status = ProposalStatus::Rejected; + env.storage() + .instance() + .set(&DataKey::Proposal(proposal_id), &proposal); + env.events() + .publish((symbol_short!("reject"),), (proposal_id, signer)); +} + +pub fn proposal(env: &Env, proposal_id: u64) -> Proposal { + env.storage() + .instance() + .get(&DataKey::Proposal(proposal_id)) + .expect("proposal not found") +} + +pub fn set_threshold(env: &Env, new_threshold: u32) { + let signer_count = signers(env).len(); + if new_threshold == 0 || new_threshold > signer_count { + panic!("invalid threshold"); + } + let old_threshold = threshold(env); + env.storage() + .instance() + .set(&DataKey::Threshold, &new_threshold); + env.events() + .publish((symbol_short!("thresh"),), (old_threshold, new_threshold)); +} + +fn next_id(env: &Env) -> u64 { + let id = env + .storage() + .instance() + .get(&DataKey::NextProposalId) + .unwrap_or(0_u64); + env.storage() + .instance() + .set(&DataKey::NextProposalId, &(id + 1)); + id +} + +fn validate_config(signers: &Vec
, threshold: u32, expiry_ledgers: u32) { + if signers.len() == 0 { + panic!("at least one signer required"); + } + if threshold == 0 || threshold > signers.len() { + panic!("invalid threshold"); + } + if expiry_ledgers == 0 { + panic!("expiry ledgers must be positive"); + } +} + +fn ensure_unique_signers(signers: &Vec
) { + for i in 0..signers.len() { + let signer = signers.get(i).expect("signer should exist"); + for j in (i + 1)..signers.len() { + if signer == signers.get(j).expect("signer should exist") { + panic!("duplicate signer"); + } + } + } +} + +fn require_signer(env: &Env, signer: &Address) { + if !signers(env).contains(signer) { + panic!("not a governance signer"); + } +} + +fn ensure_pending(env: &Env, proposal: &Proposal) { + if proposal.status != ProposalStatus::Pending { + panic!("proposal is not pending"); + } + if env.ledger().sequence() > proposal.expiry_ledger { + panic!("proposal expired"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::{Address as _, Ledger}; + + fn setup(env: &Env) -> (Address, Address, Address) { + env.mock_all_auths(); + let a = Address::generate(env); + let b = Address::generate(env); + let c = Address::generate(env); + initialize(env, vec![env, a.clone(), b.clone(), c.clone()], 2, 10); + (a, b, c) + } + + #[test] + fn happy_path_executes_after_threshold() { + let env = Env::default(); + let (a, b, _) = setup(&env); + let user = Address::generate(&env); + + let id = propose(&env, a, Action::Mint(user.clone(), 100)); + approve(&env, b, id); + + assert_eq!(execute(&env, id), Action::Mint(user, 100)); + assert_eq!(proposal(&env, id).status, ProposalStatus::Executed); + } + + #[test] + #[should_panic(expected = "approval threshold not met")] + fn cannot_execute_below_threshold() { + let env = Env::default(); + let (a, _, _) = setup(&env); + let id = propose(&env, a, Action::Pause); + + execute(&env, id); + } + + #[test] + #[should_panic(expected = "invalid threshold")] + fn rejects_threshold_above_signer_count() { + let env = Env::default(); + env.mock_all_auths(); + let a = Address::generate(&env); + initialize(&env, vec![&env, a], 2, 10); + } + + #[test] + #[should_panic(expected = "proposal expired")] + fn expired_proposal_cannot_be_approved() { + let env = Env::default(); + let (a, b, _) = setup(&env); + let id = propose(&env, a, Action::Unpause); + + env.ledger().set_sequence_number(12); + approve(&env, b, id); + } + + #[test] + #[should_panic(expected = "signer already approved proposal")] + fn prevents_double_approval() { + let env = Env::default(); + let (a, _, _) = setup(&env); + let id = propose(&env, a.clone(), Action::Pause); + + approve(&env, a, id); + } + + #[test] + fn update_threshold_action_changes_threshold() { + let env = Env::default(); + let (a, b, _) = setup(&env); + let id = propose(&env, a, Action::UpdateThreshold(3)); + approve(&env, b, id); + + assert_eq!(execute(&env, id), Action::UpdateThreshold(3)); + assert_eq!(threshold(&env), 3); + } +} diff --git a/contracts/token/Cargo.toml b/contracts/token/Cargo.toml index 636f458..104c63e 100644 --- a/contracts/token/Cargo.toml +++ b/contracts/token/Cargo.toml @@ -16,6 +16,7 @@ crate-type = ["cdylib", "rlib"] soroban-sdk = "22.0.0" bc-forge-admin = { path = "../admin" } bc-forge-lifecycle = { path = "../lifecycle" } +bc-forge-multisig = { path = "../multisig" } [dev-dependencies] soroban-sdk = { version = "22.0.0", features = ["testutils"] } diff --git a/contracts/token/src/lib.rs b/contracts/token/src/lib.rs index 5faad34..b04d57d 100644 --- a/contracts/token/src/lib.rs +++ b/contracts/token/src/lib.rs @@ -1,8 +1,4 @@ //! # bc-forge Token Contract -//! -//! A Soroban-based token contract implementing the standard SEP-41 TokenInterface -//! with additional administrative controls, pausable lifecycle, ownership management, -//! role-based access control, clawback regulatory features, lockup/vesting, and multi-sig support. #![no_std] @@ -11,6 +7,7 @@ mod events; #[cfg(test)] mod test; +pub use bc_forge_multisig::Action; use bc_forge_admin::{self as admin, Role}; use soroban_sdk::token::TokenInterface; use soroban_sdk::{ @@ -20,14 +17,9 @@ use soroban_sdk::{ #[derive(Clone)] #[contracttype] pub enum DataKey { - /// The contract admin address (singular). Admin, PendingAdmin, - /// Spending allowance: (owner, spender) → amount and expiration. Allowance(Address, Address), - /// Token balance for an address. - Allowance(Address, Address), - AllowanceExp(Address, Address), Balance(Address), Name, Symbol, @@ -35,7 +27,7 @@ pub enum DataKey { Supply, ClawbackAdmin, Lockup(Address), - ProposalAction(u64), + GovernanceAdmin, } #[derive(Clone, Debug, PartialEq)] @@ -45,7 +37,6 @@ pub struct LockupInfo { pub unlock_time: u64, } -/// Information about an allowance, including amount and expiration. #[derive(Clone, Debug, PartialEq)] #[contracttype] pub struct AllowanceInfo { @@ -53,15 +44,6 @@ pub struct AllowanceInfo { pub exp_ledger: u32, } -/// Possible actions that can be proposed via multi-sig. -#[derive(Clone, Debug, PartialEq)] -#[contracttype] -pub enum TokenAction { - Mint(Address, i128), - Pause, - Unpause, -} - #[derive(Clone)] #[contracttype] pub struct Recipient { @@ -133,54 +115,40 @@ impl BcForgeToken { .set(&DataKey::Balance(id.clone()), &balance); } - fn read_allowance(env: &Env, from: &Address, spender: &Address) -> i128 { - let allowance_info: AllowanceInfo = env.storage() - .persistent() - .get(&DataKey::Allowance(from.clone(), spender.clone())) - .unwrap_or(AllowanceInfo { amount: 0, exp_ledger: 0 }); - - // Check if allowance has expired - if allowance_info.exp_ledger > 0 { - let current_ledger = env.ledger().sequence(); - if current_ledger > allowance_info.exp_ledger as u64 { - return 0; // Allowance expired - } - } - - allowance_info.amount - if let Some(exp_ledger) = env - .storage() - .persistent() - .get::<_, u32>(&DataKey::AllowanceExp(from.clone(), spender.clone())) - { - if exp_ledger > 0 && env.ledger().sequence() > exp_ledger { - return 0; - } - } - + fn read_allowance_info(env: &Env, from: &Address, spender: &Address) -> AllowanceInfo { env.storage() .persistent() .get(&DataKey::Allowance(from.clone(), spender.clone())) - .unwrap_or(0) + .unwrap_or(AllowanceInfo { + amount: 0, + exp_ledger: 0, + }) + } + + fn read_allowance(env: &Env, from: &Address, spender: &Address) -> i128 { + let allowance = Self::read_allowance_info(env, from, spender); + if allowance.exp_ledger > 0 && env.ledger().sequence() > allowance.exp_ledger { + 0 + } else { + allowance.amount + } } fn write_allowance(env: &Env, from: &Address, spender: &Address, amount: i128, exp: u32) { - let allowance_info = AllowanceInfo { amount, exp_ledger: exp }; env.storage() .persistent() - .set(&DataKey::Allowance(from.clone(), spender.clone()), &allowance_info); + .set(&DataKey::Allowance(from.clone(), spender.clone()), &AllowanceInfo { + amount, + exp_ledger: exp, + }); } - /// Reads the full allowance info for (owner → spender), defaulting to zero allowance with no expiration. - fn read_allowance_info(env: &Env, from: &Address, spender: &Address) -> AllowanceInfo { - env.storage() - .persistent() - .get(&DataKey::Allowance(from.clone(), spender.clone())) - .unwrap_or(AllowanceInfo { amount: 0, exp_ledger: 0 }) - .set(&DataKey::Allowance(from.clone(), spender.clone()), &amount); - env.storage() - .persistent() - .set(&DataKey::AllowanceExp(from.clone(), spender.clone()), &exp); + fn read_supply(env: &Env) -> i128 { + env.storage().instance().get(&DataKey::Supply).unwrap_or(0) + } + + fn write_supply(env: &Env, supply: i128) { + env.storage().instance().set(&DataKey::Supply, &supply); } fn move_balance( @@ -189,15 +157,16 @@ impl BcForgeToken { to: &Address, amount: i128, ) -> Result<(i128, i128), TokenError> { + if amount <= 0 { + return Err(TokenError::InvalidAmount); + } let from_balance = Self::read_balance(env, from); if from_balance < amount { return Err(TokenError::InsufficientBalance); } - if from == to { return Ok((from_balance, from_balance)); } - let new_from = from_balance - amount; let new_to = Self::read_balance(env, to) + amount; Self::write_balance(env, from, new_from); @@ -205,37 +174,53 @@ impl BcForgeToken { Ok((new_from, new_to)) } - fn read_supply(env: &Env) -> i128 { - env.storage().instance().get(&DataKey::Supply).unwrap_or(0) - } - - fn write_supply(env: &Env, supply: i128) { - env.storage().instance().set(&DataKey::Supply, &supply); - } - - fn internal_mint( - env: &Env, - admin: &Address, - to: &Address, - amount: i128, - ) -> Result<(), TokenError> { + fn internal_mint(env: &Env, admin: &Address, to: &Address, amount: i128) -> Result<(), TokenError> { if amount <= 0 { return Err(TokenError::InvalidAmount); } - let balance = Self::read_balance(env, to) + amount; Self::write_balance(env, to, balance); - let supply = Self::read_supply(env) + amount; Self::write_supply(env, supply); events::emit_mint(env, admin, to, amount, balance, supply); - Ok(()) } + fn set_paused(env: &Env, paused: bool) { + env.storage() + .instance() + .set(&bc_forge_lifecycle::LifecycleKey::Paused, &paused); + } + fn read_pending_admin(env: &Env) -> Option
{ env.storage().instance().get(&DataKey::PendingAdmin) } + + fn execute_governed_action(env: Env, proposal_id: u64) -> Result<(), TokenError> { + Self::ensure_initialized(&env)?; + let action = bc_forge_multisig::execute(&env, proposal_id); + let current_admin = Self::read_admin(&env)?; + match action { + Action::Mint(to, amount) => { + Self::ensure_not_paused(&env)?; + Self::internal_mint(&env, ¤t_admin, &to, amount)?; + } + Action::Pause => { + Self::set_paused(&env, true); + events::emit_paused(&env, ¤t_admin); + } + Action::Unpause => { + Self::set_paused(&env, false); + events::emit_unpaused(&env, ¤t_admin); + } + Action::TransferOwnership(new_admin) => { + Self::set_admin(&env, &new_admin); + events::emit_ownership_transferred(&env, ¤t_admin, &new_admin); + } + Action::UpdateThreshold(_) => {} + } + Ok(()) + } } #[contractimpl] @@ -257,7 +242,24 @@ impl BcForgeToken { env.storage().instance().set(&DataKey::Symbol, &symbol); Self::write_supply(&env, 0); events::emit_initialized(&env, &admin, decimal, &name, &symbol); + Ok(()) + } + pub fn enable_multisig_governance( + env: Env, + governance_admin: Address, + signers: Vec
, + threshold: u32, + expiry_ledgers: u32, + ) -> Result<(), TokenError> { + let current_admin = Self::read_admin(&env)?; + current_admin.require_auth(); + bc_forge_multisig::initialize(&env, signers, threshold, expiry_ledgers); + env.storage() + .instance() + .set(&DataKey::GovernanceAdmin, &governance_admin); + Self::set_admin(&env, &governance_admin); + events::emit_ownership_transferred(&env, ¤t_admin, &governance_admin); Ok(()) } @@ -274,19 +276,10 @@ impl BcForgeToken { Self::ensure_not_paused(&env)?; let current_admin = Self::read_admin(&env)?; current_admin.require_auth(); - - for i in 0..recipients.len() { - let recipient = recipients.get(i).expect("recipient should exist"); - if recipient.amount <= 0 { - return Err(TokenError::InvalidAmount); - } - } - for i in 0..recipients.len() { let recipient = recipients.get(i).expect("recipient should exist"); Self::internal_mint(&env, ¤t_admin, &recipient.address, recipient.amount)?; } - Ok(()) } @@ -295,22 +288,17 @@ impl BcForgeToken { Self::panic_on_err(&env, Self::ensure_not_paused(&env)); from.require_auth(); - let mut total: i128 = 0; + let mut total = 0_i128; for i in 0..recipients.len() { let (_, amount) = recipients.get(i).expect("recipient should exist"); if amount <= 0 { soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount); } - total = match total.checked_add(amount) { - Some(total) => total, - None => soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount), - }; + total += amount; } - if Self::read_balance(&env, &from) < total { soroban_sdk::panic_with_error!(&env, TokenError::InsufficientBalance); } - for i in 0..recipients.len() { let (to, amount) = recipients.get(i).expect("recipient should exist"); let _ = Self::panic_on_err(&env, Self::move_balance(&env, &from, &to, amount)); @@ -323,57 +311,28 @@ impl BcForgeToken { Self::read_supply(&env) } - pub fn set_admin_pool(env: Env, pool: Vec
, threshold: u32) { - let current_admin = Self::read_admin(&env).expect("contract not initialized"); - current_admin.require_auth(); - admin::set_admin_pool(&env, pool, threshold); + pub fn propose(env: Env, signer: Address, action: Action) -> u64 { + bc_forge_multisig::propose(&env, signer, action) } - pub fn propose_action( - env: Env, - signer: Address, - action: TokenAction, - description: String, - ) -> u64 { - let id = admin::create_proposal(&env, signer, description); - env.storage() - .instance() - .set(&DataKey::ProposalAction(id), &action); - id + pub fn propose_action(env: Env, signer: Address, action: Action, _description: String) -> u64 { + bc_forge_multisig::propose(&env, signer, action) } pub fn approve_proposal(env: Env, signer: Address, proposal_id: u64) { - admin::approve_proposal(&env, signer, proposal_id); + bc_forge_multisig::approve(&env, signer, proposal_id); } - pub fn execute_proposal(env: Env, proposal_id: u64) { - admin::mark_executed(&env, proposal_id); - let action: TokenAction = env - .storage() - .instance() - .get(&DataKey::ProposalAction(proposal_id)) - .expect("proposal action not found"); + pub fn reject(env: Env, signer: Address, proposal_id: u64) { + bc_forge_multisig::reject(&env, signer, proposal_id); + } - match action { - TokenAction::Mint(to, amount) => { - Self::panic_on_err(&env, Self::ensure_not_paused(&env)); - let current_admin = Self::read_admin(&env).expect("contract not initialized"); - Self::panic_on_err(&env, Self::internal_mint(&env, ¤t_admin, &to, amount)); - } - TokenAction::Pause => { - let current_admin = Self::read_admin(&env).expect("contract not initialized"); - bc_forge_lifecycle::pause(env.clone(), current_admin.clone()); - events::emit_paused(&env, ¤t_admin); - } - TokenAction::Unpause => { - let current_admin = Self::read_admin(&env).expect("contract not initialized"); - bc_forge_lifecycle::unpause(env.clone(), current_admin.clone()); - events::emit_unpaused(&env, ¤t_admin); - } - } - env.storage() - .instance() - .remove(&DataKey::ProposalAction(proposal_id)); + pub fn execute(env: Env, proposal_id: u64) -> Result<(), TokenError> { + Self::execute_governed_action(env, proposal_id) + } + + pub fn execute_proposal(env: Env, proposal_id: u64) -> Result<(), TokenError> { + Self::execute_governed_action(env, proposal_id) } pub fn set_clawback_admin(env: Env, clawback_admin: Address) { @@ -392,11 +351,6 @@ impl BcForgeToken { .get(&DataKey::ClawbackAdmin) .expect("clawback admin not set"); clawback_admin.require_auth(); - - if amount <= 0 { - return Err(TokenError::InvalidAmount); - } - let _ = Self::move_balance(&env, &from, &to, amount)?; events::emit_clawback(&env, &clawback_admin, &from, &to, amount); Ok(()) @@ -422,33 +376,18 @@ impl BcForgeToken { ) -> Result<(), TokenError> { let current_admin = Self::read_admin(&env)?; current_admin.require_auth(); - if amount <= 0 { return Err(TokenError::InvalidAmount); } - let balance = Self::read_balance(&env, &user); if balance < amount { return Err(TokenError::InsufficientBalance); } - Self::write_balance(&env, &user, balance - amount); - let mut lockup = env - .storage() - .persistent() - .get::<_, LockupInfo>(&DataKey::Lockup(user.clone())) - .unwrap_or(LockupInfo { - amount: 0, - unlock_time: 0, - }); - lockup.amount += amount; - if unlock_time > lockup.unlock_time { - lockup.unlock_time = unlock_time; - } env.storage() .persistent() - .set(&DataKey::Lockup(user.clone()), &lockup); - events::emit_locked(&env, &user, amount, lockup.unlock_time); + .set(&DataKey::Lockup(user.clone()), &LockupInfo { amount, unlock_time }); + events::emit_locked(&env, &user, amount, unlock_time); Ok(()) } @@ -459,11 +398,9 @@ impl BcForgeToken { .persistent() .get(&DataKey::Lockup(user.clone())) .expect("no lockup found"); - if env.ledger().timestamp() < lockup.unlock_time { panic!("tokens are still locked"); } - let balance = Self::read_balance(&env, &user); Self::write_balance(&env, &user, balance + lockup.amount); env.storage() @@ -514,14 +451,16 @@ impl BcForgeToken { pub fn pause(env: Env) -> Result<(), TokenError> { let current_admin = Self::read_admin(&env)?; - bc_forge_lifecycle::pause(env.clone(), current_admin.clone()); + current_admin.require_auth(); + Self::set_paused(&env, true); events::emit_paused(&env, ¤t_admin); Ok(()) } pub fn unpause(env: Env) -> Result<(), TokenError> { let current_admin = Self::read_admin(&env)?; - bc_forge_lifecycle::unpause(env.clone(), current_admin.clone()); + current_admin.require_auth(); + Self::set_paused(&env, false); events::emit_unpaused(&env, ¤t_admin); Ok(()) } @@ -536,7 +475,7 @@ impl BcForgeToken { } pub fn version(env: Env) -> String { - String::from_str(&env, "1.1.0") + String::from_str(&env, "1.2.0") } pub fn update_name(env: Env, new_name: String) -> Result<(), TokenError> { @@ -592,11 +531,6 @@ impl TokenInterface for BcForgeToken { Self::panic_on_err(&env, Self::ensure_initialized(&env)); Self::panic_on_err(&env, Self::ensure_not_paused(&env)); from.require_auth(); - - if amount <= 0 { - soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount); - } - let _ = Self::panic_on_err(&env, Self::move_balance(&env, &from, &to, amount)); events::emit_transfer(&env, &from, &to, amount); } @@ -605,22 +539,16 @@ impl TokenInterface for BcForgeToken { Self::panic_on_err(&env, Self::ensure_initialized(&env)); Self::panic_on_err(&env, Self::ensure_not_paused(&env)); spender.require_auth(); - if amount <= 0 { soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount); } - let allowance = Self::read_allowance(&env, &from, &spender); if allowance < amount { soroban_sdk::panic_with_error!(&env, TokenError::InsufficientAllowance); } - - Self::move_balance(&env, &from, &to, amount); - // Preserve the original expiration let allowance_info = Self::read_allowance_info(&env, &from, &spender); - Self::write_allowance(&env, &from, &spender, allowance - amount, allowance_info.exp_ledger); let _ = Self::panic_on_err(&env, Self::move_balance(&env, &from, &to, amount)); - Self::write_allowance(&env, &from, &spender, allowance - amount, 0); + Self::write_allowance(&env, &from, &spender, allowance - amount, allowance_info.exp_ledger); events::emit_transfer_from(&env, &spender, &from, &to, amount, allowance - amount); } @@ -628,46 +556,36 @@ impl TokenInterface for BcForgeToken { Self::panic_on_err(&env, Self::ensure_initialized(&env)); Self::panic_on_err(&env, Self::ensure_not_paused(&env)); from.require_auth(); - if amount <= 0 { soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount); } - let balance = Self::read_balance(&env, &from); if balance < amount { soroban_sdk::panic_with_error!(&env, TokenError::InsufficientBalance); } - - let new_balance = balance - amount; - Self::write_balance(&env, &from, new_balance); + Self::write_balance(&env, &from, balance - amount); let supply = Self::read_supply(&env) - amount; Self::write_supply(&env, supply); - events::emit_burn(&env, &from, amount, new_balance, supply); + events::emit_burn(&env, &from, amount, balance - amount, supply); } fn burn_from(env: Env, spender: Address, from: Address, amount: i128) { Self::panic_on_err(&env, Self::ensure_initialized(&env)); Self::panic_on_err(&env, Self::ensure_not_paused(&env)); spender.require_auth(); - if amount <= 0 { soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount); } - let allowance = Self::read_allowance(&env, &from, &spender); if allowance < amount { soroban_sdk::panic_with_error!(&env, TokenError::InsufficientAllowance); } - let balance = Self::read_balance(&env, &from); if balance < amount { soroban_sdk::panic_with_error!(&env, TokenError::InsufficientBalance); } - - // Preserve the original expiration let allowance_info = Self::read_allowance_info(&env, &from, &spender); Self::write_allowance(&env, &from, &spender, allowance - amount, allowance_info.exp_ledger); - Self::write_allowance(&env, &from, &spender, allowance - amount, 0); Self::write_balance(&env, &from, balance - amount); let supply = Self::read_supply(&env) - amount; Self::write_supply(&env, supply); diff --git a/contracts/token/src/test.rs b/contracts/token/src/test.rs index 1de36a0..f11c370 100644 --- a/contracts/token/src/test.rs +++ b/contracts/token/src/test.rs @@ -1,11 +1,12 @@ #![cfg(test)] use soroban_sdk::testutils::Address as _; -use soroban_sdk::{vec, Address, Env, String, Vec}; +use soroban_sdk::{vec, Address, Env, String}; -use crate::{BcForgeToken, BcForgeTokenClient, TokenError}; +use crate::{Action, BcForgeToken, BcForgeTokenClient, TokenError}; fn setup(env: &Env) -> (BcForgeTokenClient<'_>, Address) { + env.mock_all_auths(); let contract_id = env.register(BcForgeToken, ()); let client = BcForgeTokenClient::new(env, &contract_id); let admin = Address::generate(env); @@ -21,892 +22,59 @@ fn setup(env: &Env) -> (BcForgeTokenClient<'_>, Address) { } #[test] -fn test_transfer() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _admin) = setup(&env); - let from = Address::generate(&env); - let to = Address::generate(&env); - - client.mint(&from, &1000); - client.transfer(&from, &to, &300); - - assert_eq!(client.balance(&from), 700); - assert_eq!(client.balance(&to), 300); - assert_eq!(client.supply(), 1000); -} - -#[test] -fn test_transfer_insufficient_balance_returns_error() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let admin = init_default(&env, &client); - let sender = Address::generate(&env); - let receiver = Address::generate(&env); - - let _ = client.mint(&sender, &100); - assert_eq!( - client.try_transfer(&sender, &receiver, &200), - Err(Ok(TokenError::InsufficientBalance)) - ); - client.mint(&admin, &sender, &100); - client.transfer(&sender, &receiver, &200); -} - -// ─── Allowance & Transfer From ─────────────────────────────────────────────── - -#[test] -fn test_approve_and_transfer_from() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let admin = init_default(&env, &client); - let owner = Address::generate(&env); - let spender = Address::generate(&env); - let receiver = Address::generate(&env); - - let _ = client.mint(&owner, &1000); - client.mint(&admin, &owner, &1000); - client.approve(&owner, &spender, &500, &0); - - assert_eq!(client.allowance(&owner, &spender), 500); - - client.transfer_from(&spender, &owner, &receiver, &200); - - assert_eq!(client.balance(&owner), 800); - assert_eq!(client.balance(&receiver), 200); - assert_eq!(client.allowance(&owner, &spender), 300); -} - -#[test] -fn test_transfer_from_insufficient_allowance_returns_error() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let admin = init_default(&env, &client); - let owner = Address::generate(&env); - let spender = Address::generate(&env); - let receiver = Address::generate(&env); - - let _ = client.mint(&owner, &1000); - client.mint(&admin, &owner, &1000); - client.approve(&owner, &spender, &100, &0); - assert_eq!( - client.try_transfer_from(&spender, &owner, &receiver, &200), - Err(Ok(TokenError::InsufficientAllowance)) - ); -} - -#[test] -fn test_allowance_with_future_expiration() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let owner = Address::generate(&env); - let spender = Address::generate(&env); - let receiver = Address::generate(&env); - - client.mint(&owner, &1000); - - // Set expiration to ledger 1000 (future) - let current_ledger = env.ledger().sequence(); - env.ledger().set(current_ledger + 100); - - client.approve(&owner, &spender, &500, &1000); - - // Should be usable - assert_eq!(client.allowance(&owner, &spender), 500); - - client.transfer_from(&spender, &owner, &receiver, &200); - assert_eq!(client.balance(&receiver), 200); - assert_eq!(client.allowance(&owner, &spender), 300); -} - -#[test] -fn test_allowance_with_past_expiration_returns_zero() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let owner = Address::generate(&env); - let spender = Address::generate(&env); - - client.mint(&owner, &1000); - - // Set expiration to ledger 100 - client.approve(&owner, &spender, &500, &100); - - // Move to ledger 200 (past expiration) - env.ledger().set(200); - - // Allowance should be 0 (expired) - assert_eq!(client.allowance(&owner, &spender), 0); -} - -#[test] -#[should_panic(expected = "insufficient allowance")] -fn test_transfer_from_with_expired_allowance_fails() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let owner = Address::generate(&env); - let spender = Address::generate(&env); - let receiver = Address::generate(&env); - - client.mint(&owner, &1000); - - // Set expiration to ledger 100 - client.approve(&owner, &spender, &500, &100); - - // Move to ledger 200 (past expiration) - env.ledger().set(200); - - // Should fail with insufficient allowance (expired) - client.transfer_from(&spender, &owner, &receiver, &200); -} - -#[test] -fn test_allowance_with_future_expiration() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let owner = Address::generate(&env); - let spender = Address::generate(&env); - let receiver = Address::generate(&env); - - client.mint(&owner, &1000); - - // Set expiration to ledger 1000 (future) - let current_ledger = env.ledger().sequence(); - env.ledger().set(current_ledger + 100); - - client.approve(&owner, &spender, &500, &1000); - - // Should be usable - assert_eq!(client.allowance(&owner, &spender), 500); - - client.transfer_from(&spender, &owner, &receiver, &200); - assert_eq!(client.balance(&receiver), 200); - assert_eq!(client.allowance(&owner, &spender), 300); -} - -#[test] -fn test_allowance_with_past_expiration_returns_zero() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let owner = Address::generate(&env); - let spender = Address::generate(&env); - - client.mint(&owner, &1000); - - // Set expiration to ledger 100 - client.approve(&owner, &spender, &500, &100); - - // Move to ledger 200 (past expiration) - env.ledger().set(200); - - // Allowance should be 0 (expired) - assert_eq!(client.allowance(&owner, &spender), 0); -} - -#[test] -#[should_panic(expected = "insufficient allowance")] -fn test_transfer_from_with_expired_allowance_fails() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let owner = Address::generate(&env); - let spender = Address::generate(&env); - let receiver = Address::generate(&env); - - client.mint(&owner, &1000); - - // Set expiration to ledger 100 - client.approve(&owner, &spender, &500, &100); - - // Move to ledger 200 (past expiration) - env.ledger().set(200); - - // Should fail with insufficient allowance (expired) - client.transfer_from(&spender, &owner, &receiver, &200); -} - -#[test] -fn test_allowance_with_future_expiration() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let owner = Address::generate(&env); - let spender = Address::generate(&env); - let receiver = Address::generate(&env); - - client.mint(&owner, &1000); - - // Set expiration to ledger 1000 (future) - let current_ledger = env.ledger().sequence(); - env.ledger().set(current_ledger + 100); - - client.approve(&owner, &spender, &500, &1000); - - // Should be usable - assert_eq!(client.allowance(&owner, &spender), 500); - - client.transfer_from(&spender, &owner, &receiver, &200); - assert_eq!(client.balance(&receiver), 200); - assert_eq!(client.allowance(&owner, &spender), 300); -} - -#[test] -fn test_allowance_with_past_expiration_returns_zero() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let owner = Address::generate(&env); - let spender = Address::generate(&env); - - client.mint(&owner, &1000); - - // Set expiration to ledger 100 - client.approve(&owner, &spender, &500, &100); - - // Move to ledger 200 (past expiration) - env.ledger().set(200); - - // Allowance should be 0 (expired) - assert_eq!(client.allowance(&owner, &spender), 0); -} - -#[test] -#[should_panic(expected = "insufficient allowance")] -fn test_transfer_from_with_expired_allowance_fails() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let owner = Address::generate(&env); - let spender = Address::generate(&env); - let receiver = Address::generate(&env); - - client.mint(&owner, &1000); - - // Set expiration to ledger 100 - client.approve(&owner, &spender, &500, &100); - - // Move to ledger 200 (past expiration) - env.ledger().set(200); - - // Should fail with insufficient allowance (expired) - client.transfer_from(&spender, &owner, &receiver, &200); -} - -#[test] -fn test_allowance_with_future_expiration() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let owner = Address::generate(&env); - let spender = Address::generate(&env); - let receiver = Address::generate(&env); - - client.mint(&owner, &1000); - - // Set expiration to ledger 1000 (future) - let current_ledger = env.ledger().sequence(); - env.ledger().set(current_ledger + 100); - - client.approve(&owner, &spender, &500, &1000); - - // Should be usable - assert_eq!(client.allowance(&owner, &spender), 500); - - client.transfer_from(&spender, &owner, &receiver, &200); - assert_eq!(client.balance(&receiver), 200); - assert_eq!(client.allowance(&owner, &spender), 300); -} - -#[test] -fn test_allowance_with_past_expiration_returns_zero() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let owner = Address::generate(&env); - let spender = Address::generate(&env); - - client.mint(&owner, &1000); - - // Set expiration to ledger 100 - client.approve(&owner, &spender, &500, &100); - - // Move to ledger 200 (past expiration) - env.ledger().set(200); - - // Allowance should be 0 (expired) - assert_eq!(client.allowance(&owner, &spender), 0); -} - -#[test] -#[should_panic(expected = "insufficient allowance")] -fn test_transfer_from_with_expired_allowance_fails() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let owner = Address::generate(&env); - let spender = Address::generate(&env); - let receiver = Address::generate(&env); - - client.mint(&owner, &1000); - - // Set expiration to ledger 100 - client.approve(&owner, &spender, &500, &100); - - // Move to ledger 200 (past expiration) - env.ledger().set(200); - - // Should fail with insufficient allowance (expired) - client.transfer_from(&spender, &owner, &receiver, &200); -} - -// ─── Burn ──────────────────────────────────────────────────────────────────── - -#[test] -fn test_burn() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let admin = init_default(&env, &client); - let user = Address::generate(&env); - - let _ = client.mint(&user, &1000); - client.mint(&admin, &user, &1000); - client.burn(&user, &300); - - assert_eq!(client.balance(&user), 700); - assert_eq!(client.supply(), 700); -} - -#[test] -fn test_burn_insufficient_balance_returns_error() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let admin = init_default(&env, &client); - let user = Address::generate(&env); - - let _ = client.mint(&user, &100); - assert_eq!( - client.try_burn(&user, &200), - Err(Ok(TokenError::InsufficientBalance)) - ); - client.mint(&admin, &user, &100); - client.burn(&user, &200); -} - -#[test] -fn test_burn_from() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let admin = init_default(&env, &client); - let owner = Address::generate(&env); - let spender = Address::generate(&env); - - let _ = client.mint(&owner, &1000); - client.mint(&admin, &owner, &1000); - client.approve(&owner, &spender, &500, &0); - client.burn_from(&spender, &owner, &200); - - assert_eq!(client.balance(&owner), 800); - assert_eq!(client.allowance(&owner, &spender), 300); - assert_eq!(client.supply(), 800); -} - -#[test] -#[should_panic(expected = "insufficient allowance")] -fn test_burn_from_with_expired_allowance_fails() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let owner = Address::generate(&env); - let spender = Address::generate(&env); - - client.mint(&owner, &1000); - - // Set expiration to ledger 100 - client.approve(&owner, &spender, &500, &100); - - // Move to ledger 200 (past expiration) - env.ledger().set(200); - - // Should fail with insufficient allowance (expired) - client.burn_from(&spender, &owner, &200); -} - -#[test] -fn test_burn_from_preserves_expiration() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let owner = Address::generate(&env); - let spender = Address::generate(&env); - - client.mint(&owner, &1000); - - // Set expiration to ledger 1000 (future) - client.approve(&owner, &spender, &500, &1000); - - // Burn some tokens - client.burn_from(&spender, &owner, &200); - - // Allowance should be reduced but expiration preserved - assert_eq!(client.allowance(&owner, &spender), 300); - assert_eq!(client.balance(&owner), 800); - assert_eq!(client.supply(), 800); - - // Move to ledger 500 (still before expiration) - env.ledger().set(500); - assert_eq!(client.allowance(&owner, &spender), 300); - - // Move to ledger 1001 (past expiration) - env.ledger().set(1001); - assert_eq!(client.allowance(&owner, &spender), 0); -} - -#[test] -fn test_transfer_from_preserves_expiration() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let owner = Address::generate(&env); - let spender = Address::generate(&env); - let receiver = Address::generate(&env); - - client.mint(&owner, &1000); - - // Set expiration to ledger 1000 (future) - client.approve(&owner, &spender, &500, &1000); - - // Transfer some tokens - client.transfer_from(&spender, &owner, &receiver, &200); - - // Allowance should be reduced but expiration preserved - assert_eq!(client.allowance(&owner, &spender), 300); - assert_eq!(client.balance(&receiver), 200); - - // Move to ledger 500 (still before expiration) - env.ledger().set(500); - assert_eq!(client.allowance(&owner, &spender), 300); - - // Move to ledger 1001 (past expiration) - env.ledger().set(1001); - assert_eq!(client.allowance(&owner, &spender), 0); -} - -#[test] -fn test_approve_with_zero_expiration_clears_expiration() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let owner = Address::generate(&env); - let spender = Address::generate(&env); - - client.mint(&owner, &1000); - - // Set expiration to ledger 1000 - client.approve(&owner, &spender, &500, &1000); - - // Verify allowance is set with expiration - assert_eq!(client.allowance(&owner, &spender), 500); - - // Re-approve with exp=0 (clear expiration) - client.approve(&owner, &spender, &300, &0); - - // Allowance should still work even after moving far in the future - env.ledger().set(10000); - assert_eq!(client.allowance(&owner, &spender), 300); -} - -// ─── Ownership ─────────────────────────────────────────────────────────────── - -#[test] -fn test_transfer_ownership() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let new_admin = Address::generate(&env); - let user = Address::generate(&env); - - let _ = client.transfer_ownership(&new_admin); - - // New admin should be able to mint - let _ = client.mint(&user, &500); - client.mint(&new_admin, &user, &500); - assert_eq!(client.balance(&user), 500); -} - -#[test] -fn test_two_step_ownership_transfer_happy_path() { -fn test_role_management() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let admin = init_default(&env, &client); - let new_admin = Address::generate(&env); - let user = Address::generate(&env); - - // Initially no pending owner - assert!(client.pending_owner().is_none()); - - // Propose new admin - client.propose_owner(&new_admin); - - // Check pending owner - let pending = client.pending_owner(); - assert!(pending.is_some()); - assert_eq!(pending.unwrap(), new_admin); - - // New admin accepts - client.accept_ownership(); - - // Pending owner should be cleared - assert!(client.pending_owner().is_none()); - - // New admin should be able to mint - client.mint(&user, &500); - assert_eq!(client.balance(&user), 500); -} - -#[test] -#[should_panic(expected = "no pending ownership transfer")] -fn test_accept_ownership_without_proposal_fails() { - let minter = Address::generate(&env); - let user = Address::generate(&env); - - // Minter doesn't have the role initially - assert!(!client.has_role(&Role::Minter, &minter)); - - // Admin grants Minter role - client.grant_role(&Role::Minter, &minter); - assert!(client.has_role(&Role::Minter, &minter)); - - // Minter can now mint - client.mint(&minter, &user, &100); - assert_eq!(client.balance(&user), 100); - - // Admin revokes Minter role - client.revoke_role(&Role::Minter, &minter); - assert!(!client.has_role(&Role::Minter, &minter)); -} - -#[test] -#[should_panic(expected = "unauthorized: missing role")] -fn test_mint_unauthorized_role() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - - // Try to accept without proposal - client.accept_ownership(); -} - -#[test] -fn test_cancel_transfer() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let admin = init_default(&env, &client); - let new_admin = Address::generate(&env); - - // Propose new admin - client.propose_owner(&new_admin); - assert!(client.pending_owner().is_some()); - - // Cancel the transfer - client.cancel_transfer(); - - // Pending owner should be cleared - assert!(client.pending_owner().is_none()); -} - -#[test] -#[should_panic(expected = "no pending ownership transfer")] -fn test_cancel_transfer_without_proposal_fails() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - - // Try to cancel without proposal - client.cancel_transfer(); -} - -#[test] -fn test_double_propose_updates_pending_admin() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let first_proposal = Address::generate(&env); - let second_proposal = Address::generate(&env); - - // First proposal - client.propose_owner(&first_proposal); - assert_eq!(client.pending_owner().unwrap(), first_proposal); - - // Second proposal (should override first) - client.propose_owner(&second_proposal); - assert_eq!(client.pending_owner().unwrap(), second_proposal); - let non_minter = Address::generate(&env); - let user = Address::generate(&env); - - client.mint(&non_minter, &user, &100); -} - -// ─── Pause / Unpause ───────────────────────────────────────────────────────── - -#[test] -fn test_mint_while_paused_returns_error() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let admin = init_default(&env, &client); - let user = Address::generate(&env); - - let _ = client.pause(); - assert_eq!( - client.try_mint(&user, &100), - Err(Ok(TokenError::ContractPaused)) - ); - client.pause(); - client.mint(&admin, &user, &100); -} - -#[test] -fn test_unpause_restores_operations() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let admin = init_default(&env, &client); - let user = Address::generate(&env); - - let _ = client.pause(); - let _ = client.unpause(); - - // Should work again - let _ = client.mint(&user, &100); - client.mint(&admin, &user, &100); - assert_eq!(client.balance(&user), 100); -} - -#[test] -fn test_transfer_while_paused_returns_error() { +fn single_admin_mint_transfer_and_pause_still_work() { let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let admin = init_default(&env, &client); - let sender = Address::generate(&env); - let receiver = Address::generate(&env); - - let _ = client.mint(&sender, &1000); - let _ = client.pause(); - assert_eq!( - client.try_transfer(&sender, &receiver, &100), - Err(Ok(TokenError::ContractPaused)) - ); - client.mint(&admin, &sender, &1000); - client.pause(); - client.transfer(&sender, &receiver, &100); -} + let (client, _) = setup(&env); + let alice = Address::generate(&env); + let bob = Address::generate(&env); -// ─── Pause/Unpause Edge Case Tests ───────────────────────────────────────── + client.mint(&alice, &100); + client.transfer(&alice, &bob, &25); + assert_eq!(client.balance(&alice), 75); + assert_eq!(client.balance(&bob), 25); -#[test] -fn test_transfer_ownership_while_paused() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let admin = init_default(&env, &client); - let new_admin = Address::generate(&env); - let _ = client.pause(); - // Ownership transfer should still work while paused - client.transfer_ownership(&new_admin); - // New admin can mint - client.mint(&new_admin, &admin, &1); -} - -#[test] -fn test_balance_query_while_paused() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let admin = init_default(&env, &client); - let user = Address::generate(&env); - client.mint(&admin, &user, &123); client.pause(); - // Balance query should still work while paused - let bal = client.balance(&user); - assert_eq!(bal, 123); -} - -// ─── Negative Admin Function Tests ───────────────────────────────────────── - -#[test] -#[should_panic(expected = "unauthorized: missing role")] -fn test_pause_unauthorized_panics() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let not_admin = Address::generate(&env); - client.pause_with_auth(¬_admin); -} - -#[test] -#[should_panic(expected = "unauthorized: missing role")] -fn test_unpause_unauthorized_panics() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let not_admin = Address::generate(&env); - client.unpause_with_auth(¬_admin); + assert_eq!(client.try_mint(&alice, &1), Err(Ok(TokenError::ContractPaused))); + client.unpause(); + client.mint(&alice, &1); + assert_eq!(client.balance(&alice), 76); } #[test] -#[should_panic(expected = "unauthorized: missing role")] -fn test_transfer_ownership_unauthorized_panics() { +fn multisig_governs_mint_and_threshold_update() { let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let not_admin = Address::generate(&env); - let new_admin = Address::generate(&env); - client.transfer_ownership_with_auth(&new_admin, ¬_admin); -} - -#[test] -#[should_panic(expected = "unauthorized: missing role")] -fn test_mint_unauthorized_panics() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _) = setup_contract(&env); - let _admin = init_default(&env, &client); - let not_admin = Address::generate(&env); + let (client, admin) = setup(&env); + let signer_a = Address::generate(&env); + let signer_b = Address::generate(&env); + let signer_c = Address::generate(&env); + let governance_admin = Address::generate(&env); let user = Address::generate(&env); - client.mint(¬_admin, &user, &100); -} - -// ─── Version ───────────────────────────────────────────────────────────────── - -#[test] -fn test_version() { -fn test_batch_transfer_multiple_recipients() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _admin) = setup(&env); - let from = Address::generate(&env); - let recipient_a = Address::generate(&env); - let recipient_b = Address::generate(&env); - let recipient_c = Address::generate(&env); - - client.mint(&from, &1000); - - let recipients = vec![ - &env, - (recipient_a.clone(), 100_i128), - (recipient_b.clone(), 250_i128), - (recipient_c.clone(), 50_i128), - ]; - client.batch_transfer(&from, &recipients); - - assert_eq!(client.balance(&from), 600); - assert_eq!(client.balance(&recipient_a), 100); - assert_eq!(client.balance(&recipient_b), 250); - assert_eq!(client.balance(&recipient_c), 50); - assert_eq!(client.supply(), 1000); -} -#[test] -fn test_batch_transfer_rejects_invalid_amount() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _admin) = setup(&env); - let from = Address::generate(&env); - let recipient = Address::generate(&env); - - client.mint(&from, &1000); - - let recipients = vec![&env, (recipient.clone(), 0_i128)]; - assert_eq!( - client.try_batch_transfer(&from, &recipients), - Err(Ok(soroban_sdk::Error::from_contract_error( - TokenError::InvalidAmount as u32 - ))) + client.enable_multisig_governance( + &governance_admin, + &vec![&env, signer_a.clone(), signer_b.clone(), signer_c.clone()], + &2, + &10, ); - assert_eq!(client.balance(&from), 1000); - assert_eq!(client.balance(&recipient), 0); -} - -#[test] -fn test_batch_transfer_rejects_insufficient_balance_before_moving_tokens() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _admin) = setup(&env); - let from = Address::generate(&env); - let recipient_a = Address::generate(&env); - let recipient_b = Address::generate(&env); - client.mint(&from, &100); + assert!(client.try_mint(&user, &100).is_err()); - let recipients = vec![ - &env, - (recipient_a.clone(), 80_i128), - (recipient_b.clone(), 40_i128), - ]; - assert_eq!( - client.try_batch_transfer(&from, &recipients), - Err(Ok(soroban_sdk::Error::from_contract_error( - TokenError::InsufficientBalance as u32 - ))) - ); - assert_eq!(client.balance(&from), 100); - assert_eq!(client.balance(&recipient_a), 0); - assert_eq!(client.balance(&recipient_b), 0); -} + let mint_id = client.propose(&signer_a, &Action::Mint(user.clone(), 100)); + assert!(client.try_execute(&mint_id).is_err()); + client.approve_proposal(&signer_b, &mint_id); + client.execute(&mint_id); + assert_eq!(client.balance(&user), 100); -#[test] -fn test_batch_transfer_while_paused_returns_error() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _admin) = setup(&env); - let from = Address::generate(&env); - let recipient = Address::generate(&env); + let threshold_id = client.propose(&signer_a, &Action::UpdateThreshold(3)); + client.approve_proposal(&signer_b, &threshold_id); + client.execute(&threshold_id); - client.mint(&from, &100); - client.pause(); + let ownership_id = client.propose(&signer_a, &Action::TransferOwnership(admin.clone())); + client.approve_proposal(&signer_b, &ownership_id); + assert!(client.try_execute(&ownership_id).is_err()); + client.approve_proposal(&signer_c, &ownership_id); + client.execute(&ownership_id); - let recipients: Vec<(Address, i128)> = vec![&env, (recipient, 10_i128)]; - assert_eq!( - client.try_batch_transfer(&from, &recipients), - Err(Ok(soroban_sdk::Error::from_contract_error( - TokenError::ContractPaused as u32 - ))) - ); + client.mint(&user, &1); + assert_eq!(client.balance(&user), 101); }