Skip to content

v1.1.0 (Cantina Fixes + Superstate Allowlist Restrictions)#189

Merged
maximebrugel merged 15 commits into
mainfrom
v1.1.0
May 14, 2026
Merged

v1.1.0 (Cantina Fixes + Superstate Allowlist Restrictions)#189
maximebrugel merged 15 commits into
mainfrom
v1.1.0

Conversation

@maximebrugel
Copy link
Copy Markdown
Collaborator

@maximebrugel maximebrugel commented May 4, 2026

Summary by CodeRabbit

  • New Features

    • Multi-mode transfer compliance (BLOCKLIST/WHITELIST/NATIVE variants), Superstate allowlist enforcement for wrapped assets, and optional collateral-based allowlist checks
    • New epoch/withdraw config flags and real-time post-deadline request behavior
  • Documentation

    • Expanded transfer-mode docs, semantics, diagrams, and usage examples; clarified totalAssets/totalSupply behavior
  • Bug Fixes

    • Tightened fee/share accrual math, allowance/approval semantics, and strengthened reentrancy/read-view protections
  • Tests

    • Large expansion covering modes, collateral checks, fees, epochs, allowances, and reentrancy/read-view scenarios

…Guard (#174)

* feat: add Superstate allowlist restrictions for WrappedAsset and TransferGuard

Enforce Superstate's `isAllowed()` compliance check at the token transfer
and transfer guard levels to prevent addresses outside the USCC allowlist
from holding or transferring wrapped tokens.

New contracts:
- SuperstateRestrictedWrappedAsset: extends WrappedAsset, checks both
  transfer parties against ISuperstateToken(underlying).isAllowed()
- SuperstateRestrictedTransferGuard: extends TransferGuard with Superstate
  allowlist enforcement on both non-null parties
- WhitelistedPartyTransferGuard: standalone guard allowing transfers when
  at least one party is on a whitelist (e.g., the Facility)
- SuperstateRestrictedWhitelistedPartyTransferGuard: combines whitelisted-
  party logic with Superstate allowlist checks
- Beacon proxy factories for all three guard variants

Restructures guard folder layout into base/, superstate/, and
whitelisted-party/ subfolders for separation of concerns.

All new contracts have 100% test coverage (line, branch, function).

* refactor: extract TransferGuardFactoryBase to deduplicate factory logic

* style: apply forge fmt formatting

* refactor: unify TransferGuard with NATIVE status, TokenMode enum, and collateral isAllowed check

Replace 4 guard contracts (TransferGuard, SuperstateRestrictedTransferGuard,
WhitelistedPartyTransferGuard, SuperstateRestrictedWhitelistedPartyTransferGuard)
with a single TransferGuard that supports 4 token modes:
- BLOCKLIST (default): all allowed except BLOCKLIST addresses
- WHITELIST: only WHITELIST/NATIVE addresses allowed
- NATIVE_ONLY: at least one NATIVE party required, no BLOCKLIST
- NATIVE_WHITELIST: all parties WHITELIST/NATIVE, at least one NATIVE

Add NATIVE address status for protocol addresses (e.g., Facility).
Add checkCollateralAllowed flag to call WrappedAsset.isAllowed() via
PositionManager.assets() for Superstate-style compliance delegation.

Add virtual isAllowed(address, uint256) hook to WrappedAsset (base returns
true). SuperstateRestrictedWrappedAsset overrides to delegate to
ISuperstateToken.isAllowed(). Base _beforeTokenTransfer now calls isAllowed
for non-null parties.

Delete 6 guard source files, 6 test files, and 2 directories.
Net: -872 lines. All 1660 tests pass.

* refactor: remove TransferGuardFactoryBase, flatten guard folder structure

Inline TransferGuardFactoryBase logic into TransferGuardFactory (only one
factory remains). Move TransferGuard and TransferGuardFactory from
src/guard/base/ to src/guard/. Remove base/ subdirectories from source,
test, and mock folders. Update all imports.

* refactor: make isAllowed public to avoid external self-call

* fix: update comments to say "all modes" instead of "both modes"

* refactor: simplify canTransfer to one storage read per address

Read each address status once upfront, then do all mode checks on the
loaded values. Remove _isAllowed, _hasNative, _isWhitelistedOrNative
helpers. Lift BLOCKLIST check to top (shared across all modes).

* refactor: decompose canTransfer mode logic into noneBlocked/nativeRequired properties

* docs: update README tree and TransferGuard section for unified guard

Update directory tree with SuperstateRestrictedWrappedAsset and isAllowed
hook. Rewrite TransferGuard section with 4 token modes (BLOCKLIST,
WHITELIST, NATIVE_ONLY, NATIVE_WHITELIST), NATIVE address status,
collateral allowlist check, and updated flow diagram.

* test: harden MockSuperstateToken with transfer restrictions and add integration tests

Tighten SuperstateRestrictedWrappedAsset NatSpec to clarify two-layer enforcement.
Add _beforeTokenTransfer to MockSuperstateToken to mirror production USCC allowlist
enforcement. Add tests proving burn-to-disallowed-recipient and mint-by-disallowed-issuer
revert at the underlying token layer.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 2026

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Free

Run ID: 1266b7fa-062a-44c4-8d96-053093397287

📥 Commits

Reviewing files that changed from the base of the PR and between 35d2f3b and 809002b.

📒 Files selected for processing (13)
  • README.md
  • src/borrow/MorphoBorrowPosition.sol
  • src/funds/USCC/USCCFund.sol
  • src/funds/WrappedAsset.sol
  • src/funds/pareto/ParetoFund.sol
  • src/interfaces/facility/base/IFacilityFunds.sol
  • src/interfaces/funds/IFund.sol
  • src/libs/common/LibPause.sol
  • src/libs/facility/LibIntent.sol
  • src/libs/request/LibTokenController.sol
  • src/manager/base/PositionManagerRebalancing.sol
  • src/request/RequestFactory.sol
  • src/request/abstract/vault/VaultController.sol
✅ Files skipped from review due to trivial changes (8)
  • src/libs/request/LibTokenController.sol
  • src/interfaces/facility/base/IFacilityFunds.sol
  • src/request/RequestFactory.sol
  • src/manager/base/PositionManagerRebalancing.sol
  • src/libs/facility/LibIntent.sol
  • src/borrow/MorphoBorrowPosition.sol
  • src/libs/common/LibPause.sol
  • src/funds/pareto/ParetoFund.sol
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/funds/WrappedAsset.sol
  • src/interfaces/funds/IFund.sol
  • src/funds/USCC/USCCFund.sol
  • src/request/abstract/vault/VaultController.sol

📝 Walkthrough

Walkthrough

Introduces a TokenMode-based TransferGuard (BLOCKLIST/WHITELIST/NATIVE_ONLY/NATIVE_WHITELIST) with optional collateral-layer allowlist checks, adds SuperstateRestrictedWrappedAsset delegating allowlist checks to underlying Superstate token, and refactors PositionManagerBase._pendingFees fee-share computation. Documentation and tests updated for new APIs and behaviors.


Changes

TransferGuard Multi-Mode Compliance System

Layer / File(s) Summary
Enums / Event Shape
src/interfaces/guard/ITransferGuard.sol
AddressStatus adds NATIVE; TokenMode adds NATIVE_ONLY and NATIVE_WHITELIST; TokenConfigSet event now includes TokenMode mode and bool checkCollateralAllowed.
WrappedAsset Interface
src/interfaces/funds/IWrappedAsset.sol
Adds isAllowed(address account, uint256 amount) external view hook.
WrappedAsset Core
src/funds/WrappedAsset.sol
Adds public virtual isAllowed(address,uint256) defaulting to true; _beforeTokenTransfer enforces isAllowed for non-zero from/to and then calls super._beforeTokenTransfer.
Specialized Wrapped Asset
src/funds/USCC/SuperstateRestrictedWrappedAsset.sol
New contract overrides isAllowed to delegate to underlying Superstate token's isAllowed(address).
TransferGuard Core
src/guard/TransferGuard.sol
Per-token config changed to (pausedUntil, TokenMode mode, bool checkCollateralAllowed); canTransfer rewritten to enforce TokenMode rules (BLOCKLIST/WHITELIST/NATIVE_*), mint/burn bypass for NATIVE requirement, and optional collateral checks via IPositionManager(token).assets()IWrappedAsset(collateral).isAllowed(...). tokenConfig/tokenMode getters and setTokenConfig signature updated; canTransfer visibility changed to public view virtual.
Factory Docs
src/guard/TransferGuardFactory.sol
NatSpec and comments reworded; no functional API changes.
Documentation
README.md
TransferGuard section rewritten for multi-mode behavior, adds AddressStatus.NATIVE, revised flowchart, collateral-allowlist subsection, and updated usage example.
Test Harness / Handler
test/mock/guard/TransferGuardHandler.sol
Handler now tracks ghostMode (TokenMode), drives act_setTokenConfig/act_cycleTokenMode, and expands address-status selection to include NATIVE.
Mocks for Collateral Testing
test/guard/TransferGuard.t.sol
Adds MockPositionManagerForGuard and MockWrappedAssetForGuard to simulate collateral asset and isAllowed behavior for collateral-check tests.
Unit & Invariant Tests
test/guard/*, test/guard/TransferGuardFactory.t.sol
All guard tests updated to new TokenMode API and event shape; new tests cover NATIVE modes, collateral-check interactions, mint/burn bypass semantics, and updated fuzz handlers. Many other tests updated to pass explicit TokenMode values.
Superstate Integration Tests & Mocks
test/funds/USCC/SuperstateRestrictedWrappedAsset.t.sol, test/mock/funds/MockSuperstateToken.sol, test/funds/USCC/*
Adds SuperstateRestrictedWrappedAsset tests; MockSuperstateToken adds accounting pause and enforces allowlist in _beforeTokenTransfer; USCC fund code and tests now validate receiver allowlist and accounting pause behavior.

Fee Calculation Refactor

Layer / File(s) Summary
Fee Computation Logic
src/manager/base/PositionManagerBase.sol
Refactors _pendingFees(): reads totalSupply_ via ERC20.totalSupply(), computes management and performance fee assets separately (management capped at totalAssets_), charges performance only when totalAssets_ > _lastTotalAssets and gains > managementFeeAssets, sums totalFeeAssets, uses feeAdjustedAssets = totalAssets_ - totalFeeAssets as conversion base, computes managementFeeShares, and derives performanceFeeShares as remainder.
Fee Tests
test/manager/PositionManagerFee.t.sol
Adds tests validating management-fee shares use fee-adjusted base, redeeming fee shares yields approximately advertised fee assets, combined-fee share math matches separated portions, and long-elapsed-time fee assets cap correctly without underflow.

Sequence Diagram(s)

sequenceDiagram
    participant Caller
    participant TransferGuard
    participant PositionManager
    participant WrappedAsset
    participant Superstate

    Caller->>TransferGuard: canTransfer(token, from, to, amount)
    TransferGuard->>TransferGuard: load tokenConfig (pausedUntil, mode, checkCollateral)
    alt token paused
        TransferGuard-->>Caller: false
    else
        alt mode requires NATIVE and neither party NATIVE (and not mint/burn)
            TransferGuard-->>Caller: false
        else
            alt checkCollateralAllowed == true
                TransferGuard->>PositionManager: assets()
                PositionManager-->>TransferGuard: collateralAddress
                alt from != address(0)
                    TransferGuard->>WrappedAsset: isAllowed(from, amount)
                    WrappedAsset->>Superstate: isAllowed(from)
                    Superstate-->>WrappedAsset: bool
                    WrappedAsset-->>TransferGuard: bool
                    alt not allowed
                        TransferGuard-->>Caller: false
                    end
                end
                alt to != address(0)
                    TransferGuard->>WrappedAsset: isAllowed(to, amount)
                    WrappedAsset->>Superstate: isAllowed(to)
                    Superstate-->>WrappedAsset: bool
                    WrappedAsset-->>TransferGuard: bool
                    alt not allowed
                        TransferGuard-->>Caller: false
                    end
                end
            end
            TransferGuard-->>Caller: true
        end
    end
Loading
sequenceDiagram
    participant User
    participant WrappedAsset
    participant Superstate
    participant Allowlist

    User->>WrappedAsset: mint(to, amount)
    WrappedAsset->>WrappedAsset: _beforeTokenTransfer(0, to, amount)
    alt to != address(0)
        WrappedAsset->>WrappedAsset: isAllowed(to, amount)
        WrappedAsset->>Superstate: isAllowed(to)
        Superstate->>Allowlist: check address
        Allowlist-->>Superstate: allowed?
        Superstate-->>WrappedAsset: allowed?
        alt not allowed
            WrappedAsset-->>User: revert
        end
    end
    WrappedAsset->>WrappedAsset: role checks (SENDER/RECEIVER)
    WrappedAsset->>Superstate: transferFrom(...)
    Superstate-->>WrappedAsset: success
    WrappedAsset-->>User: mint completed
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Poem

🐰 I hop through modes from block to native bright,

I guard wrapped hops both day and night,
Collateral whispers, "check me please",
Fees settle softly with careful ease,
A rabbit cheers for safer token flights.

@maximebrugel maximebrugel changed the title v1.0.0 v1.1.0 May 4, 2026
Convert combined management + performance fee assets to shares
against `totalAssets - totalFeeAssets` so the minted shares redeem
to exactly the advertised fee assets, instead of underpaying fees
by pricing them against the pre-fee pool that still contains the
fee slice.
@maximebrugel maximebrugel changed the title v1.1.0 v1.1.0 (Cantina Fixes + Superstate Allowlist Restrictions) May 4, 2026
…es (#176)

In PositionManagerLP._settleShares(), the no-share-movement branches only
checked paused() and skipped blocklist, whitelist, native-only, and
collateral-allowed checks. A blocklisted MINTER_ROLE caller could perform
a neutral deposit/withdraw and bypass the guard entirely. Replace the
paused()-only check with canTransfer(msg.sender) — the same convention
used by _beforeTokenTransfer — and add coverage for blocklisted callers
in both zero-delta and zero-mint branches.
The onRequestConsumed callback in consume() could re-enter pullFunds()
or authorizeMinting() because those entrypoints lacked the nonReentrant
guard the rest of the contract relies on. A maker holding PULLER_ROLE
could withdraw and re-deposit the pending principal to mint PT/YT
without net new capital; a maker owning the Request could mint a fresh
authorization mid-consume. Bring the whole contract under one
reentrancy domain and cover the new vectors with mirroring tests.
Closes 3F-244. Three deviations from EIP-20 are addressed on the user-facing
PT/YT surface (transfer, transferFrom, approve):

1. Transfer event is now emitted on zero-value transfers, on the relevant
   token only (no spurious zero-value event on the sibling token).
2. Approval event is emitted on every successful approve, including when the
   stored value is unchanged.
3. Approve amounts in [type(uint128).max, type(uint256).max - 1] used to be
   silently clamped to the infinite-allowance sentinel and read back as
   type(uint256).max. They now revert with AllowanceTooLarge. Only values
   strictly below type(uint128).max (stored exactly) and exactly
   type(uint256).max (stored as the infinite sentinel) are accepted.

Implementation: event emission was lifted out of the inner _transfer and
_setAllowance helpers up to their public callers, where the relevant token
is known; _approve now bypasses _setAllowance and writes the sibling
allowance verbatim so the sentinel is never re-validated. Unused
LibAllowance.normalize was removed since the public allowance() view
inlines its logic.
ParetoFund.create() previously accepted orders that were guaranteed to
revert at commit() time when the upstream CDO had `isDepositDuringEpochDisabled`
or `allowAAWithdrawRequest=false`. Such stale ACCEPTED orders blocked
subsequent orders and bound Facility intents until manually canceled.

Reject deposit orders when the vault is running an epoch with
`depositDuringEpoch` disabled, and redeem orders when AA withdrawal
requests are disabled, with dedicated errors so failures are unambiguous.
)

Without this check, a redeem order would be accepted but its commit
would always revert in offchainRedeem (a burn path), leaving the order
stuck in ACCEPTED and blocking subsequent orders until manually canceled.
* fix: settle shares before outbound transfer in deposit/withdraw

`deposit()` and `withdraw()` previously transferred tokens to the caller
between `processDeposit/processWithdrawal` (which mutates NAV) and
`_settleShares()` (which mints/burns the matching shares), leaving
`totalAssets()` and `totalSupply()` desynced during the external call.
Move `_settleShares()` ahead of the outbound transfer so any token hook
or third-party view observes a consistent share price.

Add Solady's `nonReadReentrant` to `totalAssets()`, `totalSupply()` (via
override), `pendingFees()`, `collateralAmount()`, `collateralAmountQuoted()`,
and `debtAmount()` as defense-in-depth. Internal callers in the guarded
scope read `ERC20.totalSupply()` directly to bypass the override.

* fix: forge fmt
Audit finding 3F-262 flagged the absence of a reentrancy guard on
MorphoBorrowPosition.preLiquidate() and onMorphoRepay(); its own
recommendation was to keep them unguarded since callback re-entry is
economically equivalent to sequential liquidation calls. Capture that
rationale and the load-bearing role assumption (liquidators must never
hold MINTER_ROLE / FACILITATOR_ROLE) in NatSpec so the design decision
is visible at the point of reading and is not re-flagged in future
audits.
…ialize (#187)

Solady's _initializeOwner does not revert when owner is address(0), so each
contract inheriting OwnableRoles must guard its own initialize. Mirrors the
existing Facility.initialize check using LibChecks.checkNotZero.
When a WrappedAsset is shared across fund instances, totalAssets()
returns the aggregate supply across all instances, not per-fund AUM.
Document this on IFund and on USCCFund/CentrifugeFund/ParetoFund so
integrators don't assume per-fund scoping.
…#181)

* fix: allow ParetoFund redeems when vault is in keyringAllowWithdraw mode

ParetoFund.create/commit always required isWalletAllowed(address(this)),
which is stricter than the upstream IdleCDOEpochVariant — the vault skips
the wallet allowlist when keyringAllowWithdraw is true (used for
liquidations and other open-withdraw modes). Wrapped AA holders could
therefore be locked out of Grunt while the underlying vault still accepted
withdrawals.

Align the check with upstream for REDEEM orders: pass if isWalletAllowed
or keyringAllowWithdraw. DEPOSIT orders remain strictly gated on
isWalletAllowed.

* test: align invariant_safeLtvEnforcement with _isHealthy debt predicate

bp.totalBorrowed() rounds down via toAssetsDown, but _isHealthy
short-circuits on borrowShares == 0 and otherwise rounds up via
toAssetsUp. Dust shares left after a burn could leave totalBorrowed()
at 0 while _isHealthy still treated the position as having debt,
producing a false PM-7 failure under extreme oracle prices.

Use the raw Morpho borrowShares as the "no debt" predicate so the
invariant agrees with _isHealthy.

* forge fmt
…#182)

* fix: validate wUSCC receiver against Superstate allowlist in USCCFund

create() and commit() previously only checked that the fund itself was
Superstate-allowlisted. Once wUSCC mint gates on the receiver's allowlist
status, an order could pass create()/commit() with an ineligible receiver
and then get permanently stuck at unlock()/recover() when the wrapper
mint reverts. Re-validate order.receiver in both entry points so bad
orders are rejected before USDC leaves the fund.

* forge fmt
Decouple Request._canWithdraw() from the stored `repaid` flag so that
canWithdraw, convertToAssets, maxWithdraw, and maxRedeem reflect effective
state once the repayment deadline has passed, without requiring a prior
syncRepaidStatus() call. Reorder _redeem/_withdraw to sync before pricing,
which prevents the first post-deadline redeem from reverting against an
empty balance.
Documentation pass addressing audit-flagged drift between code and comments,
plus one missing invariant and two minor code tweaks.

- Fix `intialBeaconOwner` typo in RequestFactory.
- Rewrite mode-aware NatSpec for IFund.commit/recover/unlock and
  IFacilityFunds.create to spell out DEPOSIT vs REDEEM semantics.
- Fix LibIntent.init NatSpec to mention transferableIntent flag.
- Loosen README updateTarget allowed-states (DEPOSITING / RESOLVING).
- Clarify guardKey field doc in IntentProperties.
- Fix ParetoFund.totalAssets virtualPrice decimals comment (WAD-scaled).
- Fix LibPause.pauseFor cast-safety comment.
- Document WrappedAsset.burn redemption flow and zero-address transfer
  behavior under Solady ERC20.
- Document Solady ERC20 storage compatibility in LibTokenController.
- Document seizedAssets source on MorphoBorrowPosition.onMorphoRepay.
- Document maxRebalanceLoss==0 caveat for 1-2 wei phantom NAV losses.
- Add USCCFund constructor invariant: WUSCC.underlying() == USCC,
  mirroring Pareto/Centrifuge.
- Switch VaultController._assetsAndSupplies to IERC20.balanceOf.
@maximebrugel maximebrugel requested a review from maxencerb May 14, 2026 14:20
@maximebrugel maximebrugel merged commit adcdd50 into main May 14, 2026
3 checks passed
@maximebrugel maximebrugel deleted the v1.1.0 branch May 14, 2026 14:31
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