diff --git a/.gitignore b/.gitignore index e6b57103..85ebe4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,11 @@ target *.rs.bk .DS_Store .env +temp_auto_push.bat .env.* /target/ /target_local/ .md-* hidden +.gitignore +branch_structure.json diff --git a/contracts/fee/src/test_ttl_bump.rs b/contracts/fee/src/test_ttl_bump.rs index 359d9e45..64a8c945 100644 --- a/contracts/fee/src/test_ttl_bump.rs +++ b/contracts/fee/src/test_ttl_bump.rs @@ -8,6 +8,20 @@ use soroban_sdk::testutils::storage::Instance as _; use soroban_sdk::testutils::{Address as _, Ledger as _}; use soroban_sdk::{Address, Env}; +fn read_ttl(env: &Env, contract_id: &Address) -> u32 { + env.as_contract(contract_id, || env.storage().instance().get_ttl()) +} + +fn setup_ttl() -> (Env, Address, Address, Address, FeeContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let token = env.register_stellar_asset_contract(Address::generate(&env)); + let contract_id = env.register_contract(None, FeeContract); + let client = FeeContractClient::new(&env, &contract_id); + (env, admin, token, contract_id, client) +} + fn setup() -> (Env, Address, FeeContractClient<'static>) { let env = Env::default(); env.mock_all_auths(); @@ -20,10 +34,9 @@ fn setup() -> (Env, Address, FeeContractClient<'static>) { #[test] #[ignore = "stale test: fails against current contract; see docs/KNOWN_ISSUES.md"] fn test_init_bumps_instance_ttl() { - let (env, admin, client) = setup(); - - client.init(&admin); - let ttl = env.storage().instance().get_ttl(); + let (env, admin, token, contract_id, client) = setup_ttl(); + client.init(&admin, &token, &0u32); + let ttl = read_ttl(&env, &contract_id); assert_eq!(ttl, INSTANCE_BUMP_AMOUNT); } diff --git a/contracts/settlement/src/admin.rs b/contracts/settlement/src/admin.rs index 950cae11..7d16b773 100644 --- a/contracts/settlement/src/admin.rs +++ b/contracts/settlement/src/admin.rs @@ -2,7 +2,6 @@ use soroban_sdk::{Address, Env}; -use crate::types::AdminMigrationEvent; use crate::{events, timelock, CalloraSettlement, SettlementError, StorageKey}; fn require_admin(env: &Env, caller: &Address) { @@ -115,11 +114,10 @@ pub(crate) fn execute_balance_migration(env: &Env, caller: &Address, from: &Addr env, from, &migration.to.clone(), - AdminMigrationEvent { - from: from.clone(), - to: migration.to, - amount: migration.amount, - executed_at, + crate::types::AdminMigrationEvent { + old_admin: from.clone(), + new_admin: migration.to.clone(), + migrated_by: caller.clone(), }, ); } diff --git a/contracts/settlement/src/types.rs b/contracts/settlement/src/types.rs index b636cc87..1aa7b656 100644 --- a/contracts/settlement/src/types.rs +++ b/contracts/settlement/src/types.rs @@ -327,30 +327,28 @@ pub struct DeveloperForceCreditedEvent { pub token: Address, } -/// Emitted when the admin proposes or executes a timelock'd developer balance -/// migration (address rotation). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct AdminMigrationEvent { - pub from: Address, - pub to: Address, - pub amount: i128, - pub executed_at: u64, -} - -/// Emitted when the admin proposes a timelocked WASM upgrade. +/// Emitted when an admin proposes a timelocked WASM upgrade. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct UpgradeProposedEvent { + pub proposer: Address, pub wasm_hash: soroban_sdk::BytesN<32>, - pub proposed_at: u64, pub execute_after: u64, } -/// Emitted when a pending WASM upgrade is cancelled by the admin. +/// Emitted when a pending WASM upgrade proposal is cancelled. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct UpgradeCancelledEvent { + pub canceller: Address, pub wasm_hash: soroban_sdk::BytesN<32>, - pub cancelled_at: u64, +} + +/// Emitted when an admin migration is recorded. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct AdminMigrationEvent { + pub old_admin: Address, + pub new_admin: Address, + pub migrated_by: Address, } diff --git a/contracts/stake/tests/auth_snap.rs b/contracts/stake/tests/auth_snap.rs index 67e4419b..b5127aca 100644 --- a/contracts/stake/tests/auth_snap.rs +++ b/contracts/stake/tests/auth_snap.rs @@ -1,5 +1,4 @@ -#![cfg(test)] - +#[cfg(test)] extern crate std; use callora_stake::{CalloraStake, CalloraStakeClient, SUPPORTED_CAPABILITIES}; @@ -47,6 +46,7 @@ fn capability_bits_map_to_entrypoints() { let client = create_contract(&env); let caps = client.capabilities(); + assert_eq!(caps, 0); // Bit 0 -> stake/unstake entrypoints must exist in the generated client. // None are exported today, so the bit must be clear. diff --git a/contracts/upgrade/src/admin.rs b/contracts/upgrade/src/admin.rs index 375ca628..9288a385 100644 --- a/contracts/upgrade/src/admin.rs +++ b/contracts/upgrade/src/admin.rs @@ -3,6 +3,9 @@ //! Enforces a cooldown period between critical upgrade actions to prevent //! rapid abuse. This implements the requirements for the GrantFox FWC26 campaign. //! +//! `check_and_record_upgrade` is restricted to the admin stored via [`init_admin`]. +//! Any other signer receives `Unauthorized` without mutating state or emitting events. +//! //! `set_cooldown` is restricted to the admin stored via [`init_admin`] and the //! new value must lie in `MIN_COOLDOWN_SECONDS..=MAX_COOLDOWN_SECONDS`. //! @@ -112,10 +115,17 @@ pub fn get_cooldown(env: &Env) -> u64 { /// Verify that the cooldown period has elapsed since the last upgrade. /// -/// Requires auth from the caller. Returns `Ok(())` if the cooldown has elapsed -/// or if this is the first upgrade. Otherwise, returns `UpgradeError::CooldownNotElapsed`. +/// Checks, in order: caller auth, admin is initialized, caller is the admin, +/// then the cooldown. Returns `Ok(())` if the cooldown has elapsed or if this +/// is the first upgrade. Otherwise, returns `UpgradeError::CooldownNotElapsed`. /// Also updates the last upgrade time to the current ledger timestamp upon success. /// +/// # Errors +/// - `NotInitialized` if no admin is stored. +/// - `Unauthorized` if `caller` is not the stored admin. +/// - `CooldownNotElapsed` if the cooldown has not elapsed since the last upgrade. +/// - `Overflow` if the ledger timestamp is before the last upgrade time. +/// /// # Events /// On success, emits two events in order: /// 1. `upgrade_started` — topics `(upgrade_started, caller)`, data `(current_timestamp, cooldown)`. @@ -127,6 +137,11 @@ pub fn get_cooldown(env: &Env) -> u64 { pub fn check_and_record_upgrade(env: &Env, caller: &Address) -> Result<(), UpgradeError> { caller.require_auth(); + let admin = get_admin(env).ok_or(UpgradeError::NotInitialized)?; + if *caller != admin { + return Err(UpgradeError::Unauthorized); + } + let current_time = env.ledger().timestamp(); let last_time = env .storage() @@ -155,6 +170,13 @@ pub fn check_and_record_upgrade(env: &Env, caller: &Address) -> Result<(), Upgra .instance() .set(&Symbol::new(env, LAST_UPGRADE_TIME_KEY), ¤t_time); + // Keep the cooldown state live. The host contract owns instance TTL policy; + // this only refreshes the TTL of the instance entry holding the record. + env.storage().instance().extend_ttl( + crate::admin::INSTANCE_TTL_THRESHOLD, + crate::admin::INSTANCE_TTL_EXTEND, + ); + // Timestamp is now persisted — confirm the record. env.events() .publish((events::event_upgrade_recorded(env), caller), current_time); diff --git a/contracts/upgrade/src/test.rs b/contracts/upgrade/src/test.rs index db518b80..743402f1 100644 --- a/contracts/upgrade/src/test.rs +++ b/contracts/upgrade/src/test.rs @@ -7,7 +7,7 @@ #![cfg(test)] extern crate std; use crate::admin::{ - check_and_record_upgrade, get_cooldown, set_cooldown, DEFAULT_COOLDOWN_SECONDS, + check_and_record_upgrade, get_cooldown, init_admin, set_cooldown, DEFAULT_COOLDOWN_SECONDS, }; use crate::errors::UpgradeError; use soroban_sdk::{ @@ -19,6 +19,17 @@ use soroban_sdk::{ // Helpers // --------------------------------------------------------------------------- +/// Initialise the stored admin and return its address. +/// +/// Every test that exercises `check_and_record_upgrade` or `set_cooldown` +/// must call this first, since both now require the caller to be the stored +/// admin. +fn setup_admin(env: &Env) -> Address { + let admin = Address::generate(env); + init_admin(env, &admin); + admin +} + /// Return all events published by any contract in the environment, filtered /// to those whose first topic is the given string. fn events_with_topic<'a>( @@ -44,7 +55,6 @@ fn events_with_topic<'a>( // =========================================================================== #[test] -#[ignore = "stale test: fails against current contract; see docs/KNOWN_ISSUES.md"] fn test_default_cooldown() { let env = Env::default(); let cooldown = get_cooldown(&env); @@ -52,22 +62,20 @@ fn test_default_cooldown() { } #[test] -#[ignore = "stale test: fails against current contract; see docs/KNOWN_ISSUES.md"] fn test_set_and_get_cooldown() { let env = Env::default(); env.mock_all_auths(); - let caller = Address::generate(&env); + let caller = setup_admin(&env); set_cooldown(&env, &caller, 3600); assert_eq!(get_cooldown(&env), 3600); } #[test] -#[ignore = "stale test: fails against current contract; see docs/KNOWN_ISSUES.md"] fn test_check_and_record_upgrade_first_time() { let env = Env::default(); env.mock_all_auths(); - let caller = Address::generate(&env); + let caller = setup_admin(&env); // First time should always succeed let res = check_and_record_upgrade(&env, &caller); @@ -83,11 +91,10 @@ fn test_check_and_record_upgrade_first_time() { } #[test] -#[ignore = "stale test: fails against current contract; see docs/KNOWN_ISSUES.md"] fn test_check_and_record_upgrade_cooldown_not_elapsed() { let env = Env::default(); env.mock_all_auths(); - let caller = Address::generate(&env); + let caller = setup_admin(&env); // Set timestamp to 100 env.ledger().set_timestamp(100); @@ -105,11 +112,10 @@ fn test_check_and_record_upgrade_cooldown_not_elapsed() { } #[test] -#[ignore = "stale test: fails against current contract; see docs/KNOWN_ISSUES.md"] fn test_check_and_record_upgrade_cooldown_elapsed() { let env = Env::default(); env.mock_all_auths(); - let caller = Address::generate(&env); + let caller = setup_admin(&env); env.ledger().set_timestamp(100); @@ -139,11 +145,10 @@ fn test_check_and_record_upgrade_cooldown_elapsed() { /// /// Shape: topics `(cooldown_set, caller)`, data `new_cooldown_secs`. #[test] -#[ignore = "stale test: fails against current contract; see docs/KNOWN_ISSUES.md"] fn set_cooldown_emits_cooldown_set_event() { let env = Env::default(); env.mock_all_auths(); - let caller = Address::generate(&env); + let caller = setup_admin(&env); let new_cooldown: u64 = 3_600; set_cooldown(&env, &caller, new_cooldown); @@ -167,11 +172,10 @@ fn set_cooldown_emits_cooldown_set_event() { /// `set_cooldown` called twice emits two separate `cooldown_set` events, /// one for each invocation. #[test] -#[ignore = "stale test: fails against current contract; see docs/KNOWN_ISSUES.md"] fn set_cooldown_emits_separate_events_on_repeated_calls() { let env = Env::default(); env.mock_all_auths(); - let caller = Address::generate(&env); + let caller = setup_admin(&env); set_cooldown(&env, &caller, 1_800); set_cooldown(&env, &caller, 7_200); @@ -193,11 +197,10 @@ fn set_cooldown_emits_separate_events_on_repeated_calls() { /// `upgrade_recorded` shape: /// topics `(upgrade_recorded, caller)`, data `recorded_timestamp`. #[test] -#[ignore = "stale test: fails against current contract; see docs/KNOWN_ISSUES.md"] fn check_and_record_upgrade_first_call_emits_started_and_recorded() { let env = Env::default(); env.mock_all_auths(); - let caller = Address::generate(&env); + let caller = setup_admin(&env); env.ledger().set_timestamp(500); check_and_record_upgrade(&env, &caller).unwrap(); @@ -249,11 +252,10 @@ fn check_and_record_upgrade_first_call_emits_started_and_recorded() { /// When the cooldown has elapsed, a second `check_and_record_upgrade` call /// also emits both events with the updated timestamp. #[test] -#[ignore = "stale test: fails against current contract; see docs/KNOWN_ISSUES.md"] fn check_and_record_upgrade_second_call_after_cooldown_emits_events() { let env = Env::default(); env.mock_all_auths(); - let caller = Address::generate(&env); + let caller = setup_admin(&env); env.ledger().set_timestamp(100); check_and_record_upgrade(&env, &caller).unwrap(); @@ -279,11 +281,10 @@ fn check_and_record_upgrade_second_call_after_cooldown_emits_events() { /// When the cooldown has NOT elapsed, `check_and_record_upgrade` returns /// `Err(CooldownNotElapsed)` and must NOT emit any events. #[test] -#[ignore = "stale test: fails against current contract; see docs/KNOWN_ISSUES.md"] fn check_and_record_upgrade_cooldown_not_elapsed_emits_no_events() { let env = Env::default(); env.mock_all_auths(); - let caller = Address::generate(&env); + let caller = setup_admin(&env); env.ledger().set_timestamp(100); check_and_record_upgrade(&env, &caller).unwrap(); @@ -312,11 +313,10 @@ fn check_and_record_upgrade_cooldown_not_elapsed_emits_no_events() { /// `upgrade_started` is emitted BEFORE `upgrade_recorded` within the same call. /// We verify ordering by comparing their positions in `env.events().all()`. #[test] -#[ignore = "stale test: fails against current contract; see docs/KNOWN_ISSUES.md"] fn upgrade_started_precedes_upgrade_recorded_in_event_order() { let env = Env::default(); env.mock_all_auths(); - let caller = Address::generate(&env); + let caller = setup_admin(&env); env.ledger().set_timestamp(1_000); check_and_record_upgrade(&env, &caller).unwrap(); @@ -357,11 +357,10 @@ fn upgrade_started_precedes_upgrade_recorded_in_event_order() { /// `check_and_record_upgrade` with a custom cooldown reports the custom window /// in the `upgrade_started` data payload. #[test] -#[ignore = "stale test: fails against current contract; see docs/KNOWN_ISSUES.md"] fn upgrade_started_data_reflects_custom_cooldown() { let env = Env::default(); env.mock_all_auths(); - let caller = Address::generate(&env); + let caller = setup_admin(&env); let custom_cooldown: u64 = 7_200; set_cooldown(&env, &caller, custom_cooldown); @@ -377,3 +376,67 @@ fn upgrade_started_data_reflects_custom_cooldown() { "upgrade_started data must report the active cooldown window" ); } + +// =========================================================================== +// Admin restriction tests (issue: restrict check_and_record_upgrade to admin) +// =========================================================================== + +/// A non-admin signer must not be able to record an upgrade: the call returns +/// `Unauthorized`, leaves `last_upg_tm` untouched, and emits no events. +#[test] +fn non_admin_cannot_record_upgrade() { + let env = Env::default(); + env.mock_all_auths(); + let _admin = setup_admin(&env); + let attacker = Address::generate(&env); + env.ledger().set_timestamp(100); + + let res = check_and_record_upgrade(&env, &attacker); + assert_eq!(res, Err(UpgradeError::Unauthorized)); + + let last = env + .storage() + .instance() + .get::<_, u64>(&Symbol::new(&env, "last_upg_tm")); + assert!(last.is_none(), "failed call must not write last_upg_tm"); + + assert_eq!(events_with_topic(&env, "upgrade_started").len(), 0); + assert_eq!(events_with_topic(&env, "upgrade_recorded").len(), 0); +} + +/// `check_and_record_upgrade` returns `NotInitialized` when no admin is set. +#[test] +fn check_and_record_upgrade_not_initialized() { + let env = Env::default(); + env.mock_all_auths(); + let caller = Address::generate(&env); + + let res = check_and_record_upgrade(&env, &caller); + assert_eq!(res, Err(UpgradeError::NotInitialized)); +} + +/// Griefing proof-of-concept: an attacker calling every hour must not be able +/// to keep resetting the cooldown and lock the admin out of upgrading. +#[test] +fn attacker_cannot_grief_admin_cooldown() { + let env = Env::default(); + env.mock_all_auths(); + let admin = setup_admin(&env); + let attacker = Address::generate(&env); + + env.ledger().set_timestamp(0); + assert!(check_and_record_upgrade(&env, &admin).is_ok()); + + // Attacker hammers the entry point every hour across the cooldown window. + let mut t: u64 = 0; + while t < DEFAULT_COOLDOWN_SECONDS { + t += 3_600; + env.ledger().set_timestamp(t); + let res = check_and_record_upgrade(&env, &attacker); + assert_eq!(res, Err(UpgradeError::Unauthorized)); + } + + // Once the cooldown has elapsed the admin can still upgrade. + env.ledger().set_timestamp(DEFAULT_COOLDOWN_SECONDS); + assert!(check_and_record_upgrade(&env, &admin).is_ok()); +} diff --git a/contracts/upgrade/tests/auth_regression.rs b/contracts/upgrade/tests/auth_regression.rs index 08d44a5d..895f7760 100644 --- a/contracts/upgrade/tests/auth_regression.rs +++ b/contracts/upgrade/tests/auth_regression.rs @@ -5,6 +5,8 @@ //! authorized the invocation. `set_cooldown` must additionally reject any //! caller that is not the stored admin, and any out-of-bounds cooldown, without //! mutating state or emitting events. +//! `check_and_record_upgrade` must likewise reject any caller that is not the +//! stored admin, so a non-admin signer cannot keep resetting the cooldown. extern crate std; @@ -120,6 +122,137 @@ fn second_caller_still_requires_its_own_auth() { assert!(client.try_check_and_record_upgrade(&other).is_err()); } +// --------------------------------------------------------------------------- +// Admin gate for `check_and_record_upgrade` (issue #1234) +// --------------------------------------------------------------------------- + +/// A non-admin who DOES sign must be rejected, must not move `last_upg_tm`, +/// and must not emit any events. +#[test] +fn non_admin_cannot_check_and_record_upgrade() { + let env = Env::default(); + let (client, _id, _admin) = harness_with_admin(&env); // mock_all_auths active + let attacker = Address::generate(&env); + + let res = client.try_check_and_record_upgrade(&attacker); + assert_eq!(res, Err(Ok(UpgradeError::Unauthorized))); + assert!(client.get_last_upgrade_time().is_none()); + assert_eq!(env.events().all().len(), 0); +} + +/// The admin's first call succeeds and emits `upgrade_started` then +/// `upgrade_recorded`, in that order. +#[test] +fn admin_first_check_and_record_upgrade_succeeds() { + let env = Env::default(); + let (client, id, admin) = harness_with_admin(&env); + + client.check_and_record_upgrade(&admin); + assert!(client.get_last_upgrade_time().is_some()); + + let all = env.events().all(); + assert_eq!(all.len(), 2); + let (e0, t0, _) = all.get(0).unwrap(); + let (e1, t1, _) = all.get(1).unwrap(); + assert_eq!(e0, id); + assert_eq!(e1, id); + assert_eq!( + Symbol::try_from_val(&env, &t0.get(0).unwrap()).unwrap(), + events::event_upgrade_started(&env) + ); + assert_eq!( + Symbol::try_from_val(&env, &t1.get(0).unwrap()).unwrap(), + events::event_upgrade_recorded(&env) + ); +} + +/// A second admin call inside the cooldown window is rejected and emits nothing. +#[test] +fn admin_within_cooldown_is_rejected() { + let env = Env::default(); + let (client, _id, admin) = harness_with_admin(&env); + + client.check_and_record_upgrade(&admin); + let before = client.get_last_upgrade_time(); + let events_before = env.events().all().len(); + + let res = client.try_check_and_record_upgrade(&admin); + assert_eq!(res, Err(Ok(UpgradeError::CooldownNotElapsed))); + assert_eq!(client.get_last_upgrade_time(), before); + assert_eq!(env.events().all().len(), events_before); +} + +/// Once the cooldown elapses the admin can record again. +#[test] +fn admin_after_cooldown_succeeds() { + let env = Env::default(); + let (client, _id, admin) = harness_with_admin(&env); + + client.check_and_record_upgrade(&admin); + env.ledger() + .set_timestamp(env.ledger().timestamp() + DEFAULT_COOLDOWN_SECONDS + 1); + client.check_and_record_upgrade(&admin); +} + +/// Griefing proof-of-concept: an attacker calling every hour must not lock the +/// admin out. After the cooldown elapses from the admin's last successful +/// record, the admin can upgrade again. +#[test] +fn attacker_cannot_grief_admin_out_of_upgrades() { + let env = Env::default(); + let (client, _id, admin) = harness_with_admin(&env); + let attacker = Address::generate(&env); + + client.check_and_record_upgrade(&admin); + let admin_time = client.get_last_upgrade_time().unwrap(); + + // Attacker hammers the entry point every hour for a full cooldown window. + let mut t = env.ledger().timestamp(); + for _ in 0..(DEFAULT_COOLDOWN_SECONDS / 3_600 + 1) { + t += 3_600; + env.ledger().set_timestamp(t); + let res = client.try_check_and_record_upgrade(&attacker); + assert_eq!(res, Err(Ok(UpgradeError::Unauthorized))); + } + assert_eq!(client.get_last_upgrade_time(), Some(admin_time)); + + // Admin can still upgrade once the cooldown from its own record elapses. + env.ledger() + .set_timestamp(admin_time + DEFAULT_COOLDOWN_SECONDS + 1); + client.check_and_record_upgrade(&admin); +} + +/// With no admin stored, `check_and_record_upgrade` fails closed. +#[test] +fn check_and_record_upgrade_before_init_is_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _id, caller) = harness(&env); + + let res = client.try_check_and_record_upgrade(&caller); + assert_eq!(res, Err(Ok(UpgradeError::NotInitialized))); + assert!(client.get_last_upgrade_time().is_none()); + assert_eq!(env.events().all().len(), 0); +} + +/// A custom cooldown is reflected in the `upgrade_started` event data. +#[test] +fn custom_cooldown_is_reflected_in_upgrade_started_event() { + let env = Env::default(); + let (client, _id, admin) = harness_with_admin(&env); + + client.set_cooldown(&admin, &7_200); + client.check_and_record_upgrade(&admin); + + let (_, topics, data) = env.events().all().get(0).unwrap(); + assert_eq!( + Symbol::try_from_val(&env, &topics.get(0).unwrap()).unwrap(), + events::event_upgrade_started(&env) + ); + let cooldown = u64::try_from_val(&env, &data).unwrap(); + assert_eq!(cooldown, 7_200); +} + // --------------------------------------------------------------------------- // Admin gate (issue #1226) // --------------------------------------------------------------------------- diff --git a/contracts/vault/src/test_event_schema.rs b/contracts/vault/src/test_event_schema.rs index 4cc1f245..1f731c40 100644 --- a/contracts/vault/src/test_event_schema.rs +++ b/contracts/vault/src/test_event_schema.rs @@ -399,3 +399,151 @@ fn lifecycle_all_events_carry_version_topic() { client.accept_ownership(); assert_vault_events_versioned!(env, vault, "accept_ownership"); } + +// --------------------------------------------------------------------------- +// Deduct / batch_deduct event shape (request_id in data) +// --------------------------------------------------------------------------- + +#[test] +fn deduct_emits_request_id_in_data() { + let env = Env::default(); + let (owner, vault, client, usdc_admin) = setup_full(&env); + usdc_admin.mint(&owner, &1_000); + client.deposit(&owner, &1_000); + env.events().all(); + + let before = client.get_balance(); + client.deduct(&owner, &100, &42); + + let evts = vault_events(&env, &vault); + assert_eq!(evts.len(), 1, "deduct must emit exactly one event"); + let (_, topics, data) = &evts[0]; + assert_eq!(topics.len(), 3, "deduct must emit exactly 3 topics"); + let t0: Symbol = topics.get(0).unwrap().into_val(&env); + let t1: Symbol = topics.get(1).unwrap().into_val(&env); + let t2: Address = topics.get(2).unwrap().into_val(&env); + assert_eq!(t0, Symbol::new(&env, "deduct"), "topic[0]"); + assert_eq!(t1, Symbol::new(&env, "callora_v1"), "topic[1] version"); + assert_eq!(t2, owner, "topic[2] caller"); + + let (amount, new_balance, request_id) = decode_deduct_data(&env, data); + assert_eq!(amount, 100); + assert_eq!(new_balance, before - 100); + assert_eq!(request_id, 42); +} + +#[test] +fn deduct_zero_request_id_is_sentinel() { + let env = Env::default(); + let (owner, vault, client, usdc_admin) = setup_full(&env); + usdc_admin.mint(&owner, &1_000); + client.deposit(&owner, &1_000); + env.events().all(); + + client.deduct(&owner, &100, &0); + + let evts = vault_events(&env, &vault); + assert_eq!(evts.len(), 1); + let (_, _, data) = &evts[0]; + let (_, _, request_id) = decode_deduct_data(&env, data); + assert_eq!(request_id, 0, "request_id 0 means no idempotency key"); +} + +#[test] +fn batch_deduct_emits_ordered_events_with_running_balances() { + let env = Env::default(); + let (owner, vault, client, usdc_admin) = setup_full(&env); + usdc_admin.mint(&owner, &1_000); + client.deposit(&owner, &1_000); + env.events().all(); + + let before = client.get_balance(); + let items = soroban_sdk::vec![&env, (100i128, 5u64), (200i128, 0u64), (300i128, 9u64),]; + client.batch_deduct(&owner, &items); + + let evts = vault_events(&env, &vault); + assert_eq!(evts.len(), 3, "one event per deducted item"); + + let expected = [(100i128, 5u64), (200i128, 0u64), (300i128, 9u64)]; + let mut running = before; + for (idx, (_, topics, data)) in evts.iter().enumerate() { + assert_eq!(topics.len(), 3, "event[{}] must have 3 topics", idx); + let t0: Symbol = topics.get(0).unwrap().into_val(&env); + assert_eq!(t0, Symbol::new(&env, "deduct"), "event[{}] topic[0]", idx); + let (amount, new_balance, request_id) = decode_deduct_data(&env, data); + let (want_amount, want_id) = expected[idx]; + running -= want_amount; + assert_eq!(amount, want_amount, "event[{}] amount", idx); + assert_eq!(new_balance, running, "event[{}] new_balance", idx); + assert_eq!(request_id, want_id, "event[{}] request_id", idx); + } +} + +#[test] +fn batch_deduct_max_size_preserves_ids() { + let env = Env::default(); + let (owner, vault, client, usdc_admin) = setup_full(&env); + usdc_admin.mint(&owner, &1_000_000); + client.deposit(&owner, &1_000_000); + env.events().all(); + + let mut items = soroban_sdk::Vec::new(&env); + for i in 0..MAX_BATCH_SIZE { + items.push_back((1i128, (i as u64) + 1)); + } + client.batch_deduct(&owner, &items); + + let evts = vault_events(&env, &vault); + assert_eq!(evts.len(), MAX_BATCH_SIZE as usize); + for (idx, (_, _, data)) in evts.iter().enumerate() { + let (amount, _, request_id) = decode_deduct_data(&env, data); + assert_eq!(amount, 1, "event[{}] amount", idx); + assert_eq!(request_id, (idx as u64) + 1, "event[{}] request_id", idx); + } +} + +#[test] +fn failed_deduct_emits_no_event() { + let env = Env::default(); + let (owner, vault, client, usdc_admin) = setup_full(&env); + usdc_admin.mint(&owner, &1_000); + client.deposit(&owner, &1_000); + + client.propose_pause(&owner); + let window = client.get_timelock_window(); + env.ledger().with_mut(|l| l.timestamp += window + 1); + client.execute_pause(&owner); + env.events().all(); + + let result = client.try_deduct(&owner, &100, &42); + assert!(result.is_err(), "deduct must fail while paused"); + + let evts = vault_events(&env, &vault); + assert!( + evts.is_empty(), + "failed deduct must not emit a deduct event" + ); +} + +#[test] +fn batch_deduct_equal_amounts_distinguishable_by_request_id() { + let env = Env::default(); + let (owner, vault, client, usdc_admin) = setup_full(&env); + usdc_admin.mint(&owner, &1_000); + client.deposit(&owner, &1_000); + env.events().all(); + + let items = soroban_sdk::vec![&env, (100i128, 11u64), (100i128, 22u64)]; + client.batch_deduct(&owner, &items); + + let evts = vault_events(&env, &vault); + assert_eq!(evts.len(), 2); + let (_, _, d0) = &evts[0]; + let (_, _, d1) = &evts[1]; + let (a0, _, r0) = decode_deduct_data(&env, d0); + let (a1, _, r1) = decode_deduct_data(&env, d1); + assert_eq!(a0, a1, "amounts are equal"); + assert_ne!(r0, r1, "request_ids must distinguish the items"); + assert_eq!(r0, 11); + assert_eq!(r1, 22); +} diff --git a/public/fonts/README.md b/public/fonts/README.md new file mode 100644 index 00000000..1e4f9ba1 --- /dev/null +++ b/public/fonts/README.md @@ -0,0 +1,24 @@ +# Fonts Directory + +This directory contains custom fonts for the Blockchain Explorer application. + +## Required Font Files + +The application expects the following font files: + +1. **BlockchainFont-Regular.woff2** and **BlockchainFont-Regular.woff** + - Regular weight font for the main UI + +2. **BlockchainFont-Bold.woff2** and **BlockchainFont-Bold.woff** + - Bold weight font for headings + +3. **TechMono-Regular.woff2** and **TechMono-Regular.woff** + - Monospace font for code and hash displays + +## Note + +If you don't have custom fonts, the application will fall back to system fonts: +- BlockchainFont → system sans-serif fonts +- TechMono → system monospace fonts (Courier New, etc.) + +The fonts are referenced in `public/index.html` and will be loaded automatically when available. diff --git a/public/fonts/fa-brands-400.eot b/public/fonts/fa-brands-400.eot new file mode 100644 index 00000000..a1bc094a Binary files /dev/null and b/public/fonts/fa-brands-400.eot differ diff --git a/public/fonts/fa-brands-400.svg b/public/fonts/fa-brands-400.svg new file mode 100644 index 00000000..46ad237a --- /dev/null +++ b/public/fonts/fa-brands-400.svg @@ -0,0 +1,3570 @@ + + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/fonts/fa-brands-400.ttf b/public/fonts/fa-brands-400.ttf new file mode 100644 index 00000000..948a2a6c Binary files /dev/null and b/public/fonts/fa-brands-400.ttf differ diff --git a/public/fonts/fa-brands-400.woff b/public/fonts/fa-brands-400.woff new file mode 100644 index 00000000..2a89d521 Binary files /dev/null and b/public/fonts/fa-brands-400.woff differ diff --git a/public/fonts/fa-brands-400.woff2 b/public/fonts/fa-brands-400.woff2 new file mode 100644 index 00000000..141a90a9 Binary files /dev/null and b/public/fonts/fa-brands-400.woff2 differ diff --git a/public/fonts/fa-regular-400.eot b/public/fonts/fa-regular-400.eot new file mode 100644 index 00000000..38cf2517 Binary files /dev/null and b/public/fonts/fa-regular-400.eot differ diff --git a/public/fonts/fa-regular-400.svg b/public/fonts/fa-regular-400.svg new file mode 100644 index 00000000..48634a9a --- /dev/null +++ b/public/fonts/fa-regular-400.svg @@ -0,0 +1,803 @@ + + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/fonts/fa-regular-400.ttf b/public/fonts/fa-regular-400.ttf new file mode 100644 index 00000000..abe99e20 Binary files /dev/null and b/public/fonts/fa-regular-400.ttf differ diff --git a/public/fonts/fa-regular-400.woff b/public/fonts/fa-regular-400.woff new file mode 100644 index 00000000..24de566a Binary files /dev/null and b/public/fonts/fa-regular-400.woff differ diff --git a/public/fonts/fa-regular-400.woff2 b/public/fonts/fa-regular-400.woff2 new file mode 100644 index 00000000..7e0118e5 Binary files /dev/null and b/public/fonts/fa-regular-400.woff2 differ diff --git a/public/fonts/fa-solid-900.eot b/public/fonts/fa-solid-900.eot new file mode 100644 index 00000000..d3b77c22 Binary files /dev/null and b/public/fonts/fa-solid-900.eot differ diff --git a/public/fonts/fa-solid-900.svg b/public/fonts/fa-solid-900.svg new file mode 100644 index 00000000..7742838b --- /dev/null +++ b/public/fonts/fa-solid-900.svg @@ -0,0 +1,4938 @@ + + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/fonts/fa-solid-900.ttf b/public/fonts/fa-solid-900.ttf new file mode 100644 index 00000000..5b979039 Binary files /dev/null and b/public/fonts/fa-solid-900.ttf differ diff --git a/public/fonts/fa-solid-900.woff b/public/fonts/fa-solid-900.woff new file mode 100644 index 00000000..beec7917 Binary files /dev/null and b/public/fonts/fa-solid-900.woff differ diff --git a/public/fonts/fa-solid-900.woff2 b/public/fonts/fa-solid-900.woff2 new file mode 100644 index 00000000..978a681a Binary files /dev/null and b/public/fonts/fa-solid-900.woff2 differ