Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions family_wallet/docs/fw-emergency-volume.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
35 changes: 31 additions & 4 deletions family_wallet/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -1946,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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -2895,4 +2922,4 @@ impl FamilyWallet {
#[cfg(test)]
mod events_schema_test;
#[cfg(test)]
mod test;
mod test;
Loading
Loading