feat(family-wallet): enforce min_balance reservation#751
Open
bukkybyte wants to merge 3 commits into
Open
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat(family-wallet): harden min_balance floor enforcement on emergency transfers — #720
closes #720
Summary
EmergencyConfig.min_balanceis now enforced with a dedicated, typed error(
Error::MinBalanceViolation) instead of an untypedpanic!, uses checkedarithmetic 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:
current_balancefrom the sameTokenClient/token address usedfor the actual transfer later in the same call, with no intervening
external call — so there was no TOCTOU gap.
execute_transaction_internal, so a rejection alreadyprevented the transfer and the
EmergencyEvent::TransferExecevent (whichis only published after that call succeeds) was already correctly gated.
What was genuinely missing, and what this PR fixes:
Errorvariant — failures were an opaque string panic,unlike every other rejection path in this file (
Error::Unauthorized,Error::TransactionNotFound, etc. viapanic_with_error!).current_balance - amount) instead of thechecked-arithmetic discipline used elsewhere in this same function for the
daily-volume cap.
(
test_emergency_transfer_min_balance_enforced), covering the rejectioncase 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.rsError::MinBalanceViolation = 24.execute_emergency_transfer_now: replaced the unchecked subtraction +string panic with
checked_sub+panic_with_error!(&env, Error::MinBalanceViolation).min_balance == 0disables-the-floor semantic (consistent withconfigure_emergency's validation, which only rejects negativemin_balance), and why there's no TOCTOU window.family_wallet/src/test.rstry_propose_emergency_transferinstead of#[should_panic(expected = "...")]string matching, matching the existing pattern used for otherpanic_with_error!paths in this file (e.g.assert_eq!(result, Err(Ok(Error::Unauthorized)))intest_unauthorized_signer).emergency_error::<T>(err: Error)helper. This was necessarybecause
propose_emergency_transferreturns a bareu64rather thanResult<u64, Error>, so its generatedtry_*client method surfaces apanic_with_error!failure asErr(Ok(soroban_sdk::Error))in the outerResult, notErr(Ok(crate::Error))the way aResult-returning functionlike
configure_multisigdoes. The helper builds the correct nested shapefrom our typed enum so every call site stays correct if this ever changes.
test_emergency_transfer_min_balance_boundary_exact_floor_succeeds—inclusive boundary: a transfer landing the balance exactly on
min_balancesucceeds.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_floor—min_balance = 0allows draining to exactly zero.test_emergency_transfer_min_balance_interacts_with_daily_limit— provesthe floor and the daily cap are independent: a floor-only rejection
leaves
EM_VOLuntouched (read directly viaenv.as_contractinstancestorage 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— acooldown 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_LASTunset and adds noem_execauditentry, as a proxy for "no execution / no
TransferExecevent."family_wallet/docs/fw-emergency-volume.mdTOCTOU 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
156 passed, 1 failed (unrelated, pre-existing:
test_precision_spending_overflow_gracefulis missingenv.mock_all_auths(), soclient.init()'s internalowner.require_auth()call fails — this test was already failing before this change and is out of
scope for #720).
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.lockpins several transitive cratesthat 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 anisolated scratch copy with those specific transitive dependencies
temporarily downgraded via
cargo update --preciseto the oldestversion satisfying each crate's own declared constraints — the real
Cargo.lockshipped in this PR is untouched. One unrelated, pre-existingline in
lib.rs(///doc-comment directly preceding a statement, aroundthe spending-tracker accumulation logic) also required a
//reformat tobuild 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 clippydirectly — please run clippy in CI as normal for thisPR.
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 thethree changed files, for direct reference/copy if preferred over the patch.