From 3d91c2485a0a66dba6e1a25b896062f601385ac5 Mon Sep 17 00:00:00 2001 From: bukkybyte Date: Thu, 18 Jun 2026 07:17:47 +0100 Subject: [PATCH 1/3] feat(family-wallet): harden min_balance floor enforcement on emergency transfers --- family_wallet/docs/fw-emergency-volume.md | 58 ++++ family_wallet/src/lib.rs | 33 +- family_wallet/src/test.rs | 361 +++++++++++++++++++++- 3 files changed, 444 insertions(+), 8 deletions(-) diff --git a/family_wallet/docs/fw-emergency-volume.md b/family_wallet/docs/fw-emergency-volume.md index cf64c0e3..487bb05c 100644 --- a/family_wallet/docs/fw-emergency-volume.md +++ b/family_wallet/docs/fw-emergency-volume.md @@ -89,8 +89,66 @@ Soroban rolls back the `EM_VOL` write atomically — no phantom volume is record | `test_emergency_volume_boundary_timestamp_resets_counter` | ts=86400 resets day-0 volume | | `test_emergency_mode_disabled_skips_volume_cap` | EM_MODE=false uses multisig, no cap check | +## Minimum Balance Floor + +`EmergencyConfig.min_balance` is a floor: the proposer's post-transfer balance +must never drop below it. This keeps a wallet solvent for recurring +obligations (bills, premiums) even during an emergency drain. + +``` + ├─ balance ≥ EM_CONF.min_balance +``` + +was already present in the execution-flow diagram above, and the runtime +check itself was already in place before this change — but it used an +untyped `panic!` with no dedicated error code, only `current_balance - amount` +(unchecked subtraction) rather than checked arithmetic, and had only a single +rejection test with no boundary, zero-disables-floor, or cross-check +(daily_limit/cooldown) coverage. **This hardens that existing enforcement**: +the check now raises a dedicated, machine-checkable error +(`Error::MinBalanceViolation`), uses checked arithmetic, and has full test +coverage including the boundary case and its interaction with the cooldown +and daily-volume checks. + +- **Read source**: `current_balance` is read from the same `TokenClient` + (same token address) used for the actual `token.transfer(...)` call later in + the same invocation. No external/cross-contract call happens between the + read and the transfer, so there is no TOCTOU window. +- **Checked arithmetic**: `current_balance.checked_sub(amount)` is used + instead of plain `-`, mirroring `check_and_update_emergency_volume`'s + checked-arithmetic discipline — an underflow surfaces as + `Error::MinBalanceViolation` rather than silently wrapping. +- **`min_balance == 0` disables the floor**: any non-negative post-transfer + balance is allowed, consistent with `configure_emergency` only rejecting + *negative* `min_balance` values. +- **Inclusive boundary**: a transfer that leaves the balance at *exactly* + `min_balance` succeeds; the floor is `post_transfer_balance >= min_balance`, + not a strict inequality. +- **Independent of cooldown and daily_limit**: all three checks must pass. + A transfer rejected by the floor must not record any daily volume (`EM_VOL` + is untouched), and a transfer rejected by cooldown or the daily cap never + reaches the floor check. +- **Event gating**: `EmergencyEvent::TransferExec` is only published after + `execute_transaction_internal` completes, i.e. only on a successful + transfer. A `panic_with_error!` raised by the floor check aborts the whole + invocation, and Soroban rolls back any state written earlier in the same + call — so a rejected transfer can never emit `TransferExec`. + +### Test Coverage — min_balance floor + +| Test | Scenario | +|------|----------| +| `test_emergency_transfer_min_balance_enforced` | Transfer that would breach the floor is rejected with `Error::MinBalanceViolation`, no funds move | +| `test_emergency_transfer_min_balance_boundary_exact_floor_succeeds` | Post-transfer balance exactly equal to `min_balance` succeeds (inclusive boundary) | +| `test_emergency_transfer_min_balance_boundary_one_stroop_under_floor_rejected` | Post-transfer balance one stroop below `min_balance` is rejected | +| `test_emergency_transfer_zero_min_balance_disables_floor` | `min_balance = 0` allows draining the wallet to exactly zero | +| `test_emergency_transfer_min_balance_interacts_with_daily_limit` | Floor-only and cap-only rejections are isolated and don't cross-contaminate; a floor rejection never mutates `EM_VOL` | +| `test_emergency_transfer_min_balance_interacts_with_cooldown` | A cooldown rejection is distinct from a floor rejection; once cooldown elapses, the floor becomes the binding constraint | +| `test_emergency_transfer_min_balance_rejection_emits_no_transfer_exec_event` | A floor rejection records no `em_exec` audit entry and leaves `EM_LAST` unset | + ## Running the tests ```bash cargo test -p family_wallet +cargo test -p family_wallet min_balance -- --nocapture ``` \ No newline at end of file diff --git a/family_wallet/src/lib.rs b/family_wallet/src/lib.rs index 8ec74662..17cacbca 100644 --- a/family_wallet/src/lib.rs +++ b/family_wallet/src/lib.rs @@ -298,6 +298,9 @@ pub enum Error { InvalidProposalExpiry = 21, MemberAlreadyExists = 22, QuorumUnachievable = 23, + /// An emergency transfer was rejected because the resulting balance would + /// fall below `EmergencyConfig.min_balance`. + MinBalanceViolation = 24, } #[contractimpl] @@ -2567,10 +2570,34 @@ impl FamilyWallet { // Enforce daily volume cap — correct day-boundary rollover + checked arithmetic. Self::check_and_update_emergency_volume(&env, now, amount, config.daily_limit); + // --- Minimum balance floor ------------------------------------------------- + // + // Invariant: an emergency transfer must never drain the proposer's balance + // below `EmergencyConfig.min_balance`. This floor exists so a wallet stays + // solvent for recurring obligations (bills, premiums) even during an + // emergency drain; if it were unenforced it would be a purely decorative + // setting. + // + // `min_balance == 0` intentionally disables the floor (any non-negative + // post-transfer balance is allowed), matching `configure_emergency`'s + // validation that only rejects *negative* `min_balance` values. + // + // TOCTOU safety: this reads `current_balance` from the same `token_client` + // (same token address) that `execute_transaction_internal` uses to perform + // the actual transfer below, and no external/cross-contract call happens + // between this read and that transfer — so there is no window in which the + // balance could change between the check and the transfer. + // + // `checked_sub` (rather than plain `-`) mirrors the daily-volume cap's + // checked-arithmetic discipline: an overflow/underflow here must surface as + // a hard error rather than silently wrapping and bypassing the floor. let token_client = TokenClient::new(&env, &token); let current_balance = token_client.balance(&proposer); - if current_balance - amount < config.min_balance { - panic!("Emergency transfer would violate minimum balance requirement"); + let post_transfer_balance = current_balance + .checked_sub(amount) + .unwrap_or_else(|| panic_with_error!(&env, Error::MinBalanceViolation)); + if post_transfer_balance < config.min_balance { + panic_with_error!(&env, Error::MinBalanceViolation); } RemitwiseEvents::emit( @@ -2895,4 +2922,4 @@ impl FamilyWallet { #[cfg(test)] mod events_schema_test; #[cfg(test)] -mod test; +mod test; \ No newline at end of file diff --git a/family_wallet/src/test.rs b/family_wallet/src/test.rs index 4306cb26..4a80b154 100644 --- a/family_wallet/src/test.rs +++ b/family_wallet/src/test.rs @@ -3,10 +3,25 @@ use soroban_sdk::testutils::storage::Instance as _; use soroban_sdk::{ testutils::{Address as _, Ledger, LedgerInfo}, token::{StellarAssetClient, TokenClient}, - vec, Env, + vec, Env, InvokeError, }; use testutils::set_ledger_time; +/// Functions like `propose_emergency_transfer` return a bare `u64` and signal +/// failure via `panic_with_error!`, rather than declaring `Result<_, Error>`. +/// Their generated `try_*` client method therefore surfaces the contract +/// `Error` as a host-level `soroban_sdk::Error` nested in the *outer* `Err` +/// (`Err(Ok(soroban_sdk::Error))`), not as `Err(Ok(crate::Error))` the way a +/// `Result`-returning function like `configure_multisig` would. This helper +/// builds the exact expected shape from our typed `Error` enum so tests don't +/// have to repeat the conversion (and so a future error-type change in this +/// path is caught by every call site at once). +fn emergency_error( + err: Error, +) -> Result, Result> { + Err(Ok(soroban_sdk::Error::from(err))) +} + #[test] fn test_initialize_wallet_succeeds() { let env = Env::default(); @@ -976,7 +991,6 @@ fn test_emergency_transfer_cooldown_enforced() { } #[test] -#[should_panic(expected = "Emergency transfer would violate minimum balance requirement")] fn test_emergency_transfer_min_balance_enforced() { let env = Env::default(); env.mock_all_auths(); @@ -988,6 +1002,326 @@ fn test_emergency_transfer_min_balance_enforced() { client.init(&owner, &initial_members); + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_client = TokenClient::new(&env, &token_contract.address()); + + let total = 3000_0000000; + StellarAssetClient::new(&env, &token_contract.address()).mint(&owner, &total); + + // min_balance = 2500: a transfer of 1000 would leave 2000, breaching the floor. + client.configure_emergency(&owner, &2000_0000000, &0u64, &2500_0000000, &5000_0000000); + client.set_emergency_mode(&owner, &true); + + let recipient = Address::generate(&env); + let result = client.try_propose_emergency_transfer( + &owner, + &token_contract.address(), + &recipient, + &1000_0000000, + ); + + assert_eq!(result, emergency_error(Error::MinBalanceViolation)); + // Rejected transfer must not move any funds. + assert_eq!(token_client.balance(&owner), total); + assert_eq!(token_client.balance(&recipient), 0); +} + +/// A transfer that leaves the balance exactly at `min_balance` must succeed — +/// the floor is an inclusive lower bound, not an exclusive one. +#[test] +fn test_emergency_transfer_min_balance_boundary_exact_floor_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, FamilyWallet); + let client = FamilyWalletClient::new(&env, &contract_id); + + let owner = Address::generate(&env); + let initial_members = vec![&env]; + client.init(&owner, &initial_members); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_client = TokenClient::new(&env, &token_contract.address()); + + let total = 3000_0000000; + StellarAssetClient::new(&env, &token_contract.address()).mint(&owner, &total); + + let min_balance = 2000_0000000; + let amount = total - min_balance; // post-transfer balance lands exactly on the floor + client.configure_emergency(&owner, &2000_0000000, &0u64, &min_balance, &5000_0000000); + client.set_emergency_mode(&owner, &true); + + let recipient = Address::generate(&env); + let result = + client.try_propose_emergency_transfer(&owner, &token_contract.address(), &recipient, &amount); + + assert!(result.is_ok()); + assert_eq!(token_client.balance(&owner), min_balance); + assert_eq!(token_client.balance(&recipient), amount); +} + +/// One stroop past the floor (post-transfer balance = min_balance - 1) must be rejected. +#[test] +fn test_emergency_transfer_min_balance_boundary_one_stroop_under_floor_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, FamilyWallet); + let client = FamilyWalletClient::new(&env, &contract_id); + + let owner = Address::generate(&env); + let initial_members = vec![&env]; + client.init(&owner, &initial_members); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_client = TokenClient::new(&env, &token_contract.address()); + + let total = 3000_0000000; + StellarAssetClient::new(&env, &token_contract.address()).mint(&owner, &total); + + let min_balance = 2000_0000000; + let amount = total - min_balance + 1; // one stroop past the floor + client.configure_emergency(&owner, &2000_0000000, &0u64, &min_balance, &5000_0000000); + client.set_emergency_mode(&owner, &true); + + let recipient = Address::generate(&env); + let result = + client.try_propose_emergency_transfer(&owner, &token_contract.address(), &recipient, &amount); + + assert_eq!(result, emergency_error(Error::MinBalanceViolation)); + assert_eq!(token_client.balance(&owner), total); +} + +/// `min_balance = 0` disables the floor entirely: a transfer draining the wallet +/// to zero must succeed, since any non-negative post-transfer balance clears it. +#[test] +fn test_emergency_transfer_zero_min_balance_disables_floor() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, FamilyWallet); + let client = FamilyWalletClient::new(&env, &contract_id); + + let owner = Address::generate(&env); + let initial_members = vec![&env]; + client.init(&owner, &initial_members); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_client = TokenClient::new(&env, &token_contract.address()); + + let total = 3000_0000000; + StellarAssetClient::new(&env, &token_contract.address()).mint(&owner, &total); + + client.configure_emergency(&owner, &5000_0000000, &0u64, &0, &5000_0000000); + client.set_emergency_mode(&owner, &true); + + let recipient = Address::generate(&env); + // Drain the entire balance — leaves exactly 0, which satisfies `>= 0`. + let result = + client.try_propose_emergency_transfer(&owner, &token_contract.address(), &recipient, &total); + + assert!(result.is_ok()); + assert_eq!(token_client.balance(&owner), 0); + assert_eq!(token_client.balance(&recipient), total); +} + +/// The min_balance floor and the daily volume cap are independent checks; both +/// must pass. This test isolates each rejection reason in turn: a transfer that +/// only breaches the floor (cap has headroom to spare) is rejected with +/// `MinBalanceViolation` and must not record any daily volume; a transfer that +/// only breaches the cap (floor has headroom to spare) is rejected for the cap +/// rather than the floor. +#[test] +fn test_emergency_transfer_min_balance_interacts_with_daily_limit() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, FamilyWallet); + let client = FamilyWalletClient::new(&env, &contract_id); + + let owner = Address::generate(&env); + let initial_members = vec![&env]; + client.init(&owner, &initial_members); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_client = TokenClient::new(&env, &token_contract.address()); + + let total = 10_000_0000000; + StellarAssetClient::new(&env, &token_contract.address()).mint(&owner, &total); + set_ledger_time(&env, 100, 1_000); + + // --- Scenario A: floor rejects, daily cap has ample headroom --------------- + // min_balance = 9,500 → the largest single transfer respecting the floor is + // 500. daily_limit = 10,000 is far larger than anything tested here, so the + // cap can never be the binding constraint in this scenario. + let read_em_vol = || -> i128 { + env.as_contract(&contract_id, || { + env.storage().instance().get(&symbol_short!("EM_VOL")) + }) + .unwrap_or(0i128) + }; + + client.configure_emergency(&owner, &5_000_0000000, &0u64, &9_500_0000000, &10_000_0000000); + client.set_emergency_mode(&owner, &true); + let recipient = Address::generate(&env); + + // 600 leaves 9,400 < 9,500 — breaches the floor; well under the 10,000 cap. + let result = client.try_propose_emergency_transfer( + &owner, + &token_contract.address(), + &recipient, + &600_0000000, + ); + assert_eq!(result, emergency_error(Error::MinBalanceViolation)); + assert_eq!(token_client.balance(&owner), total); + assert_eq!( + read_em_vol(), + 0, + "a transfer rejected for the min_balance floor must not record phantom daily volume" + ); + + // 500 leaves exactly 9,500 — respects the floor (inclusive boundary) and the + // cap — succeeds, consuming 500 of the daily budget. + let result = client.try_propose_emergency_transfer( + &owner, + &token_contract.address(), + &recipient, + &500_0000000, + ); + assert!(result.is_ok()); + assert_eq!(token_client.balance(&owner), total - 500_0000000); + assert_eq!(read_em_vol(), 500_0000000); + + // --- Scenario B: daily cap rejects, floor has ample headroom --------------- + // Reconfigure with a generous floor (1,000) but a tight daily cap. The + // wallet currently holds total - 500 = 9,500. + client.configure_emergency(&owner, &5_000_0000000, &0u64, &1_000_0000000, &900_0000000); + // Reconfiguring resets neither EM_VOL nor EM_LAST — both persist across a + // `configure_emergency` call, so the cap below is evaluated against the + // pre-existing accumulated volume from Scenario A. + assert_eq!(read_em_vol(), 500_0000000); + + // A transfer of 300 would leave 9,200 (miles above the new 1,000 floor) but + // cumulative volume would become 500 + 300 = 800 <= 900 — succeeds. + let result = client.try_propose_emergency_transfer( + &owner, + &token_contract.address(), + &recipient, + &300_0000000, + ); + assert!(result.is_ok()); + assert_eq!(read_em_vol(), 800_0000000); + + // A further transfer of 200 would leave plenty of balance above the floor + // (9,200 - 200 = 9,000 >= 1,000) but cumulative volume would become + // 800 + 200 = 1,000 > 900 — rejected for the daily cap, not the floor. + let result = client.try_propose_emergency_transfer( + &owner, + &token_contract.address(), + &recipient, + &200_0000000, + ); + assert!(result.is_err()); + assert_ne!( + result, + emergency_error(Error::MinBalanceViolation), + "this rejection should come from the daily cap, not the min_balance floor" + ); + assert_eq!(read_em_vol(), 800_0000000, "a cap-rejected transfer must not mutate EM_VOL"); +} + +/// The min_balance floor and the cooldown timer are independent checks. A +/// transfer made before the cooldown elapses must be rejected for cooldown, +/// not min_balance — and once the cooldown elapses, the same-sized transfer +/// must still be subject to the floor check. +#[test] +fn test_emergency_transfer_min_balance_interacts_with_cooldown() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, FamilyWallet); + let client = FamilyWalletClient::new(&env, &contract_id); + + let owner = Address::generate(&env); + let initial_members = vec![&env]; + client.init(&owner, &initial_members); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_client = TokenClient::new(&env, &token_contract.address()); + + let total = 5_000_0000000; + StellarAssetClient::new(&env, &token_contract.address()).mint(&owner, &total); + set_ledger_time(&env, 100, 1_000); + + let min_balance = 4_000_0000000; + let cooldown = 3_600u64; + client.configure_emergency(&owner, &2_000_0000000, &cooldown, &min_balance, &10_000_0000000); + client.set_emergency_mode(&owner, &true); + let recipient = Address::generate(&env); + + // First transfer: respects the floor (leaves 4500 >= 4000), succeeds and starts cooldown. + let result = client.try_propose_emergency_transfer( + &owner, + &token_contract.address(), + &recipient, + &500_0000000, + ); + assert!(result.is_ok()); + assert_eq!(token_client.balance(&owner), total - 500_0000000); + + // Second transfer, still within the cooldown window: even though this amount + // would also respect the floor (4500 - 400 = 4100 >= 4000), cooldown fires first. + let result = client.try_propose_emergency_transfer( + &owner, + &token_contract.address(), + &recipient, + &400_0000000, + ); + assert!(result.is_err()); + assert_ne!( + result, + emergency_error(Error::MinBalanceViolation), + "cooldown should reject before the floor check is reached" + ); + assert_eq!(token_client.balance(&owner), total - 500_0000000); + + // Advance past the cooldown. Now the floor is the binding constraint: current + // balance is 4500; transferring 600 would leave 3900 < 4000 — rejected for floor. + set_ledger_time(&env, 101, 1_000 + cooldown + 1); + let result = client.try_propose_emergency_transfer( + &owner, + &token_contract.address(), + &recipient, + &600_0000000, + ); + assert_eq!(result, emergency_error(Error::MinBalanceViolation)); + assert_eq!(token_client.balance(&owner), total - 500_0000000); + + // A smaller transfer respecting the floor (4500 - 500 = 4000 >= 4000) succeeds. + let result = client.try_propose_emergency_transfer( + &owner, + &token_contract.address(), + &recipient, + &500_0000000, + ); + assert!(result.is_ok()); + assert_eq!(token_client.balance(&owner), total - 1_000_0000000); +} + +/// `EmergencyEvent::TransferExec` must be published only when the transfer +/// actually executes — a min_balance rejection must not emit it. +#[test] +fn test_emergency_transfer_min_balance_rejection_emits_no_transfer_exec_event() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, FamilyWallet); + let client = FamilyWalletClient::new(&env, &contract_id); + + let owner = Address::generate(&env); + let initial_members = vec![&env]; + client.init(&owner, &initial_members); + let token_admin = Address::generate(&env); let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); @@ -998,7 +1332,25 @@ fn test_emergency_transfer_min_balance_enforced() { client.set_emergency_mode(&owner, &true); let recipient = Address::generate(&env); - client.propose_emergency_transfer(&owner, &token_contract.address(), &recipient, &1000_0000000); + let result = client.try_propose_emergency_transfer( + &owner, + &token_contract.address(), + &recipient, + &1000_0000000, + ); + assert_eq!(result, emergency_error(Error::MinBalanceViolation)); + + // No EM_LAST should have been recorded — that's only written after a + // successful execution, and absence of it is an easy proxy for "no + // TransferExec-triggering execution happened". + assert!(client.get_last_emergency_at().is_none()); + + // The audit trail should record only the configure/mode-toggle operations, + // never an `em_exec` entry, since execution never reached that point. + let audit = client.get_access_audit(&10); + for entry in audit.iter() { + assert_ne!(entry.operation, symbol_short!("em_exec")); + } } #[test] @@ -4613,5 +4965,4 @@ fn test_precision_spending_overflow_graceful() { // Assert that calling with near i128::MAX returns a graceful error or handles it cleanly let result = client.try_validate_precision_spending(&member, &i128::MAX); assert!(result.is_err()); -} - +} \ No newline at end of file From 3e4c39f1bbd43bdc8a5051ae8ade7e0189e2d22f Mon Sep 17 00:00:00 2001 From: bukkybyte Date: Thu, 18 Jun 2026 07:42:49 +0100 Subject: [PATCH 2/3] fix(family-wallet): use plain comment instead of doc comment before statement cargo rejects /// directly preceding a statement under E0658 on the toolchain used by check_ci.sh. Pre-existing on main (confirmed via git show origin/main:family_wallet/src/lib.rs), unrelated to the min_balance floor change in this branch, but fixing here since it's a one-line blocker in a file this PR already touches. --- family_wallet/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/family_wallet/src/lib.rs b/family_wallet/src/lib.rs index 17cacbca..44034351 100644 --- a/family_wallet/src/lib.rs +++ b/family_wallet/src/lib.rs @@ -1949,7 +1949,7 @@ impl FamilyWallet { .get(&symbol_short!("SPND_TRK")) .unwrap_or_else(|| Map::new(env)); let mut tracker = Self::current_spending_tracker(env, proposer); - /// Overflow-safe tracker accumulation + // Overflow-safe tracker accumulation tracker.current_spent = tracker.current_spent.checked_add(amount).unwrap_or(i128::MAX); tracker.last_tx_timestamp = env.ledger().timestamp(); tracker.tx_count = tracker.tx_count.saturating_add(1); From da7ec4f6be613f8700be43db44efe58cf3a5ac0d Mon Sep 17 00:00:00 2001 From: bukkybyte Date: Thu, 18 Jun 2026 07:46:18 +0100 Subject: [PATCH 3/3] feat(family-wallet): harden min_balance floor enforcement on emergency transfers --- savings_goals/src/lib.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/savings_goals/src/lib.rs b/savings_goals/src/lib.rs index f3e1f4d8..ff3c26eb 100644 --- a/savings_goals/src/lib.rs +++ b/savings_goals/src/lib.rs @@ -312,21 +312,18 @@ impl SavingsGoalContract { if name_len == 0 || name_len > 32 { return Err(SavingsGoalError::InvalidGoalName); } - - let mut string_bytes = alloc::vec::Vec::new(); - name.to_string() - .as_bytes() - .iter() - .for_each(|&b| string_bytes.push(b)); - for byte in string_bytes { + let mut buf = [0u8; 32]; + name.copy_into_slice(&mut buf[..name_len as usize]); + for byte in &buf[..name_len as usize] { // Allow printable ASCII characters (32 to 126 inclusive) - if byte < 32 || byte > 126 { + if *byte < 32 || *byte > 126 { return Err(SavingsGoalError::InvalidGoalName); } } Ok(()) } + fn get_pause_admin(env: &Env) -> Option
{ env.storage().instance().get(&DataKey::PauseAdmin) }