Skip to content

maintenance-pool::withdraw's recipient is never validated against the contract's own address — self-payment corrupts the balance invariant without theft #44

Description

@chonilius

Overview

maintenance-pool::withdraw (contracts/maintenance-pool/src/lib.rs:116-157) takes an arbitrary recipient: Address with no validation against it beyond existing generally (it isn't checked against anything at all — not env.current_contract_address(), not treasury, not admin). This is a structurally different situation from #28 ("Correctness audit of compute_split under zero-recipient-adjacent, duplicate-address, and self-referential edge cases"), which is explicitly scoped to the recipients: Vec<(Address, u32)> parameter of escrow::release/milestones::release_issuewithdraw doesn't use compute_split at all (it does a direct, single transfer, contracts/maintenance-pool/src/lib.rs:146-149), so none of #28's analysis or eventual tests cover this function.

Two concrete scenarios #28 structurally cannot reach:

  1. recipient == env.current_contract_address(). If the admin (or a compromised/buggy backend integration) accidentally passes the pool contract's own address as recipient, the payout transfer (contracts/maintenance-pool/src/lib.rs:149) sends tokens from the contract to itself. Regardless of whether the underlying SEP-41 token treats this as a no-op or a real balance-preserving transfer, the bookkeeping proceeds unconditionally: pool.balance -= amount and pool.total_withdrawn += amount (lines 151-152) both still execute, exactly as if the tokens had genuinely left the contract. The result: pool.balance (and its derived invariant, balance == total_deposited - total_withdrawn, the exact invariant Long-run invariant fuzzing of total_deposited/total_withdrawn/balance drift in maintenance-pool #29's long-run fuzzer is meant to protect) silently diverges from the contract's actual on-chain token balance — the contract now holds more tokens than its own accounting believes it does, with no theft involved, just corrupted bookkeeping that Long-run invariant fuzzing of total_deposited/total_withdrawn/balance drift in maintenance-pool #29's fuzzer isn't guaranteed to generate this specific adversarial input for on its own.
  2. recipient == treasury or recipient == admin. Not necessarily harmful (an admin might legitimately want to route a withdrawal to the treasury or pay themselves as a maintainer), but currently completely untested — no test in contracts/maintenance-pool/src/test.rs exercises either. Given withdraw already does two separate transfers in sequence when fee > 0 (fee to treasury, then payout to recipient — lines 146-149), a recipient == treasury call means treasury receives two transfers in the same call; this should be confirmed harmless and locked in by a test, exactly as Correctness audit of compute_split under zero-recipient-adjacent, duplicate-address, and self-referential edge cases #28 does for the analogous recipient == treasury case in escrow::release.

Requirements

  • Reject recipient == env.current_contract_address() in withdraw with a new Error variant, since (per the analysis above) this scenario has no legitimate use and silently corrupts the balance/total_withdrawn invariant with no recovery path (compounding with the "no sweep function" issue filed in this batch, since even a corrected accounting can't reclaim tokens that a self-payment never actually removed).
  • Add explicit tests for recipient == treasury and recipient == admin, confirming (or, if something is found to be actually wrong, fixing) that these are handled correctly — mirroring Correctness audit of compute_split under zero-recipient-adjacent, duplicate-address, and self-referential edge cases #28's methodology exactly, just applied to withdraw's single-recipient parameter instead of a recipients Vec.

Acceptance Criteria

  • withdraw rejects recipient == env.current_contract_address() with a new, clearly-named Error variant (e.g. InvalidRecipient)
  • test_withdraw_rejects_self_payment_to_contract_address added, reproducing the balance-corruption scenario as a pre-fix demonstration before asserting the new rejection
  • test_withdraw_to_treasury_and_admin_addresses added, explicitly locking in the (expected-harmless) two-transfers-same-address behavior
  • cargo test --workspace passes

Additional Notes

Metadata

Metadata

Assignees

No one assigned

    Labels

    GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignbugSomething isn't workingsecuritySecurity-related issuevery hardVery difficult task, expert-level effort required

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions