feat(EXSC-409): core ERC-4626 vault wrapper (S1)#1992
Conversation
…system constraints) Encode the Diamond/Vault-Wrapper product boundary in the agent harness: - 002-architecture.md (always-apply): add [CONV:ARCH-VAULTWRAPPER] so the boundary fires unconditionally every session, correcting the rule's Diamond-only framing. - 108-vault-wrapper.md (new, scoped): operational constraints that activate on src/VaultWrapper/**, test/solidity/VaultWrapper/**, and the vault-wrapper deploy scripts. The deploy-script glob makes the rule fire at the decision point where the load-bearing script/deploy/facets/ location matters. Product boundary lives in always-apply 002; operational detail in the scoped rule (no duplication). Volatile state stays in Linear. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ultWrapper/ Reflect the S8 decision (#1936) to move the vault-wrapper deploy script out of the Diamond-era script/deploy/facets/ lookup dir into its own folder. Flip the [CONV:VW-DEPLOY-DIR] guidance: scripts are standalone, run by explicit path, and deliberately decoupled from the Diamond deploy tooling — not kept in facets/. Update the activation glob from facets/ to script/deploy/vaultWrapper/**. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add LiFiVaultWrapper, the Solady ERC-4626 beacon implementation behind the factory: write-once initialize, virtual-share inflation mitigation, reentrancy guards, and deposit/withdraw routing to the underlying yield source via an IYieldAdapter delegatecall (the read path stays a plain view call). - Extend IYieldAdapter with deposit/withdraw (delegatecall-only, stateless) and totalAssets(underlying, holder); implement them in ERC4626Adapter. - Capture the deploying factory write-once for the later global circuit breaker. - Replace the temporary MockVaultWrapper: the factory, beacon, and timelock tests now exercise the real implementation; only the underlying is mocked. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…FiVaultWrapper.sol:initialize) CodeRabbit: initialize had no event for off-chain indexing of an instance's config. Emit Initialized(asset, underlying, adapter, vaultWrapperAdmin, factory, integratorShareBps) and cover it with a vm.expectEmit test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pper.sol:feeRate) CodeRabbit: feeRate/feeEnabled indexed the [4] arrays by _feeType with only Solidity's implicit out-of-bounds panic. Add an explicit InvalidFeeType(uint8) revert making the valid range (0-3) explicit, with a revert test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each instance now reports a distinct ERC20 name/symbol composed from the
underlying asset symbol ("LI.FI Earn USDC" / "lfUSDC") instead of a shared
generic string, so wallets and explorers can tell instances apart. Reads the
symbol via Solady MetadataReaderLib (handles string/bytes32/missing) and falls
back to "VW" when the asset exposes no symbol.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the upcoming-feature call sites now so follow-up tickets only fill in bodies. Adds a _beforeOperation() guard (pause -> access -> fee accrual) at the top of deposit/mint/withdraw/redeem, overrides the four preview functions to route through _entryFee/_exitFee, and skims/routes fees in the deposit/withdraw hooks via _routeFee. All stubs are no-ops (fees return 0, guards pass), so behaviour is unchanged and the suite stays green; bodies land in the fee (S2), access (S4), and pause (S5) tickets. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the Solady ERC4626 base with OpenZeppelin's ERC4626Upgradeable (v4.9.2, matching the vendored openzeppelin-contracts). Source-verified survey of major vault protocols showed no production use of Solady's ERC4626 and no documented standalone audit of it, whereas OZ's base has broad precedent (MetaMorpho, Gearbox, Ethena) — and Kiln DeFi, the closest comparable (integrator-facing beacon-proxy vaults with delegatecall connectors + per-vault fees), is built on ERC4626Upgradeable. Changes: - Add openzeppelin-contracts-upgradeable submodule (v4.9.2) + remappings; switch the bare @openzeppelin/ remapping to explicit @openzeppelin/contracts/ and @openzeppelin/contracts-upgradeable/ prefixes to avoid the auto-detect clash. - asset/decimals/name/symbol now live in OZ proxy storage via __ERC4626_init / __ERC20_init (per-instance name/symbol still derived from the asset symbol). - initialize uses OZ's initializer guard; adapter routing moves into _deposit / _withdraw overrides (OZ has no after/before hooks); reentrancy via ReentrancyGuardUpgradeable. - Unchanged: delegatecall IYieldAdapter, no-op fee/pause/access seams, fee-adjusted preview overrides, factory and ILiFiVaultWrapper interface. Inflation protection now relies on OZ's virtual-share offset (default +1). All 92 vault-wrapper tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…:278) CR (minor): a delegatecalled adapter runs in this wrapper's storage context, so "stateless, cannot touch storage" is misleading — statelessness is an audit/governance invariant, not enforced by this contract. Reworded accordingly.
…pper.sol:29) CR (major): the proxy initializer is guarded but the implementation itself could be initialized directly. Add a constructor calling _disableInitializers() (OZ best practice, matches Kiln). Rework the unit-test harness to deploy the impl behind an UpgradeableBeacon + BeaconProxy so initialize is still exercised end-to-end through a proxy.
CR (major): a single-shot initializer should not rely solely on upstream factory validation. Reject zero asset/underlying/adapter/admin, integratorShareBps over 10000, and an adapter that resolves the underlying to an asset other than the one passed (defense-in-depth against bricking/mispricing). Add testRevert_ coverage for each path. Redundant in the factory flow but cheap insurance on a fund-custody contract.
CR (major): the prior override pulled from the yield source before super._withdraw spent the allowance and burned shares. Inline OZ's sequence so checks/effects (spend allowance, burn) precede the adapter interaction and the asset transfer, satisfying Checks-Effects-Interactions instead of relying solely on nonReentrant.
# Conflicts: # script/deploy/vaultWrapper/DeployLiFiVaultWrapperFactory.s.sol # src/VaultWrapper/adapters/ERC4626Adapter.sol # src/VaultWrapper/mocks/MockVaultWrapper.sol
Move all VaultWrapper code (wrapper, adapter, deploy script, tests) onto OpenZeppelin v5.6.1 while the Diamond stays on the vendored OZ v4.9.2. - Vendor a second OZ core submodule (lib/openzeppelin-contracts-v5 @ v5.6.1) because v5's upgradeable package imports the non-upgradeable core as a peer. - Bump lib/openzeppelin-contracts-upgradeable v4.9.2 -> v5.6.1. - Route VaultWrapper src/test/script and the upgradeable lib to v5 core via path-scoped remappings; the global @openzeppelin/contracts/ stays v4.9.2. - LiFiVaultWrapper: ReentrancyGuardUpgradeable -> core ReentrancyGuard (v5 is proxy-safe, no __init), IERC20Upgradeable/SafeERC20Upgradeable -> IERC20/ SafeERC20; extract _initErc4626Metadata to avoid v5 stack-too-deep. - Deploy script: v5 UpgradeableBeacon(impl, initialOwner) sets the timelock as owner at construction. - Tests: v5 custom errors (InvalidInitialization, ReentrancyGuardReentrantCall, OwnableUnauthorizedAccount, BeaconInvalidImplementation, TimelockUnexpectedOperationState) and DEFAULT_ADMIN_ROLE. 97 VaultWrapper tests pass; full repo build green (Diamond unaffected). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LiFiVaultWrapper and the deploy script are unreleased; revert the @Custom:version churn (2.0.0 -> 1.0.0, 1.3.0 -> 1.2.0) so this PR introduces no version change against its base. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add [CONV:VW-OZ-VERSION] (dual-OZ-version split + path-scoped remappings; the high-value, silent-failure constraint) and note v5's two-arg UpgradeableBeacon constructor in [CONV:VW-BEACON]. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace Solady LibClone beacon-proxy deployment with OZ BeaconProxy + Create2 so the VaultWrapper proxy layer is single-vendor OZ. A shared _proxyInitCode() keeps deploy and predictAddress hashing identical init code; salt reuse now reverts with OZ Errors.FailedDeployment. Solady remains only for MetadataReaderLib (no OZ equivalent). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two hardening fixes flagged by review of the S1 core: 1. Adapter return values were ignored and ERC4626Adapter echoed its input, so a fee-charging or short-paying yield source could silently dilute minted shares or let a withdrawal pull from the wrapper's buffer. The adapter now returns the actual asset balance delta, _routeThroughAdapter forwards it, and _deposit/_withdraw revert with AdapterDepositShortfall / AdapterWithdrawShortfall when the yield source moves less than requested. 2. Add a reserved __gap to the upgradeable beacon implementation so future versions can append wrapper state without shifting derived-module storage (append-only, no-reorder upgrade invariant documented inline). Tests: a configurable LossyVault proves both shortfall guards fire; the 58/99 existing suites stay green (standard vaults move exact amounts). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address the review point that the balance-delta deposit measurement does not catch share-side dilution (a vault that consumes the full asset but credits fewer shares). Rather than switch to convertToAssets(shareDelta) — which rounds below the requested amount on any non-1:1 vault and would revert healthy deposits — keep the rounding-safe delta guard (which catches short-accepting sources) and document that internal-deposit-fee / fee-on-transfer sources are unsupported and need a dedicated adapter. Makes the previously-silent 1:1 assumption explicit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…1.0.0 Review follow-ups on the S1 PR: - ERC4626Adapter: replace Solady SafeTransferLib.safeApproveWithRetry with OZ SafeERC20.forceApprove so the wrapper subsystem stays single-vendor OZ. - Use named arguments on all IERC4626 calls (deposit/withdraw/convertToAssets, balanceOf). - Reset in-development @Custom:version tags to 1.0.0 (adapter, IYieldAdapter, deploy script). - 108-vault-wrapper rule: drop the stale CWIA parenthetical and the historical "do not reintroduce Solady LibClone" note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…o (LiFiVaultWrapperFactory.sol:174,272)
CodeRabbit (major): the split validation allowed integratorShareBps == 100%,
leaving LI.FI 0% — violating rule 108-vault-wrapper line 72 ("the integrator/LI.FI
split is validated < 100% only"). Both the setDefaultSplit and deploy paths used
`> BPS_DENOMINATOR`; tightened to `>=` and aligned the NatSpec. Added boundary
tests asserting exactly 100% reverts and 99.99% is accepted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code-review follow-ups on the S1 PR: - initialize: rejected only > 100% while the factory (and rule 108) enforce < 100%; aligned the wrapper's defense-in-depth check to >= 10_000 and assert exactly 100% reverts. - _withdraw: stop reimplementing OZ's withdraw sequence; override only the _transferOut seam so OZ keeps the allowance spend, share burn, and Withdraw event. Behaviour is unchanged (redeem assets+fee, revert on shortfall before paying the receiver, skim fee, transfer assets) and it removes the asymmetry with _deposit plus the risk of drifting from OZ on upgrade. - cache asset() in a local across the two reads in the withdraw path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- initialize no longer takes _asset: it derives the asset from _underlying via the adapter's resolveAsset, so the stored asset cannot disagree with what the adapter reports. Drops the redundant param and the now-impossible AssetMismatch error/check; the factory still resolves the asset for its WrapperDeployed event. - vaultWrapperAdmin is now transferable via a two-step (propose/accept) flow, mirroring Ownable2Step, so the per-vault controller can be rotated without a redeploy and a mistyped address cannot strand the role. Reserved storage gap reduced 50 -> 49 to absorb the new pendingVaultWrapperAdmin slot. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…LiFiVaultWrapper.sol:147) CodeRabbit (major): removing _asset dropped the implicit zero-check the old AssetMismatch validation provided. The reference adapter reverts on a zero asset, but the wrapper should not trust an arbitrary governance-approved adapter. Revert ZeroAddress when resolveAsset returns address(0); test via MockZeroAdapter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…trancyGuard OZ v5.6.1 ships no ReentrancyGuardUpgradeable and ReentrancyGuardTransient requires EIP-1153 on every target chain. Document that the plain guard is proxy-safe here (the check treats the proxy's zero-initialized slot as NOT_ENTERED) so the recurring review flag has a standing answer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The earlier note claimed the guard occupies sequential slot 0; that is wrong. OZ v5's ReentrancyGuard (v5.5.0+) stores its status in a fixed ERC-7201 namespaced slot via StorageSlot and is @Custom:stateless, so it occupies no sequential slot (the wrapper's own `underlying` is slot 0) and is collision-free behind a beacon proxy — which is why OZ retired the Upgradeable variant. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Test Coverage ReportLine Coverage: 90.20% (3602 / 3993 lines) |
S5. Add VaultWrapperPausable abstract mixin with two independent instance flags (emergencyPaused via the LI.FI emergency multisig read live from the factory, integratorPaused via the vault admin) plus a live factory.globalPaused() read. The pause guard is wired into deposit/mint only; withdraw/redeem stay structurally open under every pause combination. _requireNotPaused was removed from the shared _beforeOperation (which previously gated exits too) and replaced by _requireDepositsNotPaused on inflows — fixing the latent freeze-on-exit defect. ILiFiVaultWrapperFactory gains globalPaused()/emergencyPauser() view declarations (already satisfied by the factory's public getters). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial review gaps: add a real-factory end-to-end suite proving factory.globalPause() freezes a live instance's deposits (and unpause resumes), plus the documented carve-out that a deploy while globally paused yields a frozen-from-birth instance. Cover mint() under integrator and global pause, and withdraw-open under global-pause-alone. Blank line after pragma in LiFiVaultWrapper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Append-only upgrade gap so the pause mixin can gain state without shifting the storage of the contracts that inherit it, consistent with the wrapper's own gap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… breaker Removes the wrapper-level emergency pause (story A13): the LI.FI emergency authority now halts an instance's deposits only via the factory-level global circuit breaker (A14), never per-instance. Integrator pause (I28) and the withdrawals-always-open guarantee (U20) are unchanged. - VaultWrapperPausable: drop emergencyPaused/emergencyPause(Unpause)/ EmergencyPauseSet/NotEmergencyPauser/onlyEmergencyPauser/_emergencyPauseAuthority; depositsPaused() = integratorPaused || _globalPaused(). - Wrapper: drop the _emergencyPauseAuthority() override (no factory.emergencyPauser() read). - ILiFiVaultWrapperFactory: drop the now-unused emergencyPauser() view (keep globalPaused()); the factory's own NotEmergencyPauser error is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iew 1+3) - maxDeposit/maxMint return 0 while any pause source is engaged, so EIP-4626 consumers see the vault as closed and don't build deposits that revert. - Move the pause gate from the deposit/mint entrypoints into the single _deposit chokepoint both route through, gating every current and future inflow by construction. Consequence: with max* == 0, OZ's deposit/mint reject a non-zero amount with ERC4626ExceededMaxDeposit/Mint before _deposit runs; the _deposit guard (DepositsPaused) is the backstop for zero-amount / non-standard inflows that clear the max check. Tests updated accordingly (+ blank-line fix per CONV:BLANKLINES). 123/123 VaultWrapper tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the hand-rolled vaultWrapperAdmin two-step transfer with OZ Ownable2StepUpgradeable. owner() is the per-vault admin, rotated via transferOwnership/acceptOwnership. renounceOwnership is overridden to revert so the custody contract can never be left ownerless. OZ v5 stores _owner/_pendingOwner in ERC-7201 namespaced slots, freeing the two sequential admin slots; __gap goes 49 -> 50. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…into feature/exsc-413-s5-pause-instance-global-read-withdrawals-always-open # Conflicts: # src/VaultWrapper/LiFiVaultWrapper.sol
…ble mixin Dissolve the VaultWrapperPausable mixin into LiFiVaultWrapper. The mixin's indirection (integrator-pause-authority hook, requireDepositsNotPaused helper, global-paused hook, custom NotIntegratorAdmin modifier/error) added no value now that the wrapper is the only consumer. - pause()/unpause() replace integratorPause()/integratorUnpause(), gated by OZ's existing onlyOwner (the authority was always owner()). - Pause check inlined at the _deposit chokepoint; global breaker read inlined into depositsPaused(). - Storage bool integratorPaused -> paused (packs with integratorShareBps); event IntegratorPauseSet -> PauseSet. Behaviour unchanged: dual gate (max*==0 + _deposit guard) on inflows, exits always open (U20). Unreleased v1.0.0, so the storage-layout shift is a no-op. 124/124 VaultWrapper tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The instance `paused` flag is now toggled by either the per-vault owner (integrator) or the factory's emergency pauser, in either direction — a single shared flag, so one authority can lift the other's pause. Adds an `onlyPauseAuthority` modifier (owner() or factory.emergencyPauser(), read live) and a `NotPauseAuthority` error; re-adds `emergencyPauser()` to the factory interface so the wrapper can read it. The factory-level global breaker is unchanged. 127/127 VaultWrapper tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hten pause bodies Move the modifier above the functions into a dedicated Modifiers section and drop the blank line before the emit in pause()/unpause(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the pause guard from the _deposit chokepoint to the deposit()/mint() entrypoints, reverting the named DepositsPaused before super runs (code-review). Previously maxDeposit/maxMint==0 made OZ revert ERC4626ExceededMaxDeposit/Mint first, so DepositsPaused was unreachable on the normal path and callers saw a misleading capacity error. maxDeposit/maxMint still return 0 for EIP-4626 consumers. Also: PauseSet NatSpec now names both pause authorities; the LiFiVaultWrapper.t.sol factory stub gained emergencyPauser(). 127/127 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🔍 QA Review — EXSC-409 · S1 — Core ERC-4626 + immutable args (CWIA)
PR: #1992 · What this PR doesIntroduces Ticket quality notesThe title references "CWIA" (Clones-with-immutable-args), and the AC states "Clone immutable args via clones-with-immutable-args." The implementation uses OZ Acceptance Criteria check
Version / Audit check
FindingsC-1 — CI Failure:
|
| Area | Result |
|---|---|
Delegatecall target (adapter) |
Write-once at init, no setter. ERC4626Adapter is stateless. ✅ |
| Initializer guard | _disableInitializers() in constructor; initializer on initialize(). ✅ |
| Reentrancy | nonReentrant on all four entrypoints; OZ v5 ERC-7201 slot (collision-free). ✅ |
CEI in _deposit |
super._deposit() (pull + mint) before adapter delegation; revert unwinds atomically. ✅ |
CEI in _transferOut |
Burn happens inside OZ _withdraw before _transferOut; shortfall check fires before receiver transfer. ✅ |
| Admin role | Two-step ownership; renounceOwnership disabled. Appropriate for a custody contract. ✅ |
forceApprove in adapter |
Under delegatecall approves yield source for wrapper's asset. Target is governance-approved. ✅ |
| Adapter shortfall guards | AdapterDepositShortfall + AdapterWithdrawShortfall tested with LossyVault. ✅ |
| Inflation attack | _decimalsOffset() == 0 + live totalAssets() read. Not a bug; NatSpec should be more explicit (L-4). |
| Beacon upgrade path | Owner = 48h timelock; upgradeTo gated. No bypass in this PR. ✅ |
SafeERC20 direct use |
CI check failing — C-1. Architecturally intentional; needs allowlist carve-out. |
Test coverage assessment
| Scenario | Covered |
|---|---|
| Round-trip deposit/withdraw/mint/redeem | ✅ |
| Preview correctness vs actual | ✅ |
| Yield accrual share math | ✅ |
| Two-depositor proportional shares | ✅ |
| Adapter revert bubbles up | ✅ |
| Reentrancy blocked | ✅ |
| Deposit/withdraw shortfall from adapter | ✅ |
| Two-step ownership transfer | ✅ |
renounceOwnership disabled |
✅ |
initialize() rejects zero admin |
✅ |
| Double-init blocked | ✅ |
initialize() rejects zero underlying |
❌ L-5 |
initialize() rejects zero adapter |
❌ L-5 |
| Allowance-based withdraw (caller ≠ owner) | ❌ L-6 |
| Fees / access / pause (S2–S5) | Out of S1 scope — deferred |
Coverage report: Line 89.83% / Function 93.27% / Branch 72.83% (uncovered branches are primarily no-op stubs for S2–S5 seams — expected for S1 scope).
Ticket coverage rating: Substantial, with one blocking CI issue and two AC discrepancies requiring explicit acceptance
Downstream impact
- EXSC-410 (S2 — Fee engine), EXSC-412 (S4 — Access), EXSC-413 (S5 — Pause), EXSC-414 (S6 — MEV/slippage), EXSC-415 (S7 — Metadata), EXSC-419 (S13 — Composer) all block on this PR. The
previewMintfee-basis mismatch (L-7) is a direct risk for S2. - C-1 (CI allowlist) must be resolved to unblock PR merge; it does not block downstream development on stacked PRs.
✅ Verdict: Needs Work
| Severity | Count | Items |
|---|---|---|
| 🔴 Critical | 1 | C-1 — CI enforceLibAssetRouting allowlist missing VaultWrapper files (red CI) |
| 🟠 Medium | 2 | M-2 — CWIA/chainLockId AC deviation; M-3 — IYieldAdapter v2.0.0 versioning |
| 🟡 Low | 5 | L-4 — NatSpec inflation posture; L-5 — init zero-addr tests; L-6 — allowance withdraw tests; L-7 — previewMint fee basis; L-8 — Initialized event name |
| ℹ️ Informational | 2 | I-9 — impl initialized() returns true (expected); I-10 — OZ override param naming (acceptable) |
Posted by lifi-qa-agent[bot] · EXSC-409 · QA Agent SDK
There was a problem hiding this comment.
Requesting changes on 8 items — each requires either a code fix or an explicit acceptance comment with justification before this review is considered complete.
| # | Severity | Type | Issue / File |
|---|---|---|---|
| 1 | 🔴 Critical | Code | enforceLibAssetRouting CI check failing — VaultWrapper files not in allowlist |
| 2 | 🟠 Medium | Documentation | AC / ticket title claims CWIA; implementation uses BeaconProxy; chainLockId absent |
| 3 | 🟠 Medium | Convention | IYieldAdapter.sol versioned at 2.0.0 with no deployed 1.0.0 predecessor |
| 4 | 🟡 Low | Documentation | NatSpec missing _decimalsOffset() == 0 risk acceptance and inflation posture |
| 5 | 🟡 Low | Test gap | test/solidity/VaultWrapper/LiFiVaultWrapper.t.sol (existing) |
| 6 | 🟡 Low | Test gap | test/solidity/VaultWrapper/LiFiVaultWrapper.t.sol (existing) |
| 7 | 🟡 Low | Documentation | previewMint/previewRedeem fee-basis mismatch creates forward risk for S2 |
| 8 | 🟡 Low | Convention | Initialized event name collides with OZ Initializable's event in the ABI |
1. [Critical] enforceLibAssetRouting CI check is red — VaultWrapper files missing from allowlist
src/VaultWrapper/LiFiVaultWrapper.sol and src/VaultWrapper/adapters/ERC4626Adapter.sol both use SafeERC20.safeTransfer() / forceApprove() directly. The enforceLibAssetRouting.yml workflow scans all of src/ and fails unless the file is on its allowlist. Neither is. The check is currently red.
The use is architecturally correct (CONV:VW-OZ-VERSION sanctions OZ v5 SafeERC20 for the VaultWrapper subsystem — it is not a Diamond contract, so LibAsset routing does not apply). The CI allowlist needs a carve-out:
# VaultWrapper subsystem uses OZ v5 SafeERC20 directly (CONV:VW-OZ-VERSION);
# standalone product, not Diamond — LibAsset routing does not apply.
"src/VaultWrapper/LiFiVaultWrapper.sol"
"src/VaultWrapper/adapters/ERC4626Adapter.sol"2. [Medium] Ticket AC deviation — CWIA / chainLockId
The ticket title ("immutable args (CWIA)") and AC ("Clone immutable args … via clones-with-immutable-args") conflict with the implementation (OZ BeaconProxy + write-once initialize() storage, no bytecode-immutable args, no chainLockId). The CONV:VW-BEACON rule documents the BeaconProxy choice as locked and correct.
Required: Update EXSC-409's AC and title to reflect the BeaconProxy/initialize design, and note chainLockId status (deferred to initData or a later ticket). Alternatively, add a team comment on the PR explicitly accepting the deviation with rationale. This is required before QA can verify the ticket against its stated ACs.
3. [Medium] IYieldAdapter.sol @custom:version 2.0.0 — incorrect semver for an undeployed interface
A MAJOR semver bump signals a breaking change against an already-released version. There is no deployed IYieldAdapter v1.0.0 to break from — this is the initial ship. Every other new file in the subsystem starts at 1.0.0. Change @custom:version 2.0.0 to @custom:version 1.0.0.
4. [Low] NatSpec gap — _decimalsOffset() == 0 inflation posture
@custom:notice says "Inflation-attack protection relies on the ERC-4626 virtual-share offset." But _decimalsOffset() is not overridden and defaults to 0. Because totalAssets() reads a live balance from the underlying vault, a large direct transfer inflates totalAssets() and can disadvantage a first depositor.
Add a NatSpec note explaining: (a) why offset=0 is acceptable here, (b) that totalAssets() reads live balance intentionally, (c) what factory-side or operational mitigation applies (e.g., minimum first deposit, or yield source's own inflation protection).
5. [Low] Test gap — LiFiVaultWrapper.t.sol
- Missing:
testRevert_InitializeRejectsZeroUnderlying— verify_underlying == address(0)triggersZeroAddress() - Missing:
testRevert_InitializeRejectsZeroAdapter— verify_adapter == address(0)triggersZeroAddress()
6. [Low] Test gap — LiFiVaultWrapper.t.sol
- Missing:
test_WithdrawByApprovedCaller— Alice deposits, approves Bob's share allowance, Bob callswithdraw(assets, alice, alice), verify shares burned from Alice and assets reach Alice - Missing:
test_RedeemByApprovedCaller— symmetric forredeem
7. [Low] previewMint/previewRedeem fee-basis mismatch — forward S2 risk
previewMint applies _entryFee on the net amount; _deposit applies it on the gross amount. When S2 enables non-zero entry fees, the quoted cost and the actual deduction will drift on the mint path. Same asymmetry on previewRedeem / _transferOut for exit fees.
Not a bug today (fees are zero). Add a // NOTE(S2): _entryFee here is applied to NET; _deposit applies to GROSS — align before enabling non-zero entry fees comment in previewMint and a corresponding note in previewRedeem.
8. [Low] Initialized event name collides with OZ Initializable's event
LiFiVaultWrapper declares event Initialized(address indexed asset, ...). OZ Initializable v5 emits event Initialized(uint64 version). Different signatures → different topic hashes, but both events appear in the ABI and both fire during initialize(), creating noise for indexers and SDK consumers that match by name.
Rename to event WrapperInitialized(...).
💡 Once you've addressed the items above, re-apply the "Agent Review Request" label to trigger an automated re-review.
The VaultWrapper subsystem is standalone OpenZeppelin v5 (SafeERC20 / forceApprove), not a Diamond contract, and is scoped to its own OZ v5 remapping. LibAsset is a v4.9.2 Diamond library and does not apply. Add a folder-level prefix exemption so subsystem files pass the check and new files stay covered without per-file allowlist churn. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The VaultWrapper subsystem is pre-release, so there is no shipped 1.0.0 to MAJOR-bump from. Align IYieldAdapter with the rest of the subsystem, which is all at 1.0.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…exsc-409-s1-core-erc-4626-immutable-args-cwia
…obal breaker Per review (mirooon / QA): the shared instance-pause flag diverged from the authoritative Pause Authority Matrix + Legal Topic 3. New model: the integrator (owner) is the sole authority over the instance `paused` flag; LI.FI's only lever over a clone is the factory-level global circuit breaker. This makes I28 'no LI.FI involvement' hold and removes the cross-lift. - pause()/unpause() gated by OZ onlyOwner; drop onlyPauseAuthority modifier and NotPauseAuthority error; drop emergencyPauser() from the factory interface (no longer read by the wrapper). depositsPaused() = paused || factory.globalPaused(). Also addresses review nits: - rename two E2E tests to testRevert_ (call vm.expectRevert) [400-solidity-tests] - add testRevert_StrangerCannotUnpause; add test_OwnershipTransferOpenUnderPause - add address(wrapper) emitter to test_PauseSettersEmitEvents - NatSpec: previews intentionally ignore pause per EIP-4626; TODO(pause) seam for the future V2 reward-injection inflow 126/126 VaultWrapper tests pass; solhint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The event shared the name and firing point of OpenZeppelin Initializable.Initialized, which lands both in the ABI and confuses indexers/log readers. Rename to VaultWrapperConfigured. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The standalone Earn Vault Wrapper builds on OpenZeppelin v5, whose ERC4626Upgradeable requires solc >= 0.8.24, so the subsystem's real floor is above the Diamond's 0.8.17 and it cannot compile under the solc_floor profile. Nothing in the Diamond imports it, so skipping src/VaultWrapper/** does not weaken the Diamond's floor check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…into feature/exsc-413-s5-pause-instance-global-read-withdrawals-always-open
…tance-global-read-withdrawals-always-open feat(EXSC-413): S5 — pause (instance + global-read; withdrawals-always-open)
Which Linear task belongs to this PR?
EXSC-409 — S1: Core ERC-4626 + immutable args
Stacked PR. Base is
feature/exsc-544-…(#1969), which is itself stacked on the S8 factory branch. Review the incremental diff against the base branch, and do not merge before its parents land.Why did I implement it this way?
S1 is the base vault wrapper implementation that sits behind the beacon the factory (#1969's parent) already deploys.
ERC4626Upgradeablebase (v5.6.1), beacon implementation, initialize-based identity — not CWIA. Despite the ticket title, the merged factory deploysLibCloneERC-1967 beacon proxies and callsinitializewrite-once (see[CONV:VW-BEACON]).LiFiVaultWrapperis built on OZ's upgradeable ERC-4626 so the asset, decimals, and share metadata live in proxy storage (a beacon proxy cannot carry constructor-set immutables per instance), configured via the existingILiFiVaultWrapper.initializesignature under OZ'sinitializerguard. CWIA bytecode-immutable identity stays deferred. OZ over Solady because a source-verified survey of major vault protocols found no production use of Solady's ERC-4626 and no documented standalone audit of it, while OZ's base has broad precedent (MetaMorpho, Gearbox, Ethena) — and Kiln DeFi, the closest comparable (integrator-facing beacon-proxy vaults with delegatecall connectors + per-vault fees), is built onERC4626Upgradeable. The VaultWrapper subsystem runs on OZ v5.6.1 while the Diamond stays on the vendored v4.9.2 (see the dependency note below).IYieldAdapteris extended withdeposit/withdraw(delegatecall-only, so they run in the wrapper's context and the wrapper holds the yield-source position) andtotalAssets(underlying, holder)(plain view call). The call-type split is the security contract — delegatecalled methods must be stateless — and is documented in the interface NatSpec. Adapter routing lives in the_deposit/_withdrawoverrides (OZ has no after/before hooks). This keeps "new yield source = new adapter, no wrapper/beacon change" ([CONV:VW-ADAPTERS]).ReentrancyGuardondeposit/mint/withdraw/redeem(v5's guard is proxy-safe — namespaced storage, no initializer); OZ virtual-share offset for inflation-attack mitigation; adapter revert data bubbled verbatim.initialize(factory = msg.sender) to unblock the S5 global circuit breaker read.name/symbolderive from the underlying asset symbol (LI.FI Earn USDC/lfUSDC) via SoladyMetadataReaderLib(a utility — handlesstring/bytes32/missing), falling back toVWwhen the asset exposes no symbol, so instances are distinguishable in wallets and explorers.MockVaultWrapperis deleted; the factory, beacon, and timelock tests now exercise the realLiFiVaultWrapper, and only the underlying yield source is mocked (SolmateMockERC4626)._beforeOperation()(pause → access → fee accrual) is wired into the four entrypoints, thepreview*functions route through_entryFee/_exitFee, and the deposit/withdraw hooks skim/route fees via_routeFee. Every stub is a no-op today (fees return 0, guards pass), so behaviour is unchanged; the bodies land in the fee/access/pause tickets.initDataand the fee config remain stored opaquely._disableInitializers()(only beacon proxies initialize);initializerejects zero asset/underlying/adapter/admin,integratorShareBps > 10000, and an adapter that resolves the underlying to a different asset than the one passed. These are defense-in-depth — redundant for the trusted factory flow, but cheap insurance on a custody contract, withtestRevert_coverage for each path.Deferred CodeRabbit finding (final local re-run is fully clean): earlier
/pr-readyruns flagged that the wrapper stores/exposes fee config andinitDatawhile fee, pause, and access enforcement are still no-op seams — so a vault could appear fee-enabled/permissioned without runtime enforcement. Deferred intentionally: storing config ahead of enforcement is the merged factory's deliberate staged design (the factory validates fees against bounds/caps and passes them through), so rejecting it in the wrapper would break the factory's plumbing and its fee-enabled deploy tests. The subsystem is unreleased and will not be production-deployed before the enforcement modules (S2 fees / S4 access / S5 pause) land — which is when these guards come off the seams.New dependencies (OZ v5 for the VaultWrapper only): the subsystem builds on OpenZeppelin v5.6.1 while the Diamond stays on the vendored v4.9.2 core. Because v5's
-upgradeablepackage imports the non-upgradeable core as a peer, this vendors a second core submodulelib/openzeppelin-contracts-v5(v5.6.1) and bumpslib/openzeppelin-contracts-upgradeableto v5.6.1. Version routing is by path-scoped remappings:@openzeppelin/contracts/resolves to v5 forsrc/VaultWrapper/,test/solidity/VaultWrapper/,script/deploy/vaultWrapper/, and the upgradeable lib itself; the global stays v4.9.2, so the Diamond is unaffected ([CONV:VW-OZ-VERSION]). v5 specifics absorbed: coreReentrancyGuard/IERC20/SafeERC20/Initializable, two-argUpgradeableBeacon(impl, owner), custom-error reverts, and_initErc4626Metadataextracted to avoid a v5 stack-too-deep (no repo-widevia_ir).Pending security triage (gates Ready/merge): Olympix flags 4 design-inherent alerts on
LiFiVaultWrapper.sol—reentrancy-events×2 (events after the guarded adapter interaction) andarbitrary-delegatecall+call-without-gas-budgeton the delegatecall adapter route. These are the wrapper's intended design (governance-approved adapter,nonReentrant+ CEI) and predate the v5 change; they must be dismissed-with-justification (or addressed) in code scanning before thesecurityAlertsReviewgate will let this PR stay Ready for Review.Divergences from the
wrapper-researchflow doc (which predates the merged factory) that are intentional: delegatecall adapter vs the doc's call-based/no-adapter;namespace+adaptersalt vsintegrator+chainLockId(chainLockId dropped); singleintegratorShareBpsvs per-fee-type splits; opaqueinitDatavs explicit init fields.Checklist before requesting a review
/pr-ready(local CodeRabbit) on this branch and resolved (or explicitly documented) all findings — see.agents/commands/pr-ready.mdChecklist for reviewer (DO NOT DEPLOY and contracts BEFORE CHECKING THIS!!!)