feat(campaign): add per-donor rate limiting & dust-attack protection (#91) - #221
Open
endofdays7 wants to merge 4 commits into
Open
feat(campaign): add per-donor rate limiting & dust-attack protection (#91)#221endofdays7 wants to merge 4 commits into
endofdays7 wants to merge 4 commits into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Resolves #91 — Add per-donor rate limiting & dust-attack protection.
Problem
min_donation_amountwas 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:MAX_DONATIONS_PER_BLOCK = 10(constant)max_donations_per_donor: Option<u32>None(unlimited)donation_count >= limitmin_donation_interval_seconds: Option<u64>None(no interval)All three fire with the new
Error::DonationRateLimited = 100typed error.Files Changed
campaign/src/types.rs—DonationRateLimited = 100error;max_donations_per_donorandmin_donation_interval_secondsoptional fields onCampaignDatacampaign/src/lib.rs—MAX_DONATIONS_PER_BLOCKconstant; updatedinitialize()signature; three rate-limit guards indonate()campaign/src/test/rate_limiting_tests.rs(new) — 12 tests covering all acceptance criteriainitialize()call sites andCampaignDatastruct literals updated withNonedefaults — no breaking changeAcceptance Criteria
Error::DonationRateLimitedNone, None) preserve current behaviour — no breaking change to existing campaignsTesting
12 new tests in
campaign/src/test/rate_limiting_tests.rs:test_rate_limit_defaults_none_allow_multiple_donations— backward compattest_burst_cap_allows_exactly_max_donations_per_block— boundary allowtest_burst_cap_rejects_donation_exceeding_max_per_block— burst rejectiontest_burst_cap_resets_after_ledger_advance— cap resets per-ledgertest_lifetime_cap_rejects_at_limit— lifetime rejectiontest_lifetime_cap_allows_donations_below_limit— below limit passestest_interval_rejects_donation_within_cooldown— cooldown activetest_interval_allows_donation_after_cooldown_expires— cooldown expiredtest_interval_first_donation_always_allowed— first donation bypasstest_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