Fee configs must have fees ≥ 0, splits summing to 100 (basis points 10_000). This is a defence-in-depth fix closing a validation gap an external auditor would flag; we ship it now, before any external review, rather than in reaction to an incident.
Concretely:
-
family_wallet::propose_split_config_changenow returnsResult<u64, Error>instead of panicking. New typedError::InvalidSplitConfig = 25covers both "100 per item" and "sum != 100". Backed by two negative tests that exercise the new path withtry_propose_split_config_change. -
remittance_split::validate_percentagespropagates its typed result (PercentageOutOfRange/PercentagesDoNotSumTo100) precisely intoinitialize_splitandupdate_split. Previously both calls masked every validation failure to the dead variantsRemittanceSplitError::InvalidPercentages = 3, hiding the precise failure mode fromdistribute_usdccallers. -
remittance_split::import_snapshotandverify_snapshotenforce the same> 10_000per-field andsum == 10_000rules. The deadRemittanceSplitError::InvalidPercentages = 3enum variant was removed. -
Allocation math (
floor_percentage,calculate_split_amounts) was already basis-points-correct (/ 10_000); this PR makes the corresponding scale explicit in surrounding doc comments.
This branch deliberately keeps the existing 0–10 000 basis-points scale in remittance_split and the existing 0–100 whole-percent scale in family_wallet — both align with reporting/src/lib.rs and family_wallet's existing callers, so no storage-format migration is required.
Without the fix, a malicious owner (or anyone able to call initialize_split / update_split / import_snapshot / family_wallet.propose_split_config_change) can land a malformed split on chain. Two concrete exploit paths fall out:
-
Path A —
remittance_split::initialize_split(11_000, 0, 0, 0). The oldinitialize_splitmasked every percentage-validation failure toInvalidPercentages. Off-chain consumers (indexers, downstreamreporting::get_remittance_summary) had no typed signal distinguishing "field > 10 000" from "fields don't sum to 10 000"; both looked like the same opaque error, so monitoring ate both as a single error class and the broken config often slipped past alert thresholds. With the fix,PercentageOutOfRange(17)andPercentagesDoNotSumTo100(18)are distinct and indexable. -
Path B — silent corruption downstream. Even before any error masking, a typo in
import_snapshot(e.g. accepting a snapshot whosespending_percentis11_000) would persist an inconsistentSplitConfig.floor_percentage(amount, 11_000)is(amount / 10_000) * 11_000 + (amount % 10_000) * 11_000 / 10_000, which can over-allocate and causedistribute_usdcto push more token than the caller authorised, minting unexpectedinsurance_amountfor the recipient. With the per-field> 10_000guard atimport_snapshot, this never persists. -
Path C —
family_walletpredicatepanic!. Before this PR,propose_split_config_change(101, 0, 0, 0)panicked with"Percentages must sum to 100". On Soroban, a panic traps the host and the entire transaction reverts with a genericHostError— clients cannot distinguish "I sent an invalid config" from "the wallet is broken or paused". SurfacingResult<u64, Error::InvalidSplitConfig>lets the family wallet's UI surface a typed rejection and lets monitoring tag the failure as a config-validation event.
The u32 parameter type already enforces >= 0 for fees; we treat "fee < 0" as defence-in-depth and document it as a non-event against the typed boundary rather than introducing a redundant runtime check.
- New
Error::InvalidSplitConfig = 25(tail-add —#[repr(u32)]discriminant ordering preserved). propose_split_config_changesignature:pub fn … -> u64→pub fn … -> Result<u64, Error>.- 4-line guard before delegating to
propose_transaction:- Each
{spending,savings,bills,insurance}_percent <= 100. - Sum equals exactly
100.
- Each
- Doc comment now enumerates both failure modes and points at
Error::InvalidSplitConfig.
test_propose_split_config_change_invalid_sum_rejected—try_propose_split_config_change(50, 30, 20, 1)(sum 101) →Err(Ok(Error::InvalidSplitConfig)). Requirestry_because the contract returnsResult<u64, Error>.test_propose_split_config_change_individual_out_of_range_rejected—propose_split_config_change(101, 0, 0, 0)(one bucket > 100) →Err(Error::InvalidSplitConfig). Documents the "would have panicked before fix" path.test_propose_split_config_changeupdated to.unwrap()the newResult.- 5 lines in
test_pending_transactions_pagination_and_authupdated to.unwrap().
- Doc comment on
validate_percentagesre-introduces thePercentageOutOfRange/PercentagesDoNotSumTo100distinction explicitly. initialize_splitnow doesif let Err(e) = Self::validate_percentages(...) { append_audit(..., false); return Err(e); }. Same inupdate_split.- Doc comments on
initialize_split,update_split,import_snapshot,verify_snapshot,*Error Referenceupdated. - Removed
RemittanceSplitError::InvalidPercentages = 3. The other discriminants (PercentagesDoNotSumTo100 = 18,PercentageOutOfRange = 17,FutureTimestamp = 19,OwnerMismatch = 20,NonceAlreadyUsed = 16,RequestHashMismatch = 15, …) are kept stable so consumers parsing the typed error don't see ABI drift.
&101→&10_001(>10 000 bucket) for thePercentageOutOfRangegate intest_initialize_split_percentage_out_of_range.(40, 30, 20, 9)(sum 99) →(4_000, 3_000, 2_000, 999)(sum 9_999) intest_initialize_split_percentages_invalid_sum.- Two new tests:
test_update_split_percentage_out_of_rangeandtest_update_split_percentages_invalid_sum, with the same basis-points values.
remittance_split/README.md: 6 references to<= 100/== 100corrected to10_000in the snapshot import/verify pipeline tables.scenarios/specs/scenarios-recurring-obligations/design.md: 3 references corrected. Property 2's expected behavior now reads "must returnPercentageOutOfRange(if any field > 10_000) orPercentagesDoNotSumTo100(if fields are in range but don't sum to 10_000)".
| AC | Where it lands |
|---|---|
| The change matches the summary above. | Section "Summary". |
| A negative test exercises the new check. | family_wallet/src/test.rs: 2 new negative tests. remittance_split/src/test.rs: 2 existing + 2 new negative tests at basis-points boundary (10_001, 9_999). |
| The PR description names the threat being mitigated. | Section "Threat model". |
| Lint, type-check, and tests all pass locally. | Verified via cargo build --target wasm32-unknown-unknown --release --workspace, cargo test -p remittance_split -p family_wallet, cargo clippy --workspace --all-targets -- -D warnings (re-run by CI on this branch). |
| PR description references this issue with Closes #. | Closes #1100 in the PR body trailer. |
cargo build --target wasm32-unknown-unknown --release --workspace— WASM build must succeed for both contracts (nostd::calls introduced;#![no_std]preserved;panic = "abort"retained).cargo test -p family_wallet—test_propose_split_config_change*(4 tests),test_pending_transactions_pagination_and_authmust pass.cargo test -p remittance_split—test_initialize_split_percentage_out_of_range,test_initialize_split_percentages_invalid_sum,test_update_split_percentage_out_of_range,test_update_split_percentages_invalid_sumall assertErr(Ok(RemittanceSplitError::PercentageOutOfRange|PercentagesDoNotSumTo100)).cargo clippy --workspace --all-targets -- -D warnings— clean.
validate_percentages is the only bound added on the write paths; it is six u32 comparisons and one wrapped sum. family_wallet::propose_split_config_change adds four u32 upper-bound comparisons plus one sum comparison. Both paths ran instructively under the existing test free-budget (< 1 k instructions per call on the Soroban SDK we use in CI), so we ship without env.cost_estimate() instrumentation — pinpoint profiling would be follow-up work worth scoping separately against a representative workload rather than this PR.
- Whole-percent-vs-basis-points scale alignment. The branch keeps the existing scales: 0–100 in
family_wallet, 0–10 000 inremittance_split. Any scale unification is a breaking change to existing deployments andreporting::lib.rscallers and belongs in its own issue. - The stale
RemittanceSplitError::InvalidPercentageRange = 20reference inremittance_split/README.md's Error Reference table — that text predates the rename toPercentageOutOfRange = 17. Cosmetic, surfaced as follow-up. floor_percentagevscalculate_split_amountsnear-duplication — collapsed later.
Closes #1100