Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
154 changes: 4 additions & 150 deletions contracts/admin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address>,
pub executed: bool,
}

pub fn set_admin(env: &Env, admin: &Address) {
env.storage().instance().set(&AdminKey::Admin, admin);
env.storage()
Expand All @@ -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);
Expand All @@ -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<Address>, 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<Address> {
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);
}
19 changes: 19 additions & 0 deletions contracts/multisig/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
Loading