Skip to content

fix: remove repay timelock mechanism#124

Merged
maxencerb merged 2 commits into
audit-fixes-02-2026from
fix/remove-repay-timelock
Mar 5, 2026
Merged

fix: remove repay timelock mechanism#124
maxencerb merged 2 commits into
audit-fixes-02-2026from
fix/remove-repay-timelock

Conversation

@maxencerb
Copy link
Copy Markdown
Collaborator

Fixes 3F-66

Summary

Removes the repay timelock mechanism entirely. Guardian signatures (3F-69) now provide stronger, proactive security by requiring quorum approval before setRequest can be called — making the reactive timelock redundant.

The timelock also blocked flash loan request contracts that need atomic setRequest + repay in the same transaction.

Changes

  • Remove repayTimelock from FacilityStorageData storage struct
  • Remove requestSetAt from Intent struct (no longer needed without timelock)
  • Remove repayAvailableAt(), setRepayTimelock(), _setRepayTimelock() functions
  • Remove timelock enforcement in repay()
  • Remove RepayTimelockSet event and RepayTimelockActive error
  • Remove repayTimelock_ param from initialize()
  • Update facilityConfig() and getIntent() return values
  • Delete FacilityRepayTimelockPoC.t.sol and all timelock-related tests
  • Remove FAC-11 and FAC-12 invariants (timelock enforcement and requestSetAt consistency)

Test plan

  • All 1232 tests pass (0 failed)
  • 100% coverage on all modified source files
  • Build passes with FOUNDRY_PROFILE=ci forge build

Guardian signatures (3F-69) now provide stronger, proactive security
by requiring quorum approval before setRequest can be called. The
repay timelock is redundant and blocks flash loan request contracts
that need atomic setRequest + repay in the same transaction.

Removes repayTimelock from storage, requestSetAt from Intent struct,
and all related functions, events, errors, and tests.
@linear
Copy link
Copy Markdown

linear Bot commented Mar 4, 2026

3F-66 [CS-GRUNT-006] Facilitator Theft With Malicious Request

ChainSecurity I-11 — CRITICAL

Source: ChainSecurity Audit
Severity: Critical — Complete theft of all user deposits by FACILITATOR_ROLE
Location: FacilityIntents.sol:81-84 (lock), FacilityIntents.sol:156-182 (setRequest), FacilityRequests.sol:47-63 (repay), RequestFactory.sol:111-131 (createRequest), VaultController.sol:237-244 (burnAll)


Description

If an intent is in DEPOSITING state, the Facilitator can steal all deposits within two blocks. The attack exploits the fact that the Facilitator controls the entire intent lifecycle: lock, setRequest, repay, and resolve — with no checks that the Request is legitimate or that repaid funds flow back to depositors.

Attack Sequence

Block N (single transaction via multicall or contract):

  1. lock(intentId) — transitions intent from DEPOSITING to RESOLVING
  2. RequestFactory.createRequest(facilitator, facilitator, facilitator, asset, ..., block.timestamp + 12) — creates a malicious Request where the Facilitator is owner, puller, AND consumer. repaymentDeadline set to 12 seconds (next block)
  3. authorizeMinting(self, 1, 1) then mint() on the Request — deposits 1 wei of the asset, mints 1 PT + 1 YT. Facilitator now holds 100% of PT/YT supply
  4. setRequest(intentId, maliciousRequest) — links the malicious Request to the intent. setRequest uses getIntent(id) (no state restriction), and the only validation is that the Request's asset matches the PM's debt asset
  5. repay(intentId, totalFacilityBalance) — the Facility transfers ALL deposited funds to the malicious Request via safeTransferFrom. This works because the repayment deadline hasn't passed yet (same block)

Block N+1 (12 seconds later):

  1. burnAll(self, self) — the repaymentDeadline has passed, so _syncWithdrawalStatus() returns true, enabling withdrawals. The Facilitator burns their 1 PT + 1 YT and receives:
    • pAssets = min(totalBalance, ptSupply) = min(allFunds + 1, 1) = 1
    • yAssets = totalBalance - pAssets = allFunds
    • Total extracted = allFunds + 1 (net theft = allFunds)

Root Causes

  1. setRequest() has no validation on Request origin — accepts any contract, not just protocol-deployed Requests. The factory is permissionless, so the Facilitator can create their own Request with arbitrary parameters.
  2. repay() has no bounds — the Facility can transfer its entire balance to a Request with no cap relative to the intent's actual deposits or pulled amounts.
  3. Facilitator controls the full lifecycle — a single role (FACILITATOR_ROLE) can execute lock → setRequest → repay → resolve with no guardian oversight, timelock, or multi-sig requirement.
  4. repaymentDeadline acts as an unconditional withdrawal enabler — once the deadline passes, _syncWithdrawalStatus() sets repaid = true regardless of whether actual repayment occurred. Combined with the Facilitator holding 100% of PT/YT supply, this allows full extraction.
  5. No separation of concerns — the same role that can move funds out of the Facility can also create and link the destination. There's no independent verification that funds were legitimately borrowed and repaid.

Impact

