Skip to content
31 changes: 30 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# Security Policy

## Supported Versions

We actively support the following versions of bc-forge:

| Version | Supported |
| --- | --- |
| `main` branch | Yes |
| Latest tagged release | Yes |
| Older released versions | No |

If a vulnerability affects an older release, please still report it. We may not ship fixes for every historical version, but we will review the impact and decide whether backporting is appropriate.

## Reporting a Vulnerability

Please report security issues privately so we can investigate before any public disclosure.

Preferred contact methods:

1. GitHub Security Advisories for a private report.
2. GitHub Discussions for non-sensitive coordination and general security questions.

Please include:

- A clear description of the issue
- The affected component and version, if known
- Steps to reproduce
- Any proof of concept, logs, or screenshots that help us confirm the impact

We aim to acknowledge reports promptly and work with reporters toward a safe fix and coordinated disclosure.
This document outlines how security issues should be reported and handled for the bc-forge project.

## Supported Versions
Expand Down Expand Up @@ -66,4 +95,4 @@ We ask that researchers follow responsible disclosure practices:
- Allow us reasonable time to address the issue before public disclosure
- Respect user privacy and data protection requirements

We appreciate the security community's efforts to help keep bc-forge secure.
We appreciate the security community's efforts to help keep bc-forge secure.
1 change: 1 addition & 0 deletions contracts/admin/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Reusable access-control primitives for Soroban contracts.

#![no_std]
#![allow(clippy::manual_assert)]

use bc_forge_ttl as ttl;
use soroban_sdk::{contracttype, vec, Address, Env, String, Vec};
Expand Down
1 change: 1 addition & 0 deletions contracts/lifecycle/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//! all token transfers and minting until the admin unpauses.

#![no_std]
#![allow(clippy::manual_assert)]

use bc_forge_ttl as ttl;
use soroban_sdk::{contracttype, Address, Env};
Expand Down
223 changes: 223 additions & 0 deletions contracts/token/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! A compact SEP-41-compatible token used by the vesting contract tests.

#![no_std]
#![allow(clippy::manual_assert)]

mod events;
mod reentrancy_guard;
Expand All @@ -29,7 +30,9 @@ pub struct Recipient {
pub enum DataKey {
/// The contract admin address (singular).
Admin,
/// Pending admin address for a two-step ownership transfer.
PendingAdmin,
/// Spending allowance: (owner, spender) → amount.
/// Spending allowance: (owner, spender) -> amount and expiration.
Allowance(Address, Address),
AllowanceExp(Address, Address),
Expand Down Expand Up @@ -103,6 +106,14 @@ impl BcForgeToken {
.set(&DataKey::Balance(address.clone()), &amount);
}

fn read_allowance(env: &Env, from: &Address, spender: &Address) -> i128 {
if env
.storage()
.persistent()
.get::<_, u32>(&DataKey::AllowanceExp(from.clone(), spender.clone()))
.is_some_and(|exp_ledger| exp_ledger > 0 && env.ledger().sequence() > exp_ledger)
{
return 0;
fn read_supply(env: &Env) -> i128 {
let key = DataKey::Supply;
if env.storage().instance().has(&key) {
Expand Down Expand Up @@ -175,6 +186,11 @@ impl BcForgeToken {
events::emit_mint(env, admin_address, to, amount, new_balance, new_supply);
Ok(())
}

/// Reads the pending admin address, if any.
fn read_pending_admin(env: &Env) -> Option<Address> {
env.storage().instance().get(&DataKey::PendingAdmin)
}
}

#[contractimpl]
Expand Down Expand Up @@ -237,6 +253,21 @@ impl BcForgeToken {
}
Self::internal_mint(&env, &current_admin, &recipient.to, recipient.amount)?;
}
total = match total.checked_add(amount) {
Some(total) => total,
None => soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount),
};
}

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");
Self::panic_on_err(&env, Self::move_balance(&env, &from, &to, amount));
events::emit_transfer(&env, &from, &to, amount);
}
Ok(())
})
}
Expand All @@ -247,6 +278,154 @@ impl BcForgeToken {
Self::read_supply(&env)
}

pub fn set_admin_pool(env: Env, pool: Vec<Address>, 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_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 approve_proposal(env: Env, signer: Address, proposal_id: u64) {
admin::approve_proposal(&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");

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, &current_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, &current_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, &current_admin);
}
}
env.storage()
.instance()
.remove(&DataKey::ProposalAction(proposal_id));
}

pub fn set_clawback_admin(env: Env, clawback_admin: Address) {
let current_admin = Self::read_admin(&env).expect("contract not initialized");
current_admin.require_auth();
env.storage()
.instance()
.set(&DataKey::ClawbackAdmin, &clawback_admin);
}

pub fn clawback(env: Env, from: Address, to: Address, amount: i128) -> Result<(), TokenError> {
Self::ensure_initialized(&env)?;
let clawback_admin: Address = env
.storage()
.instance()
.get(&DataKey::ClawbackAdmin)
.expect("clawback admin not set");
clawback_admin.require_auth();
if amount <= 0 {
return Err(TokenError::InvalidAmount);
}

Self::move_balance(&env, &from, &to, amount)?;
events::emit_clawback(&env, &clawback_admin, &from, &to, amount);
Ok(())
}

pub fn grant_role(env: Env, role: Role, address: Address) {
admin::grant_role(&env, role, &address);
}

pub fn revoke_role(env: Env, role: Role, address: Address) {
admin::revoke_role(&env, role, &address);
}

pub fn has_role(env: Env, role: Role, address: Address) -> bool {
admin::has_role(&env, role, &address)
}

pub fn lock_tokens(
env: Env,
user: Address,
amount: i128,
unlock_time: u64,
) -> 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);
Ok(())
}

pub fn withdraw_locked(env: Env, user: Address) {
user.require_auth();
let lockup: LockupInfo = env
.storage()
.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()
.persistent()
.remove(&DataKey::Lockup(user.clone()));
events::emit_withdraw_locked(&env, &user, lockup.amount);
}

pub fn transfer_ownership(env: Env, new_admin: Address) -> Result<(), TokenError> {
Self::ensure_initialized(&env)?;
let current_admin = admin::get_admin(&env);
Expand All @@ -256,6 +435,38 @@ impl BcForgeToken {
Ok(())
}

pub fn propose_ownership(env: Env, new_admin: Address) -> Result<(), TokenError> {
let current_admin = Self::read_admin(&env)?;
current_admin.require_auth();
env.storage()
.instance()
.set(&DataKey::PendingAdmin, &new_admin);
events::emit_ownership_proposed(&env, &current_admin, &new_admin);
Ok(())
}

pub fn accept_ownership(env: Env) {
let pending_admin = Self::read_pending_admin(&env).expect("no pending ownership transfer");
pending_admin.require_auth();
let old_admin = Self::read_admin(&env).expect("contract not initialized");
Self::set_admin(&env, &pending_admin);
env.storage().instance().remove(&DataKey::PendingAdmin);
events::emit_ownership_accepted(&env, &old_admin, &pending_admin);
}

pub fn cancel_ownership_transfer(env: Env) -> Result<(), TokenError> {
let current_admin = Self::read_admin(&env)?;
current_admin.require_auth();
let pending_admin = Self::read_pending_admin(&env).expect("no pending ownership transfer");
env.storage().instance().remove(&DataKey::PendingAdmin);
events::emit_ownership_cancelled(&env, &current_admin, &pending_admin);
Ok(())
}

pub fn pending_owner(env: Env) -> Option<Address> {
Self::read_pending_admin(&env)
}

pub fn pause(env: Env) -> Result<(), TokenError> {
Self::ensure_initialized(&env)?;
let admin_address = admin::get_admin(&env);
Expand Down Expand Up @@ -301,6 +512,16 @@ impl TokenInterface for BcForgeToken {
}

fn transfer(env: Env, from: Address, to: Address, amount: i128) {
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);
}

Self::panic_on_err(&env, Self::move_balance(&env, &from, &to, amount));
events::emit_transfer(&env, &from, &to, amount);
Self::extend_instance_ttl_for_call(&env);
reentrancy_guard!(&env, "transfer_guard", {
Self::panic_on_err(&env, Self::ensure_initialized(&env));
Expand Down Expand Up @@ -335,6 +556,8 @@ impl TokenInterface for BcForgeToken {
soroban_sdk::panic_with_error!(&env, TokenError::InsufficientAllowance);
}

Self::panic_on_err(&env, Self::move_balance(&env, &from, &to, amount));
Self::write_allowance(&env, &from, &spender, allowance - amount, 0);
let allowance_data = Self::read_allowance_data(&env, &from, &spender);
Self::panic_on_err(&env, Self::move_balance(&env, &from, &to, amount));
Self::write_allowance(
Expand Down
Loading