feat: PERC-8461 — enable recurring maintenance fees per spec §8.2 - #79
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
✅ Files skipped from review due to trivial changes (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 Walkthrough🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
dcccrypto
left a comment
There was a problem hiding this comment.
Security Review — APPROVED ✅
PERC-8461: Enable recurring maintenance fees (spec §8.2)
Changes Reviewed (+238/-2, 2 files)
settle_maintenance_fee_internal: Replaces stub with full fee computation + charging logicMAX_MAINTENANCE_FEE_PER_SLOT(10^12) +MAX_PROTOCOL_FEE_ABS(10^18) constantsParams::validate()rejectsmaintenance_fee_per_slot > MAX_MAINTENANCE_FEE_PER_SLOT- 8 unit tests covering accrual, zero-dt, zero-rate, force_close_resolved, params validation, fee_credits buffer, partial payment, constants
Security Analysis
Arithmetic Safety
fee_due:fee_per_slot.checked_mul(dt as u128)— prevents u128 overflow ✅fee_credits:checked_sub(fee_due as i128)— safe cast since fee_due ≤ MAX_PROTOCOL_FEE_ABS (10^18) ≪ i128::MAX ✅- Capital deduction:
pay = min(debt, cap), thencap - pay— underflow impossible ✅ - Fee_credits add-back:
checked_add(pay_i128)withmin(pay, i128::MAX as u128)u128→i128 guard ✅ - Insurance:
balance += pay— consistent with existing insurance update pattern, bounded by total system capital ✅
CEI Pattern (Critical)
last_fee_slot = now_slotstamped BEFOREfee_creditsdeduction and capital charge ✅- Prevents re-charge on transaction retry or partial failure ✅
Fee Accumulation Cap
fee_due > MAX_PROTOCOL_FEE_ABS→ rejects stale accounts with unbounded fee debt ✅- MAX_MAINTENANCE_FEE_PER_SLOT enforced in Params::validate() — admin cannot exceed cap ✅
Conservation
- Capital decreases by
pay, insurance increases bypay— net-zero, conservation maintained ✅ - Fee_credits adjusted by same
pay_i128amount — debt correctly reduced ✅
Edge Cases Verified
| Edge Case | Status |
|---|---|
| fee_per_slot == 0 (disabled) | Stamps slot, returns Ok — no charge ✅ |
| dt == 0 (same slot) | Returns Ok — no charge ✅ |
| now_slot < last_fee_slot | Err(Overflow) via checked_sub ✅ |
| fee_due overflows u128 | Err(Overflow) via checked_mul ✅ |
| Stale account (huge dt) | Capped at MAX_PROTOCOL_FEE_ABS ✅ |
| Insufficient capital | Pays min(debt, cap), remainder stays as negative fee_credits ✅ |
| fee_credits has coupon buffer | Credits consumed first, then capital ✅ |
No findings. Approve for merge.
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
tests/unit_tests.rs (2)
6833-6835: Avoid minting fee credits via direct field mutation in this test.Directly setting
fee_creditsbypasses coupon-prefunding semantics and can hide accounting regressions. Prefer usingdeposit_fee_creditsin setup.Proposed setup change
-// Give account 500 fee credits (coupon) -engine.accounts[idx as usize].fee_credits = I128::new(500); +// Give account 500 fee credits via the public helper (keeps accounting realistic) +engine.deposit_fee_credits(idx, 500, 0).unwrap();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/unit_tests.rs` around lines 6833 - 6835, The test is directly mutating engine.accounts[idx as usize].fee_credits which bypasses coupon-prefunding logic; change the setup to call the existing deposit_fee_credits helper (or the engine method named deposit_fee_credits) for that account instead, then call engine.recompute_aggregates() as before; locate the mutation of fee_credits in the test and replace it with a deposit_fee_credits(...) invocation using the same account index and amount (500) so coupon/accounting semantics are exercised.
6809-6822: Missing assertion for slot-marker advancement in zero-rate path.This test verifies
paid == 0and unchanged capital, but not the spec-critical marker update. Add an assertion thatlast_fee_slotadvances tonow_slot(Line 6818 path).Proposed test assertion
let paid = engine .settle_maintenance_fee(idx, 1_000_000, DEFAULT_ORACLE) .unwrap(); assert_eq!(paid, 0); assert_eq!(engine.accounts[idx as usize].capital.get(), 100_000); +assert_eq!( + engine.accounts[idx as usize].last_fee_slot, + 1_000_000, + "zero-rate settlement should still advance last_fee_slot" +);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/unit_tests.rs` around lines 6809 - 6822, Add an assertion in test_maintenance_fee_zero_rate_noop to verify the per-account fee marker is advanced: after calling settle_maintenance_fee(idx, 1_000_000, ...), assert that the account's last_fee_slot equals the now slot passed (i.e., check engine.accounts[idx as usize].last_fee_slot is updated to 1_000_000 or to the now_slot used). This ensures the zero-rate path still updates the slot marker in RiskEngine.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/percolator.rs`:
- Around line 1956-2021: The fee calculation is inconsistent:
settle_maintenance_fee_internal uses checked arithmetic and MAX_PROTOCOL_FEE_ABS
but other paths (RiskEngine::settle_maintenance_fee,
RiskEngine::settle_maintenance_fee_best_effort_for_crank, and the manual
settlement in RiskEngine::deposit) still use saturating dt * fee_per_slot;
extract a shared helper (e.g., compute_maintenance_fee(dt: u64, fee_per_slot:
u128) -> Result<u128, RiskError>) that performs checked_sub for dt, checked_mul
for fee_due, and validates fee_due <= MAX_PROTOCOL_FEE_ABS using the same error
(RiskError::Overflow), then replace the inline dt*fee_per_slot logic in
settle_maintenance_fee_internal, RiskEngine::settle_maintenance_fee,
RiskEngine::settle_maintenance_fee_best_effort_for_crank, and
RiskEngine::deposit to call this helper and use its returned fee_due.
- Around line 603-605: RiskParams currently only bounds maintenance_fee_per_slot
but allows combinations with max_crank_staleness_slots that make
RiskEngine::settle_maintenance_fee_internal() overflow/fail when fee_per_slot *
dt > MAX_PROTOCOL_FEE_ABS; fix by adding a cross-field validation in
RiskParams::validate(): ensure
maintenance_fee_per_slot.checked_mul(max_crank_staleness_slots).is_some() and
that maintenance_fee_per_slot * max_crank_staleness_slots <=
MAX_PROTOCOL_FEE_ABS (or equivalently require max_crank_staleness_slots <=
MAX_PROTOCOL_FEE_ABS / maintenance_fee_per_slot, handling zero), returning an
error if the condition fails; alternatively (if you prefer runtime robustness)
modify RiskEngine::settle_maintenance_fee_internal() to process dt in capped
chunks such that each chunk satisfies fee_per_slot * chunk_dt <=
MAX_PROTOCOL_FEE_ABS and loop until full dt is settled.
In `@tests/unit_tests.rs`:
- Around line 6868-6882: The test uses u16::MAX but maintenance-fee accrual dt
is a slot delta of type u64, so update the assertion in
test_maintenance_fee_constants to multiply MAX_MAINTENANCE_FEE_PER_SLOT by
u64::MAX (cast to u128) instead of u16::MAX; change the computation of max_fee
(currently using u16::MAX) to use (u64::MAX as u128) and keep the assert that
max_fee <= MAX_PROTOCOL_FEE_ABS (update the assertion message if desired) so the
check correctly reflects the real dt type.
---
Nitpick comments:
In `@tests/unit_tests.rs`:
- Around line 6833-6835: The test is directly mutating engine.accounts[idx as
usize].fee_credits which bypasses coupon-prefunding logic; change the setup to
call the existing deposit_fee_credits helper (or the engine method named
deposit_fee_credits) for that account instead, then call
engine.recompute_aggregates() as before; locate the mutation of fee_credits in
the test and replace it with a deposit_fee_credits(...) invocation using the
same account index and amount (500) so coupon/accounting semantics are
exercised.
- Around line 6809-6822: Add an assertion in test_maintenance_fee_zero_rate_noop
to verify the per-account fee marker is advanced: after calling
settle_maintenance_fee(idx, 1_000_000, ...), assert that the account's
last_fee_slot equals the now slot passed (i.e., check engine.accounts[idx as
usize].last_fee_slot is updated to 1_000_000 or to the now_slot used). This
ensures the zero-rate path still updates the slot marker in RiskEngine.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: df52fb99-e37c-42b3-829e-f8970de9d455
📒 Files selected for processing (2)
src/percolator.rstests/unit_tests.rs
| /// settle_maintenance_fee_internal (spec §8.2): compute and charge recurring maintenance fees. | ||
| /// | ||
| /// Algorithm (upstream e357d431): | ||
| /// 1. Compute dt = now_slot - last_fee_slot (checked to prevent underflow) | ||
| /// 2. If dt == 0, no-op | ||
| /// 3. fee_due = maintenance_fee_per_slot * dt (checked_mul prevents overflow) | ||
| /// 4. Validate fee_due <= MAX_PROTOCOL_FEE_ABS (rejects absurd accumulations) | ||
| /// 5. Stamp last_fee_slot BEFORE charging (prevents re-charge on retry/failure) | ||
| /// 6. Deduct from fee_credits; if negative, pay from capital into insurance | ||
| /// | ||
| /// Used by force_close_resolved and keeper_crank paths where margin checks | ||
| /// are inappropriate (the caller handles position closure/liquidation). | ||
| fn settle_maintenance_fee_internal(&mut self, idx: usize, now_slot: u64) -> Result<()> { | ||
| let fee_per_slot = self.params.maintenance_fee_per_slot.get(); | ||
| if fee_per_slot == 0 { | ||
| // No maintenance fee configured — just advance the slot marker | ||
| self.accounts[idx].last_fee_slot = now_slot; | ||
| return Ok(()); | ||
| } | ||
|
|
||
| let last_slot = self.accounts[idx].last_fee_slot; | ||
| // Checked subtraction: now_slot must be >= last_fee_slot | ||
| let dt = now_slot.checked_sub(last_slot).ok_or(RiskError::Overflow)?; | ||
| if dt == 0 { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| // Compute fee_due with checked arithmetic (prevents u128 overflow) | ||
| let fee_due = fee_per_slot | ||
| .checked_mul(dt as u128) | ||
| .ok_or(RiskError::Overflow)?; | ||
|
|
||
| // Cap: reject absurd fee accumulation (e.g. stale account with huge dt) | ||
| if fee_due > MAX_PROTOCOL_FEE_ABS { | ||
| return Err(RiskError::Overflow); | ||
| } | ||
|
|
||
| // CRITICAL: stamp last_fee_slot BEFORE charging. | ||
| // If the charge below fails or the transaction is retried, we will NOT | ||
| // re-compute the same fee window. This is the upstream CEI pattern. | ||
| self.accounts[idx].last_fee_slot = now_slot; | ||
|
|
||
| // Deduct from fee_credits (coupon buffer) | ||
| let fc = self.accounts[idx].fee_credits.get(); | ||
| let new_fc = fc.checked_sub(fee_due as i128).ok_or(RiskError::Overflow)?; | ||
| self.accounts[idx].fee_credits = I128::new(new_fc); | ||
|
|
||
| // If fee_credits went negative, pay debt from capital into insurance | ||
| if new_fc < 0 { | ||
| let debt = neg_i128_to_u128(new_fc); | ||
| let cap = self.accounts[idx].capital.get(); | ||
| let pay = core::cmp::min(debt, cap); | ||
| if pay > 0 { | ||
| self.set_capital(idx, cap - pay); | ||
| let pay_i128 = core::cmp::min(pay, i128::MAX as u128) as i128; | ||
| self.accounts[idx].fee_credits = I128::new( | ||
| self.accounts[idx] | ||
| .fee_credits | ||
| .get() | ||
| .checked_add(pay_i128) | ||
| .ok_or(RiskError::Overflow)?, | ||
| ); | ||
| self.insurance_fund.balance += pay; | ||
| self.insurance_fund.fee_revenue += pay; | ||
| } | ||
| } |
There was a problem hiding this comment.
Unify §8.2 fee calculation across all settlement entrypoints.
This helper now enforces checked arithmetic and MAX_PROTOCOL_FEE_ABS, but the other fee paths in this file — RiskEngine::settle_maintenance_fee, RiskEngine::settle_maintenance_fee_best_effort_for_crank, and the manual settlement in RiskEngine::deposit — still use the old saturating dt * fee_per_slot logic. The same stale account can therefore be rejected here, charged uncapped on normal user ops, or processed differently in the fallback crank path. Please factor the dt / fee_due computation into a shared helper and reuse it everywhere maintenance fees are settled.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/percolator.rs` around lines 1956 - 2021, The fee calculation is
inconsistent: settle_maintenance_fee_internal uses checked arithmetic and
MAX_PROTOCOL_FEE_ABS but other paths (RiskEngine::settle_maintenance_fee,
RiskEngine::settle_maintenance_fee_best_effort_for_crank, and the manual
settlement in RiskEngine::deposit) still use saturating dt * fee_per_slot;
extract a shared helper (e.g., compute_maintenance_fee(dt: u64, fee_per_slot:
u128) -> Result<u128, RiskError>) that performs checked_sub for dt, checked_mul
for fee_due, and validates fee_due <= MAX_PROTOCOL_FEE_ABS using the same error
(RiskError::Overflow), then replace the inline dt*fee_per_slot logic in
settle_maintenance_fee_internal, RiskEngine::settle_maintenance_fee,
RiskEngine::settle_maintenance_fee_best_effort_for_crank, and
RiskEngine::deposit to call this helper and use its returned fee_due.
| /// MAX_PROTOCOL_FEE_ABS and MAX_MAINTENANCE_FEE_PER_SLOT constants are sane. | ||
| #[test] | ||
| fn test_maintenance_fee_constants() { | ||
| use percolator::{MAX_MAINTENANCE_FEE_PER_SLOT, MAX_PROTOCOL_FEE_ABS}; | ||
|
|
||
| assert_eq!(MAX_MAINTENANCE_FEE_PER_SLOT, 1_000_000_000_000); | ||
| assert_eq!(MAX_PROTOCOL_FEE_ABS, 1_000_000_000_000_000_000); | ||
|
|
||
| // MAX_MAINTENANCE_FEE_PER_SLOT * u16::MAX should not exceed MAX_PROTOCOL_FEE_ABS | ||
| // (i.e., even at max rate for max funding dt, the fee is within cap) | ||
| let max_fee = MAX_MAINTENANCE_FEE_PER_SLOT * (u16::MAX as u128); | ||
| assert!( | ||
| max_fee <= MAX_PROTOCOL_FEE_ABS, | ||
| "max_fee_per_slot * max_dt must fit within MAX_PROTOCOL_FEE_ABS" | ||
| ); |
There was a problem hiding this comment.
u16::MAX is the wrong bound for maintenance-fee accrual safety.
This assertion is too weak: maintenance fee dt is slot delta (u64), not funding dt (u16). The current check can pass even if the cap guard is broken for larger dt.
Stronger cap-bound assertion and test intent
// MAX_MAINTENANCE_FEE_PER_SLOT * u16::MAX should not exceed MAX_PROTOCOL_FEE_ABS
-// (i.e., even at max rate for max funding dt, the fee is within cap)
-let max_fee = MAX_MAINTENANCE_FEE_PER_SLOT * (u16::MAX as u128);
+// Maintenance dt is slot-based (u64). Bound should be derived from the cap itself.
+let dt_cap = MAX_PROTOCOL_FEE_ABS / MAX_MAINTENANCE_FEE_PER_SLOT;
+let max_fee = MAX_MAINTENANCE_FEE_PER_SLOT * dt_cap;
assert!(
max_fee <= MAX_PROTOCOL_FEE_ABS,
- "max_fee_per_slot * max_dt must fit within MAX_PROTOCOL_FEE_ABS"
+ "max_fee_per_slot * dt_cap must fit within MAX_PROTOCOL_FEE_ABS"
);
+assert!(
+ MAX_MAINTENANCE_FEE_PER_SLOT.saturating_mul(dt_cap + 1) > MAX_PROTOCOL_FEE_ABS,
+ "dt above cap-derived bound must exceed MAX_PROTOCOL_FEE_ABS"
+);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /// MAX_PROTOCOL_FEE_ABS and MAX_MAINTENANCE_FEE_PER_SLOT constants are sane. | |
| #[test] | |
| fn test_maintenance_fee_constants() { | |
| use percolator::{MAX_MAINTENANCE_FEE_PER_SLOT, MAX_PROTOCOL_FEE_ABS}; | |
| assert_eq!(MAX_MAINTENANCE_FEE_PER_SLOT, 1_000_000_000_000); | |
| assert_eq!(MAX_PROTOCOL_FEE_ABS, 1_000_000_000_000_000_000); | |
| // MAX_MAINTENANCE_FEE_PER_SLOT * u16::MAX should not exceed MAX_PROTOCOL_FEE_ABS | |
| // (i.e., even at max rate for max funding dt, the fee is within cap) | |
| let max_fee = MAX_MAINTENANCE_FEE_PER_SLOT * (u16::MAX as u128); | |
| assert!( | |
| max_fee <= MAX_PROTOCOL_FEE_ABS, | |
| "max_fee_per_slot * max_dt must fit within MAX_PROTOCOL_FEE_ABS" | |
| ); | |
| /// MAX_PROTOCOL_FEE_ABS and MAX_MAINTENANCE_FEE_PER_SLOT constants are sane. | |
| #[test] | |
| fn test_maintenance_fee_constants() { | |
| use percolator::{MAX_MAINTENANCE_FEE_PER_SLOT, MAX_PROTOCOL_FEE_ABS}; | |
| assert_eq!(MAX_MAINTENANCE_FEE_PER_SLOT, 1_000_000_000_000); | |
| assert_eq!(MAX_PROTOCOL_FEE_ABS, 1_000_000_000_000_000_000); | |
| // Maintenance dt is slot-based (u64). Bound should be derived from the cap itself. | |
| let dt_cap = MAX_PROTOCOL_FEE_ABS / MAX_MAINTENANCE_FEE_PER_SLOT; | |
| let max_fee = MAX_MAINTENANCE_FEE_PER_SLOT * dt_cap; | |
| assert!( | |
| max_fee <= MAX_PROTOCOL_FEE_ABS, | |
| "max_fee_per_slot * dt_cap must fit within MAX_PROTOCOL_FEE_ABS" | |
| ); | |
| assert!( | |
| MAX_MAINTENANCE_FEE_PER_SLOT.saturating_mul(dt_cap + 1) > MAX_PROTOCOL_FEE_ABS, | |
| "dt above cap-derived bound must exceed MAX_PROTOCOL_FEE_ABS" | |
| ); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/unit_tests.rs` around lines 6868 - 6882, The test uses u16::MAX but
maintenance-fee accrual dt is a slot delta of type u64, so update the assertion
in test_maintenance_fee_constants to multiply MAX_MAINTENANCE_FEE_PER_SLOT by
u64::MAX (cast to u128) instead of u16::MAX; change the computation of max_fee
(currently using u16::MAX) to use (u64::MAX as u128) and keep the assert that
max_fee <= MAX_PROTOCOL_FEE_ABS (update the assertion message if desired) so the
check correctly reflects the real dt type.
dcccrypto
left a comment
There was a problem hiding this comment.
Security APPROVED — PERC-8461 maintenance fees.
Reviewed: settle_maintenance_fee_internal algorithm, constants, Params validation, CEI pattern, arithmetic safety, fee_credits buffer, partial payment edge cases.
Key verifications:
- CEI pattern: last_fee_slot stamped BEFORE charge — prevents re-charge on retry/failure ✅
- checked_mul for fee_due (fee_per_slot × dt) — no u128 overflow ✅
- checked_sub for fee_credits deduction — handles negative credits safely ✅
- MAX_PROTOCOL_FEE_ABS (10^18): caps single-call accumulation for stale accounts ✅
- MAX_MAINTENANCE_FEE_PER_SLOT (10^12): enforced in Params::validate() ✅
- Partial payment: min(debt, capital) — never drains more than available ✅
- pay_i128 cast: clamped to i128::MAX before i128 cast — safe ✅
- neg_i128_to_u128: correctly converts negative fee_credits debt to u128 pay amount ✅
- 8 unit tests: basic accrual, zero dt, force_close path, params validation, zero rate, credits buffer, partial payment, constants sanity ✅
No findings.
Replace settle_maintenance_fee_internal stub with full implementation: - Compute fee_due = maintenance_fee_per_slot × dt with checked arithmetic - Validate fee_due ≤ MAX_PROTOCOL_FEE_ABS (prevents unbounded accumulation) - Stamp last_fee_slot BEFORE charging (CEI: no re-charge on retry) - Deduct from fee_credits; if negative, pay from capital into insurance - Skip entirely when fee_per_slot == 0 (backward compatible) Add constants: - MAX_MAINTENANCE_FEE_PER_SLOT (10^12): admin param cap - MAX_PROTOCOL_FEE_ABS (10^18): per-invocation fee cap Add Params::validate() check for maintenance_fee_per_slot ≤ cap. 8 new tests covering: basic accrual, zero-dt noop, force_close path, params validation, zero-rate noop, fee_credits buffer, partial payment, constant sanity.
fdfdb28 to
a208170
Compare
dcccrypto
left a comment
There was a problem hiding this comment.
🔒 Security Review: APPROVED
Reviewed: PERC-8461 — recurring maintenance fees (spec §8.2). 247 lines + 170 lines tests.
Implementation (settle_maintenance_fee_internal):
✅ fee_per_slot == 0 → early return (no-op), advances slot marker only
✅ checked_sub on now_slot - last_fee_slot — prevents underflow
✅ dt == 0 → early return (idempotent)
✅ checked_mul on fee_per_slot * dt — prevents u128 overflow
✅ fee_due > MAX_PROTOCOL_FEE_ABS cap — rejects absurd fee accumulation from stale accounts
✅ CRITICAL CEI pattern: last_fee_slot stamped BEFORE charging — prevents re-charge on retry
✅ fee_credits (coupon buffer) deducted first, then debt paid from capital into insurance
✅ neg_i128_to_u128 for debt conversion — safe signed→unsigned
✅ min(debt, cap) — never drains more capital than exists
✅ insurance_fund.balance += pay, fee_revenue += pay — correct accounting
Params validation:
✅ MAX_MAINTENANCE_FEE_PER_SLOT = 10^12 validated in Params::validate()
✅ MAX_PROTOCOL_FEE_ABS = 10^18 — generous cap that prevents overflow
Tests (7):
✅ Basic accrual, zero dt, force_close path, params validation, zero rate, credits buffer, partial payment, constants sanity
No findings. Solid implementation matching upstream spec.
Summary
Replaces the
settle_maintenance_fee_internalstub (which only stampedlast_fee_slot) with a full implementation per upstream e357d43 and spec §8.2.Changes
Constants
MAX_MAINTENANCE_FEE_PER_SLOT(10^12): admin cannot set fee_per_slot above thisMAX_PROTOCOL_FEE_ABS(10^18): single invocation fee cap prevents unbounded accumulationsettle_maintenance_fee_internal
fee_due = maintenance_fee_per_slot * dtwithchecked_mulfee_due <= MAX_PROTOCOL_FEE_ABSlast_fee_slotbefore charging (CEI pattern)fee_credits; if negative, pay from capital into insuranceParams::validate()
maintenance_fee_per_slot > MAX_MAINTENANCE_FEE_PER_SLOTTests
8 new tests (201 total). cargo test 201/201 pass. cargo clippy 0 warnings.
Closes PERC-8461 (SYNC-04)
Summary by CodeRabbit
New Features
Improvements
Tests