Skip to content

feat(campaign): add per-donor rate limiting & dust-attack protection (#91) - #221

Open
endofdays7 wants to merge 4 commits into
OrbitChainLabs:mainfrom
endofdays7:fix/issue-91-rate-limiting
Open

feat(campaign): add per-donor rate limiting & dust-attack protection (#91)#221
endofdays7 wants to merge 4 commits into
OrbitChainLabs:mainfrom
endofdays7:fix/issue-91-rate-limiting

Conversation

@endofdays7

Copy link
Copy Markdown

Summary

Resolves #91 — Add per-donor rate limiting & dust-attack protection.

Problem

min_donation_amount was the only spam mitigation. An adversary could flood the contract with thousands of min-value donations per block, inflating gas costs for honest users and bloating per-donor donation history indefinitely.

Solution

Three independent guards added inside donate(), all evaluated before any storage mutation — a rejected call has zero side effects:

Guard Parameter Default Behaviour
Per-block burst cap MAX_DONATIONS_PER_BLOCK = 10 (constant) Always active Rejects if donor has already reached 10 donations in the current ledger sequence number
Lifetime cap max_donations_per_donor: Option<u32> None (unlimited) Rejects if donor's total donation_count >= limit
Cooldown interval min_donation_interval_seconds: Option<u64> None (no interval) Rejects if time since last donation < interval; first-ever donation always allowed

All three fire with the new Error::DonationRateLimited = 100 typed error.

Files Changed

  • campaign/src/types.rsDonationRateLimited = 100 error; max_donations_per_donor and min_donation_interval_seconds optional fields on CampaignData
  • campaign/src/lib.rsMAX_DONATIONS_PER_BLOCK constant; updated initialize() signature; three rate-limit guards in donate()
  • campaign/src/test/rate_limiting_tests.rs (new) — 12 tests covering all acceptance criteria
  • All existing initialize() call sites and CampaignData struct literals updated with None defaults — no breaking change

Acceptance Criteria

  • Burst donations beyond rate-limit threshold panic with Error::DonationRateLimited
  • Tests cover back-to-back donations within a single ledger (burst cap + reset after ledger advance)
  • Default values (None, None) preserve current behaviour — no breaking change to existing campaigns

Testing

12 new tests in campaign/src/test/rate_limiting_tests.rs:

  • test_rate_limit_defaults_none_allow_multiple_donations — backward compat
  • test_burst_cap_allows_exactly_max_donations_per_block — boundary allow
  • test_burst_cap_rejects_donation_exceeding_max_per_block — burst rejection
  • test_burst_cap_resets_after_ledger_advance — cap resets per-ledger
  • test_lifetime_cap_rejects_at_limit — lifetime rejection
  • test_lifetime_cap_allows_donations_below_limit — below limit passes
  • test_interval_rejects_donation_within_cooldown — cooldown active
  • test_interval_allows_donation_after_cooldown_expires — cooldown expired
  • test_interval_first_donation_always_allowed — first donation bypass
  • test_interval_boundary_one_second_short_rejected — boundary (interval-1)
  • test_interval_boundary_exact_expiry_accepted — boundary (interval)
  • test_combined_guards_lifetime_cap_fires_independently — combined guards

…rbitChainLabs#91)

Add three independent guards inside `donate()` to prevent adversarial
micro-donation spam that would inflate gas costs for honest users and
bloat per-donor donation history.

## Changes

### campaign/src/types.rs
- Add `Error::DonationRateLimited = 100` typed error variant
- Add `max_donations_per_donor: Option<u32>` to `CampaignData`
- Add `min_donation_interval_seconds: Option<u64>` to `CampaignData`
- Both fields default to `None` — no breaking change to existing campaigns

### campaign/src/lib.rs
- Add `MAX_DONATIONS_PER_BLOCK: u32 = 10` constant (global burst cap)
- Update `initialize()` to accept `max_donations_per_donor` and
  `min_donation_interval_seconds` as the 8th and 9th parameters
- Add three rate-limit guards inside `donate()`, evaluated **before**
  any storage mutation so a rejected call has zero side effects:
  1. Per-ledger burst cap: rejects if donor already reached
     `MAX_DONATIONS_PER_BLOCK` in the current ledger sequence number
  2. Lifetime cap: rejects if `donation_count >= max_donations_per_donor`
  3. Interval cooldown: rejects if time since last donation < interval;
     first-ever donation (`last_donation_time == 0`) always allowed

### campaign/src/test/rate_limiting_tests.rs (new)
12 tests covering all acceptance criteria:
- Defaults (`None, None`) preserve existing unlimited behaviour
- Burst cap: allows exactly MAX, rejects MAX+1, resets on next ledger
- Lifetime cap: rejects at limit, allows below limit
- Interval: rejects within cooldown, allows after expiry, first donation
  always passes, boundary cases (interval-1 rejected, interval accepted)
- Combined guards: lifetime cap fires independently of interval

### All existing call sites updated
- `initialize()` calls across all test files, fuzz targets, and testkit
  updated with `None, None` trailing args — no behaviour change
- All `CampaignData { ... }` struct literals updated with the two new
  `Option` fields set to `None`

Acceptance criteria satisfied:
- [x] Burst donations beyond rate-limit threshold panic with typed error
- [x] Tests cover back-to-back donations within a single ledger
- [x] Default values (`None`) preserve current behaviour — no breaking change

Closes OrbitChainLabs#91
The v4 flat glob-string format for label config is no longer accepted
by actions/labeler@v5 and produces:

  Error: found unexpected type for label 'api'
         (should be array of config options)

on every PR. Migrate all labels to the v5 schema where each entry is
an object with a `changed-files` key containing `any-glob-to-any-file`
— the direct v5 equivalent of the old flat glob list.
- ci.yml: remove invalid 'cache: false' from dtolnay/rust-toolchain steps
- ci.yml: update stale comment from four to five contract crates
- coverage.yml: add missing orbitchain-batch-donor to CONTRACTS
- contract.rs: add blank line for rustfmt import grouping
- lib.rs: #[allow(clippy::too_many_arguments)] on initialize()
- claim_refund_tests.rs: add missing rate-limit args to 3 initialize() calls
- claim_refund_tests.rs: remove unused imports (core::ops::Add, log)
- get_campaign_status_tests.rs: remove unused imports (BytesN, MilestoneData, MilestoneStatus)
- integration_tests.rs: remove unused imports (CampaignData, DonorRecord)
- negative_path_tests.rs: remove unused imports (CampaignData, DataKey, Error); fix unused variables
- rate_limiting_tests.rs: set non-zero ledger timestamp to fix underflow panics; seed donor record to avoid auth frame collision
- batch-donor/tests.rs: fix unused variable warning
…from PR head

The labeler workflow uses pull_request_target, which runs from the base
branch and has no checkout step.  Without a checkout, actions/labeler@v5
fetches .github/labeler.yml via the API from the base branch — which
still has the v4 flat-string format, causing 'found unexpected type for
label' errors.

Add actions/checkout@v4 pointing at the PR head SHA so the labeler
reads the v5-format config from the PR branch itself.
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.

Add per-donor rate limiting & dust-attack protection

1 participant