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
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.
Description
The
callora-vaultcontract protects prepaid USDC that API consumers deposit and meters it out viadeductandbatch_deduct. Today the only spend guard ismax_deduct, which caps the size of a single deduction (contracts/vault/src/lib.rs:637, enforced asamount > 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 byset_authorized_caller) can drain an entire vault balance by issuing many max-sizeddeductcalls back-to-back, or a single largebatch_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
window_seconds: u64andmax_per_window: i128.max_per_window == 0or unset) so existing deployments behave identically — this must not be a breaking change.set_deduction_velocity_cap(caller: Address, window_seconds: u64, max_per_window: i128) -> Result<(), VaultError>get_deduction_velocity_cap(env: Env) -> Option<(u64, i128)>deductandbatch_deduct. Forbatch_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).env.ledger().timestamp()). When the current window has elapsed, the consumed counter resets before the new deduction is evaluated.VaultError::ExceedsDeductionVelocity.Non-functional / repo conventions
checked_add/checked_subwith explicit error returns (per the Security Notes inREADME.mdandSECURITY.md).require_not_paused) and authorization model — the cap is an additional check, not a replacement.EVENT_SCHEMA.md:velocity_cap_setwhen the policy changes(window_seconds, max_per_window).deduct_rejected_velocitywhen a deduction is rejected by the cap.scripts/check-wasm-size.sh.Acceptance criteria
contracts/vault/src/lib.rs.deductandbatch_deduct; batch validates the cumulative batch total atomically.EVENT_SCHEMA.md.///doc comments on every new public entrypoint;docs/interfaces/vault.jsonandcontracts/vault/STORAGE.mdupdated.scripts/coverage.sh).Suggested execution
1. Fork the repo and create a branch
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 indeductandbatch_deduct,VaultError::ExceedsDeductionVelocity, and the two events.EVENT_SCHEMA.md,docs/interfaces/vault.json,contracts/vault/STORAGE.md, and the Security Notes / checklist inSECURITY.md.3. Write comprehensive tests in
contracts/vault/src/test.rs(or a newtest_velocity.rsmodule):ExceedsDeductionVelocity;window_secondselapses (advance ledger time);batch_deductwhose cumulative total crosses the cap reverts atomically (no partial state change);i128boundaries.4. Test and commit
Include the test output and a short security note (threat addressed, residual risk) in the PR description.
Example commit message
Guidelines
.github/workflows/coverage.yml).scripts/check-wasm-size.sh).