Skip to content

feat: PERC-8461 — enable recurring maintenance fees per spec §8.2 - #79

Merged
dcccrypto merged 2 commits into
masterfrom
feature/PERC-8461-maintenance-fees
Apr 4, 2026
Merged

feat: PERC-8461 — enable recurring maintenance fees per spec §8.2#79
dcccrypto merged 2 commits into
masterfrom
feature/PERC-8461-maintenance-fees

Conversation

@dcccrypto

@dcccrypto dcccrypto commented Apr 4, 2026

Copy link
Copy Markdown
Owner

Summary

Replaces the settle_maintenance_fee_internal stub (which only stamped last_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 this
  • MAX_PROTOCOL_FEE_ABS (10^18): single invocation fee cap prevents unbounded accumulation

settle_maintenance_fee_internal

  • Compute fee_due = maintenance_fee_per_slot * dt with checked_mul
  • Validate fee_due <= MAX_PROTOCOL_FEE_ABS
  • Stamp last_fee_slot before charging (CEI pattern)
  • Deduct from fee_credits; if negative, pay from capital into insurance
  • Zero fee_per_slot -> advance slot marker only (backward compatible)

Params::validate()

  • Rejects maintenance_fee_per_slot > MAX_MAINTENANCE_FEE_PER_SLOT

Tests

8 new tests (201 total). cargo test 201/201 pass. cargo clippy 0 warnings.

Closes PERC-8461 (SYNC-04)

Summary by CodeRabbit

  • New Features

    • Recurring maintenance fees are now automatically charged and settled from fee credits and account capital.
  • Improvements

    • Added safety limits on maintenance fee and protocol fee parameters to prevent excessive charges.
    • Enhanced parameter validation to enforce maintenance fee constraints and prevent misconfiguration.
    • Settlement now advances fee timestamps before applying charges to avoid double-charging on retries.
  • Tests

    • Added unit tests covering recurring fee accrual, payment order, edge cases, and parameter validation.

@coderabbitai

coderabbitai Bot commented Apr 4, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 31ed441e-0b62-4b83-ba3a-633904dcc928

📥 Commits

Reviewing files that changed from the base of the PR and between fdfdb28 and a208170.

📒 Files selected for processing (2)
  • src/percolator.rs
  • tests/unit_tests.rs
✅ Files skipped from review due to trivial changes (1)
  • tests/unit_tests.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/percolator.rs

📝 Walkthrough
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The description provides a comprehensive summary of changes, implementation details, and test coverage. However, the required checklist items (tests pass, clippy clean, format check, and optional Kani verification) are not explicitly confirmed as completed. Explicitly confirm completion of all checklist items (cargo test, cargo clippy, cargo fmt --check) and indicate whether Kani verification was performed, as this PR modifies math and invariant logic.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: implementing recurring maintenance fees per specification section 8.2, with a clear reference to the PERC-8461 feature task.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/PERC-8461-maintenance-fees

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@dcccrypto dcccrypto left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 logic
  • MAX_MAINTENANCE_FEE_PER_SLOT (10^12) + MAX_PROTOCOL_FEE_ABS (10^18) constants
  • Params::validate() rejects maintenance_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), then cap - pay — underflow impossible ✅
  • Fee_credits add-back: checked_add(pay_i128) with min(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_slot stamped BEFORE fee_credits deduction 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 by pay — net-zero, conservation maintained ✅
  • Fee_credits adjusted by same pay_i128 amount — 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_credits bypasses coupon-prefunding semantics and can hide accounting regressions. Prefer using deposit_fee_credits in 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 == 0 and unchanged capital, but not the spec-critical marker update. Add an assertion that last_fee_slot advances to now_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

📥 Commits

Reviewing files that changed from the base of the PR and between 4d10b11 and fdfdb28.

📒 Files selected for processing (2)
  • src/percolator.rs
  • tests/unit_tests.rs

Comment thread src/percolator.rs
Comment thread src/percolator.rs
Comment on lines +1956 to +2021
/// 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;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment thread tests/unit_tests.rs
Comment on lines +6868 to +6882
/// 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"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
/// 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 dcccrypto left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@dcccrypto
dcccrypto force-pushed the feature/PERC-8461-maintenance-fees branch from fdfdb28 to a208170 Compare April 4, 2026 18:53

@dcccrypto dcccrypto left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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.

@dcccrypto
dcccrypto merged commit c864aaf into master Apr 4, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant