Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 49 additions & 28 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
# `actions/labeler@v5`. Each top-level key is a repo label that the
# bot applies when any glob on its list matches a changed file.
#
# v5 schema: each label must be a list of objects with a `changed-files`
# key containing `any-glob-to-any-file` (or `all-globs-to-any-file` etc.).
# The flat glob-string format used in v4 is no longer valid and causes
# "found unexpected type" errors at runtime.
#
# Existing repo labels we map to: api, cli, frontend, ci, documentation,
# storage, testing, security. New labels Dependabot creates on first
# run are listed in `.github/dependabot.yml` and need not be defined here.
Expand All @@ -12,52 +17,68 @@

# Rust Soroban contracts — the canonical on-chain API surface.
api:
- "campaign/**"
- "common/**"
- "token-bridge/**"
- "crates/contracts/core/**"
- "crates/contracts/batch-donor/**"
- changed-files:
- any-glob-to-any-file:
- "campaign/**"
- "common/**"
- "token-bridge/**"
- "crates/contracts/core/**"
- "crates/contracts/batch-donor/**"

# Native CLI tooling (`cargo run -p orbitchain-tools -- …`).
cli:
- "crates/tools/**"
- changed-files:
- any-glob-to-any-file:
- "crates/tools/**"

# Frontend / mobile-wallet surfaces (Playwright + wallet integration).
frontend:
- "wallet_connect.html"
- "wallet_connect.js"
- "tests/e2e/**"
- "playwright.config.js"
- changed-files:
- any-glob-to-any-file:
- "wallet_connect.html"
- "wallet_connect.js"
- "tests/e2e/**"
- "playwright.config.js"

# CI / build infrastructure.
ci:
- ".github/workflows/**"
- ".github/*.md"
- ".github/ISSUE_TEMPLATE/**"
- ".cargo/**"
- "rust-toolchain.toml"
- "Cargo.toml"
- "Cargo.lock"
- changed-files:
- any-glob-to-any-file:
- ".github/workflows/**"
- ".github/*.md"
- ".github/ISSUE_TEMPLATE/**"
- ".cargo/**"
- "rust-toolchain.toml"
- "Cargo.toml"
- "Cargo.lock"

# Documentation and reference material.
documentation:
- "docs/**"
- "**/*.md"
- changed-files:
- any-glob-to-any-file:
- "docs/**"
- "**/*.md"

# Persistent / temporary storage backend code paths.
storage:
- "**/storage*.rs"
- "**/backend.rs"
- changed-files:
- any-glob-to-any-file:
- "**/storage*.rs"
- "**/backend.rs"

# Tests / property-based / fuzz harnesses.
testing:
- "**/tests/**"
- "**/*test*.rs"
- "**/proptest-strategies/**"
- "**/fuzz_targets/**"
- "fuzz/**"
- changed-files:
- any-glob-to-any-file:
- "**/tests/**"
- "**/*test*.rs"
- "**/proptest-strategies/**"
- "**/fuzz_targets/**"
- "fuzz/**"

# Security process and security-sensitive code.
security:
- "SECURITY.md"
- "**/security*.rs"
- changed-files:
- any-glob-to-any-file:
- "SECURITY.md"
- "**/security*.rs"
18 changes: 1 addition & 17 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,12 @@ jobs:
- name: Install Rust toolchain
# Honors rust-toolchain.toml (channel + targets + components)
uses: dtolnay/rust-toolchain@stable
with:
# No `cache: true` here — Swatinem/rust-cache handles caching below.
cache: false
- name: Cache cargo registry and target
uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- name: cargo fmt --check (contracts)
# Scope to the four Soroban contract crates only. crates/tools is a
# Scope to the five Soroban contract crates only. crates/tools is a
# native CLI with separate formatting conventions and pre-existing
# rustfmt drift that is intentionally out of scope for this CI gate
# — see the PR description for the tracked follow-up.
Expand All @@ -63,8 +60,6 @@ jobs:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
cache: false
- name: Cache cargo registry and target
uses: Swatinem/rust-cache@v2
with:
Expand All @@ -90,8 +85,6 @@ jobs:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
cache: false
- name: Cache cargo registry and target
uses: Swatinem/rust-cache@v2
with:
Expand All @@ -112,7 +105,6 @@ jobs:
# Rust (>=1.84); wasm32-unknown-unknown on Rust >=1.82 enables
# reference-types/multi-value which the Soroban host cannot honour.
targets: wasm32v1-none
cache: false
- name: Cache cargo registry and target
uses: Swatinem/rust-cache@v2
with:
Expand Down Expand Up @@ -171,8 +163,6 @@ jobs:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
cache: false
- name: Cache cargo registry and target
uses: Swatinem/rust-cache@v2
with:
Expand All @@ -188,8 +178,6 @@ jobs:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
cache: false
- name: Cache cargo registry and target
uses: Swatinem/rust-cache@v2
with:
Expand All @@ -205,8 +193,6 @@ jobs:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
cache: false
- name: Cache cargo registry and target
uses: Swatinem/rust-cache@v2
with:
Expand All @@ -222,8 +208,6 @@ jobs:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
cache: false
- name: Cache cargo registry and target
uses: Swatinem/rust-cache@v2
with:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ env:
CONTRACTS: >-
-p orbitchain-campaign
-p orbitchain-common
-p orbitchain-batch-donor
-p orbitchain-core
-p orbitchain-token-bridge
# Coverage floor (line coverage, percent) — the value issue #114 asks for.
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
# Check out the PR head so labeler reads .github/labeler.yml from the
# PR branch (where the v5-format config lives), not the base branch.
ref: ${{ github.event.pull_request.head.sha }}
- name: Apply labels
uses: actions/labeler@v5
with:
Expand Down
2 changes: 2 additions & 0 deletions campaign/src/asset_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ mod tests {
created_at_ledger: 0,
created_at_time: 0,
concluded_at_ledger: None,
max_donations_per_donor: None,
min_donation_interval_seconds: None,
}
}

Expand Down
1 change: 1 addition & 0 deletions campaign/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::storage::{get_campaign, is_frozen, set_campaign};
use crate::types::{CampaignStatus, Error};
use crate::validation::validate_campaign_transition;
use crate::MAX_DEADLINE_GAP_SECONDS;

use soroban_sdk::{panic_with_error, Env};

/// Issue #212 – End the campaign early (before deadline).
Expand Down
2 changes: 2 additions & 0 deletions campaign/src/get_all_milestones.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ mod tests {
created_at_ledger: env.ledger().sequence(),
created_at_time: env.ledger().timestamp(),
concluded_at_ledger: None,
max_donations_per_donor: None,
min_donation_interval_seconds: None,
};
env.storage()
.persistent()
Expand Down
2 changes: 2 additions & 0 deletions campaign/src/get_milestone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ mod tests {
created_at_ledger: env.ledger().sequence(),
created_at_time: env.ledger().timestamp(),
concluded_at_ledger: None,
max_donations_per_donor: None,
min_donation_interval_seconds: None,
};
env.storage()
.persistent()
Expand Down
71 changes: 67 additions & 4 deletions campaign/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ pub const VERSION: u32 = 1;
/// Refunds are only permitted within this window after campaign end or cancellation.
pub const REFUND_WINDOW: u64 = 30 * 24 * 60 * 60;

/// Issue #91 — per-block burst cap: maximum number of donation calls accepted
/// from a single donor address within one Soroban ledger sequence number.
///
/// Soroban executes at most one transaction per ledger per account (enforced by
/// sequence numbers), so this cap is an additional guard against multi-sender
/// dust spam in the same ledger. Set to `10` as a conservative default that
/// allows burst funding while blocking adversarial micro-donation floods.
///
/// This constant applies globally to every campaign regardless of the
/// per-campaign `max_donations_per_donor` setting.
pub const MAX_DONATIONS_PER_BLOCK: u32 = 10;

