diff --git a/SECURITY.md b/SECURITY.md index b1f3cdd..1931a87 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 @@ -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. \ No newline at end of file +We appreciate the security community's efforts to help keep bc-forge secure. diff --git a/contracts/admin/src/lib.rs b/contracts/admin/src/lib.rs index b91c1f8..46bd48a 100644 --- a/contracts/admin/src/lib.rs +++ b/contracts/admin/src/lib.rs @@ -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}; diff --git a/contracts/lifecycle/src/lib.rs b/contracts/lifecycle/src/lib.rs index 6188a59..54661a2 100644 --- a/contracts/lifecycle/src/lib.rs +++ b/contracts/lifecycle/src/lib.rs @@ -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}; diff --git a/contracts/token/src/lib.rs b/contracts/token/src/lib.rs index 10aedfa..070876a 100644 --- a/contracts/token/src/lib.rs +++ b/contracts/token/src/lib.rs @@ -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 rate_limit; @@ -27,7 +28,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), @@ -101,6 +104,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) { @@ -183,6 +194,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
{ + env.storage().instance().get(&DataKey::PendingAdmin) + } } #[contractimpl] @@ -246,6 +262,21 @@ impl BcForgeToken { } Self::internal_mint(&env, ¤t_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(()) }) } @@ -256,6 +287,154 @@ 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_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, ¤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 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); @@ -265,6 +444,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, ¤t_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, ¤t_admin, &pending_admin); + Ok(()) + } + + pub fn pending_owner(env: Env) -> Option { + Self::read_pending_admin(&env) + } + pub fn pause(env: Env) -> Result<(), TokenError> { Self::ensure_initialized(&env)?; let admin_address = admin::get_admin(&env); @@ -310,6 +521,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)); @@ -344,6 +565,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( diff --git a/contracts/token/src/test.rs b/contracts/token/src/test.rs index 24e420e..fa93977 100644 --- a/contracts/token/src/test.rs +++ b/contracts/token/src/test.rs @@ -39,6 +39,157 @@ fn test_mint_transfer_and_supply() { assert_eq!(client.balance(&from), 700); assert_eq!(client.balance(&to), 300); + assert_eq!(client.supply(), 1000); +} + +#[test] +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_propose_accept_ownership() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup(&env); + let new_admin = Address::generate(&env); + + client.propose_ownership(&new_admin).unwrap(); + + assert_eq!(admin, BcForgeToken::read_admin(&env).unwrap()); + assert_eq!(client.pending_owner(), Some(new_admin.clone())); + + client.accept_ownership(); + + assert_eq!(BcForgeToken::read_admin(&env).unwrap(), new_admin); + assert_eq!(client.pending_owner(), None); +} + +#[test] +fn test_cancel_ownership_transfer() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup(&env); + let new_admin = Address::generate(&env); + + client.propose_ownership(&new_admin).unwrap(); + client.cancel_ownership_transfer().unwrap(); + + assert_eq!(BcForgeToken::read_admin(&env).unwrap(), admin); + assert_eq!(client.pending_owner(), None); +} + +#[test] +#[should_panic(expected = "no pending ownership transfer")] +fn test_accept_ownership_without_pending_panics() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup(&env); + + client.accept_ownership(); +} + +#[test] +fn test_transfer_ownership_alias_proposes() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup(&env); + let new_admin = Address::generate(&env); + + client.propose_ownership(&new_admin).unwrap(); + + assert_eq!(BcForgeToken::read_admin(&env).unwrap(), admin); + assert_eq!(client.pending_owner(), Some(new_admin)); +} + +// ─── Pause / Unpause ───────────────────────────────────────────────────────── +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 + ))) + ); + 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); + + 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); +} + +#[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); + + client.mint(&from, &100); + client.pause(); + + 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 + ))) + ); assert_eq!(client.supply(), 1_000); } diff --git a/indexer/package.json b/indexer/package.json index 0085233..c242841 100644 --- a/indexer/package.json +++ b/indexer/package.json @@ -8,6 +8,7 @@ "dev": "tsx src/index.ts", "build": "tsc", "start": "node dist/index.js", + "test": "node --test --import tsx", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev" }, diff --git a/indexer/src/api.ts b/indexer/src/api.ts index a6d9254..a10a452 100644 --- a/indexer/src/api.ts +++ b/indexer/src/api.ts @@ -1,7 +1,7 @@ import express from 'express'; -import { PrismaClient } from '@prisma/client'; +import { getPrismaClient } from './lib/prisma'; -const prisma = new PrismaClient(); +const prisma = getPrismaClient(); const router = express.Router(); /** diff --git a/indexer/src/index.ts b/indexer/src/index.ts index 276e843..6cbfb0d 100644 --- a/indexer/src/index.ts +++ b/indexer/src/index.ts @@ -1,6 +1,7 @@ import express from 'express'; import { runIndexer } from './indexer'; import apiRouter from './api'; +import { disconnectPrismaClient } from './lib/prisma'; import dotenv from 'dotenv'; dotenv.config(); @@ -27,3 +28,17 @@ app.listen(PORT, () => { process.exit(1); }); }); + +async function shutdown(signal: string): Promise