From 52b51861f1c762fbed8c5832ffbc37678f198a62 Mon Sep 17 00:00:00 2001 From: Okeke Chinedu Emmanuel Date: Sun, 26 Jul 2026 04:58:35 +0100 Subject: [PATCH 1/3] Add WASM release dry-run checks --- .github/workflows/ci.yml | 42 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be201e3..eaaf998 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ permissions: env: CARGO_TERM_COLOR: always + WASM_MAX_BYTES: 262144 jobs: check-and-test: @@ -40,6 +41,45 @@ jobs: - name: Run workspace unit and integration tests run: cargo test --all --verbose - - name: Build WASM contracts + - name: Build WASM contracts in release mode run: | cargo build --release --target wasm32-unknown-unknown + + - name: Report WASM artifact sizes + run: | + set -euo pipefail + + shopt -s nullglob + wasm_artifacts=(target/wasm32-unknown-unknown/release/*.wasm) + + if (( ${#wasm_artifacts[@]} == 0 )); then + echo "No WASM artifacts found in target/wasm32-unknown-unknown/release" >&2 + exit 1 + fi + + printf '%-64s %12s %12s\n' "Artifact" "Bytes" "KiB" + for artifact in "${wasm_artifacts[@]}"; do + size_bytes=$(stat -c%s "$artifact") + size_kib=$(awk -v bytes="$size_bytes" 'BEGIN { printf "%.2f", bytes / 1024 }') + printf '%-64s %12d %12s\n' "$artifact" "$size_bytes" "$size_kib" + done + + - name: Enforce WASM artifact size limit + run: | + set -euo pipefail + + shopt -s nullglob + wasm_artifacts=(target/wasm32-unknown-unknown/release/*.wasm) + + if (( ${#wasm_artifacts[@]} == 0 )); then + echo "No WASM artifacts found in target/wasm32-unknown-unknown/release" >&2 + exit 1 + fi + + for artifact in "${wasm_artifacts[@]}"; do + size_bytes=$(stat -c%s "$artifact") + if (( size_bytes > WASM_MAX_BYTES )); then + echo "${artifact} is ${size_bytes} bytes, exceeding the ${WASM_MAX_BYTES}-byte limit" >&2 + exit 1 + fi + done From cb0d8083d474ece14e9daea5b7cb890b19512b20 Mon Sep 17 00:00:00 2001 From: Okeke Chinedu Emmanuel Date: Sun, 26 Jul 2026 05:19:01 +0100 Subject: [PATCH 2/3] ci: add cargo audit vulnerability scanning --- .github/workflows/ci.yml | 21 +++++++++++++++++++++ DEVELOPMENT.md | 18 +++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be201e3..e36cdb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,3 +43,24 @@ jobs: - name: Build WASM contracts run: | cargo build --release --target wasm32-unknown-unknown + + audit-job: + name: Dependency Vulnerability Audit + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo dependencies and build cache + uses: Swatinem/rust-cache@v2 + with: + key: cargo-audit + + - name: Install cargo-audit + run: cargo install cargo-audit --locked + + - name: Run dependency vulnerability audit + run: cargo audit diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a766578..56d7aa2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -18,12 +18,28 @@ cargo clippy --all-targets --all-features -- -D warnings # 3. Run all unit and integration tests across all workspace contracts cargo test --all --verbose -# 4. Build release WASM binaries for target wasm32-unknown-unknown +# 4. Audit dependency vulnerabilities against the RustSec advisory database +cargo audit + +# 5. Build release WASM binaries for target wasm32-unknown-unknown cargo build --release --target wasm32-unknown-unknown ``` --- +## 🔐 Dependency Vulnerability Triage + +CI runs `cargo audit` on every Pull Request targeting `dev` and every push to `dev`. When an advisory is reported: + +1. Confirm the affected crate, version range, and advisory details in the RustSec database. +2. Prefer upgrading the vulnerable dependency or its direct parent dependency in the same Pull Request. +3. If no patched version is available, document the impact analysis, affected code paths, and mitigation plan in the Pull Request before requesting review. +4. Do not add advisory ignores unless the advisory is demonstrably unreachable or a maintainer approves a temporary exception with a tracked follow-up issue. + +Run `cargo install cargo-audit --locked` once locally if the `cargo audit` command is unavailable. + +--- + ## 🛠️ Local Development Setup ### 1. Requirements From 1e40bd535135de3eced08d4faeddce92c44ccaf4 Mon Sep 17 00:00:00 2001 From: Akatenvictor <“akatenvictor@gmail.com”> Date: Mon, 27 Jul 2026 02:08:58 +0100 Subject: [PATCH 3/3] feat: Add account freeze/unfreeze functionality - Add Frozen storage key in types.rs - Add AccountFrozen and AccountNotFrozen error variants - Add account_frozen and account_unfrozen events - Add storage functions for frozen accounts - Implement freeze_account, unfreeze_account, and is_frozen functions - Add frozen account checks to transfer and transfer_from - Add comprehensive unit tests for freeze/unfreeze operations Resolves: Token Freeze/Unfreeze Account Restrict Hooks --- contracts/invoice-token/src/errors.rs | 6 + contracts/invoice-token/src/events.rs | 12 + contracts/invoice-token/src/lib.rs | 55 +++ contracts/invoice-token/src/storage.rs | 22 + contracts/invoice-token/src/test.rs | 626 +++++++++++++++++++++++++ contracts/invoice-token/src/types.rs | 2 + 6 files changed, 723 insertions(+) diff --git a/contracts/invoice-token/src/errors.rs b/contracts/invoice-token/src/errors.rs index ffc5633..9419caf 100644 --- a/contracts/invoice-token/src/errors.rs +++ b/contracts/invoice-token/src/errors.rs @@ -29,4 +29,10 @@ pub enum Error { InvalidExpiration = 10, /// Contract is paused and the requested operation is temporarily disabled. Paused = 11, + /// Address parameter is zero (null). + InvalidAddress = 12, + /// Account is frozen and cannot perform transfers. + AccountFrozen = 13, + /// Account is not frozen and cannot be unfrozen. + AccountNotFrozen = 14, } diff --git a/contracts/invoice-token/src/events.rs b/contracts/invoice-token/src/events.rs index 17cabb2..1a951a6 100644 --- a/contracts/invoice-token/src/events.rs +++ b/contracts/invoice-token/src/events.rs @@ -56,3 +56,15 @@ pub fn paused_updated_event(env: &Env, old_value: bool, new_value: bool) { (old_value, new_value), ); } + +/// Emit account frozen event. +pub fn account_frozen_event(env: &Env, account: &Address) { + env.events() + .publish((Symbol::new(env, "account_frozen"),), account); +} + +/// Emit account unfrozen event. +pub fn account_unfrozen_event(env: &Env, account: &Address) { + env.events() + .publish((Symbol::new(env, "account_unfrozen"),), account); +} diff --git a/contracts/invoice-token/src/lib.rs b/contracts/invoice-token/src/lib.rs index 14686f3..a79bc70 100644 --- a/contracts/invoice-token/src/lib.rs +++ b/contracts/invoice-token/src/lib.rs @@ -94,6 +94,10 @@ impl InvoiceToken { if meta.transfer_locked && from != meta.admin { return Err(Error::TransferLocked); } + // Check if from account is frozen + if storage::is_frozen(&env, &from) { + return Err(Error::AccountFrozen); + } let from_balance = storage::get_balance(&env, &from); if from_balance < amount { return Err(Error::InsufficientBalance); @@ -149,6 +153,10 @@ impl InvoiceToken { if meta.transfer_locked && from != meta.admin { return Err(Error::TransferLocked); } + // Check if from account is frozen + if storage::is_frozen(&env, &from) { + return Err(Error::AccountFrozen); + } let ledger = env.ledger().sequence(); let allow = storage::get_allowance_data(&env, &from, &spender) .ok_or(Error::InsufficientAllowance)?; @@ -324,6 +332,53 @@ impl InvoiceToken { let meta = storage::get_metadata(&env).ok_or(Error::NotInit)?; Ok(meta.paused) } + + // ---------- Account Freeze/Unfreeze ---------- + + /// Freeze an account, preventing it from transferring tokens. Admin only. + pub fn freeze_account(env: Env, account: Address) -> Result<(), Error> { + // Validate address parameters + Self::validate_address(&account)?; + + let meta = storage::get_metadata(&env).ok_or(Error::NotInit)?; + meta.admin.require_auth(); + + // Check if account is already frozen + if storage::is_frozen(&env, &account) { + return Err(Error::AccountFrozen); + } + + storage::freeze_account(&env, &account); + events::account_frozen_event(&env, &account); + Ok(()) + } + + /// Unfreeze an account, allowing it to transfer tokens again. Admin only. + pub fn unfreeze_account(env: Env, account: Address) -> Result<(), Error> { + // Validate address parameters + Self::validate_address(&account)?; + + let meta = storage::get_metadata(&env).ok_or(Error::NotInit)?; + meta.admin.require_auth(); + + // Check if account is not frozen + if !storage::is_frozen(&env, &account) { + return Err(Error::AccountNotFrozen); + } + + storage::unfreeze_account(&env, &account); + events::account_unfrozen_event(&env, &account); + Ok(()) + } + + /// Check if an account is frozen. + pub fn is_frozen(env: Env, account: Address) -> Result { + // Validate address parameters + Self::validate_address(&account)?; + + storage::get_metadata(&env).ok_or(Error::NotInit)?; + Ok(storage::is_frozen(&env, &account)) + } } #[cfg(test)] diff --git a/contracts/invoice-token/src/storage.rs b/contracts/invoice-token/src/storage.rs index 169486a..517d725 100644 --- a/contracts/invoice-token/src/storage.rs +++ b/contracts/invoice-token/src/storage.rs @@ -98,3 +98,25 @@ pub fn get_allowance_data( .persistent() .get(&StorageKey::Allowance(from.clone(), spender.clone())) } + +/// Check if an account is frozen. +pub fn is_frozen(env: &soroban_sdk::Env, addr: &Address) -> bool { + env.storage() + .persistent() + .get(&StorageKey::Frozen(addr.clone())) + .unwrap_or(false) +} + +/// Freeze an account. +pub fn freeze_account(env: &soroban_sdk::Env, addr: &Address) { + env.storage() + .persistent() + .set(&StorageKey::Frozen(addr.clone()), &true); +} + +/// Unfreeze an account. +pub fn unfreeze_account(env: &soroban_sdk::Env, addr: &Address) { + env.storage() + .persistent() + .remove(&StorageKey::Frozen(addr.clone())); +} diff --git a/contracts/invoice-token/src/test.rs b/contracts/invoice-token/src/test.rs index ca441bc..a6853e2 100644 --- a/contracts/invoice-token/src/test.rs +++ b/contracts/invoice-token/src/test.rs @@ -1094,3 +1094,629 @@ fn test_approve_positive_amount_invalid_expiration_rejected() { // Verify no allowance was set assert_eq!(client.allowance(&admin, &spender), 0); } + +// ========== Invalid Address Validation Tests ========== + +#[test] +fn test_initialize_with_zero_address_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(InvoiceToken, ()); + let client = InvoiceTokenClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let minter = Address::generate(&env); + let name = SorobanString::from_str(&env, "Invoice INV-001"); + let symbol = SorobanString::from_str(&env, "INV001"); + let invoice_id = Symbol::new(&env, "inv_001"); + + // Test with zero admin address + let zero_address = Address::default(); + let result = client.try_initialize(&zero_address, &name, &symbol, &7u32, &invoice_id, &minter); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); + + // Test with zero minter address + let result = client.try_initialize(&admin, &name, &symbol, &7u32, &invoice_id, &zero_address); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); +} + +#[test] +fn test_transfer_with_zero_address_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, minter) = setup_token(&env); + + // Mint tokens to admin + client.mint(&admin, &1000, &minter); + + let zero_address = Address::default(); + + // Test with zero 'from' address + let recipient = Address::generate(&env); + let result = client.try_transfer(&zero_address, &recipient, &100); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); + + // Test with zero 'to' address + let result = client.try_transfer(&admin, &zero_address, &100); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); +} + +#[test] +fn test_approve_with_zero_address_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, _minter) = setup_token(&env); + + let zero_address = Address::default(); + let spender = Address::generate(&env); + let expiration = env.ledger().sequence() + 100; + + // Test with zero 'from' address + let result = client.try_approve(&zero_address, &spender, &500, &expiration); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); + + // Test with zero 'spender' address + let result = client.try_approve(&admin, &zero_address, &500, &expiration); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); +} + +#[test] +fn test_transfer_from_with_zero_address_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, minter) = setup_token(&env); + + // Mint tokens to admin and unlock transfers + client.mint(&admin, &1000, &minter); + client.set_transfer_locked(&admin, &false); + + let zero_address = Address::default(); + let spender = Address::generate(&env); + let recipient = Address::generate(&env); + let expiration = env.ledger().sequence() + 100; + + // Setup allowance + client.approve(&admin, &spender, &500, &expiration); + + // Test with zero 'spender' address + let result = client.try_transfer_from(&zero_address, &admin, &recipient, &100); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); + + // Test with zero 'from' address + let result = client.try_transfer_from(&spender, &zero_address, &recipient, &100); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); + + // Test with zero 'to' address + let result = client.try_transfer_from(&spender, &admin, &zero_address, &100); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); +} + +#[test] +fn test_burn_with_zero_address_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, minter) = setup_token(&env); + + // Mint tokens to admin + client.mint(&admin, &1000, &minter); + + let zero_address = Address::default(); + + // Test with zero 'from' address + let result = client.try_burn(&zero_address, &100); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); +} + +#[test] +fn test_burn_from_with_zero_address_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, minter) = setup_token(&env); + + // Mint tokens to admin + client.mint(&admin, &1000, &minter); + + let zero_address = Address::default(); + let spender = Address::generate(&env); + let expiration = env.ledger().sequence() + 100; + + // Setup allowance + client.approve(&admin, &spender, &500, &expiration); + + // Test with zero 'spender' address + let result = client.try_burn_from(&zero_address, &admin, &100); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); + + // Test with zero 'from' address + let result = client.try_burn_from(&spender, &zero_address, &100); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); +} + +#[test] +fn test_mint_with_zero_address_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, minter) = setup_token(&env); + + let zero_address = Address::default(); + + // Test with zero 'to' address + let result = client.try_mint(&zero_address, &100, &admin); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); + + // Test with zero 'by' address (even though it won't pass auth, should fail with InvalidAddress first) + let recipient = Address::generate(&env); + let result = client.try_mint(&recipient, &100, &zero_address); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); +} + +#[test] +fn test_set_transfer_locked_with_zero_address_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, _minter) = setup_token(&env); + + let zero_address = Address::default(); + + // Test with zero 'caller' address + let result = client.try_set_transfer_locked(&zero_address, &false); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); + + // Valid caller should still work + client.set_transfer_locked(&admin, &false); + assert!(!client.transfer_locked()); +} + +#[test] +fn test_set_minter_with_zero_address_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _minter) = setup_token(&env); + + let zero_address = Address::default(); + + // Test with zero 'new_minter' address + let result = client.try_set_minter(&zero_address); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); +} + +#[test] +fn test_balance_with_zero_address_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _minter) = setup_token(&env); + + let zero_address = Address::default(); + + // Test with zero 'id' address + let result = client.try_balance(&zero_address); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); +} + +#[test] +fn test_allowance_with_zero_address_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, _minter) = setup_token(&env); + + let zero_address = Address::default(); + let spender = Address::generate(&env); + + // Test with zero 'from' address + let result = client.try_allowance(&zero_address, &spender); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); + + // Test with zero 'spender' address + let result = client.try_allowance(&admin, &zero_address); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); +} + +#[test] +fn test_valid_addresses_still_work() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, minter) = setup_token(&env); + + // Test that normal operations still work with valid addresses + + // Mint tokens + let user = Address::generate(&env); + client.mint(&user, &1000, &minter); + assert_eq!(client.balance(&user), 1000); + + // Check balance + assert_eq!(client.balance(&user), 1000); + + // Set allowance + let spender = Address::generate(&env); + let expiration = env.ledger().sequence() + 100; + client.approve(&user, &spender, &500, &expiration); + assert_eq!(client.allowance(&user, &spender), 500); + + // Unlock transfers + client.set_transfer_locked(&admin, &false); + + // Transfer + let recipient = Address::generate(&env); + client.transfer(&user, &recipient, &100); + assert_eq!(client.balance(&user), 900); + assert_eq!(client.balance(&recipient), 100); + + // Burn + client.burn(&user, &50); + assert_eq!(client.balance(&user), 850); + + // Set new minter + let new_minter = Address::generate(&env); + client.set_minter(&new_minter); +} + +// ========== Account Freeze/Unfreeze Tests ========== + +#[test] +fn test_freeze_account_by_admin() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, minter) = setup_token(&env); + + // Mint tokens to a user + let user = Address::generate(&env); + client.mint(&user, &1000, &minter); + + // Initially not frozen + assert!(!client.is_frozen(&user)); + + // Admin freezes the account + client.freeze_account(&user); + assert!(client.is_frozen(&user)); +} + +#[test] +fn test_freeze_account_unauthorized_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, minter) = setup_token(&env); + + // Mint tokens to a user + let user = Address::generate(&env); + client.mint(&user, &1000, &minter); + + // Non-admin cannot freeze + let stranger = Address::generate(&env); + let result = client.try_freeze_account(&stranger, &user); + assert_eq!(result, Err(Ok(crate::errors::Error::Unauthorized))); + + // Account should not be frozen + assert!(!client.is_frozen(&user)); +} + +#[test] +fn test_freeze_already_frozen_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, minter) = setup_token(&env); + + // Mint tokens to a user + let user = Address::generate(&env); + client.mint(&user, &1000, &minter); + + // Freeze the account + client.freeze_account(&user); + assert!(client.is_frozen(&user)); + + // Try to freeze again should fail + let result = client.try_freeze_account(&admin, &user); + assert_eq!(result, Err(Ok(crate::errors::Error::AccountFrozen))); +} + +#[test] +fn test_unfreeze_account_by_admin() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, minter) = setup_token(&env); + + // Mint tokens to a user + let user = Address::generate(&env); + client.mint(&user, &1000, &minter); + + // Freeze the account + client.freeze_account(&user); + assert!(client.is_frozen(&user)); + + // Admin unfreezes the account + client.unfreeze_account(&user); + assert!(!client.is_frozen(&user)); +} + +#[test] +fn test_unfreeze_account_unauthorized_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, minter) = setup_token(&env); + + // Mint tokens to a user + let user = Address::generate(&env); + client.mint(&user, &1000, &minter); + + // Freeze the account + client.freeze_account(&user); + assert!(client.is_frozen(&user)); + + // Non-admin cannot unfreeze + let stranger = Address::generate(&env); + let result = client.try_unfreeze_account(&stranger, &user); + assert_eq!(result, Err(Ok(crate::errors::Error::Unauthorized))); + + // Account should still be frozen + assert!(client.is_frozen(&user)); +} + +#[test] +fn test_unfreeze_not_frozen_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, minter) = setup_token(&env); + + // Mint tokens to a user + let user = Address::generate(&env); + client.mint(&user, &1000, &minter); + + // Account is not frozen + assert!(!client.is_frozen(&user)); + + // Try to unfreeze should fail + let result = client.try_unfreeze_account(&admin, &user); + assert_eq!(result, Err(Ok(crate::errors::Error::AccountNotFrozen))); +} + +#[test] +fn test_frozen_account_cannot_transfer() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, minter) = setup_token(&env); + + // Mint tokens to a user + let user = Address::generate(&env); + client.mint(&user, &1000, &minter); + + // Unlock transfers + client.set_transfer_locked(&admin, &false); + + // Freeze the account + client.freeze_account(&user); + assert!(client.is_frozen(&user)); + + // Transfer should fail + let recipient = Address::generate(&env); + let result = client.try_transfer(&user, &recipient, &100); + assert_eq!(result, Err(Ok(crate::errors::Error::AccountFrozen))); + + // Balances should remain unchanged + assert_eq!(client.balance(&user), 1000); + assert_eq!(client.balance(&recipient), 0); +} + +#[test] +fn test_frozen_account_cannot_transfer_from() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, minter) = setup_token(&env); + + // Mint tokens to a user + let user = Address::generate(&env); + client.mint(&user, &1000, &minter); + + // Unlock transfers + client.set_transfer_locked(&admin, &false); + + // User approves spender + let spender = Address::generate(&env); + let expiration = env.ledger().sequence() + 100; + client.approve(&user, &spender, &500, &expiration); + + // Freeze the account + client.freeze_account(&user); + assert!(client.is_frozen(&user)); + + // transfer_from should fail + let recipient = Address::generate(&env); + let result = client.try_transfer_from(&spender, &user, &recipient, &100); + assert_eq!(result, Err(Ok(crate::errors::Error::AccountFrozen))); + + // Balances should remain unchanged + assert_eq!(client.balance(&user), 1000); + assert_eq!(client.balance(&recipient), 0); +} + +#[test] +fn test_unfrozen_account_can_transfer() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, minter) = setup_token(&env); + + // Mint tokens to a user + let user = Address::generate(&env); + client.mint(&user, &1000, &minter); + + // Unlock transfers + client.set_transfer_locked(&admin, &false); + + // Freeze the account + client.freeze_account(&user); + assert!(client.is_frozen(&user)); + + // Unfreeze the account + client.unfreeze_account(&user); + assert!(!client.is_frozen(&user)); + + // Transfer should now succeed + let recipient = Address::generate(&env); + client.transfer(&user, &recipient, &100); + assert_eq!(client.balance(&user), 900); + assert_eq!(client.balance(&recipient), 100); +} + +#[test] +fn test_freeze_account_event_emission() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, minter) = setup_token(&env); + + // Mint tokens to a user + let user = Address::generate(&env); + client.mint(&user, &1000, &minter); + + // Freeze the account + client.freeze_account(&user); + + // Check event emission + let events = env.events().all(); + let event = events.last().unwrap(); + let (_contract_addr, topics, data) = event; + + assert_eq!( + topics, + (Symbol::new(&env, "account_frozen"),).into_val(&env) + ); + + let account: Address = data.try_into_val(&env).unwrap(); + assert_eq!(account, user); +} + +#[test] +fn test_unfreeze_account_event_emission() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, minter) = setup_token(&env); + + // Mint tokens to a user + let user = Address::generate(&env); + client.mint(&user, &1000, &minter); + + // Freeze the account + client.freeze_account(&user); + + // Unfreeze the account + client.unfreeze_account(&user); + + // Check event emission (should be the last event) + let events = env.events().all(); + let event = events.last().unwrap(); + let (_contract_addr, topics, data) = event; + + assert_eq!( + topics, + (Symbol::new(&env, "account_unfrozen"),).into_val(&env) + ); + + let account: Address = data.try_into_val(&env).unwrap(); + assert_eq!(account, user); +} + +#[test] +fn test_freeze_account_with_zero_address_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, _minter) = setup_token(&env); + + let zero_address = Address::default(); + + // Test with zero account address + let result = client.try_freeze_account(&admin, &zero_address); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); +} + +#[test] +fn test_unfreeze_account_with_zero_address_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, _minter) = setup_token(&env); + + let zero_address = Address::default(); + + // Test with zero account address + let result = client.try_unfreeze_account(&admin, &zero_address); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); +} + +#[test] +fn test_is_frozen_with_zero_address_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _minter) = setup_token(&env); + + let zero_address = Address::default(); + + // Test with zero account address + let result = client.try_is_frozen(&zero_address); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAddress))); +} + +#[test] +fn test_admin_can_transfer_while_frozen() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, minter) = setup_token(&env); + + // Mint tokens to admin + client.mint(&admin, &1000, &minter); + + // Freeze admin account (edge case - admin can freeze themselves) + client.freeze_account(&admin); + assert!(client.is_frozen(&admin)); + + // Even admin cannot transfer if frozen + let recipient = Address::generate(&env); + let result = client.try_transfer(&admin, &recipient, &100); + assert_eq!(result, Err(Ok(crate::errors::Error::AccountFrozen))); + + // Unfreeze admin + client.unfreeze_account(&admin); + assert!(!client.is_frozen(&admin)); + + // Admin can now transfer + client.transfer(&admin, &recipient, &100); + assert_eq!(client.balance(&admin), 900); + assert_eq!(client.balance(&recipient), 100); +} + +#[test] +fn test_multiple_accounts_can_be_frozen() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, minter) = setup_token(&env); + + // Mint tokens to multiple users + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + let user3 = Address::generate(&env); + client.mint(&user1, &1000, &minter); + client.mint(&user2, &1000, &minter); + client.mint(&user3, &1000, &minter); + + // Unlock transfers + client.set_transfer_locked(&admin, &false); + + // Freeze user1 and user2 + client.freeze_account(&user1); + client.freeze_account(&user2); + + // Check frozen status + assert!(client.is_frozen(&user1)); + assert!(client.is_frozen(&user2)); + assert!(!client.is_frozen(&user3)); + + // user1 cannot transfer + let recipient = Address::generate(&env); + let result = client.try_transfer(&user1, &recipient, &100); + assert_eq!(result, Err(Ok(crate::errors::Error::AccountFrozen))); + + // user2 cannot transfer + let result = client.try_transfer(&user2, &recipient, &100); + assert_eq!(result, Err(Ok(crate::errors::Error::AccountFrozen))); + + // user3 can transfer + client.transfer(&user3, &recipient, &100); + assert_eq!(client.balance(&user3), 900); + assert_eq!(client.balance(&recipient), 100); +} diff --git a/contracts/invoice-token/src/types.rs b/contracts/invoice-token/src/types.rs index 650e9e3..18e65bc 100644 --- a/contracts/invoice-token/src/types.rs +++ b/contracts/invoice-token/src/types.rs @@ -15,6 +15,8 @@ pub enum StorageKey { Balance(soroban_sdk::Address), /// Persistent: allowance (from, spender) -> AllowanceData. Allowance(soroban_sdk::Address, soroban_sdk::Address), + /// Persistent: frozen account status. + Frozen(soroban_sdk::Address), } /// Token metadata and admin config (instance storage).