// Re-export the workspace semver constants so the campaign contract exposes
// them through its own `pub use` surface. The legacy `VERSION: u32` constant
// above is preserved for backwards compatibility with pre-0.2 callers — new
Expand All @@ -94,6 +106,12 @@ impl CampaignContract {
/// Requires: Creator authorization via `creator.require_auth()`
/// Can only be called once per contract instance
///
/// Issue #91 — Rate limiting / dust-attack protection:
/// - `max_donations_per_donor`: optional lifetime cap on donations per donor
/// address. Pass `None` to preserve the unrestricted default.
/// - `min_donation_interval_seconds`: optional cooldown between consecutive
/// donations from the same address. Pass `None` to disable.
///
/// # Panics
/// - `Error::Unauthorized` if caller is not the creator
/// - `Error::AlreadyInitialized` if campaign already exists
Expand All @@ -104,6 +122,9 @@ impl CampaignContract {
/// - `Error::InvalidMilestoneCount` if milestone count is not 1-5
/// - `Error::InvalidMilestones` if milestones are not sorted ascending
/// - `Error::MilestoneMismatch` if last milestone.target_amount != goal_amount
/// `#[allow(clippy::too_many_arguments)]`: this is the public contract
/// entrypoint and cannot be refactored without a breaking API change.
#[allow(clippy::too_many_arguments)]
pub fn initialize(
env: Env,
creator: soroban_sdk::Address,
Expand All @@ -112,6 +133,8 @@ impl CampaignContract {
accepted_assets: Vec<StellarAsset>,
milestones: Vec<MilestoneData>,
min_donation_amount: i128,
max_donations_per_donor: Option<u32>,
min_donation_interval_seconds: Option<u64>,
) -> Result<(), Error> {
creator.require_auth();

Expand Down Expand Up @@ -153,6 +176,8 @@ impl CampaignContract {
created_at_ledger: env.ledger().sequence(),
created_at_time: env.ledger().timestamp(),
concluded_at_ledger: None,
max_donations_per_donor,
min_donation_interval_seconds,
};

set_campaign(&env, &campaign);
Expand Down Expand Up @@ -253,15 +278,52 @@ impl CampaignContract {
panic_with_error(&env, Error::AssetBlocked);
}

storage_increment_asset_raised(&env, &asset_address, amount);
increment_donor_asset_donation(&env, &donor, &asset_address, amount);

// Update donor record
// Issue #91 — Rate limiting / dust-attack protection.
// Load the donor record before any storage mutation so all three guards
// can inspect current state and reject the call without partial writes.
let existing_donor = get_donor(&env, &donor);
let is_new_donor = existing_donor.is_none();
let mut donor_record =
existing_donor.unwrap_or_else(|| DonorRecord::new_for(donor.clone(), asset.clone()));

// Guard 1: per-ledger burst cap (MAX_DONATIONS_PER_BLOCK).
// Soroban ledger sequence numbers increment every ~5 s; this prevents
// a single donor from submitting an unlimited burst of micro-donations
// in one ledger, which inflates gas costs for honest users and bloats
// the per-donor donation history.
if donor_record.last_donation_ledger == env.ledger().sequence()
&& donor_record.donation_count >= MAX_DONATIONS_PER_BLOCK
{
panic_with_error(&env, Error::DonationRateLimited);
}

// Guard 2: configurable lifetime donation cap per donor.
// `None` (default) preserves the previous unlimited behaviour.
if let Some(max_per_donor) = campaign.max_donations_per_donor {
if donor_record.donation_count >= max_per_donor {
panic_with_error(&env, Error::DonationRateLimited);
}
}

// Guard 3: configurable minimum interval between donations.
// `None` (default) disables the interval check.
// `last_donation_time == 0` means first-ever donation and is always allowed.
if let Some(interval) = campaign.min_donation_interval_seconds {
if donor_record.last_donation_time > 0 {
let elapsed = env
.ledger()
.timestamp()
.saturating_sub(donor_record.last_donation_time);
if elapsed < interval {
panic_with_error(&env, Error::DonationRateLimited);
}
}
}

// All guards passed — now mutate storage.
storage_increment_asset_raised(&env, &asset_address, amount);
increment_donor_asset_donation(&env, &donor, &asset_address, amount);

donor_record.apply_donation(
&env,
amount,
Expand Down Expand Up @@ -808,6 +870,7 @@ mod test {
pub mod invariant_tests;
pub mod milestone_batch_tests;
pub mod negative_path_tests;
pub mod rate_limiting_tests;
pub mod refund_eligibility_tests;
pub mod release_milestone_tests;
pub mod report_cache_tests;
Expand Down
2 changes: 2 additions & 0 deletions campaign/src/multi_asset_release.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,8 @@ mod tests {
created_at_ledger: 0,
created_at_time: 0,
concluded_at_ledger: None,
max_donations_per_donor: None,
min_donation_interval_seconds: None,
};
set_campaign(&env, &campaign);

Expand Down
2 changes: 2 additions & 0 deletions campaign/src/test/bump_storage_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ fn setup_campaign(env: &Env, milestone_count: u32) {
created_at_ledger: env.ledger().sequence(),
created_at_time: env.ledger().timestamp(),
concluded_at_ledger: None,
max_donations_per_donor: None,
min_donation_interval_seconds: None,
};
set_campaign(env, &campaign);
}
Expand Down
Loading
Loading