From b905cc4d6f0b76f89f0293aa123bfc321d03828a Mon Sep 17 00:00:00 2001 From: Dinma179 Date: Sun, 27 Sep 2026 20:54:28 +0200 Subject: [PATCH 1/4] test(factory): pin set_pool_wasm_hash event to carry old and new hash (#410) --- soroban/contracts/factory/src/test.rs | 64 +++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/soroban/contracts/factory/src/test.rs b/soroban/contracts/factory/src/test.rs index 845bc63..a5b6aa9 100644 --- a/soroban/contracts/factory/src/test.rs +++ b/soroban/contracts/factory/src/test.rs @@ -324,6 +324,70 @@ fn test_set_pool_wasm_hash_rejects_zero_hash() { ); } +// #410 — the audit trail for a WASM-hash change is only useful if the event +// carries the *previous* hash as well as the new one: without the old hash an +// operator cannot tell which build was live before the change, so a rollback +// decision has nothing to verify against. This pins both hashes in the emitted +// event so a future edit to the payload cannot silently drop the old value. +#[test] +fn test_set_pool_wasm_hash_event_carries_old_and_new_hash() { + let t = setup(); + let original_hash = t.wasm_hash.clone(); + let new_hash = upload_replacement_wasm(&t.env); + + t.client.set_pool_wasm_hash(&new_hash); + + assert_eq!( + t.env.events().all(), + vec![ + &t.env, + ( + t.factory_addr.clone(), + vec![ + &t.env, + symbol_short!("factory").into_val(&t.env), + symbol_short!("wasm_set").into_val(&t.env), + ], + (original_hash, new_hash).into_val(&t.env), + ) + ] + ); +} + +#[test] +fn test_set_pool_wasm_hash_event_old_hash_matches_superseded_value() { + let t = setup(); + let first_hash = upload_replacement_wasm(&t.env); + let second_hash = BytesN::from_array(&t.env, &[7u8; 32]); + + t.client.set_pool_wasm_hash(&first_hash); + t.client.set_pool_wasm_hash(&second_hash); + + // The second change must report the first change's value as the old hash, + // which is only checkable by reading the events in order. + let events = t.env.events().all(); + let wasm_set: Vec<_> = events + .events() + .iter() + .filter(|(_, topics, _)| { + topics + == &vec![ + &t.env, + symbol_short!("factory").into_val(&t.env), + symbol_short!("wasm_set").into_val(&t.env), + ] + }) + .collect(); + + assert_eq!(wasm_set.len(), 2); + let (_, _, second_payload) = wasm_set[1]; + assert_eq!( + second_payload, + (first_hash, second_hash).into_val(&t.env), + "the second event must pair the superseded hash with the new one" + ); +} + // ── pool_count ──────────────────────────────────────────────────────────────── #[test] From 8d4388d74d1d169673000d1b4c83e782ffd7aabc Mon Sep 17 00:00:00 2001 From: Dinma179 Date: Sun, 27 Sep 2026 20:55:03 +0200 Subject: [PATCH 2/4] test(vesting): pin release event payload for indexer audit trail (#408) --- soroban/contracts/vesting-wallet/src/test.rs | 82 +++++++++++++++++++- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/soroban/contracts/vesting-wallet/src/test.rs b/soroban/contracts/vesting-wallet/src/test.rs index bec52f9..7f471af 100644 --- a/soroban/contracts/vesting-wallet/src/test.rs +++ b/soroban/contracts/vesting-wallet/src/test.rs @@ -2,11 +2,16 @@ use super::*; use soroban_sdk::{ + symbol_short, testutils::{Address as _, Events, Ledger, MockAuth, MockAuthInvoke}, token::{StellarAssetClient, TokenClient}, - vec, Address, Env, + vec, Address, Env, IntoVal, }; +// The contract crate is `#![no_std]`; the event assertions below collect into +// `std` collections, so the shim is declared here (same as factory's tests). +extern crate std; + // ── Test helpers ────────────────────────────────────────────────────────────── struct TestEnv { @@ -372,8 +377,38 @@ fn test_release_emits_event() { ); } +// #408 — indexers reconstruct vesting progress purely from these events, so +// the payload is the audit trail: it must name the beneficiary, the amount +// released by *this* call, and the cumulative total released afterwards. +// Asserting only that "an event exists" would let any of those three values +// regress (or the topic rename) without a single test failing, which is exactly +// the failure mode the issue describes. +#[test] +fn test_release_event_payload_identifies_beneficiary_and_amounts() { + let t = setup(0, 100, 1_000); + advance_ledgers(&t.env, 50); + + let released_now = t.client.release(); // half of 1_000 is vested at 50/100 + + assert_eq!( + t.env.events().all(), + vec![ + &t.env, + ( + t.contract_id.clone(), + vec![ + &t.env, + symbol_short!("vest").into_val(&t.env), + symbol_short!("released").into_val(&t.env), + ], + (t.beneficiary.clone(), released_now, released_now).into_val(&t.env), + ) + ] + ); +} + #[test] -fn test_release_emits_event_with_cumulative_total() { +fn test_release_event_with_cumulative_total() { let t = setup(0, 100, 1_000); advance_ledgers(&t.env, 50); t.client.release(); // 500 released @@ -381,8 +416,49 @@ fn test_release_emits_event_with_cumulative_total() { advance_ledgers(&t.env, 25); t.client.release(); // 250 releasable, cumulative total 750 + // The second event must report this call's 250 *and* the running 750, so an + // indexer can track progress from the events alone. let events = t.env.events().all(); - assert!(!events.events().is_empty()); + let released_events: Vec<_> = events + .events() + .iter() + .filter(|(_, topics, _)| { + topics + == &vec![ + &t.env, + symbol_short!("vest").into_val(&t.env), + symbol_short!("released").into_val(&t.env), + ] + }) + .collect(); + + assert_eq!(released_events.len(), 2); + let (_, _, second_payload) = released_events[1]; + assert_eq!( + second_payload, + (t.beneficiary.clone(), 250i128, 750i128).into_val(&t.env), + "cumulative released total must be carried in the event" + ); +} + +#[test] +fn test_release_with_nothing_releasable_emits_no_event() { + let t = setup(0, 100, 1_000); + // Before the cliff nothing is releasable, so there is no transfer to + // report: an event here would tell indexers a release happened when it + // did not. + let amount = t.client.release(); + + assert_eq!(amount, 0); + let released_events = t.env.events().all().events().iter().filter(|(_, topics, _)| { + topics + == &vec![ + &t.env, + symbol_short!("vest").into_val(&t.env), + symbol_short!("released").into_val(&t.env), + ] + }); + assert_eq!(released_events.count(), 0); } // ── revoke tests ────────────────────────────────────────────────────────────── From 293500ffc745133abd770c102d6e44247fbc431b Mon Sep 17 00:00:00 2001 From: Dinma179 Date: Sun, 27 Sep 2026 20:56:49 +0200 Subject: [PATCH 3/4] feat(vesting): add release_all convenience entry point (#407) --- soroban/contracts/vesting-wallet/src/lib.rs | 19 +++ soroban/contracts/vesting-wallet/src/test.rs | 127 +++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/soroban/contracts/vesting-wallet/src/lib.rs b/soroban/contracts/vesting-wallet/src/lib.rs index 87949e6..223e80f 100644 --- a/soroban/contracts/vesting-wallet/src/lib.rs +++ b/soroban/contracts/vesting-wallet/src/lib.rs @@ -322,6 +322,25 @@ impl VestingWallet { Ok(()) } + /// Claim every currently-vested token in a single call. + /// + /// Convenience wrapper over {@link VestingWallet::release}, which already + /// transfers the whole vested-but-unclaimed balance in one transfer, so + /// this adds a self-documenting entry point rather than a second code path + /// — the release logic, the beneficiary authorisation and the + /// `vest/released` event are identical because it delegates. + /// + /// Note the signature deliberately takes no `beneficiary` argument: the + /// beneficiary is read from storage and is the account that must authorise + /// the call, so an argument would either be ignored or let a third party + /// force a release at a time the beneficiary did not choose (#407). + /// + /// Returns the total amount transferred, which is 0 when nothing has vested + /// yet, and `NotInitialized` if the wallet was never initialized. + pub fn release_all(env: Env) -> Result { + Self::release(env) + } + /// Return the total amount vested as of the current ledger. pub fn vested_amount(env: Env) -> Result { require_initialized(&env)?; diff --git a/soroban/contracts/vesting-wallet/src/test.rs b/soroban/contracts/vesting-wallet/src/test.rs index 7f471af..e910ecd 100644 --- a/soroban/contracts/vesting-wallet/src/test.rs +++ b/soroban/contracts/vesting-wallet/src/test.rs @@ -723,6 +723,133 @@ fn test_release_requires_beneficiary_auth() { assert!(released > 0); } +// ── release_all tests (#407) ────────────────────────────────────────────────── + +#[test] +fn test_release_all_claims_everything_vested_in_one_call() { + let t = setup(0, 100, 1_000); + // Mid-schedule: 60% vested, and none of it claimed yet. + advance_ledgers(&t.env, 60); + + let released = t.client.release_all(); + + // One call claims the whole vested balance — no repeated release needed. + assert_eq!(released, 600); + assert_eq!(t.token.balance(&t.beneficiary), 600); + assert_eq!(t.client.released_amount(), 600); + assert_eq!(t.client.releasable(), 0); +} + +#[test] +fn test_release_all_returns_zero_before_anything_vests() { + let t = setup(50, 100, 1_000); + // Still before the cliff. + let released = t.client.release_all(); + + assert_eq!(released, 0); + assert_eq!(t.token.balance(&t.beneficiary), 0); +} + +#[test] +fn test_release_all_after_end_claims_the_entire_schedule() { + let t = setup(0, 100, 1_000); + advance_ledgers(&t.env, 200); + + assert_eq!(t.client.release_all(), 1_000); + assert_eq!(t.token.balance(&t.beneficiary), 1_000); +} + +#[test] +fn test_release_all_is_idempotent_after_a_full_claim() { + let t = setup(0, 100, 1_000); + advance_ledgers(&t.env, 200); + + assert_eq!(t.client.release_all(), 1_000); + // A second call has nothing left to hand over. + assert_eq!(t.client.release_all(), 0); + assert_eq!(t.token.balance(&t.beneficiary), 1_000); +} + +#[test] +fn test_release_all_emits_the_same_event_as_release() { + let t = setup(0, 100, 1_000); + advance_ledgers(&t.env, 50); + + let released = t.client.release_all(); + + assert_eq!( + t.env.events().all(), + vec![ + &t.env, + ( + t.contract_id.clone(), + vec![ + &t.env, + symbol_short!("vest").into_val(&t.env), + symbol_short!("released").into_val(&t.env), + ], + (t.beneficiary.clone(), released, released).into_val(&t.env), + ) + ], + "release_all must stay on the same audited event as release" + ); +} + +#[test] +fn test_release_all_requires_beneficiary_auth() { + let t = setup(0, 100, 1_000); + advance_ledgers(&t.env, 50); + + t.env.mock_auths(&[MockAuth { + address: &t.beneficiary, + invoke: &MockAuthInvoke { + contract: &t.contract_id, + fn_name: "release_all", + args: vec![&t.env], + sub_invokes: &[], + }, + }]); + + assert!(t.client.release_all() > 0); +} + +#[test] +fn test_release_all_rejects_an_unauthorized_caller() { + // The convenience entry point must not become a way for a third party to + // force a release at a time the beneficiary did not choose. + let t = setup(0, 100, 1_000); + advance_ledgers(&t.env, 50); + + t.env.mock_auths(&[]); + + assert!(t.client.try_release_all().is_err()); + assert_eq!(t.token.balance(&t.beneficiary), 0); +} + +#[test] +fn test_release_all_on_uninitialized_wallet_errors() { + 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_release_all(), + Err(Ok(VestingError::NotInitialized)) + )); +} + +#[test] +fn test_release_all_counts_as_a_release_operation() { + let t = setup(0, 100, 1_000); + advance_ledgers(&t.env, 50); + assert_eq!(t.client.release_count(), 0); + + t.client.release_all(); + + assert_eq!(t.client.release_count(), 1); +} + // ── admin access-control regression tests (#406) ────────────────────────────── // // The reported `clawback` entry point does not exist in this contract, but From 6c783573d205bd6ee9b79a747c9c97f81b429351 Mon Sep 17 00:00:00 2001 From: Dinma179 Date: Sun, 27 Sep 2026 20:58:10 +0200 Subject: [PATCH 4/4] feat(vesting): add get_vesting_overview for single-call schedule and progress (#409) --- soroban/contracts/vesting-wallet/src/lib.rs | 40 +++++- soroban/contracts/vesting-wallet/src/test.rs | 124 ++++++++++++++++++ soroban/contracts/vesting-wallet/src/types.rs | 28 ++++ 3 files changed, 191 insertions(+), 1 deletion(-) diff --git a/soroban/contracts/vesting-wallet/src/lib.rs b/soroban/contracts/vesting-wallet/src/lib.rs index 223e80f..e7cf788 100644 --- a/soroban/contracts/vesting-wallet/src/lib.rs +++ b/soroban/contracts/vesting-wallet/src/lib.rs @@ -6,7 +6,7 @@ mod types; use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env}; use types::DataKey; -pub use types::{AdminTransferred, VestingError, VestingSchedule}; +pub use types::{AdminTransferred, VestingError, VestingOverview, VestingSchedule}; // Persistent-storage TTL: extend to ~60 days if below ~30 days (at ~5 s/ledger). const TTL_THRESHOLD: u32 = 518_400; @@ -408,6 +408,44 @@ impl VestingWallet { }) } + /// Return the whole schedule *and* its live progress in a single call. + /// + /// `get_vesting_schedule` returns the configured parameters only, so a + /// frontend still had to follow it with `vested_amount`, + /// `released_amount` and `releasable` — four round trips for one vesting + /// overview, and a real risk of the components disagreeing because they + /// were read at different ledgers. This returns all of it atomically. + /// + /// Kept as a separate type from `VestingSchedule` so that struct's layout — + /// and the XDR clients already decode — is untouched (#409). + /// + /// `releasable_amount` is what `release_all` would transfer right now. + /// Returns `NotInitialized` if the wallet has not been initialized. + pub fn get_vesting_overview(env: Env) -> Result { + require_initialized(&env)?; + bump_instance(&env); + + let vested = compute_vested(&env)?; + let released = get_released(&env); + + Ok(VestingOverview { + beneficiary: get_beneficiary(&env), + token: get_token(&env), + total_amount: get_total_amount(&env), + start_ledger: get_start_ledger(&env), + cliff_ledger: get_cliff_ledger(&env), + end_ledger: get_end_ledger(&env), + revocable: is_revocable(&env), + revoked: is_revoked(&env), + vested_amount: vested, + released_amount: released, + // Saturating, not plain subtraction: after revocation the frozen + // vested amount can be lower than what was already released, and a + // negative "releasable" would be nonsense to a caller. + releasable_amount: vested.saturating_sub(released), + }) + } + /// Returns `(start_ledger, cliff_ledger, end_ledger)` in a single read for /// frontends that render the vesting schedule (#256). Returns /// `NotInitialized` if the wallet has not been initialized. diff --git a/soroban/contracts/vesting-wallet/src/test.rs b/soroban/contracts/vesting-wallet/src/test.rs index e910ecd..ae0907f 100644 --- a/soroban/contracts/vesting-wallet/src/test.rs +++ b/soroban/contracts/vesting-wallet/src/test.rs @@ -673,6 +673,130 @@ fn test_get_vesting_schedule_uninitialized_returns_not_initialized() { )); } +// ── get_vesting_overview tests (#409) ───────────────────────────────────────── +// +// A single-call schedule *and* progress read, so a dashboard does not have to +// reconcile four separately-timed contract calls. + +#[test] +fn test_get_vesting_overview_returns_schedule_and_progress() { + let t = setup_schedule(50, 200, 1_000, true); + advance_ledgers(&t.env, 100); // halfway between start and end + + let overview = t.client.get_vesting_overview(); + + // Schedule fields match get_vesting_schedule exactly. + assert_eq!(overview.beneficiary, t.beneficiary); + assert_eq!(overview.token, t.token_address); + assert_eq!(overview.total_amount, 1_000); + assert_eq!(overview.start_ledger, t.start); + assert_eq!(overview.cliff_ledger, t.start + 50); + assert_eq!(overview.end_ledger, t.start + 250); + assert!(overview.revocable); + assert!(!overview.revoked); + + // Progress fields answer the three follow-up calls in one shot. + // Vesting runs from start (not cliff) to end = start + 50 + 200 = start+250, + // so at start+100 exactly 1000 * 100/250 is vested. + assert_eq!(overview.vested_amount, 400); + assert_eq!(overview.released_amount, 0); + assert_eq!(overview.releasable_amount, 400); +} + +#[test] +fn test_get_vesting_overview_reflects_amounts_already_released() { + let t = setup(0, 100, 1_000); + advance_ledgers(&t.env, 50); + t.client.release(); // 500 out + + let overview = t.client.get_vesting_overview(); + + assert_eq!(overview.vested_amount, 500); + assert_eq!(overview.released_amount, 500); + assert_eq!(overview.releasable_amount, 0); +} + +#[test] +fn test_get_vesting_overview_matches_the_individual_queries() { + let t = setup(0, 100, 1_000); + advance_ledgers(&t.env, 40); + + let overview = t.client.get_vesting_overview(); + + // The point of the combined call: it must agree with the separate reads. + assert_eq!(overview.vested_amount, t.client.vested_amount()); + assert_eq!(overview.released_amount, t.client.released_amount()); + assert_eq!(overview.releasable_amount, t.client.releasable()); +} + +#[test] +fn test_get_vesting_overview_is_zero_before_the_cliff() { + let t = setup(50, 200, 1_000); + + let overview = t.client.get_vesting_overview(); + + assert_eq!(overview.vested_amount, 0); + assert_eq!(overview.released_amount, 0); + assert_eq!(overview.releasable_amount, 0); +} + +#[test] +fn test_get_vesting_overview_reports_full_vesting_after_end() { + let t = setup(0, 100, 1_000); + advance_ledgers(&t.env, 200); + + let overview = t.client.get_vesting_overview(); + + assert_eq!(overview.vested_amount, 1_000); + assert_eq!(overview.releasable_amount, 1_000); +} + +#[test] +fn test_get_vesting_overview_reports_revocation() { + let t = setup_revocable(0, 200, 1_000); + advance_ledgers(&t.env, 100); + t.client.revoke(); + + let overview = t.client.get_vesting_overview(); + + assert!(overview.revoked); + // Vested is frozen at the revocation point, never decreasing below what has + // already been released. + assert_eq!(overview.vested_amount, 500); + assert_eq!(overview.released_amount, 0); + assert_eq!(overview.releasable_amount, 500); +} + +#[test] +fn test_get_vesting_overview_releasable_never_goes_negative_after_emergency_withdraw() { + // emergency_withdraw zeroes RevokedVested while released may already be + // non-zero, so releasable must saturate rather than wrap negative. + let t = setup(0, 100, 1_000); + advance_ledgers(&t.env, 50); + t.client.release(); // 500 released + t.client.emergency_withdraw(); // zeroes the frozen vested amount + + let overview = t.client.get_vesting_overview(); + + assert!(overview.revoked); + assert_eq!(overview.released_amount, 500); + assert_eq!(overview.releasable_amount, 0); + assert!(overview.releasable_amount >= 0); +} + +#[test] +fn test_get_vesting_overview_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_get_vesting_overview(), + Err(Ok(VestingError::NotInitialized)) + )); +} + #[test] fn test_release_count_increments_on_release() { let t = setup(50, 200, 1_000); diff --git a/soroban/contracts/vesting-wallet/src/types.rs b/soroban/contracts/vesting-wallet/src/types.rs index 4d6375c..fb5df9c 100644 --- a/soroban/contracts/vesting-wallet/src/types.rs +++ b/soroban/contracts/vesting-wallet/src/types.rs @@ -65,3 +65,31 @@ pub struct VestingSchedule { pub end_ledger: u32, pub revocable: bool, } + +/// A vesting schedule plus its live progress, returned by +/// `get_vesting_overview`. +/// +/// `VestingSchedule` above carries the configured schedule only. A dashboard +/// also needs how far along that schedule is, which previously meant three +/// further contract calls (`vested_amount`, `released_amount`, `releasable`). +/// This is a separate type rather than extra fields on `VestingSchedule` so the +/// existing struct's layout — and therefore its XDR, which clients already +/// decode — is unchanged (#409). +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VestingOverview { + pub beneficiary: Address, + pub token: Address, + pub total_amount: i128, + pub start_ledger: u32, + pub cliff_ledger: u32, + pub end_ledger: u32, + pub revocable: bool, + pub revoked: bool, + /// Total vested as of the current ledger (frozen at revocation). + pub vested_amount: i128, + /// Cumulative amount already transferred to the beneficiary. + pub released_amount: i128, + /// Vested but not yet claimed; the amount `release_all` would transfer. + pub releasable_amount: i128, +}