From 717345c1fa4fa8a8603f30dbfd674cb540f625fb Mon Sep 17 00:00:00 2001 From: Gazzy-Lee Date: Sun, 31 May 2026 00:55:00 +0100 Subject: [PATCH] Add freeze blacklist module and integrate token freeze guards --- contracts/admin/src/lib.rs | 1 - contracts/freeze/Cargo.toml | 22 +++++ contracts/freeze/src/lib.rs | 157 ++++++++++++++++++++++++++++++++++++ contracts/token/Cargo.toml | 1 + contracts/token/src/lib.rs | 98 ++++++++++++++-------- sdk/src/client.ts | 39 +++++++++ 6 files changed, 284 insertions(+), 34 deletions(-) create mode 100644 contracts/freeze/Cargo.toml create mode 100644 contracts/freeze/src/lib.rs diff --git a/contracts/admin/src/lib.rs b/contracts/admin/src/lib.rs index e76d173..4eeecea 100644 --- a/contracts/admin/src/lib.rs +++ b/contracts/admin/src/lib.rs @@ -154,7 +154,6 @@ pub fn create_proposal(env: &Env, creator: Address, description: String) -> u64 let proposal = Proposal { creator: creator.clone(), - action_type, description, approvals: vec![env, creator], executed: false, diff --git a/contracts/freeze/Cargo.toml b/contracts/freeze/Cargo.toml new file mode 100644 index 0000000..56d6f56 --- /dev/null +++ b/contracts/freeze/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "bc-forge-freeze" +version = "0.1.0" +edition = "2021" +publish = false +description = "Freeze blacklist primitives for bc-forge token contracts" +repository = "https://github.com/BCPathway/bc-forge" +license = "MIT" +keywords = ["soroban", "freeze", "token", "control"] +categories = ["cryptography::cryptocurrencies"] + +[lib] +crate-type = ["rlib"] + +[dependencies] +soroban-sdk = "22.0.0" + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/contracts/freeze/src/lib.rs b/contracts/freeze/src/lib.rs new file mode 100644 index 0000000..e1d5ac7 --- /dev/null +++ b/contracts/freeze/src/lib.rs @@ -0,0 +1,157 @@ +#![no_std] + +use soroban_sdk::{contracttype, symbol_short, Address, Env}; + +#[derive(Clone)] +#[contracttype] +pub enum FreezeKey { + Address(Address), + All, +} + +pub fn freeze_address(env: &Env, admin: Address, address: Address) { + admin.require_auth(); + env.storage() + .persistent() + .set(&FreezeKey::Address(address.clone()), &true); + emit_address_frozen(env, &admin, &address); +} + +pub fn unfreeze_address(env: &Env, admin: Address, address: Address) { + admin.require_auth(); + env.storage() + .persistent() + .remove(&FreezeKey::Address(address.clone())); + emit_address_unfrozen(env, &admin, &address); +} + +pub fn is_address_frozen(env: &Env, address: &Address) -> bool { + env.storage() + .persistent() + .get(&FreezeKey::Address(address.clone())) + .unwrap_or(false) +} + +pub fn freeze_all(env: &Env, admin: Address) { + admin.require_auth(); + env.storage().persistent().set(&FreezeKey::All, &true); + emit_global_frozen(env, &admin); +} + +pub fn unfreeze_all(env: &Env, admin: Address) { + admin.require_auth(); + env.storage().persistent().remove(&FreezeKey::All); + emit_global_unfrozen(env, &admin); +} + +pub fn is_all_frozen(env: &Env) -> bool { + env.storage() + .persistent() + .get(&FreezeKey::All) + .unwrap_or(false) +} + +pub fn is_frozen(env: &Env, address: &Address) -> bool { + if is_all_frozen(env) { + true + } else { + is_address_frozen(env, address) + } +} + +fn emit_address_frozen(env: &Env, admin: &Address, address: &Address) { + env.events().publish( + (symbol_short!("frze"),), + (admin.clone(), address.clone()), + ); +} + +fn emit_address_unfrozen(env: &Env, admin: &Address, address: &Address) { + env.events().publish( + (symbol_short!("unfr"),), + (admin.clone(), address.clone()), + ); +} + +fn emit_global_frozen(env: &Env, admin: &Address) { + env.events().publish((symbol_short!("glfz"),), (admin.clone(),)); +} + +fn emit_global_unfrozen(env: &Env, admin: &Address) { + env.events().publish((symbol_short!("gufz"),), (admin.clone(),)); +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::{contract, contractimpl, Env}; + + #[contract] + struct FreezeContract; + + #[contractimpl] + impl FreezeContract { + pub fn freeze_address(env: Env, admin: Address, address: Address) { + super::freeze_address(&env, admin, address); + } + + pub fn unfreeze_address(env: Env, admin: Address, address: Address) { + super::unfreeze_address(&env, admin, address); + } + + pub fn is_address_frozen(env: Env, address: Address) -> bool { + super::is_address_frozen(&env, &address) + } + + pub fn freeze_all(env: Env, admin: Address) { + super::freeze_all(&env, admin); + } + + pub fn unfreeze_all(env: Env, admin: Address) { + super::unfreeze_all(&env, admin); + } + + pub fn is_all_frozen(env: Env) -> bool { + super::is_all_frozen(&env) + } + + pub fn is_frozen(env: Env, address: Address) -> bool { + super::is_frozen(&env, &address) + } + } + + #[test] + fn test_freeze_and_unfreeze_address() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(FreezeContract, ()); + let client = FreezeContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let user = Address::generate(&env); + + client.freeze_address(&admin, &user); + assert!(client.is_address_frozen(&user)); + + client.unfreeze_address(&admin, &user); + assert!(!client.is_address_frozen(&user)); + } + + #[test] + fn test_freeze_all_blocks_addresses() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(FreezeContract, ()); + let client = FreezeContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let user = Address::generate(&env); + + client.freeze_all(&admin); + assert!(client.is_all_frozen()); + assert!(client.is_frozen(&user)); + + client.unfreeze_all(&admin); + assert!(!client.is_all_frozen()); + assert!(!client.is_frozen(&user)); + } +} diff --git a/contracts/token/Cargo.toml b/contracts/token/Cargo.toml index 636f458..5f10de8 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-freeze = { path = "../freeze" } [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..8f81483 100644 --- a/contracts/token/src/lib.rs +++ b/contracts/token/src/lib.rs @@ -12,6 +12,7 @@ mod events; mod test; use bc_forge_admin::{self as admin, Role}; +use bc_forge_freeze as freeze; use soroban_sdk::token::TokenInterface; use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, String, Vec, @@ -23,11 +24,9 @@ pub enum DataKey { /// The contract admin address (singular). Admin, PendingAdmin, - /// Spending allowance: (owner, spender) → amount and expiration. + /// Stored allowance info for (owner, spender). Allowance(Address, Address), /// Token balance for an address. - Allowance(Address, Address), - AllowanceExp(Address, Address), Balance(Address), Name, Symbol, @@ -79,6 +78,8 @@ pub enum TokenError { InsufficientBalance = 4, InsufficientAllowance = 5, ContractPaused = 6, + ContractFrozen = 7, + AddressFrozen = 8, } #[contract] @@ -138,30 +139,12 @@ impl BcForgeToken { .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; - } + + if allowance_info.exp_ledger > 0 && env.ledger().sequence() > allowance_info.exp_ledger { + return 0; } - env.storage() - .persistent() - .get(&DataKey::Allowance(from.clone(), spender.clone())) - .unwrap_or(0) + allowance_info.amount } fn write_allowance(env: &Env, from: &Address, spender: &Address, amount: i128, exp: u32) { @@ -177,10 +160,16 @@ impl BcForgeToken { .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 ensure_not_frozen(env: &Env, address: &Address) -> Result<(), TokenError> { + if freeze::is_all_frozen(env) { + return Err(TokenError::ContractFrozen); + } + if freeze::is_address_frozen(env, address) { + return Err(TokenError::AddressFrozen); + } + Ok(()) } fn move_balance( @@ -266,6 +255,7 @@ impl BcForgeToken { Self::ensure_not_paused(&env)?; let current_admin = Self::read_admin(&env)?; current_admin.require_auth(); + Self::ensure_not_frozen(&env, &to)?; Self::internal_mint(&env, ¤t_admin, &to, amount) } @@ -280,6 +270,7 @@ impl BcForgeToken { if recipient.amount <= 0 { return Err(TokenError::InvalidAmount); } + Self::ensure_not_frozen(&env, &recipient.address)?; } for i in 0..recipients.len() { @@ -290,17 +281,56 @@ impl BcForgeToken { Ok(()) } + pub fn freeze_address(env: Env, address: Address) -> Result<(), TokenError> { + Self::ensure_initialized(&env)?; + let current_admin = Self::read_admin(&env)?; + current_admin.require_auth(); + freeze::freeze_address(&env, current_admin.clone(), address); + Ok(()) + } + + pub fn unfreeze_address(env: Env, address: Address) -> Result<(), TokenError> { + Self::ensure_initialized(&env)?; + let current_admin = Self::read_admin(&env)?; + current_admin.require_auth(); + freeze::unfreeze_address(&env, current_admin.clone(), address); + Ok(()) + } + + pub fn freeze_all(env: Env) -> Result<(), TokenError> { + Self::ensure_initialized(&env)?; + let current_admin = Self::read_admin(&env)?; + current_admin.require_auth(); + freeze::freeze_all(&env, current_admin.clone()); + Ok(()) + } + + pub fn unfreeze_all(env: Env) -> Result<(), TokenError> { + Self::ensure_initialized(&env)?; + let current_admin = Self::read_admin(&env)?; + current_admin.require_auth(); + freeze::unfreeze_all(&env, current_admin.clone()); + Ok(()) + } + + pub fn is_frozen(env: Env, address: Address) -> bool { + Self::panic_on_err(&env, Self::ensure_initialized(&env)); + freeze::is_frozen(&env, &address) + } + pub fn batch_transfer(env: Env, from: Address, recipients: Vec<(Address, i128)>) { Self::panic_on_err(&env, Self::ensure_initialized(&env)); Self::panic_on_err(&env, Self::ensure_not_paused(&env)); from.require_auth(); + Self::panic_on_err(&env, Self::ensure_not_frozen(&env, &from)); let mut total: i128 = 0; for i in 0..recipients.len() { - let (_, amount) = recipients.get(i).expect("recipient should exist"); + let (to, amount) = recipients.get(i).expect("recipient should exist"); if amount <= 0 { soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount); } + Self::panic_on_err(&env, Self::ensure_not_frozen(&env, &to)); total = match total.checked_add(amount) { Some(total) => total, None => soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount), @@ -592,6 +622,8 @@ 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(); + Self::panic_on_err(&env, Self::ensure_not_frozen(&env, &from)); + Self::panic_on_err(&env, Self::ensure_not_frozen(&env, &to)); if amount <= 0 { soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount); @@ -605,6 +637,9 @@ 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(); + Self::panic_on_err(&env, Self::ensure_not_frozen(&env, &spender)); + Self::panic_on_err(&env, Self::ensure_not_frozen(&env, &from)); + Self::panic_on_err(&env, Self::ensure_not_frozen(&env, &to)); if amount <= 0 { soroban_sdk::panic_with_error!(&env, TokenError::InvalidAmount); @@ -615,12 +650,9 @@ impl TokenInterface for BcForgeToken { 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); events::emit_transfer_from(&env, &spender, &from, &to, amount, allowance - amount); } diff --git a/sdk/src/client.ts b/sdk/src/client.ts index 5afb91c..cd00958 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -250,6 +250,45 @@ export class bcForgeClient { ); } + /** + * Freeze a specific address. Admin-only. + * + * @param address - Address to freeze + * @param source - Admin keypair + */ + async freezeAddress(address: string, source: Keypair): Promise { + return this.invokeContract( + 'freeze_address', + [addressToScVal(address)], + source, + ); + } + + /** + * Unfreeze a specific address. Admin-only. + * + * @param address - Address to unfreeze + * @param source - Admin keypair + */ + async unfreezeAddress(address: string, source: Keypair): Promise { + return this.invokeContract( + 'unfreeze_address', + [addressToScVal(address)], + source, + ); + } + + /** + * Check whether a specific address is frozen. + * + * @param address - Address to query + * @returns True if the address is frozen or if transfers are globally frozen + */ + async isFrozen(address: string): Promise { + const result = await this.queryContract('is_frozen', [addressToScVal(address)]); + return scValToNative(result) as boolean; + } + /** * Approve a spender to use tokens on your behalf. *