Skip to content

feat(family-wallet): enforce min_balance reservation#751

Open
bukkybyte wants to merge 3 commits into
Remitwise-Org:mainfrom
bukkybyte:feat/family-wallet-min-balance-floor
Open

feat(family-wallet): enforce min_balance reservation#751
bukkybyte wants to merge 3 commits into
Remitwise-Org:mainfrom
bukkybyte:feat/family-wallet-min-balance-floor

Conversation

@bukkybyte

Copy link
Copy Markdown

feat(family-wallet): harden min_balance floor enforcement on emergency transfers — #720

closes #720

Summary

EmergencyConfig.min_balance is now enforced with a dedicated, typed error
(Error::MinBalanceViolation) instead of an untyped panic!, uses checked
arithmetic for the balance comparison, and has full test coverage (previously
only one rejection test existed).

Important correction to the ticket's premise

The ticket assumed the floor was "not clearly enforced" and might be "silently
unenforced." On inspection, the floor was already functionally enforced
before this change:

// before
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");
}
  • It read current_balance from the same TokenClient/token address used
    for the actual transfer later in the same call, with no intervening
    external call — so there was no TOCTOU gap.
  • It ran before execute_transaction_internal, so a rejection already
    prevented the transfer and the EmergencyEvent::TransferExec event (which
    is only published after that call succeeds) was already correctly gated.

What was genuinely missing, and what this PR fixes:

  • No dedicated Error variant — failures were an opaque string panic,
    unlike every other rejection path in this file (Error::Unauthorized,
    Error::TransactionNotFound, etc. via panic_with_error!).
  • Unchecked subtraction (current_balance - amount) instead of the
    checked-arithmetic discipline used elsewhere in this same function for the
    daily-volume cap.
  • Only one test existed
    (test_emergency_transfer_min_balance_enforced), covering the rejection
    case only — no success path, no boundary case, no zero-disables-floor
    case, no interaction tests with daily_limit/cooldown.

This is best framed as hardening existing enforcement, not introducing
enforcement where none existed. I flagged this distinction explicitly because
overstating the prior risk would misrepresent the actual security posture of
the code under review.

Changes

family_wallet/src/lib.rs

  • Added Error::MinBalanceViolation = 24.
  • In execute_emergency_transfer_now: replaced the unchecked subtraction +
    string panic with checked_sub + panic_with_error!(&env, Error::MinBalanceViolation).
  • Added a doc comment on the check spelling out the invariant, the
    min_balance == 0 disables-the-floor semantic (consistent with
    configure_emergency's validation, which only rejects negative
    min_balance), and why there's no TOCTOU window.

family_wallet/src/test.rs

  • Updated the existing test to assert the typed error via
    try_propose_emergency_transfer instead of #[should_panic(expected = "...")] string matching, matching the existing pattern used for other
    panic_with_error! paths in this file (e.g.
    assert_eq!(result, Err(Ok(Error::Unauthorized))) in
    test_unauthorized_signer).
  • Added a small emergency_error::<T>(err: Error) helper. This was necessary
    because propose_emergency_transfer returns a bare u64 rather than
    Result<u64, Error>, so its generated try_* client method surfaces a
    panic_with_error! failure as Err(Ok(soroban_sdk::Error)) in the outer
    Result, not Err(Ok(crate::Error)) the way a Result-returning function
    like configure_multisig does. The helper builds the correct nested shape
    from our typed enum so every call site stays correct if this ever changes.
  • Added six new tests:
    • test_emergency_transfer_min_balance_boundary_exact_floor_succeeds
      inclusive boundary: a transfer landing the balance exactly on
      min_balance succeeds.
    • test_emergency_transfer_min_balance_boundary_one_stroop_under_floor_rejected
      — one stroop past the floor is rejected.
    • test_emergency_transfer_zero_min_balance_disables_floormin_balance = 0 allows draining to exactly zero.
    • test_emergency_transfer_min_balance_interacts_with_daily_limit — proves
      the floor and the daily cap are independent: a floor-only rejection
      leaves EM_VOL untouched (read directly via env.as_contract instance
      storage access), and a cap-only rejection (floor has headroom to spare)
      is distinguished from a floor rejection.
    • 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 leaves EM_LAST unset and adds no em_exec audit
      entry, as a proxy for "no execution / no TransferExec event."

family_wallet/docs/fw-emergency-volume.md

  • Added a "Minimum Balance Floor" section documenting the same invariants,
    TOCTOU reasoning, and a test-coverage table, mirroring the existing
    "Checked Arithmetic" / "Test Coverage" sections already in this doc for the
    daily-volume cap.

Test results

cargo test -p family_wallet

156 passed, 1 failed (unrelated, pre-existing:
test_precision_spending_overflow_graceful is missing
env.mock_all_auths(), so client.init()'s internal owner.require_auth()
call fails — this test was already failing before this change and is out of
scope for #720).

cargo test -p family_wallet min_balance -- --nocapture

7 passed, 0 failed.

Verification environment note

This sandbox only had apt's rustc 1.75 available (no network access to
rustup/static.rust-lang.org). Cargo.lock pins several transitive crates
that require a newer toolchain (edition2024 / stabilized-after-1.75 features:
time, serde_with, base64ct, indexmap, ed25519-dalek, ethnum,
backtrace/addr2line). I verified the build and full test suite in an
isolated scratch copy with those specific transitive dependencies
temporarily downgraded via cargo update --precise to the oldest
version satisfying each crate's own declared constraints — the real
Cargo.lock shipped in this PR is untouched
. One unrelated, pre-existing
line in lib.rs (/// doc-comment directly preceding a statement, around
the spending-tracker accumulation logic) also required a // reformat to
build under rustc 1.75 specifically; that single-line cosmetic difference
exists only in the scratch copy used for verification and is not part of
this diff. clippy itself wasn't installable in this environment, so the new
code was reviewed manually against the existing file's idioms instead of
running cargo clippy directly — please run clippy in CI as normal for this
PR.

Files

  • min_balance_floor.patch — unified diff of all three changed files,
    suitable for git apply.
  • lib.rs, test.rs, fw-emergency-volume.md — full final versions of the
    three changed files, for direct reference/copy if preferred over the patch.

…tatement

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Family Wallet: enforce min_balance reservation in propose_emergency_transfer and add tests

1 participant