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 @@
+
+
+
+
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 @@
+
+
+
+
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 @@
+
+
+
+
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