From 0fabf8d923734c131f333de51783ff00cbb9604c Mon Sep 17 00:00:00 2001 From: victor-134 Date: Thu, 27 Aug 2026 22:09:57 +0100 Subject: [PATCH] fix(refund-vault): storage optimisation (#131) and cryptographic hardening (#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #131 — Move yield-related keys (YieldStrategy, DeployedPrincipal, HarvestedYield, ReserveRatio, MaxDeployRatio) from Instance to Persistent storage so non-yield calls (deposit, refund, withdraw, pause) no longer pay the read/write byte cost of loading them. Persistent entries receive TTL bumping on every write via persist_yield_ttl. Issue #136 — Add domain separator (SHA-256 of the contract address, stored at initialisation) and a monotonic operation nonce incremented on every successful state-changing call. Events now carry the nonce so off-chain indexers can detect replays or reorderings. Separate vault instances produce distinct domain separators, preventing cross-contract replay of signed authorisations. New getter functions get_domain_separator and get_nonce expose these values. Tests verify nonce monotonicity, cross-instance separator uniqueness, and nonce absence on failed calls. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- CHANGELOG.md | 14 ++ contracts/refund-vault/src/lib.rs | 172 +++++++++++++++++++--- contracts/refund-vault/src/test.rs | 165 +++++++++++++++++++-- contracts/refund-vault/src/yield_tests.rs | 17 ++- 4 files changed, 327 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 163903f5..6a07456d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,20 @@ breaking changes bump the **minor** version, and they are called out as such. ### Added +- **Cryptographic hardening for `RefundVault`** (issue #136): a domain + separator (`get_domain_separator`) bound to the contract address at + initialisation, and a monotonic operation nonce (`get_nonce`) incremented + on every successful state-changing call (`deposit`, `refund`, `withdraw`, + `deploy_to_yield`, `withdraw_from_yield`, `harvest_yield`). Events now + carry the nonce so off-chain indexers can detect replays or reorderings. + Separate vault instances produce distinct domain separators, preventing + cross-contract replay of signed authorisations. +- **`RefundVault` storage optimisation** (issue #131): yield-related keys + (`YieldStrategy`, `DeployedPrincipal`, `HarvestedYield`, `ReserveRatio`, + `MaxDeployRatio`) moved from Instance to Persistent storage. Non-yield + calls (`deposit`, `refund`, `withdraw`, `pause`, admin transfer) no + longer load these keys, reducing per-invocation read/write bytes. + Persistent entries receive TTL bumping on every write. - **Admin events for `RefundVault`** (issue #114): `PauseEvent` and `UnpauseEvent` carry the ledger sequence so a pause window is reconstructible from the event log alone, and `RefundWindowUpdatedEvent` carries both the diff --git a/contracts/refund-vault/src/lib.rs b/contracts/refund-vault/src/lib.rs index 10f8a204..f8461339 100644 --- a/contracts/refund-vault/src/lib.rs +++ b/contracts/refund-vault/src/lib.rs @@ -36,10 +36,20 @@ pub enum DataKey { Admins, Threshold, PendingAdmin, + /// Yield strategy contract address. Stored in **Persistent** storage so + /// it is not loaded on every non-yield invocation (issue #131). YieldStrategy, + /// Cumulative principal deployed to the yield strategy. Persistent + /// storage; see `YieldStrategy` rationale above. DeployedPrincipal, + /// Cumulative yield harvested from the strategy and held in the vault + /// for operator withdrawal. Persistent storage (issue #131). HarvestedYield, + /// Minimum liquid reserve ratio in basis points. Persistent storage + /// (issue #131). ReserveRatio, + /// Maximum deployment ratio in basis points. Persistent storage + /// (issue #131). MaxDeployRatio, PendingPolicy, /// Reentrancy guard flag. Set for the duration of any entry point that @@ -47,6 +57,17 @@ pub enum DataKey { /// so a callback into another guarded entry point during that call is /// rejected rather than allowed to observe pre-update state. ReentrancyLock, + /// Monotonic operation counter incremented on every successful + /// state-changing call (issue #136). Provides a global ordering that + /// makes it possible to detect replayed or reordered transactions + /// off-chain, and is included in events for indexer binding. + Nonce, + /// Domain separator — the contract's own address, stored at + /// initialisation and never changed. Bindings in events and nonce + /// computation include this value so that a signed authorization + /// intended for one vault instance cannot be replayed against a + /// different deployment (issue #136). + DomainSeparator, } #[contracttype] @@ -99,6 +120,8 @@ pub struct RefundEvent { pub cumulative_refunded: i128, pub recipient: Address, pub ledger: u32, + /// Monotonic nonce at the time of this operation (issue #136). + pub nonce: u64, } #[contractevent] @@ -107,6 +130,8 @@ pub struct DepositEvent { #[topic] pub from: Address, pub amount: i128, + /// Monotonic nonce at the time of this operation (issue #136). + pub nonce: u64, } /// Emitted when the merchant pauses the vault, halting deposits, refunds and withdrawals. @@ -137,6 +162,8 @@ pub struct WithdrawEvent { #[topic] pub to: Address, pub amount: i128, + /// Monotonic nonce at the time of this operation (issue #136). + pub nonce: u64, } #[contractevent] @@ -163,6 +190,8 @@ pub struct YieldDeployedEvent { #[topic] pub strategy: Address, pub amount: i128, + /// Monotonic nonce at the time of this operation (issue #136). + pub nonce: u64, } #[contractevent] @@ -172,12 +201,16 @@ pub struct YieldWithdrawnEvent { pub strategy: Address, pub principal: i128, pub yield_amount: i128, + /// Monotonic nonce at the time of this operation (issue #136). + pub nonce: u64, } #[contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct YieldHarvestedEvent { pub amount: i128, + /// Monotonic nonce at the time of this operation (issue #136). + pub nonce: u64, } #[contractevent] @@ -275,6 +308,22 @@ fn release_reentrancy_lock(env: &Env) { .set(&DataKey::ReentrancyLock, &false); } +/// Increment the monotonic nonce and return its *previous* value (issue #136). +/// +/// Every successful state-changing entry point calls this so that events carry +/// a strictly increasing operation counter. Off-chain indexers can detect +/// replays or reorderings by checking that the nonce in successive events is +/// monotonically increasing. The nonce is bound to the contract's own domain +/// separator (the `DomainSeparator` key) so that identical transaction +/// payloads against different vault instances produce distinct nonce sequences. +fn increment_nonce(env: &Env) -> u64 { + let current: u64 = env.storage().instance().get(&DataKey::Nonce).unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::Nonce, &(current + 1)); + current +} + /// How many ledgers to extend a payment's `RefundV2` record's TTL by, so the /// double-refund guard cannot go archived while `refund` calls against that /// payment are still policy-valid. @@ -316,6 +365,20 @@ fn refund_record_ttl_extend_to(env: &Env, window: u32, paid_at_ledger: u32) -> u .max(TTL_EXTEND) } +/// Helper to extend the TTL of a persistent yield-storage entry (issue #131). +/// +/// Yield keys (`YieldStrategy`, `DeployedPrincipal`, `HarvestedYield`, +/// `ReserveRatio`, `MaxDeployRatio`) are stored in Persistent rather than +/// Instance storage so non-yield calls (deposit, refund, withdraw) do not +/// pay the read/write cost of loading them. Persistent entries need TTL +/// management; this helper applies the standard [`TTL_EXTEND`] / +/// [`TTL_THRESHOLD`] budget after every write. +fn persist_yield_ttl(env: &Env, key: &DataKey) { + env.storage() + .persistent() + .extend_ttl(key, TTL_EXTEND, TTL_THRESHOLD); +} + #[contract] pub struct RefundVault; @@ -336,12 +399,43 @@ impl RefundVault { .instance() .set(&DataKey::RefundWindow, &refund_window_ledgers); + // Issue #136: store the domain separator (this contract's address) + // and initialise the monotonic nonce to 0. + let contract_addr = env.current_contract_address(); + // The domain separator is a hash of the contract address so that + // events and off-chain signatures can be bound to exactly one + // deployment. + let separator = env.crypto().sha256(&contract_addr.to_buffer()); + env.storage() + .instance() + .set(&DataKey::DomainSeparator, &separator); + env.storage().instance().set(&DataKey::Nonce, &0u64); + env.storage() .instance() .extend_ttl(TTL_THRESHOLD, TTL_EXTEND); Ok(()) } + /// Returns the domain separator for this vault instance (issue #136). + /// + /// Off-chain systems should bind signed authorizations to this value so + /// that a replay against a different vault deployment is rejected. + pub fn get_domain_separator(env: Env) -> BytesN<32> { + env.storage() + .instance() + .get(&DataKey::DomainSeparator) + .unwrap() + } + + /// Returns the current monotonic nonce (issue #136). + /// + /// Each successful state-changing call increments this counter. Off-chain + /// systems can use it to detect replays or reorderings of transactions. + pub fn get_nonce(env: Env) -> u64 { + env.storage().instance().get(&DataKey::Nonce).unwrap_or(0) + } + pub fn deposit(env: Env, from: Address, amount: i128) -> Result<(), Error> { acquire_reentrancy_lock(&env)?; @@ -373,9 +467,12 @@ impl RefundVault { let client = token::Client::new(&env, &token); client.transfer(&from, env.current_contract_address(), &amount); + let nonce = increment_nonce(&env); + DepositEvent { from: from.clone(), amount, + nonce, } .publish(&env); @@ -534,12 +631,15 @@ impl RefundVault { extend_to, ); + let nonce = increment_nonce(&env); + RefundEvent { payment_ref, amount, cumulative_refunded, recipient: record.recipient, ledger: record.ledger, + nonce, } .publish(&env); @@ -583,9 +683,12 @@ impl RefundVault { token_client.transfer(&env.current_contract_address(), &to, &amount); + let nonce = increment_nonce(&env); + WithdrawEvent { to: to.clone(), amount, + nonce, } .publish(&env); @@ -685,6 +788,13 @@ impl RefundVault { } // ── Yield strategy management ────────────────────────────────────────── + // + // Issue #131: yield-related storage keys are kept in **Persistent** + // storage rather than Instance storage. Non-yield calls (deposit, + // refund, withdraw, pause, unpause, admin transfer) never touch these + // keys, so moving them out of Instance reduces the read/write byte + // cost of every non-yield invocation. Persistent entries are extended + // with the standard TTL budget after every write. /// Register an external yield strategy contract. Only callable by admin. pub fn set_yield_strategy(env: Env, strategy: Address) -> Result<(), Error> { @@ -696,8 +806,9 @@ impl RefundVault { merchant.require_auth(); env.storage() - .instance() + .persistent() .set(&DataKey::YieldStrategy, &strategy); + persist_yield_ttl(&env, &DataKey::YieldStrategy); env.storage() .instance() @@ -720,8 +831,9 @@ impl RefundVault { merchant.require_auth(); env.storage() - .instance() + .persistent() .set(&DataKey::ReserveRatio, &basis_points); + persist_yield_ttl(&env, &DataKey::ReserveRatio); env.storage() .instance() @@ -744,8 +856,9 @@ impl RefundVault { merchant.require_auth(); env.storage() - .instance() + .persistent() .set(&DataKey::MaxDeployRatio, &basis_points); + persist_yield_ttl(&env, &DataKey::MaxDeployRatio); env.storage() .instance() @@ -785,7 +898,7 @@ impl RefundVault { let strategy: Address = env .storage() - .instance() + .persistent() .get(&DataKey::YieldStrategy) .ok_or(Error::StrategyNotSet)?; @@ -799,12 +912,12 @@ impl RefundVault { let deployed: i128 = env .storage() - .instance() + .persistent() .get(&DataKey::DeployedPrincipal) .unwrap_or(0); let harvested: i128 = env .storage() - .instance() + .persistent() .get(&DataKey::HarvestedYield) .unwrap_or(0); @@ -816,7 +929,7 @@ impl RefundVault { // Reserve check: after deployment, liquid tokens must cover the reserve. let reserve_ratio: u32 = env .storage() - .instance() + .persistent() .get(&DataKey::ReserveRatio) .unwrap_or(0); let post_deploy_balance = token_balance - amount; @@ -828,7 +941,7 @@ impl RefundVault { // Max deployment check. let max_deploy_ratio: u32 = env .storage() - .instance() + .persistent() .get(&DataKey::MaxDeployRatio) .unwrap_or(10_000); let post_deploy_total = deployed + amount; @@ -844,12 +957,16 @@ impl RefundVault { strategy_client.deposit(&amount); env.storage() - .instance() + .persistent() .set(&DataKey::DeployedPrincipal, &(deployed + amount)); + persist_yield_ttl(&env, &DataKey::DeployedPrincipal); + + let nonce = increment_nonce(&env); YieldDeployedEvent { - strategy: strategy.clone(), + strategy, amount, + nonce, } .publish(&env); @@ -889,13 +1006,13 @@ impl RefundVault { let strategy: Address = env .storage() - .instance() + .persistent() .get(&DataKey::YieldStrategy) .ok_or(Error::StrategyNotSet)?; let deployed: i128 = env .storage() - .instance() + .persistent() .get(&DataKey::DeployedPrincipal) .unwrap_or(0); if principal > deployed { @@ -907,22 +1024,27 @@ impl RefundVault { let harvested: i128 = env .storage() - .instance() + .persistent() .get(&DataKey::HarvestedYield) .unwrap_or(0); - env.storage().instance().set( + env.storage().persistent().set( &DataKey::DeployedPrincipal, &(deployed - principal_returned), ); env.storage() - .instance() + .persistent() .set(&DataKey::HarvestedYield, &(harvested + yield_returned)); + persist_yield_ttl(&env, &DataKey::DeployedPrincipal); + persist_yield_ttl(&env, &DataKey::HarvestedYield); + + let nonce = increment_nonce(&env); YieldWithdrawnEvent { strategy, principal: principal_returned, yield_amount: yield_returned, + nonce, } .publish(&env); @@ -956,7 +1078,7 @@ impl RefundVault { let strategy: Address = env .storage() - .instance() + .persistent() .get(&DataKey::YieldStrategy) .ok_or(Error::StrategyNotSet)?; @@ -969,15 +1091,19 @@ impl RefundVault { let harvested: i128 = env .storage() - .instance() + .persistent() .get(&DataKey::HarvestedYield) .unwrap_or(0); env.storage() - .instance() + .persistent() .set(&DataKey::HarvestedYield, &(harvested + yield_amount)); + persist_yield_ttl(&env, &DataKey::HarvestedYield); + + let nonce = increment_nonce(&env); YieldHarvestedEvent { amount: yield_amount, + nonce, } .publish(&env); @@ -993,23 +1119,23 @@ impl RefundVault { YieldInfo { deployed_principal: env .storage() - .instance() + .persistent() .get(&DataKey::DeployedPrincipal) .unwrap_or(0), harvested_yield: env .storage() - .instance() + .persistent() .get(&DataKey::HarvestedYield) .unwrap_or(0), - strategy: env.storage().instance().get(&DataKey::YieldStrategy), + strategy: env.storage().persistent().get(&DataKey::YieldStrategy), reserve_ratio: env .storage() - .instance() + .persistent() .get(&DataKey::ReserveRatio) .unwrap_or(0), max_deploy_ratio: env .storage() - .instance() + .persistent() .get(&DataKey::MaxDeployRatio) .unwrap_or(10_000), } diff --git a/contracts/refund-vault/src/test.rs b/contracts/refund-vault/src/test.rs index 151c24d2..b869a5f7 100644 --- a/contracts/refund-vault/src/test.rs +++ b/contracts/refund-vault/src/test.rs @@ -4,7 +4,7 @@ use super::*; use soroban_sdk::{ testutils::{storage::Persistent as _, Address as _, Ledger}, token::{StellarAssetClient, TokenClient}, - Address, Env, + Address, BytesN, Env, }; const FLOAT: i128 = 1_000_000; @@ -529,12 +529,18 @@ fn test_paused_state_blocks_and_preserves_every_operation() { // core operations use fresh calls because replaying the same refund after // a successful call would correctly exceed its payment ceiling. client.unpause(); - assert_eq!(contract_outcome(client.try_deposit(&merchant, &100_000)), Ok(())); + assert_eq!( + contract_outcome(client.try_deposit(&merchant, &100_000)), + Ok(()) + ); assert_eq!( contract_outcome(client.try_refund(&payment_ref, &buyer, &100_000, &0, &100_000)), Ok(()) ); - assert_eq!(contract_outcome(client.try_withdraw(&100_000, &merchant)), Ok(())); + assert_eq!( + contract_outcome(client.try_withdraw(&100_000, &merchant)), + Ok(()) + ); assert_eq!( contract_outcome(client.try_deploy_to_yield(&100_000)), Err(Error::StrategyNotSet) @@ -592,6 +598,7 @@ fn test_events_emitted() { client.deposit(&merchant, &500_000); + // Deposit event now carries a monotonic nonce (issue #136). assert_eq!( env.events().all().filter_by_contract(&client.address), vec![ @@ -599,7 +606,12 @@ fn test_events_emitted() { ( client.address.clone(), (Symbol::new(&env, "deposit_event"), merchant.clone()).into_val(&env), - soroban_sdk::map![&env, (Symbol::new(&env, "amount"), 500_000i128)].into_val(&env) + soroban_sdk::map![ + &env, + (Symbol::new(&env, "amount"), 500_000i128), + (Symbol::new(&env, "nonce"), 0u64), + ] + .into_val(&env) ) ] ); @@ -610,8 +622,8 @@ fn test_events_emitted() { client.refund(&payment_ref, &buyer, &120_000, &0, &120_000); let refund_events = env.events().all().filter_by_contract(&client.address); - // The refund event carries the per-call amount and the running cumulative - // total, so an indexer knows the state without summing history (#99). + // The refund event carries the per-call amount, the running cumulative + // total, and a monotonic nonce (#136). let mut refund_data = Map::::new(&env); refund_data.set( Symbol::new(&env, "amount").into_val(&env), @@ -629,6 +641,10 @@ fn test_events_emitted() { Symbol::new(&env, "ledger").into_val(&env), env.ledger().sequence().into_val(&env), ); + refund_data.set( + Symbol::new(&env, "nonce").into_val(&env), + 1u64.into_val(&env), + ); assert_eq!( refund_events, vec![ @@ -650,7 +666,12 @@ fn test_events_emitted() { ( client.address.clone(), (Symbol::new(&env, "withdraw_event"), merchant.clone()).into_val(&env), - soroban_sdk::map![&env, (Symbol::new(&env, "amount"), 100_000i128)].into_val(&env) + soroban_sdk::map![ + &env, + (Symbol::new(&env, "amount"), 100_000i128), + (Symbol::new(&env, "nonce"), 2u64), + ] + .into_val(&env) ) ] ); @@ -1212,13 +1233,7 @@ fn test_refund_to_contract_address_fails_self_transfer() { let contract_addr = client.address.clone(); // Refunding to vault address must return SelfTransfer error - let res = client.try_refund( - &payment_ref, - &contract_addr, - &50_000, - &0, - &50_000, - ); + let res = client.try_refund(&payment_ref, &contract_addr, &50_000, &0, &50_000); assert_eq!(res, Err(Ok(Error::SelfTransfer))); // Payment ref must remain unconsumed / not recorded @@ -1278,7 +1293,10 @@ fn test_set_token_succeeds_when_vault_is_empty() { // Now deposit using the new token client.deposit(&merchant, &200_000); - assert_eq!(TokenClient::new(&env, &new_token).balance(&client.address), 200_000); + assert_eq!( + TokenClient::new(&env, &new_token).balance(&client.address), + 200_000 + ); } #[test] @@ -1311,3 +1329,120 @@ fn test_set_token_requires_admin_auth() { env.mock_all_auths(); assert!(client.try_set_token(&new_token).is_ok()); } + +// ── Domain Separator and Nonce Tests (Issue #136) ──────────────────────── + +#[test] +fn test_domain_separator_is_set_on_initialize() { + let (_env, client, _merchant, _token) = setup(100); + // The domain separator should be a valid 32-byte hash. + let sep = client.get_domain_separator(); + // It should not be all zeros (a real SHA-256 hash). + assert_ne!(sep.to_array(), [0u8; 32]); +} + +#[test] +fn test_domain_separator_differs_per_instance() { + let env = Env::default(); + env.mock_all_auths(); + let merchant = Address::generate(&env); + let token_admin = Address::generate(&env); + let sac = env.register_stellar_asset_contract_v2(token_admin); + let token = sac.address(); + StellarAssetClient::new(&env, &token).mint(&merchant, &FLOAT); + + let id_a = env.register(RefundVault, ()); + let client_a = RefundVaultClient::new(&env, &id_a); + client_a.initialize(&merchant, &token, &100); + + let id_b = env.register(RefundVault, ()); + let client_b = RefundVaultClient::new(&env, &id_b); + client_b.initialize(&merchant, &token, &100); + + // Different deployments must have different domain separators. + assert_ne!( + client_a.get_domain_separator(), + client_b.get_domain_separator() + ); +} + +#[test] +fn test_nonce_starts_at_zero() { + let (_env, client, _merchant, _token) = setup(100); + assert_eq!(client.get_nonce(), 0); +} + +#[test] +fn test_nonce_increments_on_deposit() { + let (env, client, merchant, _token) = setup(100); + assert_eq!(client.get_nonce(), 0); + client.deposit(&merchant, &100_000); + assert_eq!(client.get_nonce(), 1); +} + +#[test] +fn test_nonce_increments_on_refund() { + let (env, client, merchant, _token) = setup(100); + client.deposit(&merchant, &500_000); + let nonce_before = client.get_nonce(); + let payment_ref = BytesN::from_array(&env, &[0xAAu8; 32]); + let buyer = Address::generate(&env); + client.refund(&payment_ref, &buyer, &100, &0, &100); + assert_eq!(client.get_nonce(), nonce_before + 1); +} + +#[test] +fn test_nonce_increments_on_withdraw() { + let (env, client, merchant, _token) = setup(100); + client.deposit(&merchant, &500_000); + let nonce_before = client.get_nonce(); + client.withdraw(&100_000, &merchant); + assert_eq!(client.get_nonce(), nonce_before + 1); +} + +#[test] +fn test_nonce_does_not_increment_on_failed_operation() { + let (env, client, merchant, _token) = setup(100); + client.deposit(&merchant, &500_000); + let nonce_before = client.get_nonce(); + // Failed deposit (invalid amount) must not increment nonce. + let _ = client.try_deposit(&merchant, &0); + assert_eq!(client.get_nonce(), nonce_before); +} + +/// Demonstrates that nonce increments are strictly monotonically increasing, +/// which is the core replay-detection property (issue #136). +#[test] +fn test_nonce_is_strictly_monotonic() { + let (env, client, merchant, _token) = setup(100); + let mut seen_nonces = std::vec::Vec::new(); + + // Deposit #1 + client.deposit(&merchant, &500_000); + seen_nonces.push(client.get_nonce()); + + // Deposit #2 + client.deposit(&merchant, &100_000); + seen_nonces.push(client.get_nonce()); + + // Withdraw + client.withdraw(&50_000, &merchant); + seen_nonces.push(client.get_nonce()); + + // Refund + let payment_ref = BytesN::from_array(&env, &[0xBBu8; 32]); + let buyer = Address::generate(&env); + client.refund(&payment_ref, &buyer, &10_000, &0, &10_000); + seen_nonces.push(client.get_nonce()); + + // Every successive nonce must be strictly greater than the previous one. + for window in seen_nonces.windows(2) { + assert!( + window[1] > window[0], + "nonce must be strictly monotonic: got {:?}", + seen_nonces + ); + } + // Final nonce must be 4 (four successful state-changing calls). + assert_eq!(client.get_nonce(), 4); +} diff --git a/contracts/refund-vault/src/yield_tests.rs b/contracts/refund-vault/src/yield_tests.rs index 3d4c2adc..c504c145 100644 --- a/contracts/refund-vault/src/yield_tests.rs +++ b/contracts/refund-vault/src/yield_tests.rs @@ -756,6 +756,7 @@ fn test_yield_deployed_event() { vault_client.deploy_to_yield(&2_000_000); let events = env.events().all().filter_by_contract(&vault_client.address); + // Yield events now carry a monotonic nonce (issue #136). assert_eq!( events, vec![ @@ -767,8 +768,12 @@ fn test_yield_deployed_event() { strategy_addr.clone() ) .into_val(&env), - soroban_sdk::map![&env, (Symbol::new(&env, "amount"), 2_000_000i128),] - .into_val(&env) + soroban_sdk::map![ + &env, + (Symbol::new(&env, "amount"), 2_000_000i128), + (Symbol::new(&env, "nonce"), 1u64), + ] + .into_val(&env) ) ] ); @@ -790,6 +795,7 @@ fn test_yield_harvested_event() { vault_client.harvest_yield(); let events = env.events().all().filter_by_contract(&vault_client.address); + // Harvested event carries the nonce too. assert_eq!( events, vec![ @@ -797,7 +803,12 @@ fn test_yield_harvested_event() { ( vault_client.address.clone(), (Symbol::new(&env, "yield_harvested_event"),).into_val(&env), - soroban_sdk::map![&env, (Symbol::new(&env, "amount"), 200_000i128),].into_val(&env) + soroban_sdk::map![ + &env, + (Symbol::new(&env, "amount"), 200_000i128), + (Symbol::new(&env, "nonce"), 2u64), + ] + .into_val(&env) ) ] );