Complete loss of all user deposits in any intent where the Facilitator is malicious or compromised. The attack:

  • Requires only 1 wei of capital from the attacker
  • Completes in 2 blocks (24 seconds)
  • Leaves depositors with worthless LP tokens (the Facility's balance is drained)
  • Is undetectable until after execution (no timelock, no guardian signatures)

Proof of Concept Trace

State: Intent has 1,000,000 USDC deposited by users

Block N:
  lock(id)                          → Intent: RESOLVING
  factory.createRequest(            → Creates Request owned by Facilitator
    facilitator, facilitator, facilitator,
    USDC, "X", "X", block.timestamp + 12
  )
  request.authorizeMinting(self, 1, 1)
  request.mint()                    → Facilitator deposits 1 USDC, gets 1 PT + 1 YT
  setRequest(id, request)           → Links malicious Request to intent
  repay(id, 1_000_000e6)           → Facility transfers 1M USDC to Request
                                     Request balance: 1_000_001 USDC

Block N+1:
  request.burnAll(self, self)       → Deadline passed, withdrawals enabled
                                     pAssets = min(1_000_001, 1) = 1
                                     yAssets = 1_000_001 - 1 = 1_000_000
                                     Facilitator receives: 1_000_001 USDC
                                     Net theft: 1_000_000 USDC

Recommended Fixes

Option 1 — Restrict Request linking (most impactful):

  • Only allow setRequest() for Requests created by a trusted, protocol-managed factory
  • Verify the factory address on-chain: require(trustedFactory.isRequest(newRequest))
  • Ensure the factory enforces that Request owner/puller/consumer are protocol-controlled addresses

Option 2 — Require Request before lock:

  • setRequest() should only be callable in DEPOSITING state, so depositors can see the Request before committing
  • This gives users transparency — they know which Request their funds will flow to before they deposit

Option 3 — Bound repay amounts:

  • Track the total amount pull()-ed from a Request and cap repay() to that amount
  • Prevent the Facility from sending MORE funds to a Request than were originally pulled from it

Option 4 (Preferred) — Add timelock on setRequest + repay:

  • Require a delay between setRequest() and the first repay() call
  • This gives guardians and depositors time to review and potentially pause

Option 5 — Require guardian signatures for repay:

  • Leverage the existing quorum/guardian system for repay() operations, similar to how swaps require guardian signatures

These fixes are not mutually exclusive and should ideally be combined for defense-in-depth.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 4, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Free

Run ID: 0f474860-ad6f-4b20-9534-349edb82d905

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

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

@maxencerb maxencerb marked this pull request as ready for review March 4, 2026 17:19
@maxencerb maxencerb requested a review from maximebrugel March 4, 2026 17:19
Comment thread src/libs/facility/LibIntent.sol
@maxencerb maxencerb merged commit f20bfa0 into audit-fixes-02-2026 Mar 5, 2026
3 checks passed
@maxencerb maxencerb deleted the fix/remove-repay-timelock branch March 5, 2026 13:39
maxencerb added a commit that referenced this pull request Mar 5, 2026
Resolve conflicts from PR #124 (remove repay timelock) merge into base:
- Remove repay timelock references (facilityConfig returns 2 values)
- Remove pause() from IFacility (PR #122 consolidates into pauseFor)
- Keep revertDeposit from PR #122
- Update invariant handler selectors (16 selectors)
maximebrugel added a commit that referenced this pull request Apr 8, 2026
* fix: add repay timelock to prevent facilitator deposit theft (CS-I-11) (#40)

* fix: add repay timelock to prevent facilitator deposit theft (CS-I-11)

Enforce a configurable delay between setRequest() and the first
pull()/repay() call so that guardians and depositors can review the
request before funds are moved. The timelock is a global parameter set
at initialization and only settable by the owner.

- Add uint40 repayTimelock to FacilityStorageData (packs with pausedUntil)
- Add uint40 requestSetAt to Intent struct (packs with resolved)
- Revert with RepayTimelockActive if pull/repay is called too early
- Add setRepayTimelock (onlyOwner) with RepayTimelockSet event
- Add repayAvailableAt view for off-chain monitoring
- Add PoC test demonstrating the attack is blocked
- Add FAC-11 and FAC-12 invariants for timelock enforcement

* fix: restrict repay timelock to repay only, add act_setTimelock invariant action

The timelock should only prevent repay(), not pull(). Move the timelock
enforcement from the shared _initialRequestParameters() into the repay()
function. Add act_setTimelock handler action to fuzz the timelock
duration during invariant runs.

* fix: add checkNotZero to setRepayTimelock and align NatSpec

- Validate repayTimelock is non-zero via LibChecks.checkNotZero
- Fix NatSpec: timelock only gates repay(), not pull()

* fix: add mint slippage protection to prevent front-run YT theft (CS-I-12) (#41)

Add minPt and minYt parameters to Request.mint() so the minter can
specify minimum expected PT/YT amounts. Reverts with SlippageExceeded
if authorizeMinting has been overwritten below the caller's minimums.

- Add SlippageExceeded error to LibRequestErrors
- Update IRequest.mint(uint128 minPt, uint128 minYt) signature
- Add slippage check in Request.mint() before clearing authorization
- Add PoC test demonstrating the front-run attack is blocked
- Add unit and fuzz tests for slippage protection
- Update existing mint() call sites to use new signature

* docs: document swap digest signer-address replay risk (CS-I-9) (#42)

* docs: document swap digest signer-address replay risk (CS-I-9)

Add NatSpec security notes to FacilitySwap and IFacilitySwap documenting
that the EIP-712 swap digest does not hash the signer address. SC wallet
guardians must use implementations that bind their own address into
EIP-1271 isValidSignature (e.g., Safe >= 1.3.0).

* docs: remove audit reference from NatSpec

* fix: return 0 from previewDeposit/previewMint for ERC-4626 compliance (CS-I-6) (#43)

* fix: return 0 from previewDeposit/previewMint for ERC-4626 compliance (CS-I-6)

previewDeposit() and previewMint() returned calculated amounts even
though deposit() and mint() always revert. Per ERC-4626, preview
functions must reflect the actual outcome of the corresponding
operation. Return 0 since no deposits/mints are possible.

- Update previewDeposit() and previewMint() to return 0
- Update existing tests to assert 0 return values
- Add PoC tests verifying full deposit/mint compliance surface

* chore: remove unnecessary PoC test

* fix: add mint-to-repaid timelock to prevent last-minute YT inflation (CS-I-13) (#44)

Enforce a configurable delay between the last mint/consume and setRepaid()
to prevent a colluding facilitator from inflating YT supply and atomically
marking the request as repaid, stealing yield from legitimate holders.

* docs: clarify setRequest/setFund callable in any phase (CS-I-10) (#45)

Update README state diagrams and access control table to reflect that
setFund() and setRequest() are callable in any intent phase (DEPOSITING,
RESOLVING, RESOLVED), not just RESOLVING as previously documented.

* fix: skip zero-amount transfers in FacilityLP.claim (#46)

When mulDiv rounds a token's proportional share to zero, skip the
safeTransfer call. Some ERC-20 implementations revert on zero-amount
transfers which would brick claim() for small shareholders.

* fix: createRequest new signature (#53)

* fix: correct FAC-4 deposit cap invariant (#47)

The deposit cap invariant incorrectly asserted totalSupply <= depositCap
globally. Since the cap can be reduced below current supply via
setDepositCap, the invariant should only verify that deposits cannot
push supply above the cap.

- Convert FAC-4 from global assertion to per-action ghost flag
- Add act_updateDepositCap handler action to exercise cap reduction
- Add depositExceededCap ghost checked after each successful deposit

* fix: skip setFund/setRequest when same address is re-set (#48)

Calling setFund or setRequest a second time with the same address
deleted the reverse mapping (fundsIntent/requestsIntent) via
abandonFund/abandonRequest while the intent still referenced it.
This broke the uniqueness check, allowing the same fund/request
to be assigned to another intent.

Add early return when newFund/newRequest matches the current value.

* fix: match getIntent return values in test destructuring (#55)

* fix: match getIntent return values in test destructuring

* build: update optimizer runs

* fix: add zero-amount early return in yt.withdraw (#49)

When totalYAssets = 0 (e.g. after full liquidation), convertToShares
uses the initial conversion ratio of type(uint256).max for non-zero YT
amounts, causing yt.withdraw to revert instead of gracefully handling
the edge case. Add an early return for zero-amount withdrawals.

* fix: preserve sibling allowance in PT/YT individual approve (#50)

_approve() previously zeroed the sibling token allowance because it
passed 0 for the other token. Now reads existing allowances and only
overwrites the targeted token, preserving the sibling's allowance.

* fix: block PT/YT transfer to address(0) (#52)

_transfer did not validate the recipient, allowing tokens to be sent to
address(0) without reducing totalSupply. Add checkNotZero(to) using
LibChecks so transfers to the zero address revert with AddressZero().

* fix: add `cancelRecovering()` to prevent permanent fund lock (CS-I-4) (#54)

* fix: add cancelRecovering() to prevent permanent fund lock (CS-I-4)

If recovering() is called by mistake while PROCESSING and Superstate
delivers the output tokens, funds become permanently stuck. Add
cancelRecovering() to revert internalState back to PROCESSING.

* fix: forge fmt

* refactor: USCCFund implements IUSCCFund

* test: register act_cancelRecovering and remove early returns

* fix: use safeApproveWithRetry for non-zero approvals (#56)

* fix: derive decimals from underlying token (#58)

* fix(wrapped-asset): centralize InvalidDecimals in LibFundsErrors

* refactor(wrapped-asset): derive decimals from underlying token

* fix: lock solc version to 0.8.33 in foundry.toml (Fixes 3F-76) (#61)

Lock the compiler to a specific version instead of relying on
auto-detect with floating pragma `^0.8.20`.

* fix: correct NatSpec for resolveStart in IntentProperties (#66)

Update the resolveStart comment to accurately describe it as the timestamp
at which the deposit phase ends and the resolving phase begins.

Fixes 3F-71

* fix: send assets to order.receiver instead of msg.sender (3F-73) (#62)

In recover() and unlock(), transfers and events incorrectly used
msg.sender as the recipient. The IFund interface specifies that
output assets and shares must go to order.receiver.

* fix: remove legacy cachedBalance from USCCFund (#64)

The cachedBalance storage variable was a defensive snapshot of the USCC
balance before Superstate processing. Since commit() always leaves the
fund with zero USCC (deposit sends USDC; redeem burns USCC via
offchainRedeem), the cached value was always zero and the
zeroFloorSub was a no-op. Removing it simplifies storage, gas, and
the _state() logic.

* fix: USCCFund.initialize uses _setOracle helper (#65)

* fix: use _setOracle helper in USCCFund.initialize

Extract oracle validation (contract check, decimals check, storage set)
into a private _setOracle() helper to eliminate duplication between
initialize() and setOracle().

* fix: move event in internal function

* fix: rollback cancelRecovering

* fix: add minBalance param to setRepaid() to prevent frontrun (#60)

* fix: add minBalance param to setRepaid() to prevent frontrun (Fixes 3F-79)

The facilitator could frontrun setRepaid() by calling pullFunds() to
drain the contract before repayment is finalized. Adding a minBalance
parameter lets the owner specify the minimum expected asset balance,
reverting if the balance has been drained below that threshold.

* docs: update setRepaid() references to setRepaid(uint256)

* fix: rename lltv to ltv in PositionManager codebase (#67)

The PositionManager's LTV is not a liquidation loan-to-value but a simple
loan-to-value threshold — a small buffer above the target that determines
how much collateral can be withdrawn from borrow positions based on debt.

Renames across 31 files:
- Storage field: lltv → ltv
- Functions: setLltv → setLtv, checkValidLltv → checkValidLtv
- Error: InvalidLltv → InvalidLtv
- Event: LLTVSet → LTVSet
- IBorrowPosition interface params: lltv → ltv
- All NatSpec and test references updated accordingly

Morpho's own marketParams.lltv and LiquidationLtvExceedsMarketLltv are
kept as-is since those refer to Morpho Blue's actual liquidation LTV.

Fixes 3F-80

* fix: gas savings for USCCFund storage and LibTokenBalances (#63)

- Remove unused `currentOrder` from USCCFund storage (stored but never read)
- Replace `resolvedOrder` struct (5 slots) with minimal fields (bool + 2 uint256)
- Remove redundant `delete` operations in cancel/recover/unlock (cleared by next create)
- Use direct `_values` mapping access in LibTokenBalances to skip unnecessary contains() check

Fixes 3F-75

* fix: round up shares burned in _settleShares to prevent LP value leak (3F-84) (#69)

convertToShares used mulDiv (round down) unconditionally. When burning
shares on asset decrease, rounding down means the caller surrenders
fewer shares than the value extracted, leaking fractional value from LPs.

Added a roundUp boolean param to convertToShares. The burn path in
_settleShares now passes roundUp=true (mulDivUp), ensuring the caller
surrenders at least as many shares as the value removed.

* fix: compute per-position NAV to exclude bad debt from totalAssets (3F-85) (#68)

Previously totalAssets() summed all collateral and all debt globally,
then applied zeroFloorSub once. A position with bad debt (debt > collateral)
would reduce the overall NAV, understating the value for healthy positions.

Now each position's NAV is computed individually using zeroFloorSub,
so bad-debt positions contribute 0 instead of a negative value.

* fix: accrue interest before reading Morpho market data (#70)

* fix: accrue interest before reading Morpho market data (3F-83)

Stale Morpho market data from IMorpho.market() caused debt to be
systematically understated, affecting health checks, borrowing capacity,
fee computation, and share settlement.

For state-changing functions (withdrawCollateral, borrow, preLiquidate):
call morpho.accrueInterest() before reading market data. Critically,
preLiquidate now accrues interest BEFORE the health check instead of
after, preventing stale data from blocking legitimate liquidations.

For view functions (totalBorrowed, isHealthy, maxBorrow,
availableLiquidity, availableCollateral): introduce MorphoExpectedLib,
a Solady-based library that computes expected interest-accrued market
balances without mutating state, mirroring Morpho Blue's _accrueInterest
logic exactly.

MorphoExpectedLib is fuzz-tested against actual Morpho accrueInterest
output across arbitrary supply/borrow/time/fee combinations.

* fix: use Market struct with SafeCastLib in MorphoBalancesLib

Operate directly on the Market memory struct (uint128 fields) and use
Solady SafeCastLib.toUint128() for safe casts, mirroring the original
Morpho Blue MorphoBalancesLib pattern. This ensures overflow behavior
matches Morpho exactly instead of silently allowing >uint128 values.

* fix: harden setFund/setRequest with guardian replay checks (#71)

* feat(facility): share digest replay checks across swap and intent updates

* fix: forge fmt

* fix: apply pr comments

* fix: keep _digest in caller function

* fix: move digest replay check into base _checkSignatures

* fix: cap maximum management fee at 2% yearly (3F-92) (#75)

Reduce MAX_MANAGEMENT_FEE from 5000 bps (50%) to 200 bps (2%) per year.
Update tests, handler bounds, and invariant assertions accordingly.

Fixes 3F-92

* fix: migrate TransferGuard to ERC-7201 namespaced storage (3F-91) (#76)

Move addressStatus and tokenConfig mappings into an ERC-7201 namespaced
storage struct for consistency with the rest of the codebase. Replace
public state variables with explicit view functions preserving the same
external interface. OfferReceiver already uses Solady-style per-key
assembly slots which are inherently namespaced.

Fixes 3F-91

* feat: emit Rebalanced event from PositionManager.rebalance() (#77)

Add a Rebalanced(receiver, collateralSent, debtSent, collateralExcess,
debtExcess) event to IPositionManagerRebalancing and emit it at the end
of rebalance(), making both input amounts and post-execution excess
indexable for off-chain consumers.

* fix: queue-scoped totals in processBurn + WithdrawalStrategy enum + facilityConfig (3F-87, 3F-89) (#72)

* fix: use queue-scoped totals in processBurn to fix proportional distribution (3F-87, 3F-89)

* feat: add WithdrawalStrategy enum to select sequential or proportional withdrawal

Rename processWithdrawal/processBurn to _withdrawSequential/_withdrawProportional
(private) and add a processWithdrawal dispatcher in LibOperations that branches on
a new WithdrawalStrategy enum. Both withdraw() and burn() now accept the strategy
parameter, allowing callers to choose between queue-draining (SEQUENTIAL) and
pro-rata (PROPORTIONAL) withdrawal behaviour.

* fix: combine paused/repayTimelock into facilityConfig to reduce Facility bytecode

Merge the separate paused() and repayTimelock() view functions into a single
facilityConfig() call returning (isPaused, pausedUntil, repayTimelock). This
saves enough bytecode to keep Facility under the 24,576-byte EIP-170 limit
(24,558 bytes after the WithdrawalStrategy addition).

* fix: use cumulative proportional algorithm to prevent over-repay on last position

Replace the per-position mulDiv + last-gets-remainder approach in
_withdrawProportional with a running cumulative total algorithm
(Bresenham-style) that guarantees each position's allocation never
exceeds its actual debt/collateral and leaves zero dust.

* fix: charge performance fees net of management fees (Q-26) (#74)

* fix: charge performance fees net of management fees (3F-90)

Performance fees are now computed on the net gain after deducting
management fee assets, preventing performance fees from being charged
on gains that are consumed by the management fee. When management
fee assets exceed the gross gain, no performance fee is charged.

Fixes 3F-90

* refactor: cache lastTotalAssets in local var to avoid double SLOAD

* fix: exclude self-transfers in ControlledToken fuzz test

Add vm.assume(owner != recipient) to testFuzz_approveAndTransferFrom
to prevent TransferToSelf counterexamples. The ControlledToken reverts
on self-transfers by design; the fuzz test was missing this constraint.

* chore: bump solc version to 0.8.34

* fix: adjust test to respect MAX_MANAGEMENT_FEE cap (2%)

Use 200 bps (2%) management fee with 1% gain over 1 year so that
management fees (202e18) still exceed gross gain (100e18), preserving
the original test intent while respecting the new fee cap.

* test: strengthen net-of-mgmt-fee assertions with snapshot-based comparison

Use vm.snapshotState/revertToState to run parallel fee scenarios and
assert that no incremental performance fee is charged when management
fee exceeds gains, and that performance fee on gross gain strictly
exceeds performance fee on net gain.

* style: run forge fmt

* fix: add rebalance cooldown (#73)

* fix: add rebalance cooldown to rate-limit consecutive rebalance calls (3F-88)

* refactor: consolidate rebalance fields into RebalanceConfig struct

Pack maxRebalanceLoss, rebalanceCooldown, and lastRebalanceTimestamp into
a single RebalanceConfig struct in storage. Replace separate setter
functions with a unified setRebalanceConfig(uint16, uint40) and accept
config params directly in the initializer via an internal setter.

* feat: expose lastRebalanceTimestamp in config() view

Add lastRebalanceTimestamp as the 5th return value of config() so
off-chain rebalancers can check when the next rebalance is allowed.

* fix: avoid potential uint40 overflow in cooldown check

Rearrange `timestamp < lastRebalance + cooldown` to
`timestamp - lastRebalance < cooldown` to prevent overflow
when cooldown is set to a very large value.

* fix: split config() view to keep Facility under EIP-170 size limit

Move maxRebalanceLoss, rebalanceCooldown, and lastRebalanceTimestamp
into a separate rebalanceConfig() view so config() returns only
(ltv, transferGuard). This avoids the 5-tuple ABI decode overhead
in Facility._beforeTokenTransfer and keeps the contract under the
24KB limit. Also update EIP-712 domain separator in tests to match
the shortened name/version ("3F"/"1").

* style: run forge fmt

* fix: store virtual share offset derived from debt decimals (#79)

* fix: store virtual share offset derived from debt decimals (3F-128, 3F-104)

Replace the hardcoded VIRTUAL_SHARES constant with a per-vault
virtualShareOffset stored as uint64, packed with ltv. The offset is
computed as 10^(18 - debtAsset.decimals()) using zeroFloorSub, matching
MetaMorpho's decimalsOffset approach. Decimals are hardcoded to 18
(Solady ERC20 default) and removed from PositionManagerMetadata.

Fixes 3F-128
Fixes 3F-104

* fix: expose virtualShareOffset getter and use it in invariant test

- Add virtualShareOffset() public view to IPositionManager and PositionManager
- Replace hardcoded +1 in invariant_noInflationAttack with actual virtualShareOffset
- Replace individual decimal tests with a fuzz test covering all uint8 decimals

* fix: restrict preLiquidate borrower to address(this) (3F-127) (#80)

Validate that the borrower parameter in MorphoBorrowPosition.preLiquidate()
is address(this), reverting with InvalidBorrower otherwise. This prevents
callers from triggering Morpho callbacks in an unexpected context.

Fixes 3F-127

* fix: rename _intialPmParameters to _initialPmParameters (#82)

Fixes 3F-124

* fix: cap maxRebalanceLoss at 10% (1000 BPS) in setRebalanceConfig (#83)

Fixes 3F-121

* fix: report dynamic state in recover/unlock revert messages (#85)

Fixes 3F-119

* fix: disable initializers in implementation contract constructors (#86)

* fix: disable initializers in implementation contract constructors

Fixes 3F-118

* fix: use clone proxy in PositionManagerInit tests to match _disableInitializers

The constructor now calls _disableInitializers(), so tests that create
a PositionManager via `new` and then call initialize() on the
implementation directly will revert with InvalidInitialization(). Use
LibClone.clone() to deploy a minimal proxy first, matching the pattern
in PositionManagerBaseTest.

* fix: validate addBorrowModule and enforce safeLtv >= PM LTV (#81)

* fix: validate borrow modules on add and enforce safeLtv >= PM LTV

Fixes 3F-126
Fixes 3F-120

addBorrowModule now checks collateral/debt asset match, owner is the
PM, and module safeLtv >= PM LTV. setLtv iterates all whitelisted
modules to ensure the new LTV does not exceed any module's safeLtv.

Also splits MorphoBorrowPosition.ltvs() into safeLtv() (added to
IBorrowPosition interface) and liquidationLtv() (Morpho-specific).

* test: add invariant PM-11 — module safeLtv >= PM LTV

* fix: address PR review comments

- Add ModuleAlreadyAdded error and check borrowModules.add() return value
- Remove redundant checkValidLtv call (keep it for error ordering, note defense-in-depth)
- Remove `= 0` in loop initializer
- Add test for addBorrowModule revert on duplicate module

* fix: remove redundant checkValidLtv in setLtv

The module safeLtv loop already catches LTV > WAD (any module's safeLtv
will be lower), and _storage.setLtv validates at the end for the
no-modules case (including LTV == 0).

* docs: fix outdated NatSpec and README references (#84)

Fixes 3F-123

* fix: reject duplicate entries in supply and withdrawal queues (#89)

* fix: reject duplicate entries in supply and withdrawal queues

Fixes 3F-110

* fix: use factory for borrow position creation in tests

Replace `new MorphoBorrowPosition()` with `borrowPositionFactory.createBorrowPosition()`
to work with _disableInitializers in implementation constructors.

* fix: accrue fees before addBorrowModule and removeBorrowModule (#88)

Fixes 3F-108

* fix: correct invariant test assertions to match actual totalAssets logic (#98)

PM-1 & PM-10 asserted totalAssets == max(0, totalCollateral - totalDebt),
but LibView.totalAssets() computes sum(max(0, collateral_i - debt_i)) per
position to isolate bad-debt positions. These diverge when a position is
underwater.

PM-2 asserted that 1 wei of assets yields >0 shares, which fails for
18-decimal debt tokens where virtualShareOffset = 1 and integer division
truncates to 0 when totalAssets >> totalSupply. Changed to check 1e18
which is the documented minimum meaningful deposit for 18-decimal tokens.

* fix: allow self-transfers in TokenController per ERC-20 standard (#90)

Fixes 3F-106

* fix: enforce pause check in _settleShares when totalAssets is unchanged (#91)

Fixes 3F-103

* fix: rename MorphoRebalancer event and emit actual excess values (#92)

Fixes 3F-100

* fix: validate repaymentDeadline in Request.initialize() (#93)

Fixes 3F-95

* fix: adjust preLiquidate health check and cap repaid shares (#95)

* fix: adjust preLiquidate to pass Morpho health check and cap repaid shares

Fixes 3F-97, Fixes 3F-98

When a position's LTV exceeds Morpho's LLTV, proportional pre-liquidation
fails because removing the same fraction of debt and collateral preserves
the unhealthy LTV. When seizing all collateral, rounding up repaidShares
can exceed borrowShares, causing an underflow in Morpho.

Changes to MorphoBorrowPosition.preLiquidate:
- seizedAssets mode: repay max(proportional, minimum for Morpho health),
  capped at borrowShares
- repaidShares mode: seize min(proportional, maximum for Morpho health)
- onMorphoRepay: skip withdrawCollateral when seizedAssets is 0
  (deeply underwater positions where repaying debt frees no collateral)

* test: remove issue IDs from test comments

* fix: remove useless uint256 >= 0 assert and unused variable in tests

* fix: validate order output against oracle price in USCCFund.create (#94)

* fix: validate order output against oracle price in USCCFund.create

Fixes 3F-137

* refactor: move BPS constant to Constants.sol

* fix: allow dust deposits/withdrawals that yield 0 shares (#96)

* test: add PoC tests for small asset delta ZeroShares revert

Fixes 3F-96

* fix: allow dust deposits/withdrawals that yield 0 shares

In _settleShares, when the minting branch rounds to 0 shares, skip
minting and return sharesDelta=0 instead of reverting with ZeroShares.
This lets small asset deltas succeed as micro-donations rather than
blocking legitimate operations after oracle appreciation.

The burn-side ZeroShares guard is kept as a defensive check (roundUp
makes it unreachable for any non-zero delta).

Fixes 3F-96

* fix: add zero-address check for receiver in FacilityLP (#100)

* fix: add zero-address check for receiver in FacilityLP claim and withdraw

Fixes 3F-155

* test: add zero-address receiver checks for withdraw and claim

* fix: correct safety comment in TokenController._transfer() (#101)

The comment in _transfer incorrectly referenced allowances instead of
balances. This was copied from _consumeAllowance but _transfer validates
balances (ptBalanceSender, ytBalanceSender), not allowances.

Fixes 3F-152

* fix: remove unnecessary free memory pointer update in LibOrder.toId() (#102)

The assembly block updated the free memory pointer after the keccak256
computation, but the allocated memory is never referenced afterward,
making the update unnecessary and wasteful of gas.

Fixes 3F-157

* fix: correct NatSpec error references in PositionManagerLP and PositionManagerRebalancing (#103)

Fixes 3F-150

- PositionManagerLP.deposit(), withdraw(), burn(): changed NatSpec from
  {LibManagerErrors.ZeroAmount} to {LibCommonErrors.AmountZero} to match
  the actual `CommonErrors.AmountZero()` revert
- PositionManagerRebalancing.rebalance(): changed NatSpec from
  {LibManagerErrors.Paused} to {LibCommonErrors.Paused} to match
  the actual `CommonErrors.Paused()` revert

* fix: correct off-by-one in offer expiration check (#104)

The Offer struct's NatSpec defines `expiration` as the "Unix timestamp
after which the offer becomes invalid", meaning the offer should still
be valid at exactly `block.timestamp == expiration`. The check used
`offer.expiration <= block.timestamp` which incorrectly reverted at the
boundary. Changed to `offer.expiration < block.timestamp` so offers
remain valid at the expiration timestamp.

Updated the boundary test `test_ValidOffer_AtCurrentTimestamp` to expect
success instead of revert, and corrected the `@custom:reverts` NatSpec.

Fixes 3F-144

* fix: change public role constants to internal in USCCFund and TransferGuard (#105)

* fix: change public role constants to internal in USCCFund and TransferGuard

Change OPERATOR_ROLE and DEPOSITOR_ROLE in USCCFund, and COMPLIANCE_ROLE
and PAUSER_ROLE in TransferGuard from public to internal visibility.
Remove the corresponding getter declarations from IUSCCFund interface.
Update tests to use literal role values instead of calling the removed
public getters.

Fixes 3F-147

* fix: prefix internal role constants with underscore

* refactor: use named constants for role values in fund tests

* fix: add asset compatibility check in MorphoBorrowPosition.initialize() (#107)

* fix: add asset compatibility check in MorphoBorrowPosition.initialize()

Validate that the Morpho market's collateral and loan tokens match the
PositionManager's expected collateral and debt assets during initialization.
This provides defense-in-depth against misconfigured borrow positions being
attached to a PositionManager operating on different tokens.

Fixes 3F-153

* fix: update tests for asset compatibility check in initialize()

The new AssetMismatch check in MorphoBorrowPosition.initialize() now
fires during createBorrowPosition, so tests expecting the revert at
addBorrowModule are updated accordingly.

* fix(test): false positive in invariant_repayTimelockEnforced (#111)

The handler's act_attemptRepayBeforeTimelock reused the same mockRequest
address, but setRequest short-circuits when the address is unchanged
(retaining the original requestSetAt). After a time warp the timelock
legitimately expired, yet the handler flagged it as a bypass.

Add a guard that skips the assertion when repayAvailableAt shows the
timelock has already elapsed.

* fix: prevent snapshot double-counting when PM assets overlap (#109)

* fix: prevent snapshot double-counting when PM assets overlap (CS-GRUNT-062)

Add assembly-optimized validation in _initialPmParameters that reverts
with InvalidPositionManagerAssets() if collateralAsset, debtAsset, or
positionManager addresses overlap. This prevents a malicious facilitator
from inflating intent balances via double-counted balance snapshots.

* chore: add memory safe assembly instruction

* fix: skip signatures when removing fund/request and add DeadlineExpired error (#120)

- setFund: skip deadline check and signature verification when newFund
  is address(0) (removal only requires facilitator role, not guardian
  signatures)
- setRequest: same for newRequest == address(0)
- Add DeadlineExpired error for setFund/setRequest deadline checks,
  replacing incorrect SwapExpired reuse

Fixes 3F-177

* feat: add pendingFees() view for accurate share price computation (#113)

* feat: add pendingFees() view for accurate share price computation

Add a pendingFees() view to IPositionManager that returns the current
totalAssets, totalSupply, and the pending management/performance fee
shares that would be minted on the next accrual. This allows
integrators to compute an accurate share price as:
  price = totalAssets / (totalSupply + mgmtFeeShares + perfFeeShares)

Refactors _accrueFees() to use a shared _pendingFees() internal view
in PositionManagerBase, eliminating code duplication between the
state-mutating and read-only paths.

* style: format PositionManagerFee tests with forge fmt

* docs: document allowance clamping behavior for integrators (#114)

Approving any value >= 2^128 silently produces an infinite allowance
(type(uint256).max) that is never consumed on transfers. Add NatSpec
notes across the approve/allowance surface (_setAllowance, allowance,
approveBatch, _approve, ControlledToken.approve, LibAllowance.normalize,
ITokenController) so integrators are aware of this behavior.

* fix: mitigate read-only reentrancy in FacilityLP withdraw and claim (#115)

* fix: mitigate read-only reentrancy in FacilityLP withdraw and claim

- Swap order in LibIntent.transferTokenTo to decrement amounts before
  safeTransfer (checks-effects-interactions pattern), preventing
  callbacks from observing inconsistent token balances.
- Add nonReadReentrant modifier to Facility.intentBalances so it
  reverts when called during a reentrant callback.
- Remove redundant balanceOf check in _withdrawalLpChecks since
  ERC6909._burn already reverts on insufficient balance.

* refactor: move CallbackToken to test/mock/facility

* refactor: remove redundant unchecked loop increments and bump pragma to ^0.8.22 (#116)

Since Solidity 0.8.22, the compiler automatically optimizes for-loop
increments to skip overflow checks. Remove all `unchecked { ++i; }`
blocks in loop bodies and move the increment into the for-statement.

Also bump the minimum pragma from ^0.8.20 to ^0.8.22 across all 85
source files and remove the unused WithdrawalStrategy re-export from
IPositionManager.sol, fixing downstream imports.

Fixes 3F-135

* feat: add deployment tracking to all factories (#117)

Add mapping(address => bool) and public getter to each factory so
contracts and off-chain consumers can verify whether an address was
deployed by a specific factory:

- TransferGuardFactory: isTransferGuard()
- MorphoBorrowPositionFactory: isBorrowPosition()
- USCCFundFactory: isFund()
- PositionManagerFactory: isPositionManager()

Follows the existing RequestFactory._isRequest pattern.

Fixes 3F-136

* fix: make Morpho reference immutable in MorphoBorrowPosition (#108)

* fix: make Morpho reference immutable in MorphoBorrowPosition

Store the Morpho contract address as an immutable (MORPHO) in the
implementation bytecode instead of in ERC-7201 namespaced storage.
Since the address is set once at construction time and never changes,
this saves a warm SLOAD (2100 gas) on every external call.

- Add `IMorpho public immutable MORPHO` to MorphoBorrowPosition
- Pass morpho to the constructor; remove it from initialize()
- Remove `morpho` field from BorrowPositionStorage struct
- Update MorphoBorrowPositionFactory to accept and forward morpho
- Remove morpho parameter from createBorrowPosition()
- Update all test files for the new constructor/initialize signatures

Fixes 3F-143

* fix: add checkContract validation for morpho in constructor

* fix: validate orderId in recovering() and cancelRecovering() (#112)

* fix: validate orderId in USCCFund.recovering and cancelRecovering

Add a bytes32 orderId parameter to recovering() and cancelRecovering()
and validate it matches currentOrderId, consistent with all other
state-mutating functions (commit, cancel, unlock, recover). This
prevents a stale recovering() transaction from putting the wrong order
into RECOVERING state if the current order changed while the
transaction was pending.

* docs: expand orderId NatSpec with stale transaction rationale

* fix: remove repay timelock mechanism (#124)

* fix: remove repay timelock mechanism (3F-66)

Guardian signatures (3F-69) now provide stronger, proactive security
by requiring quorum approval before setRequest can be called. The
repay timelock is redundant and blocks flash loan request contracts
that need atomic setRequest + repay in the same transaction.

Removes repayTimelock from storage, requestSetAt from Intent struct,
and all related functions, events, errors, and tests.

* style: fix formatting for forge fmt

* feat: add compliance role and revertDeposit to Facility (#122)

* feat: add compliance role and revertDeposit to Facility

- Rename PAUSER_ROLE to COMPLIANCE_ROLE (pause/unpause + revert deposits)
- Add revertDeposit() to FacilityLP allowing compliance to return deposited
  assets directly to share holders
- Merge pause/unpause/pauseFor into single pauseFor(duration) where 0 = unpause
- Fix LibPause overflow for large durations (>= PERMANENT_PAUSE)
- Widen facilityConfig/repayAvailableAt return types to uint256
- Add unchecked blocks for safe arithmetic in Facility and LibIntent
- Bytecode optimizations across FacilityIntents, FacilitySwap, FacilityRequests
- Add comprehensive tests and invariant coverage for revertDeposit

Fixes 3F-178

* feat: add receiver parameter to revertDeposit

When receiver differs from the share holder, only the owner can call
revertDeposit to prevent the compliance role from redirecting user funds
to an arbitrary address.

* docs: update README for COMPLIANCE_ROLE and revertDeposit

- Rename Pauser to Compliance in Facility role tables and mermaid diagram
- Add revertDeposit to LP Operations, Function Access Control, and Role-Based Access
- Add pauseFor to Function Access Control
- Note that only owner can set receiver != from on revertDeposit

* fix: forge fmt

* fix: add nonReentrant modifier to revertDeposit

* feat: reorder

---------

Co-authored-by: maximebrugel <maxime.brugel@gmail.com>

* fix: replace two-hop allowlist check with ISuperstateToken.isAllowed (#127)

Use ISuperstateToken(USCC).isAllowed(address(this)) instead of
resolving the allowlist address at call time via allowlistV2(). This
removes the dependency on the IAllowlist selector and is more robust
to future Superstate changes.

* fix: validate non-zero input in USCCFund.resolve() (#128)

Add input.checkNotZero() to resolve() to enforce the same non-zero
input validation that create() already requires, ensuring consistent
validation across the order lifecycle.

* fix: return 0 from maxDeposit/maxRedeem for non-depositors (#129)

maxDeposit() always returned type(uint256).max and maxRedeem() returned
the full wUSCC balance regardless of caller permissions. Now both
functions check _DEPOSITOR_ROLE and return 0 for unauthorized accounts.

* fix: add allowlist check in USCCFund.commit() (#130)

* docs: rename Prime Broker to Bridge Facilitator (#132)

Replace all "Prime Broker" / "PB" references with "Bridge Facilitator" / "BF"
across NatSpec comments, test variable names, and test comments.

* fix: bound deposit LTV per position in processDeposit (#126)

Replace proportional collateral allocation with LTV-aware deposit logic.
Each position now uses collateralForBorrow/borrowForCollateral to determine
exact collateral needs at the target LTV, preventing deposits from exceeding
the owner-configured LTV threshold.

Changes:
- Add collateralForBorrow() and borrowForCollateral() to IBorrowPosition
- Implement both in MorphoBorrowPosition with full natspec
- Add _requiredCollateral() and _maxBorrow() internal helpers
- Update processDeposit to use LTV-bounded collateral allocation
- Update MockBorrowPosition with realistic LTV-aware math
- Add comprehensive tests for new view functions and LTV constraints
- Update README to reflect LTV-enforced deposit flow

Fixes 3F-114
Fixes 3F-171

* fix: enforce per-position LTV check in proportional withdrawal

Proportional withdrawals did not verify that each position's collateral
withdrawal respects the storage LTV, unlike sequential withdrawals which
use availableCollateral(ltv). This could leave individual positions in an
unhealthy state during withdraw(). Burns are unaffected since amounts are
proportional to total debt/collateral.

Add checkLtv boolean to processWithdrawal — true for withdraw(), false
for burn(). When true, each position's proportional collateral withdrawal
is checked against availableCollateral(ltv) after repayment.

* Revert "fix: enforce per-position LTV check in proportional withdrawal"

This reverts commit 538bb229db0b1b0eea1e7506e89f332442ee98cc.

* fix: enforce per-position LTV check in proportional withdrawal (#147)

Proportional withdrawals did not verify that each position's collateral
withdrawal respects the storage LTV, unlike sequential withdrawals which
use availableCollateral(ltv). This could leave individual positions in an
unhealthy state during withdraw(). Burns are unaffected since amounts are
proportional to total debt/collateral.

Add checkLtv boolean to processWithdrawal — true for withdraw(), false
for burn(). When true, each position's proportional collateral withdrawal
is checked against availableCollateral(ltv) after repayment.

* fix: prevent burnAll revert when vault has depleted principal assets (CS-GRUNT-014) (#151)

When a vault was repaid but had zero principal assets remaining, convertToAssets
incorrectly returned ptShares (1:1 initial conversion) instead of 0, causing
burnAll to revert on the subsequent zero-balance safeTransfer.

The fix distinguishes pre-repayment from post-repayment state: initial conversion
(1:1 for PT) is now only used when supply is zero (div-by-zero guard) or when
not yet repaid. After repayment, mulDiv correctly yields 0 for depleted vaults.

* fix: mint slippage uses maxPt instead of minPt (CS-GRUNT-018) (#152)

* fix: change mint slippage from minPt to maxPt to prevent PT inflation attack (CS-GRUNT-018)

The mint function's first parameter was minPt (floor on deposit amount) but
should be maxPt (cap on deposit amount). A consumer could front-run by increasing
the PT authorization, forcing the broker to deposit more than expected for the
same yield. The maxPt parameter now caps the deposit so ptMintAuth > maxPt reverts.

Callers that previously passed mint(0, 0) for no protection should now pass
mint(type(uint128).max, 0).

* fix: bound fuzz delay to < 90 days to satisfy initialize constraint

The testFuzz_setRepaid_respectsTimelock test bounded delay to [1, 90 days]
but initialize requires repaymentDeadline > block.timestamp + mintToRepaidDelay.
When delay == 90 days the strict inequality fails. Bound to 90 days - 1.

* fix: early return on zero-authorized mint to prevent setRepaid griefing

A user with zero PT/YT minting authority could repeatedly call mint() to
bump lastMintTimestamp, permanently delaying setRepaid(). Now mint()
returns early when both ptMintAuth and ytMintAuth are zero.

* fix: add maxBalance to setRepaid to prevent over-repayment attack (CS-GRUNT-005) (#153)

A malicious facilitator who is also a significant YT holder could over-repay
assets just before setRepaid is called, inflating their YT redemption value at
the expense of intent shareholders. The new maxBalance parameter caps the
allowed balance, reverting with ExcessiveBalance if exceeded.

Pass type(uint256).max for maxBalance to skip the upper bound check.
Also fixes the pre-existing fuzz bound bug in testFuzz_setRepaid_respectsTimelock.

* fix: use maxPt=type(uint128).max in setRepaid tests to match updated mint signature (#154)

The setRepaid tests from #153 used mint(0, 0), which fails after #152
changed the first parameter from minPt to maxPt (maxPt=0 triggers
SlippageExceeded when ptMintAuth > 0).

* fix: enforce per-position LTV check on burn when withdrawal queue is partial (3F-200) (#156)

When burn passes checkLtv=false to processWithdrawal, it assumes proportional
amounts are safe because they're derived from total debt/collateral. This only
holds when the withdrawal queue covers ALL whitelisted positions. If the queue
is a strict subset, queue positions bear a disproportionate share of the
withdrawal, potentially violating individual position LTV limits.

Now checkLtv is only skipped when withdrawalQueue.length == borrowModules.length().

* fix: rename SwapDigestUsed to DigestUsed for general replay protection (CS-GRUNT-090) (#157)

* fix: skip zero-amount safeTransfer in withdrawal operation (CS-GRUNT-014) (#158)

Guard _withdrawalOperation against zero-amount transfers when vault is
depleted. Complements the convertToAssets fix (e60f5ad) with defense-in-depth
for non-standard ERC20 tokens that may revert on zero-amount transfers.

* fix: remove unreachable ZeroShares revert in _settleShares (CS-GRUNT-101) (#159)

* fix: remove incorrect comment about fee shares affecting available liquidity (CS-GRUNT-089) (#160)

Fee shares only redistribute ownership among suppliers; they do not
change totalSupplyAssets and have no effect on available liquidity.

* fix: check return value of removeBorrowModule to prevent operating on non-whitelisted modules (CS-GRUNT-093) (#161)

* fix: use strict less-than in pause check to avoid off-by-one (CS-GRUNT-091) (#162)

* fix: remove redundant accrueInterest calls in withdrawCollateral and borrow (CS-GRUNT-092) (#163)

* fix: check pause when sharesToMint rounds to zero in _settleShares (CS-GRUNT-027) (#164)

* fix: return owner-specific values in maxWithdraw and maxRedeem (CS-GRUNT-019) (#165)

maxWithdraw now returns convertToAssets(balanceOf(owner)) and maxRedeem
returns balanceOf(owner) instead of type(uint256).max, satisfying the
ERC-4626 requirement that these functions reflect the actual amount
accepted for the specific owner.

* fix: align Morpho rounding across all view and mutation functions (CS-GRUNT-085/087/097/098/099) (#166)

* fix: use two sequential ceilings in _computeSeizedAssets to match Morpho's two-floor health check (CS-GRUNT-085)

Morpho's _isHealthy applies two sequential floors: floor(collateral * price / SCALE)
then floor(result * lltv / WAD). The old code combined them into a single
floor(price * lltv), which could be 1 unit too optimistic, causing partial
pre-liquidations to revert on Morpho's withdrawCollateral health check.

* fix: use two sequential ceilings in _requiredCollateral to match _isHealthy's two-floor check (CS-GRUNT-099)

_requiredCollateral used a single combined ceiling to invert _isHealthy's
two sequential floors, causing withdraw(availableCollateral(ltv)) to
intermittently revert. Split into two mulDivUp calls matching the pattern
already applied in _computeSeizedAssets.

* fix: simulate share round-trip in collateralForBorrow to prevent post-borrow health check revert (CS-GRUNT-098)

collateralForBorrow predicted post-borrow debt as currentDebt + borrowAmount,
but Morpho's borrow() converts assets to shares via toSharesUp then _isHealthy
reconverts via toAssetsUp. This double rounding can inflate actual debt beyond
the prediction. Now simulates the exact share round-trip to match what
_isHealthy will see.

* fix: compute _maxBorrow in share space to match Morpho's rounding (CS-GRUNT-087)

_maxBorrow computed capacity in asset space, but Morpho's borrow() converts
assets to shares via toSharesUp then _isHealthy reconverts via toAssetsUp.
The compounded rounding could make borrow(maxBorrow(ltv)) revert.

Now computes in share space using Morpho's own SharesMathLib: find max
shares via toSharesDown, convert to assets via toAssetsDown. This directly
matches Morpho's rounding with no post-hoc adjustment needed. Same fix
propagates to borrowForCollateral which also delegates to _maxBorrow.

* fix: use toAssetsDown in totalBorrowed to prevent repay revert (CS-GRUNT-097)

totalBorrowed() used toAssetsUp which rounds up. When passed to repay(),
Morpho converts back via toSharesDown — the round-trip
toSharesDown(toAssetsUp(bs)) can exceed bs, causing underflow.

Switching to toAssetsDown ensures toSharesDown(toAssetsDown(bs)) <= bs,
so repay(totalBorrowed()) never reverts. May leave at most 1 dust share
whose asset value rounds to 0.

* fix: include virtualShareOffset in burn proportional calculation to prevent share inflation via direct Morpho collateral donation (#167)

* Fund Batch [01] (#97)

* feat: Centrifuge Fund Integration (JAAA & ACREDX) (#78)

* refactor: reorganize USCC fund files into funds/USCC/ subfolder

Move USCC-specific files into dedicated USCC/ subfolders to prepare for
additional fund types alongside USCCFund.

* feat: vendor Centrifuge IVaultRouter interface (3F-94)

Add a flattened, self-contained IVaultRouter interface with all
contract-type parameters replaced by plain address for ABI
compatibility. PoolId and ShareClassId UDVTs are defined inline
and IBatchedMulticall is flattened in. This removes the need for
the centrifuge_export/ vendor directory.

* feat: CentrifugeFund

* refactor: remove dead resolve(), validate output at creation, clean up _state

Remove resolve() and its storage fields (hasResolvedAmounts, resolvedInput,
resolvedOutput) which are unused — CentrifugeFund queries the vault directly
unlike USCCFund. Validate order.output against the vault's conversion rate at
creation time. Inline RECOVERING branch in _state() to avoid duplicate
claimableCancel* external calls. Collapse recovering/cancelRecovering/cancelOrder
into a single cancelRequest().

* fix: forge fmt

* refactor: add NatSpec, use before/after pattern, simplify RECOVERING state

Add NatSpec to storage struct fields, _stateHasPendingRequest, and
_stateHasPendingRecover. Document requestId = 0 convention. Use
before/after balance delta in unlock() and recover() to exclude dust.
Simplify _state() RECOVERING branch to only check claimable amount,
mirroring the PROCESSING/UNLOCKING pattern.

* doc: missing comments

* doc: add CentrifugeFund to README

* refactor: move archived order check from _state() to state()

* feat: add MAX_OUTPUT_DEVIATION slippage floor to CentrifugeFund

Enforce a minimum output relative to the current exchange rate in
create(), preventing callers from setting output to 0 or unreasonably
low values. Move BPS constant from LibConstants to global Constants.sol.

* fix: reset vault approval to 0 after commit requests

* fix: report dynamic state in unlock() revert

* fix: report dynamic state in recover() revert

* feat: use vault's maxDeposit/maxRedeem limits in CentrifugeFund

* refactor: cache redundant storage reads in CentrifugeFund

* refactor: cache computed orderId instead of storage read in cancelRequest

* refactor: simplify PROCESSING branch in _state() to match RECOVERING style

* refactor: move unknown-order guard from _state() to state()

* refactor: add constructor section and natspec, remove unused _orderId in _state()

* refactor: extract repeated owner check into _checkOwner()

* refactor: rename _checkOwner to _checkOrderOwner

* fix: return archived order first in state

* test: add CentrifugeFund test suite with 100% coverage

Unit tests (73), fuzz tests (8), invariant tests (4), and factory tests (8)
covering all CentrifugeFund operations including partial fills, slippage
guard, cancel request, and state machine transitions.

* fix: remove upper bound check on slippage guard in create()

Allow order.output > expectedOutput (optimistic pricing). Only reject
when output deviates too far below the current rate.

* fix: forge fmt

* fix: guard cancelRequest() against partial fills

Revert with PendingClaimableAssets when claimable partial-fill assets
exist (maxMint for deposits, maxWithdraw for redeems), forcing the
operator to unlock() before cancelling to prevent stuck shares.

* fix: add isPermissioned guard to commit()

Prevents opaque vault reverts if permission is revoked between
create() and commit(), matching the existing check in create().

* test: add integration tests

* build: link secret

* test: expand CentrifugeFund coverage with fork and fuzz tests

Fork: add redeem cancel lifecycle, partial redeem fill, and slippage
guard tests with real on-chain conversion rates. Fuzz: add non-1:1
rate tests for slippage guard and totalAssets. Fix invariant to use
vault.convertToAssets instead of hardcoded 1:1 assumption.

* fix: address PR review comments on CentrifugeFund

- Reset share token allowance after wrappedShare.mint in unlock() and
  recover() for safety
- Add _PENDING_REQUEST constant for readability instead of raw 0
- Change MAX_OUTPUT_DEVIATION from 1% to 5% for consistency with
  USCCFund
- Update all test thresholds to match 5% slippage guard

* feat: Pareto Fund Integration (FalconX Credit Vault) (#110)

* feat: ParetoFund

* feat: add resolve() to ParetoFund for operator output override

Allow operator/owner to override order input/output after commit
via resolve(), following the USCCFund pattern. The deposit state
transition check in _state() uses the resolved output when available.

* fix: use InvalidUnderlyingAsset error for wrappedShare mismatch

Replace misused InvalidReceiver with a dedicated InvalidUnderlyingAsset
error when wrappedShare.underlying() does not match the expected share
token during initialize(). Applies to CentrifugeFund and ParetoFund.

* refactor: rename NotAllowedToOperateWithVault to NotAllowedByFund

* style: align IParetoFund and ICentrifugeFund interfaces with IUSCCFund conventions

Add full NatSpec, section banners, and OPERATOR_ROLE/DEPOSITOR_ROLE
view getters to IParetoFund and ICentrifugeFund for consistency.

* test: add unit/invariant tests

* fix: forge fmt

* test: integration tests

* docs: add ParetoFund to README

* fix: correct totalAssets NatSpec and remove unused resolvedInput storage

- Fix virtualPrice decimal description (underlying decimals, not 18)
- Remove resolvedInput from ParetoFundStorage (no RECOVERING state)

* fix: use before/after balance pattern in unlock() for REDEEM

Use actual received balance instead of trusting withdrawsRequests()
amount, which may differ due to fees, rounding, or partial fills.

* feat: add isWalletAllowed check in commit() and test for revoked allowance

* fix: store deposit-received amount in commit() and use it in unlock()

Prevents unlock() from wrapping the entire AA tranche balance (which
could include airdrops or accidental transfers) by capturing only the
tokens actually received from depositAA via a before/after balance
pattern, storing the result in depositReceived, and using it in both
_state() and unlock().

* feat: add slippage guard on create() in ParetoFund

Reject orders whose output deviates more than 5% below the current
virtualPrice rate, matching the existing CentrifugeFund behaviour.

* fix: forge fmt

* test: add missing coverage for ParetoFund unlock and resolve edge cases

Add 6 tests covering PR review feedback:
- claimWithdrawRequest returning a different amount than withdrawsRequests
- resolve() with input=0 and output=0
- accidental tokens sent to the fund not being swept on unlock

* style: run forge fmt

* fix(test): pin fork tests to block 24570780 to prevent mainnet state drift

* fix: use `depositDuringEpoch` (#119)

* fix: Fund Batch [01] (#125)

* fix: make OPERATOR_ROLE and DEPOSITOR_ROLE internal in CentrifugeFund and ParetoFund (#123)

* feat: add `MorphoFlashLoanRequest` facilitator contract (#118)

* feat: add MorphoFlashLoanRequest facilitator contract

Introduces a facilitator contract that uses Morpho flash loans to
temporarily act as a request on a facility intent. It atomically sets
itself as the request, pulls the flash-loaned amount into the facility,
executes caller-specified operations, repays back, unsets itself, and
returns the flash loan to Morpho.

* style: run forge fmt on MorphoFlashLoanRequest

* fix: set rawDebt to full balance to prevent dust griefing

Set rawDebt to the contract's full token balance at callback start
(flash loan amount + any pre-existing dust) instead of just the flash
loan amount. This prevents an attacker from sending 1 wei of dust to
revert the entire flash loan cycle via BalanceExceedsDebt().

* test: cover remaining branch paths in MorphoFlashLoanRequest

Add 3 tests exercising revert branches that require non-zero rawDebt
in transient storage, bringing branch coverage from 50% to 100%.

* feat: add MorphoFlashLoanRequestFactory contract

* style: rename storage variable from `s` to `$`

---------

Co-authored-by: maximebrugel <maxime.brugel@gmail.com>

* fix: handle fulfilled shares stuck after `cancelRequest` races with `approveDeposits` (#133)

* fix: handle fulfilled shares stuck after cancelRequest races with approveDeposits

When cancelRequest() passes its maxMint==0 guard between Centrifuge's
multi-step fulfillment (approveDeposits → issueShares → notifyDeposit),
the deposit can be partially or fully fulfilled despite the cancel. The
RECOVERING state previously only checked claimableCancelDepositRequest,
making fulfilled shares permanently stuck.

Update _state() to check maxMint/maxWithdraw first when in RECOVERING,
returning UNLOCKING so fulfilled shares can be claimed. Update unlock()
to transition back to RECOVERING (not PROCESSING) when cancel assets
still need claiming. Update invariant model to match new state logic.

* test: add race condition handler actions and redeem partial fulfillment test

Add fulfillDepositAndCancelDeposit / fulfillRedeemAndCancelRedeem handler
actions so the fuzzer can reach the race condition path where the Centrifuge
pool fulfills both the request and its cancellation in the same epoch. Also
add test_Unlock_RedeemPartialFulfilledDuringRecovery mirroring the existing
deposit-side test.

* fix: pack `CentrifugeFundStorage` struct to save one storage slot (#134)

Move `internalState` (1-byte enum) next to `vault` (20-byte address) so
they share slot 0, reducing the struct from 6 slots to 5 and saving
~2,100 gas on cold SLOAD when both fields are accessed together.

* fix: revert on reused order ID to prevent ambiguous state (#135)

Add `OrderAlreadyExists` guard in `create()` for CentrifugeFund,
ParetoFund, and USCCFund to reject orders whose computed ID already
exists in `endedOrders`, preventing `state()` from returning a stale
ENDED result for a newly created order.

* fix: check WrappedShare is permissioned in `create()` and `commit()` (#137)

Without this check, orders proceed through ACCEPTED → PROCESSING before
reverting at `unlock()` when WrappedAsset lacks share token permissions,
locking assets recoverable only via async `cancelRequest → recover`.

* fix: add `forceEnd` to CentrifugeFund to unblock griefed orders (#136)

* feat: add `forceEnd` to CentrifugeFund to unblock griefed orders

An attacker can call `vault.requestDeposit(1, fundAddress, attacker)`
to inflate `pendingDepositRequest(0, fundAddress)`, preventing the fund
from transitioning out of PROCESSING after a legitimate fill is claimed.
`forceEnd` lets the operator or owner force-end a stuck order from
PROCESSING or RECOVERING state, bypassing the vault's pending counters.

* fix: guard `forceEnd` against stranding claimable assets

* fix: return 0 from `maxDeposit` and `maxRedeem` for non-depositors (#138)

* fix: use `lastWithdrawRequest` in ParetoFund to unblock REDEEM on apr0 vaults (#139)

When IdleCreditVault has unscaledApr == 0, requestWithdraw routes amounts
through _requestWithdrawApr0 and never increments withdrawsRequests[user].
ParetoFund._state() relied on withdrawsRequests > 0 to detect claimable
withdrawals, causing REDEEM orders to be permanently stuck in PROCESSING.

Switch to lastWithdrawRequest > 0 as the pending-withdrawal signal, which
is set on both the normal and apr0 paths and reset to 0 after claim.

* fix: remove unnecessary tranche token approvals in `ParetoFund.commit()` (#140)

IdleCDO's `requestWithdraw` burns tranche tokens directly via
`IdleCDOTranche.burn()`, which does not use `transferFrom` and
therefore requires no approval. Remove the redundant approve/reset
calls and fix the mock to match the real CDO behavior.

* fix: allow `ParetoFund.cancel()` in PENDING state per IFund spec (#141)

* fix: remove misleading `newOrderId` from `OrderResolved` event (#142)

`resolve()` emitted a `newOrderId` computed from in-memory order
mutations, but `currentOrderId` was never updated — so the emitted ID
didn't correspond to any on-chain state. Remove the parameter and the
dead `order.input`/`order.output` mutations that only existed to
compute it.

* fix: use `mulDiv` consistently in ParetoFund slippage guard (#143)

* fix: replace literal `0` with `_PENDING_REQUEST` constant in CentrifugeFund (#144)

* fix: pass `account` instead of `address(this)` in `maxDeposit` and `maxRedeem` (#145)

* fix: pass `account` instead of `address(this)` in `maxDeposit` and `maxRedeem`

* docs: add @dev comments to `maxDeposit` and `maxRedeem` explaining vault permissioning

* fix: CentrifugeFund.recover() and unlock() return PROCESSING per IFund spec (#146)

* fix: return `PROCESSING` instead of `RECOVERING` from `recover()` and `unlock()` per IFund spec

`recover()` and `unlock()` returned `RECOVERING` for partial operations,
violating the IFund interface which specifies `PROCESSING`. Decoupled the
internal state (stays `RECOVERING` for correct state-machine transitions)
from the return value (now `PROCESSING` per spec).

* fix: update stale assertion messages from "still recovering" to "still processing"

* fix: revert on instant withdrawals in ParetoFund to prevent stuck orders (#148)

When the CDO silently routes a withdrawal to the instant path,
`withdrawsRequests` doesn't increase and the order gets permanently
stuck in PROCESSING. Check before/after values and revert with
`InstantWithdrawDetected` if the request wasn't registered.

* refactor: extract `_revertIfUnclaimedFills` helper in CentrifugeFund (#149)

Deduplicate the identical claimable-fills guard from `cancelRequest()`
and `forceEnd()` into a single internal helper that also encapsulates
the `_stateHasPendingRequest` bypass for polluted claimable amounts.

* feat: `MorphoFlashLoanRequest` script (#150)

* feat: replace Operation[] loop with whitelisted script delegatecall

Replace the generic Operation[] loop in MorphoFlashLoanRequest with a
delegatecall to owner-whitelisted scripts, enabling intermediate return
values (e.g. variable share amounts from unlock) to flow between chained
facility calls.

- Add `scripts` mapping to ERC-7201 storage, `setScript`/`isScript` management
- Change `execute()` signature to accept `(script, scriptPayload)` instead of `Operation[]`
- Delegatecall whitelisted script in callback (preserves FACILITATOR_ROLE via msg.sender)
- Add SyncDeposit script: create → commit → unlock → depositManager with computed share amounts
- Update all tests to new signature; add script management and SyncDeposit integration tests

* feat: add in-script balance-diff tracking to SyncWithdrawal

Derive redeemAmount on-chain via shareToken balance diff before/after
withdrawManager, mirroring SyncDeposit's pattern. This couples the
withdrawal and redeem steps so the script provides value over separate
facilitator calls.

* feat: separate owner and executor roles in MorphoFlashLoanRequest (#155)

Migrate from Ownable to OwnableRoles so the address that whitelists
scripts (owner) is distinct from the address that executes flash loans
(EXECUTOR_ROLE). Update factory to accept executor param and add tests
for the new access control boundaries.

* fix: CentrifugeFund `forceEnd()` deadlock and claimable fills bypass [CS-GRUNTFUND-17/18] (#168)

* fix: add syncEndedOrder() to unblock intents after CentrifugeFund.forceEnd()

When forceEnd() is called directly on the fund, the Facility's Intent
retains a stale order (order.owner != address(0)), permanently blocking
resolve(), setFund(), and create(). Add syncEndedOrder() in LibIntent
that queries IFund.state(order) and clears the stale order+fund binding
when the fund reports ENDED. Auto-sync in resolve() and setFund()
(before the same-fund early return) so recovery requires no extra steps.

* fix: check claimable fills unconditionally in forceEnd()

forceEnd() delegated to _revertIfUnclaimedFills() which skips the
maxMint/maxWithdraw check when a pending request exists. An attacker
could submit a dust requestDeposit via the vault to create a pending
request, bypassing the guard and force-ending an order with real
claimable assets still present. Replace with direct unconditional checks.

* fix: use `lastWithdrawRequest` in ParetoFund instant-withdrawal guard to unblock apr0 (#169)

The instant-withdrawal guard in commit() compared withdrawsRequests before/after
requestWithdraw(), but on the apr0 path withdrawsRequests is never set — only
lastWithdrawRequest is. This caused commit() to revert with InstantWithdrawDetected
for all apr0 vaults. Switch the guard to lastWithdrawRequest, which is written on
both normal and apr0 paths but unchanged on the instant path.

Also hardens test coverage: apr0 lifecycle and transition tests pin the bug surface,
instant-withdraw test asserts clean rollback invariants, consecutive-redeem test
verifies the guard across multiple cycles, and fuzz accounting uses totalClaimable.

* fix: optimize ParetoFundStorage struct packing (#170)

Pack `internalState` and `hasResolvedAmounts` into the same storage
slot as `vault`, saving one full 32-byte slot and ~2,100 gas per
cold access in functions that read these fields together.

* fix: clear rawDebt at end of onMorphoFlashLoan [CS-GRUNTFUND-021] (#171)

* fix: clear rawDebt at end of onMorphoFlashLoan so isRepaid() is unambiguous

* test: restore repay BalanceExceedsDebt test using mock script during callback

* test: restore isRepaid BalanceExceedsDebt test using mock script during callback

* fix: mint enough tokens in isRepaid BalanceExceedsDebt test to actually hit _debt() guard

* fix: replace Solady `SafeTransferLib.balanceOf` with `IERC20.balanceOf` (#172)

* fix: replace Solady SafeTransferLib.balanceOf with IERC20.balanceOf

SafeTransferLib.balanceOf silently returns 0 on call failure, masking
bugs. IERC20.balanceOf properly reverts, matching the pattern already
used in CentrifugeFund and ParetoFund.

* fix: replace Solady SafeTransferLib.balanceOf with IERC20.balanceOf in MorphoFlashLoanRequest and Request

---------

Co-authored-by: Maxence Raballand <dev@maxencerb.com>

---------

Co-authored-by: maxencerb <dev@maxencerb.com>

* fix: add nonReentrant to setRepaid() and mint() to prevent reentrancy via consume() callback (CS-GRUNT-105) (#173)

---------

Co-authored-by: Maxence Raballand <dev@maxencerb.com>
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.

2 participants