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 a593017..b60d052 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 7e87272..d7c781e 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 4ad862a..6dc3bdb 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 reentrancy_guard; @@ -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), @@ -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) { @@ -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
{ + env.storage().instance().get(&DataKey::PendingAdmin) + } } #[contractimpl] @@ -237,6 +253,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(()) }) } @@ -247,6 +278,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); @@ -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, ¤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); @@ -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)); @@ -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( diff --git a/contracts/token/src/test.rs b/contracts/token/src/test.rs index c7d98d3..e2357c3 100644 --- a/contracts/token/src/test.rs +++ b/contracts/token/src/test.rs @@ -40,5 +40,156 @@ 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/sdk/README.md b/sdk/README.md index f747f0c..f195c7c 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -114,6 +114,18 @@ const version = await client.getVersion(); console.log('Contract version:', version); ``` +## Ownership Management + +```typescript +// Step 1: propose a new owner +await client.proposeOwnership('GNEWADMIN...ADDR', adminKeypair); + +// Step 2: the proposed owner accepts +await client.acceptOwnership(newOwnerKeypair); + +// Optional: cancel before acceptance +await client.cancelOwnershipTransfer(adminKeypair); +``` ## Batch Queries ```typescript @@ -372,9 +384,13 @@ console.log('Locked tokens withdrawn'); ## Admin Operations ```typescript -// Transfer ownership +// Backwards-compatible alias for the propose step await client.transferOwnership('GNEWADMIN...ADDR', adminKeypair); +``` + +## Admin Operations +```typescript // Emergency pause / unpause await client.pause(adminKeypair); await client.unpause(adminKeypair); @@ -421,6 +437,26 @@ When a `walletAdapter` is configured and connected, write methods may be invoked |--------|-------------| | `initialize(admin, decimals, name, symbol, source)` | One-time contract setup | | `mint(to, amount, source)` | Mint tokens (admin-only) | +| `transfer(from, to, amount, source)` | Transfer tokens | +| `approve(from, spender, amount, source)` | Set spending allowance | +| `burn(from, amount, source)` | Burn tokens | +| `proposeOwnership(newAdmin, source)` | Propose a new admin | +| `acceptOwnership(source)` | Accept a pending admin transfer | +| `cancelOwnershipTransfer(source)` | Cancel a pending admin transfer | +| `transferOwnership(newAdmin, source)` | Backwards-compatible alias for `proposeOwnership` | +| `pause(source)` | Pause contract (admin-only) | +| `unpause(source)` | Unpause contract (admin-only) | +| Method | Description | +| --------------------------------------------------- | --------------------------------------------------------------------------- | +| `initialize(admin, decimals, name, symbol, source)` | One-time contract setup | +| `mint(to, amount, source)` | Mint tokens (admin-only) | +| `batchMint(recipients, source)` | Mint tokens to multiple `{ to, amount }` recipients atomically (admin-only) | +| `transfer(from, to, amount, source)` | Transfer tokens | +| `approve(from, spender, amount, source)` | Set spending allowance | +| `burn(from, amount, source)` | Burn tokens | +| `transferOwnership(newAdmin, source)` | Transfer admin role | +| `pause(source)` | Pause contract (admin-only) | +| `unpause(source)` | Unpause contract (admin-only) | | `batchMint(recipients[], source)` | Batch mint to multiple addresses (admin-only) | | `transfer(from, to, amount, source)` | Transfer tokens | | `approve(from, spender, amount, source)` | Set spending allowance | diff --git a/sdk/package.json b/sdk/package.json index a0d3ad8..ae8c662 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -8,8 +8,8 @@ "build": "tsc", "dev": "tsc --watch", "test": "jest --passWithNoTests", - "lint": "eslint 'src/**/*.ts'", - "format": "prettier --write 'src/**/*.ts'", + "lint": "eslint src/**/*.ts", + "format": "prettier --write src/**/*.ts", "clean": "rm -rf dist" }, "keywords": [ diff --git a/sdk/src/client.regression.test.ts b/sdk/src/client.regression.test.ts new file mode 100644 index 0000000..dc31374 --- /dev/null +++ b/sdk/src/client.regression.test.ts @@ -0,0 +1,56 @@ +import { Keypair, SorobanRpc, xdr } from '@stellar/stellar-sdk'; +import { bcForgeClient } from './client'; +import * as utils from './utils'; + +jest.mock('./utils', () => ({ + buildInvokeTransaction: jest.fn(), + submitTransaction: jest.fn(), + addressToScVal: jest.fn((value: string) => value), + i128ToScVal: jest.fn((value: bigint) => value), + stringToScVal: jest.fn((value: string) => value), + u32ToScVal: jest.fn((value: number) => value), + scValToNative: jest.fn(() => 42), + buildUnsignedTransaction: jest.fn(), + signTransaction: jest.fn(), + simulateTransaction: jest.fn(), + hashToScVal: jest.fn(), +})); + +describe('bcForgeClient regression coverage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('awaits the submitted transaction before unwrapping its response', async () => { + const mockedBuildInvokeTransaction = jest.mocked(utils.buildInvokeTransaction); + const mockedSubmitTransaction = jest.mocked(utils.submitTransaction); + const mockedScValToNative = jest.mocked(utils.scValToNative); + + mockedBuildInvokeTransaction.mockResolvedValueOnce('mock-xdr'); + mockedSubmitTransaction.mockResolvedValueOnce({ + status: SorobanRpc.Api.GetTransactionStatus.SUCCESS, + hash: 'tx-hash', + returnValue: xdr.ScVal.scvU32(7), + } as unknown as Awaited