diff --git a/soroban/contracts/factory/src/lib.rs b/soroban/contracts/factory/src/lib.rs index de23edd..f0c4f18 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; @@ -78,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()), @@ -126,6 +136,52 @@ 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), + } +} + +/// 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()); @@ -173,6 +229,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() @@ -762,6 +829,121 @@ 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)) + } + + /// 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) + } + + /// 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 @@ -957,6 +1139,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() 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, } 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)) + ); +} diff --git a/soroban/contracts/farming-pool/src/lib.rs b/soroban/contracts/farming-pool/src/lib.rs index bcbbc47..686997f 100644 --- a/soroban/contracts/farming-pool/src/lib.rs +++ b/soroban/contracts/farming-pool/src/lib.rs @@ -1656,6 +1656,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); @@ -1665,7 +1670,7 @@ impl FarmingPool { ); env.events().publish( (symbol_short!("boost"), symbol_short!("mult_set")), - multiplier, + (old_multiplier, multiplier), ); Ok(()) } @@ -2006,6 +2011,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; diff --git a/soroban/contracts/farming-pool/src/test.rs b/soroban/contracts/farming-pool/src/test.rs index 3f6f2bd..24f48fa 100644 --- a/soroban/contracts/farming-pool/src/test.rs +++ b/soroban/contracts/farming-pool/src/test.rs @@ -1128,6 +1128,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); @@ -2115,6 +2162,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); diff --git a/soroban/contracts/vesting-wallet/src/test.rs b/soroban/contracts/vesting-wallet/src/test.rs index b0efe47..99108d8 100644 --- a/soroban/contracts/vesting-wallet/src/test.rs +++ b/soroban/contracts/vesting-wallet/src/test.rs @@ -431,6 +431,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).