From 5b02b5c1801b518a17fa1dba2a42a50dd5073952 Mon Sep 17 00:00:00 2001
From: bade22brazy <288135045+bade22brazy@users.noreply.github.com>
Date: Sun, 30 Aug 2026 23:40:47 +0100
Subject: [PATCH 01/10] feat(vesting-wallet): add public beneficiary getter
`get_beneficiary` was an internal helper only. Expose a `beneficiary()`
entry point (guarded by `require_initialized`) so frontends can read the
recipient address without unpacking `get_vesting_schedule` or probing a
failing call.
Refs #247
---
soroban/contracts/vesting-wallet/src/lib.rs | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/soroban/contracts/vesting-wallet/src/lib.rs b/soroban/contracts/vesting-wallet/src/lib.rs
index cb390fa..cc99d30 100644
--- a/soroban/contracts/vesting-wallet/src/lib.rs
+++ b/soroban/contracts/vesting-wallet/src/lib.rs
@@ -432,6 +432,18 @@ impl VestingWallet {
Ok(())
}
+ /// Return the beneficiary address of the vesting schedule.
+ ///
+ /// `get_beneficiary` has always existed as an internal helper; this exposes
+ /// it as a first-class read so frontends can display the recipient without
+ /// probing a failing call or unpacking `get_vesting_schedule`. Returns
+ /// `NotInitialized` if the wallet has not been initialized (#247).
+ pub fn beneficiary(env: Env) -> Result
{
+ require_initialized(&env)?;
+ bump_instance(&env);
+ Ok(get_beneficiary(&env))
+ }
+
/// Return the current admin address.
pub fn admin(env: Env) -> Result {
require_initialized(&env)?;
From a6152e687cda8e0c6f931920e3599b33c1093cc5 Mon Sep 17 00:00:00 2001
From: bade22brazy <288135045+bade22brazy@users.noreply.github.com>
Date: Sun, 30 Aug 2026 23:40:50 +0100
Subject: [PATCH 02/10] test(vesting-wallet): cover the beneficiary getter
Asserts the getter returns the configured address, follows
`transfer_beneficiary`, and returns `NotInitialized` before init.
Refs #247
---
soroban/contracts/vesting-wallet/src/test.rs | 29 ++++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/soroban/contracts/vesting-wallet/src/test.rs b/soroban/contracts/vesting-wallet/src/test.rs
index d621850..6eeb17c 100644
--- a/soroban/contracts/vesting-wallet/src/test.rs
+++ b/soroban/contracts/vesting-wallet/src/test.rs
@@ -425,6 +425,35 @@ fn test_revoked_uninitialized_returns_not_initialized() {
));
}
+#[test]
+fn test_beneficiary_getter_returns_configured_address() {
+ let t = setup(50, 200, 1_000);
+ assert_eq!(t.client.beneficiary(), t.beneficiary);
+}
+
+#[test]
+fn test_beneficiary_getter_tracks_transfer_beneficiary() {
+ let t = setup(50, 200, 1_000);
+ let new_beneficiary = Address::generate(&t.env);
+
+ t.client.transfer_beneficiary(&new_beneficiary);
+
+ assert_eq!(t.client.beneficiary(), new_beneficiary);
+}
+
+#[test]
+fn test_beneficiary_uninitialized_returns_not_initialized() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let contract_id = env.register(VestingWallet, ());
+ let client = VestingWalletClient::new(&env, &contract_id);
+
+ assert!(matches!(
+ client.try_beneficiary(),
+ Err(Ok(VestingError::NotInitialized))
+ ));
+}
+
#[test]
fn test_revoke_sends_unvested_to_admin() {
// No cliff, period = 200, total = 1000. Revoke at ledger 100 (50% vested).
From 0690a55da3edd16fecb7bf86caa82b0daa7c5e72 Mon Sep 17 00:00:00 2001
From: bade22brazy <288135045+bade22brazy@users.noreply.github.com>
Date: Sun, 30 Aug 2026 23:40:54 +0100
Subject: [PATCH 03/10] feat(farming-pool): add whitelist_count getter
Adds `whitelist_count()` / `get_whitelist_count()` returning the number of
addresses currently whitelisted. The value is derived from the canonical
`WhitelistedUsers` list that every add/remove/batch path already maintains
and dedupes, so it cannot drift the way a parallel counter could.
Refs #248
---
soroban/contracts/farming-pool/src/lib.rs | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/soroban/contracts/farming-pool/src/lib.rs b/soroban/contracts/farming-pool/src/lib.rs
index 896503d..e4f42b7 100644
--- a/soroban/contracts/farming-pool/src/lib.rs
+++ b/soroban/contracts/farming-pool/src/lib.rs
@@ -1939,6 +1939,23 @@ impl FarmingPool {
pub fn get_total_locked(env: Env) -> Result {
Self::total_locked(env)
}
+
+ /// Return the number of addresses currently on the whitelist (#248).
+ ///
+ /// Admins use this for capacity planning without paging the full list via
+ /// `get_whitelisted_users`. The value is derived from the canonical
+ /// `WhitelistedUsers` list that every add / remove / batch path already
+ /// maintains (and dedupes), rather than a parallel counter that could
+ /// silently drift out of step with that list.
+ pub fn whitelist_count(env: Env) -> Result {
+ require_initialized(&env)?;
+ bump_instance(&env);
+ Ok(get_whitelisted_users_list(&env).len())
+ }
+
+ pub fn get_whitelist_count(env: Env) -> Result {
+ Self::whitelist_count(env)
+ }
}
mod test;
From 1f5b46bd2c895b18d1b7a467a2182ce410ef7828 Mon Sep 17 00:00:00 2001
From: bade22brazy <288135045+bade22brazy@users.noreply.github.com>
Date: Sun, 30 Aug 2026 23:40:58 +0100
Subject: [PATCH 04/10] test(farming-pool): cover whitelist_count
Checks the count across single adds/removes (including duplicate adds and
no-op removes), agrees with `get_whitelisted_users().total`, and returns
`NotInitialized` before init.
Refs #248
---
soroban/contracts/farming-pool/src/test.rs | 54 ++++++++++++++++++++++
1 file changed, 54 insertions(+)
diff --git a/soroban/contracts/farming-pool/src/test.rs b/soroban/contracts/farming-pool/src/test.rs
index 38221d7..5dab85c 100644
--- a/soroban/contracts/farming-pool/src/test.rs
+++ b/soroban/contracts/farming-pool/src/test.rs
@@ -2077,6 +2077,60 @@ fn test_disable_whitelist_restores_open_access() {
assert_eq!(t.client.get_stake(&t.user).unwrap().amount, 1_000);
}
+#[test]
+fn test_whitelist_count_reflects_adds_and_removes() {
+ let t = setup(2, 1);
+ assert_eq!(t.client.whitelist_count(), 0);
+ assert_eq!(t.client.get_whitelist_count(), 0);
+
+ let user1 = Address::generate(&t.env);
+ let user2 = Address::generate(&t.env);
+
+ t.client.add_to_whitelist(&user1);
+ assert_eq!(t.client.whitelist_count(), 1);
+
+ t.client.add_to_whitelist(&user2);
+ assert_eq!(t.client.whitelist_count(), 2);
+
+ // Re-adding an existing entry must not double-count.
+ t.client.add_to_whitelist(&user1);
+ assert_eq!(t.client.whitelist_count(), 2);
+
+ t.client.remove_from_whitelist(&user1);
+ assert_eq!(t.client.whitelist_count(), 1);
+
+ // Removing a non-member is a no-op for the count.
+ t.client.remove_from_whitelist(&Address::generate(&t.env));
+ assert_eq!(t.client.whitelist_count(), 1);
+
+ t.client.remove_from_whitelist(&user2);
+ assert_eq!(t.client.whitelist_count(), 0);
+}
+
+#[test]
+fn test_whitelist_count_matches_get_whitelisted_users_total() {
+ let t = setup(2, 1);
+
+ let mut users = soroban_sdk::Vec::new(&t.env);
+ for _ in 0..5 {
+ users.push_back(Address::generate(&t.env));
+ }
+ t.client.batch_add_to_whitelist(&users);
+
+ let listed = t.client.get_whitelisted_users(&0u32, &100u32);
+ assert_eq!(t.client.whitelist_count(), listed.total);
+ assert_eq!(t.client.whitelist_count(), 5);
+}
+
+#[test]
+fn test_whitelist_count_uninitialized_returns_not_initialized() {
+ let (_env, client, _admin) = setup_uninitialized();
+ assert!(matches!(
+ client.try_whitelist_count(),
+ Err(Ok(PoolError::NotInitialized))
+ ));
+}
+
#[test]
fn test_batch_add_to_whitelist() {
let t = setup(2, 1);
From ea2a60c6b0c8b8b4c176b690cc0d97c489fce764 Mon Sep 17 00:00:00 2001
From: bade22brazy <288135045+bade22brazy@users.noreply.github.com>
Date: Sun, 30 Aug 2026 23:41:02 +0100
Subject: [PATCH 05/10] feat(farming-pool): include previous multiplier in
set_global_multiplier event
The `mult_set` event only carried the new value. Capture the old multiplier
before the write and publish `(old_multiplier, multiplier)` so off-chain
indexers have both terms for audit trails and rollback scenarios.
Refs #250
---
soroban/contracts/farming-pool/src/lib.rs | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/soroban/contracts/farming-pool/src/lib.rs b/soroban/contracts/farming-pool/src/lib.rs
index e4f42b7..892a29c 100644
--- a/soroban/contracts/farming-pool/src/lib.rs
+++ b/soroban/contracts/farming-pool/src/lib.rs
@@ -1604,6 +1604,11 @@ impl FarmingPool {
}
bump_instance(&env);
+ // Capture the previous value before overwriting it so the event can
+ // carry both terms — off-chain indexers need the old multiplier for
+ // audit trails and rollback scenarios (#250).
+ let old_multiplier = read_global_multiplier(&env);
+
env.storage()
.instance()
.set(&DataKey::GlobalMultiplier, &multiplier);
@@ -1613,7 +1618,7 @@ impl FarmingPool {
);
env.events().publish(
(symbol_short!("boost"), symbol_short!("mult_set")),
- multiplier,
+ (old_multiplier, multiplier),
);
Ok(())
}
From 81e47ae3de6574287b103b600027b9df9b216129 Mon Sep 17 00:00:00 2001
From: bade22brazy <288135045+bade22brazy@users.noreply.github.com>
Date: Sun, 30 Aug 2026 23:41:06 +0100
Subject: [PATCH 06/10] test(farming-pool): assert set_global_multiplier event
carries old and new
Verifies the emitted tuple is `(old, new)` and that a subsequent change
reports the just-superseded value as the old one.
Refs #250
---
soroban/contracts/farming-pool/src/test.rs | 47 ++++++++++++++++++++++
1 file changed, 47 insertions(+)
diff --git a/soroban/contracts/farming-pool/src/test.rs b/soroban/contracts/farming-pool/src/test.rs
index 5dab85c..d39ad17 100644
--- a/soroban/contracts/farming-pool/src/test.rs
+++ b/soroban/contracts/farming-pool/src/test.rs
@@ -1090,6 +1090,53 @@ fn test_transfer_admin_changes_admin() {
assert_eq!(t.client.admin(), new_admin);
}
+#[test]
+fn test_set_global_multiplier_emits_old_and_new() {
+ let t = setup(2, 1);
+
+ // Pool was initialized with global_multiplier = 2.
+ t.client.set_global_multiplier(&5);
+
+ assert_eq!(
+ t.env.events().all(),
+ soroban_sdk::vec![
+ &t.env,
+ (
+ t.contract_id.clone(),
+ soroban_sdk::vec![
+ &t.env,
+ soroban_sdk::symbol_short!("boost").into_val(&t.env),
+ soroban_sdk::symbol_short!("mult_set").into_val(&t.env)
+ ],
+ (2u32, 5u32).into_val(&t.env),
+ )
+ ]
+ );
+}
+
+#[test]
+fn test_set_global_multiplier_event_reports_previous_value() {
+ let t = setup(2, 1);
+
+ t.client.set_global_multiplier(&5);
+ t.client.set_global_multiplier(&3);
+
+ // The most recent event pairs the just-superseded value (5) with the new
+ // one (3), not the pool's original multiplier.
+ let events = t.env.events().all();
+ let (contract, topics, data) = events.last().unwrap();
+ assert_eq!(contract, t.contract_id);
+ assert_eq!(
+ topics,
+ soroban_sdk::vec![
+ &t.env,
+ soroban_sdk::symbol_short!("boost").into_val(&t.env),
+ soroban_sdk::symbol_short!("mult_set").into_val(&t.env)
+ ]
+ );
+ assert_eq!(data, (5u32, 3u32).into_val(&t.env));
+}
+
#[test]
fn test_transfer_admin_emits_event() {
let t = setup(2, 1);
From 172bed08ba59e127f96f5f1bf2c7a02cbfa3876c Mon Sep 17 00:00:00 2001
From: bade22brazy <288135045+bade22brazy@users.noreply.github.com>
Date: Sun, 30 Aug 2026 23:41:10 +0100
Subject: [PATCH 07/10] feat(factory): add total_tvl accumulator storage and
read-only getters
Introduces `DataKey::TotalTvl` (aggregate) and `DataKey::PoolTvl(id)`
(per-pool snapshot), plus `total_tvl()` and `pool_tvl_synced(id)` read-only
getters and a `PoolQueryFailed` error code. `total_tvl` is an O(1) read of
an incrementally-maintained accumulator rather than an unbounded
cross-contract fan-out. Also re-exports `FactoryError` from the crate root.
Refs #249
---
soroban/contracts/factory/src/lib.rs | 50 +++++++++++++++++++++++++-
soroban/contracts/factory/src/types.rs | 10 ++++++
2 files changed, 59 insertions(+), 1 deletion(-)
diff --git a/soroban/contracts/factory/src/lib.rs b/soroban/contracts/factory/src/lib.rs
index 88e78d4..142bbb1 100644
--- a/soroban/contracts/factory/src/lib.rs
+++ b/soroban/contracts/factory/src/lib.rs
@@ -5,7 +5,9 @@ mod types;
use soroban_sdk::{
contract, contractimpl, symbol_short, vec, Address, BytesN, Env, IntoVal, Symbol, Val, Vec,
};
-use types::{DataKey, FactoryError, ListPoolsResponse, PoolRecord, PoolSort};
+use types::{DataKey, ListPoolsResponse, PoolRecord, PoolSort};
+
+pub use types::FactoryError;
// ~30 days at ~5 s/ledger; extend to ~60 days when below threshold.
const TTL_THRESHOLD: u32 = 518_400;
@@ -173,6 +175,17 @@ fn read_admin_transfer_count(env: &Env) -> u32 {
.unwrap_or(0)
}
+fn read_total_tvl(env: &Env) -> i128 {
+ env.storage().instance().get(&DataKey::TotalTvl).unwrap_or(0)
+}
+
+fn read_pool_tvl(env: &Env, pool_id: u32) -> i128 {
+ env.storage()
+ .persistent()
+ .get(&DataKey::PoolTvl(pool_id))
+ .unwrap_or(0)
+}
+
fn increment_admin_transfer_count(env: &Env) {
let count = read_admin_transfer_count(env);
env.storage()
@@ -738,6 +751,41 @@ impl Factory {
Ok(read_upgrade_count(&env))
}
+ /// Aggregate value locked across every pool this factory has created, in
+ /// the pools' staking-asset base units (#249).
+ ///
+ /// This is an O(1) read of an incrementally-maintained accumulator, not a
+ /// live fan-out across pools. Each pool contributes the TVL captured by its
+ /// most recent `sync_pool_tvl` call; `create_pool` seeds a new pool at 0.
+ /// Staking activity between syncs is not reflected until `sync_pool_tvl`
+ /// (or `sync_all_pool_tvls`) runs for that pool. This is deliberate: a
+ /// factory receives no callback from a pool's stake / unstake, and a true
+ /// live sum would need an unbounded cross-contract fan-out that does not
+ /// fit Soroban's per-invocation footprint limit. Dashboards that need a
+ /// fresh figure should run `sync_all_pool_tvls` first.
+ ///
+ /// Returns `NotInitialized` if the factory has not been initialized.
+ pub fn total_tvl(env: Env) -> Result {
+ require_initialized(&env)?;
+ bump_instance(&env);
+ Ok(read_total_tvl(&env))
+ }
+
+ /// The per-pool TVL term currently folded into `total_tvl` for `pool_id` —
+ /// the value captured by the last `sync_pool_tvl` for this pool, or 0 if it
+ /// has never been synced since creation.
+ ///
+ /// Returns `NotInitialized` if the factory has not been initialized, or
+ /// `PoolNotFound` if `pool_id` has not been created.
+ pub fn pool_tvl_synced(env: Env, pool_id: u32) -> Result {
+ require_initialized(&env)?;
+ bump_instance(&env);
+ if !env.storage().persistent().has(&DataKey::Pool(pool_id)) {
+ return Err(FactoryError::PoolNotFound);
+ }
+ Ok(read_pool_tvl(&env, pool_id))
+ }
+
/// Update the WASM hash used for future `create_pool` deployments. Admin-only.
///
/// Allows the admin to point future pool deployments at a corrected or upgraded
diff --git a/soroban/contracts/factory/src/types.rs b/soroban/contracts/factory/src/types.rs
index 77b6328..b0aebe6 100644
--- a/soroban/contracts/factory/src/types.rs
+++ b/soroban/contracts/factory/src/types.rs
@@ -23,6 +23,12 @@ pub enum DataKey {
PoolsByAdmin(Address),
/// List of pool IDs currently running a specific WASM hash.
PoolsByWasmHash(BytesN<32>),
+ /// Aggregate value locked across every pool, maintained incrementally by
+ /// `sync_pool_tvl` so `total_tvl` is an O(1) read (#249).
+ TotalTvl,
+ /// Last-synced TVL for a single pool, keyed by pool ID. This is the term
+ /// currently folded into `TotalTvl` for that pool (#249).
+ PoolTvl(u32),
}
/// On-chain record for a registered farming pool.
@@ -132,4 +138,8 @@ pub enum FactoryError {
MinLockPeriodTooShort = 15,
/// `initialize` was called with an invalid admin address.
InvalidAdmin = 16,
+ /// A pool's TVL could not be read during `total_tvl` maintenance because the
+ /// deployed pool did not answer the `total_staked` getter (e.g. a pool
+ /// deployed from an older WASM that predates it).
+ PoolQueryFailed = 17,
}
From f0e743ab0159bebe81da70b4813f8d859360182a Mon Sep 17 00:00:00 2001
From: bade22brazy <288135045+bade22brazy@users.noreply.github.com>
Date: Sun, 30 Aug 2026 23:41:14 +0100
Subject: [PATCH 08/10] feat(factory): read live pool TVL cross-contract and
seed baseline on create_pool
Adds `query_pool_tvl` (one cross-contract call to the pool's `total_staked`,
which already includes locked balances) and the `pool_tvl(id)` live
read-through getter. `create_pool` now records a zero TVL baseline for the
new pool so the first sync is a clean delta.
Refs #249
---
soroban/contracts/factory/src/lib.rs | 57 ++++++++++++++++++++++++++++
1 file changed, 57 insertions(+)
diff --git a/soroban/contracts/factory/src/lib.rs b/soroban/contracts/factory/src/lib.rs
index 142bbb1..9e5a52e 100644
--- a/soroban/contracts/factory/src/lib.rs
+++ b/soroban/contracts/factory/src/lib.rs
@@ -80,6 +80,14 @@ fn bump_admin_pools(env: &Env, admin: &Address) {
);
}
+fn bump_pool_tvl(env: &Env, pool_id: u32) {
+ env.storage().persistent().extend_ttl(
+ &DataKey::PoolTvl(pool_id),
+ TTL_THRESHOLD,
+ TTL_EXTEND_TO,
+ );
+}
+
fn bump_wasm_pools(env: &Env, wasm_hash: &BytesN<32>) {
env.storage().persistent().extend_ttl(
&DataKey::PoolsByWasmHash(wasm_hash.clone()),
@@ -128,6 +136,26 @@ fn read_upgrade_count(env: &Env) -> u32 {
}
/// Build a 32-byte salt from a pool ID so each pool gets a unique, reproducible address.
+/// Live TVL of a single deployed pool, read straight from the pool via one
+/// cross-contract call to its `total_staked` getter.
+///
+/// `FarmingPool::total_staked` already covers every token held for a user —
+/// `lock_assets` credits both `TotalStaked` and `TotalLocked`, so
+/// `total_locked` is a subset of `total_staked`, not a separate term to add.
+/// Returns `PoolQueryFailed` if the pool does not answer the getter (e.g. an
+/// older WASM predating it).
+fn query_pool_tvl(env: &Env, pool: &Address) -> Result {
+ let no_args: Vec = vec![env];
+ match env.try_invoke_contract::(
+ pool,
+ &Symbol::new(env, "total_staked"),
+ no_args,
+ ) {
+ Ok(Ok(v)) => Ok(v),
+ _ => Err(FactoryError::PoolQueryFailed),
+ }
+}
+
fn pool_salt(env: &Env, pool_id: u32) -> BytesN<32> {
let mut bytes = [0u8; 32];
bytes[28..].copy_from_slice(&pool_id.to_be_bytes());
@@ -786,6 +814,26 @@ impl Factory {
Ok(read_pool_tvl(&env, pool_id))
}
+ /// Live TVL of one pool, read straight from the deployed pool contract
+ /// via its `total_staked` getter. Unlike the `total_tvl` accumulator this
+ /// always reflects the pool's current state, at the cost of a
+ /// cross-contract call.
+ ///
+ /// Returns `NotInitialized` if the factory has not been initialized,
+ /// `PoolNotFound` for an unknown `pool_id`, or `PoolQueryFailed` if the
+ /// deployed pool does not answer the TVL getters.
+ pub fn pool_tvl(env: Env, pool_id: u32) -> Result {
+ require_initialized(&env)?;
+ bump_instance(&env);
+ let record = env
+ .storage()
+ .persistent()
+ .get::(&DataKey::Pool(pool_id))
+ .ok_or(FactoryError::PoolNotFound)?;
+ bump_pool(&env, pool_id);
+ query_pool_tvl(&env, &record.address)
+ }
+
/// Update the WASM hash used for future `create_pool` deployments. Admin-only.
///
/// Allows the admin to point future pool deployments at a corrected or upgraded
@@ -981,6 +1029,15 @@ impl Factory {
.persistent()
.set(&DataKey::Pool(pool_id), &record);
bump_pool(&env, pool_id);
+
+ // A freshly deployed pool holds nothing, so its contribution to
+ // `total_tvl` starts at 0. Recording the baseline explicitly keeps the
+ // first `sync_pool_tvl` a pure delta against a known value (#249).
+ env.storage()
+ .persistent()
+ .set(&DataKey::PoolTvl(pool_id), &0i128);
+ bump_pool_tvl(&env, pool_id);
+
let asset_key = DataKey::AssetPools(asset.clone());
let mut asset_pool_ids: Vec = env
.storage()
From 5fd391d4f328665270b3bea4b5a835c41f723907 Mon Sep 17 00:00:00 2001
From: bade22brazy <288135045+bade22brazy@users.noreply.github.com>
Date: Sun, 30 Aug 2026 23:41:17 +0100
Subject: [PATCH 09/10] feat(factory): add sync_pool_tvl and sync_all_pool_tvls
maintenance calls
Permissionless calls that re-read a pool's live TVL and fold the change into
the `total_tvl` accumulator (`total_tvl += live - previous_snapshot`),
emitting a `tvl_sync` event on change. `sync_all_pool_tvls` walks a bounded
window of pool IDs, mirroring `refresh_pool_ttls`.
Refs #249
---
soroban/contracts/factory/src/lib.rs | 86 ++++++++++++++++++++++++++++
1 file changed, 86 insertions(+)
diff --git a/soroban/contracts/factory/src/lib.rs b/soroban/contracts/factory/src/lib.rs
index 9e5a52e..5f17742 100644
--- a/soroban/contracts/factory/src/lib.rs
+++ b/soroban/contracts/factory/src/lib.rs
@@ -156,6 +156,32 @@ fn query_pool_tvl(env: &Env, pool: &Address) -> Result {
}
}
+/// Re-read one pool's live TVL and fold the change into the `total_tvl`
+/// accumulator: `total_tvl += live - previous_snapshot`, then store `live` as
+/// the new snapshot. Emits `tvl_sync = (pool_id, old_snapshot, live)` when the
+/// value moved. Returns the pool's live TVL.
+fn apply_tvl_sync(env: &Env, pool_id: u32, pool: &Address) -> Result {
+ let live = query_pool_tvl(env, pool)?;
+ let previous = read_pool_tvl(env, pool_id);
+ if live != previous {
+ let aggregate = read_total_tvl(env)
+ .saturating_add(live)
+ .saturating_sub(previous);
+ env.storage().instance().set(&DataKey::TotalTvl, &aggregate);
+ env.storage()
+ .persistent()
+ .set(&DataKey::PoolTvl(pool_id), &live);
+ bump_pool_tvl(env, pool_id);
+
+ #[allow(deprecated)]
+ env.events().publish(
+ (symbol_short!("factory"), symbol_short!("tvl_sync")),
+ (pool_id, previous, live),
+ );
+ }
+ Ok(live)
+}
+
fn pool_salt(env: &Env, pool_id: u32) -> BytesN<32> {
let mut bytes = [0u8; 32];
bytes[28..].copy_from_slice(&pool_id.to_be_bytes());
@@ -834,6 +860,66 @@ impl Factory {
query_pool_tvl(&env, &record.address)
}
+ /// Refresh one pool's contribution to `total_tvl` and return its live TVL.
+ ///
+ /// Permissionless — dashboards, keepers, or the pool's own users can call
+ /// it to keep the aggregate current. Reads the pool's live TVL, adjusts the
+ /// `total_tvl` accumulator by the delta versus this pool's last-synced
+ /// value, and stores the new snapshot. Emits a `tvl_sync` event carrying
+ /// `(pool_id, old_snapshot, new_tvl)` when the value changed.
+ ///
+ /// Returns `NotInitialized` if the factory has not been initialized,
+ /// `PoolNotFound` for an unknown `pool_id`, or `PoolQueryFailed` if the
+ /// deployed pool does not answer the TVL getters.
+ pub fn sync_pool_tvl(env: Env, pool_id: u32) -> Result {
+ require_initialized(&env)?;
+ bump_instance(&env);
+ let record = env
+ .storage()
+ .persistent()
+ .get::(&DataKey::Pool(pool_id))
+ .ok_or(FactoryError::PoolNotFound)?;
+ bump_pool(&env, pool_id);
+ apply_tvl_sync(&env, pool_id, &record.address)
+ }
+
+ /// Batch-refresh a contiguous range of pools' `total_tvl` contributions,
+ /// starting at `start_id` and covering at most
+ /// `min(limit, MAX_POOL_SCAN_PER_CALL)` pool IDs. Pools that fail to answer
+ /// the TVL getters are skipped rather than aborting the batch. Returns the
+ /// next `start_id` to pass for continued paging, or a value `>= pool_count`
+ /// once the registry is exhausted. Permissionless, mirroring
+ /// `refresh_pool_ttls`.
+ ///
+ /// Returns `NotInitialized` if the factory has not been initialized.
+ pub fn sync_all_pool_tvls(
+ env: Env,
+ start_id: u32,
+ limit: u32,
+ ) -> Result {
+ require_initialized(&env)?;
+ bump_instance(&env);
+ let count: u32 = env
+ .storage()
+ .instance()
+ .get(&DataKey::PoolCount)
+ .unwrap_or(0);
+ let window = limit.min(MAX_POOL_SCAN_PER_CALL);
+ let end = start_id.saturating_add(window).min(count);
+ let mut pool_id = start_id;
+ while pool_id < end {
+ if let Some(record) = env
+ .storage()
+ .persistent()
+ .get::(&DataKey::Pool(pool_id))
+ {
+ let _ = apply_tvl_sync(&env, pool_id, &record.address);
+ }
+ pool_id += 1;
+ }
+ Ok(end)
+ }
+
/// Update the WASM hash used for future `create_pool` deployments. Admin-only.
///
/// Allows the admin to point future pool deployments at a corrected or upgraded
From d33d20be171bcd1decdb3cd64366049909ab7dbf Mon Sep 17 00:00:00 2001
From: bade22brazy <288135045+bade22brazy@users.noreply.github.com>
Date: Sun, 30 Aug 2026 23:41:21 +0100
Subject: [PATCH 10/10] test(factory): integration coverage for the TVL
aggregate
Drives factory-deployed pools through stake/lock/unstake and asserts
`total_tvl`, `pool_tvl`, `pool_tvl_synced`, `sync_pool_tvl`, and
`sync_all_pool_tvls` behave, including the unknown-pool error path.
Refs #249
---
.../factory/tests/factory_pool_integration.rs | 164 +++++++++++++++++-
1 file changed, 163 insertions(+), 1 deletion(-)
diff --git a/soroban/contracts/factory/tests/factory_pool_integration.rs b/soroban/contracts/factory/tests/factory_pool_integration.rs
index 7cde192..2aadd88 100644
--- a/soroban/contracts/factory/tests/factory_pool_integration.rs
+++ b/soroban/contracts/factory/tests/factory_pool_integration.rs
@@ -64,7 +64,7 @@ use soroban_sdk::{
Address, Env,
};
-use factory::{Factory, FactoryClient};
+use factory::{Factory, FactoryClient, FactoryError};
use farming_pool::{FarmingPoolClient, PoolError};
/// Real, compiled farming-pool WASM — see the module doc comment above for
@@ -374,3 +374,165 @@ fn end_to_end_create_pool_then_lock_and_unlock() {
assert_eq!(token.balance(&pool_address), 0);
assert!(pool_client.get_user_position(&user).is_none());
}
+
+/// Builds an initialised factory (real farming-pool WASM) with one pool and
+/// returns the factory client, the pool's live client, the pool id, and the
+/// pool address.
+fn factory_with_pool(
+ env: &Env,
+ admin: &Address,
+ asset: &Address,
+ daily_rate: u128,
+) -> (FactoryClient<'static>, FarmingPoolClient<'static>, u32, Address) {
+ let wasm_hash = env.deployer().upload_contract_wasm(FARMING_POOL_WASM);
+ let factory_addr = env.register(Factory, ());
+ let factory_client = FactoryClient::new(env, &factory_addr);
+ factory_client.initialize(admin, &wasm_hash);
+ let pool_id = factory_client.create_pool(asset, &daily_rate, &1u32, &1u64, &0i128);
+ let pool_address = factory_client.get_pool(&pool_id).address;
+ let pool_client = FarmingPoolClient::new(env, &pool_address);
+ let factory_client = unsafe {
+ core::mem::transmute::, FactoryClient<'static>>(factory_client)
+ };
+ let pool_client = unsafe {
+ core::mem::transmute::, FarmingPoolClient<'static>>(pool_client)
+ };
+ (factory_client, pool_client, pool_id, pool_address)
+}
+
+/// #249: a brand-new factory reports zero aggregate TVL, and a freshly created
+/// pool is seeded at zero — so `total_tvl` stays zero until something is staked
+/// *and* the pool is synced.
+#[test]
+fn total_tvl_starts_at_zero_for_new_factory_and_pool() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let token_admin = Address::generate(&env);
+ let asset = env.register_stellar_asset_contract_v2(token_admin);
+
+ let (factory_client, _pool_client, pool_id, _pool_address) =
+ factory_with_pool(&env, &admin, &asset.address(), 17_280u128);
+
+ assert_eq!(factory_client.total_tvl(), 0);
+ assert_eq!(factory_client.pool_tvl_synced(&pool_id), 0);
+ assert_eq!(factory_client.pool_tvl(&pool_id), 0);
+}
+
+/// #249: `total_tvl` tracks staked + locked balances once the pool is synced,
+/// and follows the balance back down after a withdrawal + re-sync.
+#[test]
+fn total_tvl_tracks_stake_and_lock_after_sync() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let user = Address::generate(&env);
+
+ let token_admin = Address::generate(&env);
+ let asset = env.register_stellar_asset_contract_v2(token_admin);
+ let token_sac = StellarAssetClient::new(&env, &asset.address());
+ const MINT: i128 = 1_000_000_000;
+ token_sac.mint(&user, &MINT);
+
+ let (factory_client, pool_client, pool_id, _pool_address) =
+ factory_with_pool(&env, &admin, &asset.address(), 17_280u128);
+
+ let stake_amount: i128 = 5_000_000;
+ let lock_amount: i128 = 3_000_000;
+ pool_client.stake(&user, &stake_amount);
+ pool_client.lock_assets(&user, &lock_amount);
+
+ // Live read sees the deposits immediately; the accumulator does not.
+ assert_eq!(factory_client.pool_tvl(&pool_id), stake_amount + lock_amount);
+ assert_eq!(factory_client.total_tvl(), 0);
+
+ let synced = factory_client.sync_pool_tvl(&pool_id);
+ assert_eq!(synced, stake_amount + lock_amount);
+ assert_eq!(factory_client.total_tvl(), stake_amount + lock_amount);
+ assert_eq!(
+ factory_client.pool_tvl_synced(&pool_id),
+ stake_amount + lock_amount
+ );
+
+ // A no-op re-sync leaves the aggregate unchanged.
+ factory_client.sync_pool_tvl(&pool_id);
+ assert_eq!(factory_client.total_tvl(), stake_amount + lock_amount);
+
+ // Withdraw the flexible stake, then re-sync: aggregate drops to the locked
+ // portion only.
+ pool_client.unstake(&user);
+ assert_eq!(factory_client.total_tvl(), stake_amount + lock_amount);
+ factory_client.sync_pool_tvl(&pool_id);
+ assert_eq!(factory_client.total_tvl(), lock_amount);
+ assert_eq!(factory_client.pool_tvl_synced(&pool_id), lock_amount);
+}
+
+/// #249: `sync_all_pool_tvls` folds every pool into the aggregate in one pass
+/// and returns a cursor past the end of the registry.
+#[test]
+fn sync_all_pool_tvls_aggregates_every_pool() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let user = Address::generate(&env);
+
+ let token_admin = Address::generate(&env);
+ let asset = env.register_stellar_asset_contract_v2(token_admin);
+ let token_sac = StellarAssetClient::new(&env, &asset.address());
+ const MINT: i128 = 1_000_000_000;
+ token_sac.mint(&user, &MINT);
+
+ let wasm_hash = env.deployer().upload_contract_wasm(FARMING_POOL_WASM);
+ let factory_addr = env.register(Factory, ());
+ let factory_client = FactoryClient::new(&env, &factory_addr);
+ factory_client.initialize(&admin, &wasm_hash);
+
+ let pool0 = factory_client.create_pool(&asset.address(), &17_280u128, &1u32, &1u64, &0i128);
+ let pool1 = factory_client.create_pool(&asset.address(), &17_280u128, &1u32, &1u64, &0i128);
+
+ let addr0 = factory_client.get_pool(&pool0).address;
+ let addr1 = factory_client.get_pool(&pool1).address;
+ let client0 = FarmingPoolClient::new(&env, &addr0);
+ let client1 = FarmingPoolClient::new(&env, &addr1);
+
+ let amount0: i128 = 2_000_000;
+ let amount1: i128 = 7_000_000;
+ client0.stake(&user, &amount0);
+ client1.stake(&user, &amount1);
+
+ let cursor = factory_client.sync_all_pool_tvls(&0, &50);
+ assert_eq!(cursor, 2);
+ assert_eq!(factory_client.total_tvl(), amount0 + amount1);
+ assert_eq!(factory_client.pool_tvl_synced(&pool0), amount0);
+ assert_eq!(factory_client.pool_tvl_synced(&pool1), amount1);
+}
+
+/// #249: the TVL views reject an unknown pool id with a typed error.
+#[test]
+fn tvl_views_reject_unknown_pool() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let token_admin = Address::generate(&env);
+ let asset = env.register_stellar_asset_contract_v2(token_admin);
+
+ let (factory_client, _pool_client, _pool_id, _pool_address) =
+ factory_with_pool(&env, &admin, &asset.address(), 17_280u128);
+
+ assert_eq!(
+ factory_client.try_pool_tvl(&99),
+ Err(Ok(FactoryError::PoolNotFound))
+ );
+ assert_eq!(
+ factory_client.try_pool_tvl_synced(&99),
+ Err(Ok(FactoryError::PoolNotFound))
+ );
+ assert_eq!(
+ factory_client.try_sync_pool_tvl(&99),
+ Err(Ok(FactoryError::PoolNotFound))
+ );
+}