Skip to content

Enforce a configurable per-window deduction velocity cap in the Callora vault #399

@greatest0fallt1me

Description

@greatest0fallt1me

Description

The callora-vault contract protects prepaid USDC that API consumers deposit and meters it out via deduct and batch_deduct. Today the only spend guard is max_deduct, which caps the size of a single deduction (contracts/vault/src/lib.rs:637, enforced as amount > max_d -> VaultError::ExceedsMaxDeduct). There is no limit on cumulative spend across many deductions in a short period.

This means a compromised, misconfigured, or buggy authorized_caller (the address set by set_authorized_caller) can drain an entire vault balance by issuing many max-sized deduct calls back-to-back, or a single large batch_deduct. For a prepaid-funds custody contract this is a meaningful exposure.

This issue adds a configurable, opt-in deduction velocity cap: a maximum cumulative amount that may be deducted within a rolling time window. It bounds worst-case loss from a caller-key compromise while remaining fully backward-compatible (disabled by default).

Requirements and context

Functional

  • Introduce a velocity policy persisted in vault storage: window_seconds: u64 and max_per_window: i128.
  • The cap is disabled by default (max_per_window == 0 or unset) so existing deployments behave identically — this must not be a breaking change.
  • Add an owner-only setter and a public view:
    • set_deduction_velocity_cap(caller: Address, window_seconds: u64, max_per_window: i128) -> Result<(), VaultError>
    • get_deduction_velocity_cap(env: Env) -> Option<(u64, i128)>
  • Enforce the cap inside both deduct and batch_deduct. For batch_deduct, the running total of the batch must be validated against the remaining window allowance so a batch can never partially apply past the cap (operation stays atomic).
  • Track consumed amount for the active window using the ledger timestamp (env.ledger().timestamp()). When the current window has elapsed, the consumed counter resets before the new deduction is evaluated.
  • Add a dedicated error variant, e.g. VaultError::ExceedsDeductionVelocity.

Non-functional / repo conventions

  • All balance/counter arithmetic must use checked_add / checked_sub with explicit error returns (per the Security Notes in README.md and SECURITY.md).
  • Respect the existing pause circuit-breaker (require_not_paused) and authorization model — the cap is an additional check, not a replacement.
  • Emit events for observability, consistent with EVENT_SCHEMA.md:
    • velocity_cap_set when the policy changes (window_seconds, max_per_window).
    • deduct_rejected_velocity when a deduction is rejected by the cap.
  • Keep the release WASM within the size budget verified by scripts/check-wasm-size.sh.

Acceptance criteria

  • New storage, setter, view, and error variant implemented in contracts/vault/src/lib.rs.
  • Cap enforced in deduct and batch_deduct; batch validates the cumulative batch total atomically.
  • Window rollover resets consumed amount correctly using ledger time.
  • Disabled-by-default behavior verified (existing tests unaffected).
  • Events added and documented in EVENT_SCHEMA.md.
  • NatSpec-style /// doc comments on every new public entrypoint; docs/interfaces/vault.json and contracts/vault/STORAGE.md updated.
  • Line coverage stays at or above 95% (scripts/coverage.sh).

Suggested execution

1. Fork the repo and create a branch

git checkout -b feature/vault-deduction-velocity-cap

2. Implement changes

  • contracts/vault/src/lib.rs — storage key for the policy + consumed-window counter, set_deduction_velocity_cap / get_deduction_velocity_cap, enforcement in deduct and batch_deduct, VaultError::ExceedsDeductionVelocity, and the two events.
  • Sketch of the enforcement helper:
/// Reverts with `ExceedsDeductionVelocity` if applying `amount` would push
/// the cumulative deduction past `max_per_window` inside the active window.
fn require_within_velocity(env: &Env, amount: i128) -> Result<(), VaultError> {
    // load policy; if disabled (max_per_window == 0) return Ok(())
    // roll the window over if now >= window_start + window_seconds
    // checked_add(consumed, amount) and compare against max_per_window
}
  • Update docs: EVENT_SCHEMA.md, docs/interfaces/vault.json, contracts/vault/STORAGE.md, and the Security Notes / checklist in SECURITY.md.

3. Write comprehensive tests in contracts/vault/src/test.rs (or a new test_velocity.rs module):

  • deduction within the cap succeeds;
  • a deduction that crosses the cap is rejected with ExceedsDeductionVelocity;
  • consumed amount resets after window_seconds elapses (advance ledger time);
  • a batch_deduct whose cumulative total crosses the cap reverts atomically (no partial state change);
  • cap disabled by default preserves current behavior;
  • non-owner calling the setter fails;
  • overflow safety at i128 boundaries.

4. Test and commit

cargo fmt --all
cargo clippy --all-targets --all-features -- -D warnings
cargo test --workspace
./scripts/check-wasm-size.sh
./scripts/coverage.sh   # line coverage must stay >= 95%

Include the test output and a short security note (threat addressed, residual risk) in the PR description.

Example commit message

feat(vault): enforce configurable per-window deduction velocity cap

Guidelines

  • Minimum 95% line coverage (CI-enforced via .github/workflows/coverage.yml).
  • NatSpec-style doc comments and updated docs for every new entrypoint and event.
  • Backward compatible: the cap is opt-in and disabled by default.
  • Stay within the Soroban WASM size budget (scripts/check-wasm-size.sh).
  • Timeframe: 96 hours.

Metadata

Metadata

Assignees

No one assigned

    Labels

    GRANTFOX OSSGrantFox OSS programMAYBE REWARDEDGrantFox — potentially rewardedOFFICIAL CAMPAIGNGrantFox official campaignadvancedHigh complexity / deep contextenhancementNew featuresecuritySecurity hardeningsmart-contractSoroban / Rust contract workvaultVault contract

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions