Skip to content

fix(contracts): cap fee_bps with a MAX_FEE_BPS sanity ceiling - #71

Open
ZuLu0890 wants to merge 2 commits into
MergeFi:mainfrom
ZuLu0890:fix/fee-bps-ceiling
Open

fix(contracts): cap fee_bps with a MAX_FEE_BPS sanity ceiling#71
ZuLu0890 wants to merge 2 commits into
MergeFi:mainfrom
ZuLu0890:fix/fee-bps-ceiling

Conversation

@ZuLu0890

Copy link
Copy Markdown
Contributor

Closes #40

Summary

All three contracts validate fee_bps at initialize with the same guard:

if fee_bps as i128 > BPS_DENOMINATOR {
    return Err(Error::InvalidFee);
}

The comparison is strictly >, not >=, so fee_bps == 10_000 — exactly
100% — is accepted rather than rejected. Because fee_bps is immutable
after initialize (tracked separately in #20), whatever value passes this
check governs every payout for the contract's entire lifetime. A 100% fee is
mathematically "valid" (no overflow, no panic) but fully predatory: it
silently zeroes every recipient payout while routing the entire escrow /
allocation / pool balance to the treasury.

This PR adds a MAX_FEE_BPS sanity ceiling (1000 bps = 10%) and rejects
anything above it with the existing InvalidFee error.

Severity / impact

This is a silent-loss, not a visible failure. The exploit produces no error,
no revert, and no panic — just a legitimate-looking release / release_issue
/ withdraw call that pays nothing to the people who did the work and
everything to the treasury:

  • Escrow & milestones (compute_split, byte-identical in both): at
    fee_bps = 10_000, fee = total * 10000 / 10000 = total, so
    distributable = total - fee = 0. Every recipient's share is 0, the
    largest-remainder dust loop has nothing to distribute
    (dust = distributable - allocated = 0 - 0 = 0), and the if share > 0
    guard silently skips every transfer.
  • Maintenance pool (withdraw): payout = amount - fee = 0, so the
    maintainer receives nothing.

Whoever controls initialize (see #33's front-running analysis for who that
could be) can set this on day one, before any sponsor ever deposits.

Why this is a "very hard"-caliber finding, not a nit

There was no lower sanity ceiling between "some reasonable treasury fee" and
"the mathematical maximum". The previous check only guarded against arithmetic
overflow past 100%, not against a legitimate-looking but fully predatory
100% fee. This mirrors the pattern flagged in
SmartDropLabs/smartdrop-contracts#89
— an admin-settable value with a lower bound but no sane upper bound — which
is the direct inspiration for treating this as a first-class finding.

Fix

1. MAX_FEE_BPS constant (all three contracts)

pub const MAX_FEE_BPS: u32 = 1_000;

Chosen and documented with reasoning on the constant itself: bounty-payout
platforms charge single-digit-percent treasury fees in practice, so 10% is
already an order of magnitude below the mathematical maximum (100%), and any
fee approaching the ceiling is itself a red flag worth surfacing. Capping here
also makes it structurally impossible to ever configure the zero-payout
failure mode.

2. Tightened guard (all three contracts)

if fee_bps > MAX_FEE_BPS {
    return Err(Error::InvalidFee);
}

Reuses the existing InvalidFee variant — no new error variant — and the
ceiling is inclusive (fee_bps == MAX_FEE_BPS remains valid). The check is
applied only in initialize, which is the sole place fee_bps is set today.

3. Ready for #20

The constant is pub and documented, so if/when #20 introduces a bounded
fee_bps setter, that setter can apply the identical ceiling and keep the two
issues' constants in sync.

Changes by file

File Change
contracts/escrow/src/lib.rs Add MAX_FEE_BPS; guard fee_bps > MAX_FEE_BPS
contracts/milestones/src/lib.rs Same
contracts/maintenance-pool/src/lib.rs Same
contracts/escrow/src/test.rs Boundary tests (below)
contracts/milestones/src/test.rs Boundary tests (below)
contracts/maintenance-pool/src/test.rs Boundary tests (below)
README.md Update "Fee mechanics" note to reference the new ceiling

Tests

Added to all three suites:

  • test_initialize_rejects_fee_bps_above_ceiling — asserts that both
    MAX_FEE_BPS + 1 (one above the ceiling) and 10_000 (the old 100%
    mathematical maximum) are rejected with Error::InvalidFee. Testing 10_000
    specifically is what locks in the fix: it was the value the old >
    guard silently accepted.
  • test_initialize_accepts_fee_bps_at_ceiling — asserts the ceiling is
    inclusive: fee_bps == MAX_FEE_BPS initializes successfully (escrow
    additionally asserts get_fee_bps() == MAX_FEE_BPS).

Verification

All CI checks pass locally:

cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace            # 51 passed; 0 failed
cargo build --target wasm32v1-none --release

Backward compatibility

  • Deployments already using a fee ≤ 10% (the test fixtures and README example
    use 2.5%–10%) are unaffected: the ceiling is inclusive.
  • The only behavior change is rejecting fee_bps > 10% at initialize, which
    no legitimate treasury fee for a bounty-payout platform should ever need.
  • No storage layout, event, or error-variant changes.

Non-goals / out of scope

Checklist

  • MAX_FEE_BPS constant added and documented with reasoning in all three contracts
  • initialize in all three contracts rejects fee_bps > MAX_FEE_BPS via InvalidFee
  • test_initialize_rejects_fee_bps_above_ceiling in all three suites
  • test_initialize_accepts_fee_bps_at_ceiling in all three suites
  • cargo test --workspace passes
  • cargo fmt --check, cargo clippy -D warnings, and the wasm build pass

@vercel

vercel Bot commented Aug 18, 2026

Copy link
Copy Markdown

@ZuLu0890 is attempting to deploy a commit to the chonilius' projects Team on Vercel.

A member of the Team first needs to authorize it.

Add MAX_FEE_BPS = 1000 (10%) to all three contracts and reject any fee_bps
above it at initialize with the existing InvalidFee error, closing the
full-fee (100%) configuration that silently zeroed every payout (MergeFi#40). Add
boundary tests locking in the inclusive ceiling and update the README.

Rebasing onto current main also surfaced pre-existing CI breakages that this
commit repairs so the workspace is green again:
- restore the missing closing brace on milestones::sort_remainders_desc,
  which left the workspace unable to compile;
- advance the multi-sponsor refund test past the new grace-period window so
  the permissionless path is actually exercised;
- normalize trailing whitespace so cargo fmt --check passes.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@ZuLu0890
ZuLu0890 force-pushed the fix/fee-bps-ceiling branch from 181cb87 to 8c8a9e9 Compare August 18, 2026 19:53
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.

All three contracts: fee_bps has no sanity ceiling below 10000 (100%) — a valid-but-predatory full-fee config silently zeros every payout

1 participant