feat(EXSC-423): integrate Vault Wrapper V1 subsystem [LiFiVaultWrapper v1.0.0, LiFiVaultWrapperFactory v1.0.0, LiFiVaultWrapperTypes v1.0.0, ReferenceAccessGate v1.0.0, ERC4626Adapter v1.0.0, IAccessGate v1.0.0, ILiFiVaultWrapper v1.0.0, ILiFiVaultWrapperFactory v1.0.0, IYieldAdapter v1.0.0, LibVaultWrapperMath v1.0.0] - #2092
gvladika wants to merge 147 commits into
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ock reverts Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… types Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…folder Standalone subsystem, not Diamond-called periphery — promote out of src/Periphery to a top-level src/VaultWrapper folder. Update imports and mirror the test tree to test/solidity/VaultWrapper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ultWrapperFactory.s.sol:33) CodeRabbit (critical): UpgradeableBeacon constructor leaves the deployer as beacon owner, so implementation upgrades for all clones sit outside governance. Transfer beacon ownership to the OWNER env address alongside the factory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Critical: validate _owner != address(0) in constructor (was passed to TransferrableOwnership unchecked; a zero owner bricks governance). - Remove redundant isGlobalPaused(); rely on the auto-generated globalPaused() getter (single accessor for one state var). - Emit WrapperDeployed before the initialize() external call (checks-effects- interactions). - Add paginated getInstances(offset, limit) for enumeration at scale. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CodeRabbit: OWNER/EMERGENCY_PAUSER/ONBOARDING_MANAGER read from env without validation; add zero-address guards via custom errors (owner also receives beacon ownership). Custom errors per repo gas-custom-errors lint rule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ests - NatSpec on MockVaultWrapper feeRate/feeEnabled. - Move underlying/assetToken state vars to top of the test contract. - Switch pause assertions to globalPaused(); add getInstances pagination test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Thread an approved IYieldAdapter through DeployParams, initialize, the CREATE2 salt, the WrapperDeployed event, and predictAddress. Replace the inlined ERC-4626 probe with adapter.probe(); the factory no longer references IERC4626. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…o feature/exsc-417-factory-yield-adapters # Conflicts: # src/VaultWrapper/LiFiVaultWrapperFactory.sol
…ctory, use specific errors Move all factory events and errors into a new ILiFiVaultWrapperFactory interface the factory inherits. Replace the generic GenericErrors.UnAuthorized and InvalidConfig reverts with specific errors: NotEmergencyPauser, NotOnboardingManager, IntegratorNotApproved, ZeroAddress, InvalidFeeBounds, InvalidSplit. The inherited onlyOwner (TransferrableOwnership) still reverts UnAuthorized and is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…arify NatSpec Store the integrator's share of the underlying-generated fees (defaultIntegratorShareBps, default 80%) rather than LI.FI's; LI.FI implicitly receives the remaining 100% - X%. Rename the constant/setter param/event field accordingly and spell out the split in NatSpec. Replace "clone" with "vault wrapper" throughout the VaultWrapper doc comments for readability. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion Immutables live in bytecode, not storage, so the beacon declaration no longer sits under the Storage header. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All fee types used the same split, so the per-FeeType mapping was unnecessary configuration. defaultIntegratorShareBps is now a single uint16; setDefaultSplit and the DefaultSplitSet event drop the FeeType parameter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the allInstances array and the instancesLength/getAllInstances/ getInstances views; enumerate off-chain via WrapperDeployed events instead. getAllInstances was an unbounded-gas footgun and the set duplicated event data. Keep instanceBySalt (duplicate-deploy guard) and isInstance (membership check). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the optional single-chain lock from the factory. Deploy authorization (integrator-in-salt + approved-integrator gate) and the underlying allowlist already prevent same-address cross-chain misuse, so the lock added a DeployParams field, a deploy-time check, an event field, an initialize arg, and the ChainLockMismatch error for marginal operator-error coverage. Salt never included chainLockId, so deterministic addresses are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ment Explain the calldata-persist ordering and its no-via_ir stack constraint once, at the persist point, and drop the duplicate at the resolveAsset call site. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…2255) * feat(VaultWrapper): put factory behind TransparentUpgradeableProxy Make LiFiVaultWrapperFactory upgradeable. Convert its constructor to an initializer (Initializable + Ownable2StepUpgradeable), move BEACON from an immutable to storage set in initialize, and deploy it behind an OZ v5 TransparentUpgradeableProxy. The proxy's ProxyAdmin is owned by the same 48h subsystem timelock that owns the factory and beacon, so a factory-logic upgrade passes the same delay. The wrapper implementation binds FACTORY to the proxy, so instances read live factory state through a stable address across upgrades. Fee caps (CAP_*) and the split validation are now guarantees of the current factory logic only; docs and the subsystem rule are updated to say so. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(VaultWrapper): verify factory proxy delegates to the deployed logic Replace the dead code-length probe in _verifyWiring (the CREATE3 deploy path already reverts on empty code, so it could never fire) with an assertion that the proxy's ERC-1967 implementation slot equals the logic deployed this run. A re-run reusing the proxy salt otherwise leaves the live proxy delegating to stale logic while every through-proxy check still passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Requesting changes on 3 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 | 🟠 Medium | Security | setIntegratorFeeReceivers missing nonReentrant — can be called from yield source hook mid-distribution |
| 2 | 🟠 Medium | Deploy script | DeployLiFiVaultWrapperFactory.s.sol missing FACTORY binding assertion after deployment |
| 3 | 🟢 Low | Test gap | LiFiVaultWrapper.t.sol (or fees test) — missing dust-deposit test case |
1. [Medium] setIntegratorFeeReceivers missing nonReentrant
All four ERC-4626 entry/exit functions (deposit, mint, withdraw, redeem), distributeFees, and setFeeRate are correctly nonReentrant. However, setIntegratorFeeReceivers (owner-only) is not. The _deposit implementation calls super._deposit first (pull + mint), then _routeFee (fee booking), then _routeThroughAdapter (external delegatecall to yield source). If the yield source's deposit hook calls back to setIntegratorFeeReceivers — possible when the integrator uses the same EOA as both admin and fee receiver — it could redirect pending fee receivers mid-distribution before the current fee routing completes.
Requested action: Add nonReentrant to setIntegratorFeeReceivers (consistent with the existing setFeeRate NatSpec rationale). If the integrator trust model explicitly makes this acceptable, an acceptance comment explaining why is sufficient.
2. [Medium] DeployLiFiVaultWrapperFactory.s.sol — no FACTORY binding assertion
LiFiVaultWrapper binds FACTORY as an immutable in the constructor. All existing proxies enforce this correctly via the initialize factory-only check. However, when the beacon's upgradeTo deploys a new implementation, nothing mechanically verifies that the new implementation's FACTORY matches the live factory proxy address. A misconfigured implementation (wrong factory, address(0)) would silently propagate to all proxy instances.
The deploy script already performs wiring checks (via WiringMismatch-style assertions) — this is a gap in that existing check set.
Requested action: Add to the post-deploy wiring verification in DeployLiFiVaultWrapperFactory.s.sol:
require(address(impl.FACTORY()) == predictedFactoryProxyAddress, "FACTORY binding mismatch");Or accept with an explicit comment explaining why the 48h timelock observation window is a sufficient substitute.
3. [Low] Test gap — LiFiVaultWrapper.t.sol (new case)
- Missing: explicit test for dust deposit at
feeBounds.maxBpsfee whereinvested == 0(e.g.deposit(1 wei, receiver)at 20% fee → succeeds, 0 shares minted, depositor loses 1 wei to fee pool). The behavior is intentional per spec, but there is no test asserting it succeeds (rather than reverts) and no test documenting thatpreviewDeposit(1 wei)correctly returns 0 in this scenario.
💡 Once you've addressed the items above, re-apply the "Agent Review Request" label to trigger an automated re-review.
reednaa
left a comment
There was a problem hiding this comment.
The codebase is compact and consise. It is a well written defensible implementation.
I could not find any issues with the code. Total report is 1 INFO with a couple GAS.
Review session consisted of 2 sets of 2 hours. Tools used: solidity-test-overhaul, weird ERC20 tokens, and manual review.
Ideally I want to give it another 2 sets of 2 hours but I will not be able to make it in time. Apologies for being too late.
* chore(VaultWrapper): drop dead solhint max-states-count disable solhint's max-states-count excludes both constants and immutables (lib/rules/best-practices/max-states-count.js filters on `!isDeclaredConst && !isImmutable`), and the contract declares exactly 15 counted state variables against a limit that only trips above 15. The pragma has been inert since `factory` became the `FACTORY` immutable; its justification comment also miscounted, naming a 16th declaration that does not exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf(VaultWrapper): read integrator receivers from storage, not a memory copy `_payIntegrators` copied the whole `integratorFeeReceivers` array into memory before the loop. The SLOAD count is identical either way — each receiver packs into one slot, and `wallet`/`bps` are read from the same slot — so the copy only added per-element allocation and MSTOREs. Measured on a cold-storage benchmark of the loop: -198 gas at 1 receiver, -2695 at the 50-receiver cap, per fee pool. `distributeFees` pays two pools. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(VaultWrapper): document underlying price-per-share as a trust assumption The trust model covered curation and the standard-ERC4626 requirement but not honest price reporting. A source that misreports price per share for a single block both charges a performance fee on a phantom gain and parks the up-only high-water mark above real price, so later genuine gains accrue no fee. Accrual runs on the permissionless distributeFees, so the moment is attacker-selectable. Records the accepted bound and names curation as the only control. Raised as INFO in peer review of #2092. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(VaultWrapper): record the beacon as address-parity-critical _proxyInitCode() embeds `beacon` in the init code that fixes every instance address, and `beacon` lives in factory proxy storage. It is write-once today (set in initialize, no setter), but a timelocked factory upgrade could add one, and changing it after the first deploy would silently relocate every predicted address and break cross-chain parity. Also records why the CREATE2 salt preimage is deliberately unversioned: it is abi.encoded, a repeated triple reverts rather than colliding, and `_nonce` already disambiguates. Raised in peer review of #2092. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Revert "perf(VaultWrapper): read integrator receivers from storage, not a memory copy" This reverts commit bcd6ada. Measurement showed the whole-array copy, the storage pointer, and a per-element memory copy all land within 0.1% of each other on distributeFees (976 gas apart at the 50-receiver cap, 80 at a single receiver, on a call dominated by ERC-20 transfers). Not worth a review round-trip or the audit attention, so the loop goes back to the form on dev-vault-wrapper and this PR keeps only the unambiguous fixes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf(VaultWrapper): copy each receiver entry into memory in the payout loop Drops the whole-array storage-to-memory copy in `_payIntegrators` and reads one entry at a time instead, as asked for in review. `FeeReceiver` packs into a single 32-byte slot (wallet@slot0+off0, bps@slot0+off20), so an entry costs one SLOAD and both fields are then read from memory — where the whole-array copy paid per-element allocation on top of the same SLOAD, and a storage pointer (the earlier attempt, since reverted) paid a warm re-read of the slot. Measured on distributeFees, both fee pools: receivers whole-array storage ptr per-entry 1 108,115 107,912 107,992 5 220,568 220,228 220,220 50 1,485,700 1,483,783 1,482,807 Per-entry wins from five receivers up. The spread is under 0.1% either way on a call dominated by ERC-20 transfers, so this is a readability-neutral tidy-up rather than a meaningful saving. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
proposal-card.ts imported the constant from safe-utils, which does not export it, so tsc failed on the file. It is declared in proposal-intent, where provenance-display.ts already reads it from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🔁 QA Re-Review — EXSC-423 — Vault Wrapper V1 (Post-Approval Commit Review)PR: #2092 | Ticket: EXSC-423 | Reviewer: QA AI | Date: 2026-09-03
Post-Approval Commit —
|
| Case | Behavior | Correct? |
|---|---|---|
| count = 1 | Loop runs 0 times (i=1, not < 1). distributed=0. firstShare = _integratorTotal. First receiver gets everything. | ✅ |
| count > 1 | Receivers i=1..count-1 get proportional mulDiv shares (rounded down). First receiver gets remainder. |
✅ |
| firstShare = 0 | Early return — avoids SLOAD of integratorFeeReceivers[0].wallet. |
✅ |
| Failed transfer (receiver i>0) | retained += share, emit. Loop continues. |
✅ |
| Failed transfer (receiver 0) | retained += firstShare, emit. |
✅ |
Underflow safety: firstShare = _integratorTotal - distributed
_setIntegratorFeeReceivers enforces sum(bps) == BASIS_POINT_SCALE (= 10000). Since mulDiv rounds down:
share_i = ⌊_integratorTotal × bps_i / 10000⌋ ≤ _integratorTotal × bps_i / 10000
distributed = Σ share_i (i=1..count-1) ≤ _integratorTotal × Σbps_i(i>0) / 10000
≤ _integratorTotal × (10000 - bps_0) / 10000 ≤ _integratorTotal
No underflow is possible. Solidity 0.8's checked arithmetic is a backstop, not a crutch here. ✅
Gas savings (per reednaa's analysis):
- Eliminates
lastIndex = count - 1stack variable integratorFeeReceivers[0].walletuses PUSH0 vs. a stack-allocated index — saves ~1 safe-sub operation- Equivalent to
fb31b9e75883in correctness, slightly more efficient in EVM execution
NatSpec: Updated to "the first wallet absorbing the integer-division remainder so the portion zeroes exactly" — accurate. ✅
No new findings. The change is a pure gas optimization with correct math. All prior Round 1–3 findings remain fully resolved.
Verdict
✅ Pass — Commit 1de5ee67ffe9 is a correct, safe gas optimization. The remainder-to-first-receiver approach is mathematically equivalent to the remainder-to-last approach (the _setIntegratorFeeReceivers bps-sum invariant guarantees no underflow). Re-approval submitted.
QA AI — SmartContract team review | EXSC-423 | PR #2092 | Post-approval re-review — 2026-09-03
There was a problem hiding this comment.
🔶 QA AI Needs Work — F-01 remains open: setIntegratorFeeReceivers missing nonReentrant guard or NatSpec documentation of the reentrancy acceptance reasoning. F-02 (FACTORY assertion) ✅ and F-03 (dust deposit test) ✅ are resolved. Full re-review posted as PR comment. One documentation-only change unblocks approval.
_payIntegrators tested `i + 1 == count` on every iteration to give the last receiver the rounding remainder. The loop now runs to count - 1 with a single path, and the last receiver is paid after it, which is what reednaa asked for on #2092. The count cannot be zero (_setIntegratorFeeReceivers is the sole writer, rejects an empty set, and initialize always runs it), so the `- 1` cannot underflow. Measured on the existing distribution tests: distributeFees drops 222 gas at one receiver and 432-864 across a fan-out with a failing wallet. The cost is the duplicated transfer/retain/emit block, which adds ~200 bytes of bytecode (+40,128 one-time on an implementation deploy) and leaves 1,370 bytes of EIP-170 headroom. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
F-02 and F-03 confirmed resolved. On F-01: two corrections, then why no change is needed. The quoted developer response isn't about this finding. "~260–400 gas at realistic receiver counts" comes from this comment on @reednaa's That figure can't apply here anyway. This contract uses OZ v5's core Why If the owner were the callback contract, there is still nothing to take. No NatSpec added, deliberately. This thread is the record for the auditor. Re-triggering the review. |
There was a problem hiding this comment.
✅ QA Pass (Round 3) — All three findings resolved: F-01 accepted with full technical justification (onlyOwner blocks all reentrancy paths); F-02 fixed (FACTORY binding assertion in PR #2296); F-03 fixed (dust-deposit test added). New commit fb31b9e (_payIntegrators loop refactoring) reviewed and safe.
Loop the integrator payout from index 1 and pay receiver 0 the remainder after it, instead of looping to count - 1 and paying the last receiver. Drops the checked subtraction on the array length along with the comment justifying why it cannot underflow, and lets index 0 fold into a constant slot offset. distributeFees costs 88 gas less; the contract is 15 bytes smaller (23,191, margin 1,385). Which wallet absorbs the dust is spec-visible, so the NatSpec on distributeFees and _payIntegrators, the fan-out test expectations and the Morpho fork scenario assertions move with it. The zero-share receiver test lists its 1-bps wallet second so the skip path stays inside the loop. Suggested by @reednaa on #2092. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
✅ QA AI Approved (re-approval) — EXSC-423 post-approval commit 1de5ee6: _payIntegrators remainder-to-first-receiver refactor verified correct. bps-sum invariant prevents underflow. Round 3 pass verdict stands.
The factory's own configuration - approved adapter, underlying allowlist, fee bounds, default split - was only reachable through the timelocked owner setters. A freshly deployed factory could therefore not deploy a single wrapper until a full 48h governance cycle had cleared, and `deploy` reverted AdapterNotApproved, UnderlyingNotAllowed, or FeeRateOutOfBounds in the meantime. Take that configuration in `initialize` instead. It runs inside the proxy's constructor, so nothing can reach the factory before it returns and the delay was protecting no prior state. Every later change still goes through the timelock. `initialize` now takes a FactoryInitParams struct, and the four setters delegate to internal helpers the initializer shares, so both paths validate and emit identically. The deploy script reads the seed values from config and deploys the ERC4626Adapter before the factory proxy, since initialize rejects an adapter with no code; `_verifyWiring` checks the seeded values too, and the seeded-config half moved into its own function to stay inside the complexity limit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🔍 QA Review — EXSC-423Review type: 🔁 Re-review (post-approval) 🧠 What this ticket doesEXSC-423 brings the whole Vault Wrapper V1 subsystem to
📋 Ticket SummaryS15 — Security review & audit support Acceptance Criteria (delta scope):
Post-Approval Commit Analysis:
|
| Contract | Version | Bumped in PR | Audit log entry | PR labels |
|---|---|---|---|---|
| LiFiVaultWrapper | v1.0.0 | n-a (new, unaudited contract; post-approval logic change stays within v1.0.0) | ⏳ pending (no auditedContracts.LiFiVaultWrapper entry in audit/auditLog.json) |
AuditRequired |
ℹ️ The audit is still pending for LiFiVaultWrapper v1.0.0. This commit changes in-scope audit code (initialize metadata path), so the auditor's commit pin must be at or after d51522f, or this delta must be sent to the auditor explicitly. AuditRequired correctly continues to block merge.
✅ Ticket Coverage — High
The delta does what it says, is small and well-contained, and comes with happy-path tests plus both "no symbol" fallback tests.
⚠️ Issues Found (3)
| # | Severity | Type | Issue |
|---|---|---|---|
| 1 | 🟡 Medium | CI / Process | 🆕 PR conflicts with main, so no forge unit tests (or other pull_request gates) ran on head d51522f |
| 2 | 🟢 Low | Test gap | 🆕 No test for oversized or bytes32 underlying symbols (truncation / gas-stipend path) |
| 3 | 🟢 Low | Code quality | 🆕 New helper _deployWrapperOver duplicates the existing _newWrapper |
🟡 [Medium] 🆕 PR conflicts with main, so the new tests have never run in CI
gh pr view reports mergeable: CONFLICTING. The branch is 147 commits ahead of and 115 behind main. GitHub does not run pull_request workflows while a PR has merge conflicts. The only check runs on d51522f are CodeQL, Aikido and check-secrets. The previously approved head a90554a had run-unit-tests (gas/misc/facets/libraries/periphery), unit-tests-required, signed-commits-required, audit-verification, solc-floor-build, deploy-smoke-test and others. As a result, the three new/rewritten tests (test_NameAndSymbolFallBackWhenAssetHasNoSymbol, test_NameDistinguishesYieldSourcesOverSameAsset, test_NameOmitsYieldSourceWhenUnderlyingHasNoSymbol) and the two updated name() assertions (LiFiVaultWrapper.t.sol:178, :332) are unverified. My static reading says they should pass, but that does not replace a green run.
Suggestion: merge or rebase main into dev-vault-wrapper, resolve the conflicts, and confirm unit-tests-required and the other required checks are green on the new head. Rebasing also changes the base of stacked PR #2417 (see Downstream).
🟢 [Low] 🆕 No test for oversized or bytes32 underlying symbols
The two failure modes that matter most for a third-party string are not pinned by a test: a very long symbol() (the 1000-byte truncation and 100k gas stipend) and a bytes32-returning symbol(). Both are handled correctly today by Solady. A test would catch a future switch to a plain IERC20Metadata(underlying).symbol() call, or a change to the readSymbol overload/limits.
Suggestion: add one mock underlying whose symbol() returns a string longer than 1000 bytes (or loops and burns gas). Assert that initialize succeeds and that bytes(name()).length is bounded. Optionally add a bytes32-returning mock.
🟢 [Low] 🆕 _deployWrapperOver duplicates _newWrapper
test/solidity/VaultWrapper/LiFiVaultWrapper.t.sol:653-674 (_deployWrapperOver) is identical to the existing _newWrapper(address) at :676-695, which already has 6 call sites.
Suggestion: delete _deployWrapperOver and call _newWrapper in the three new tests.
🧪 Test Coverage
| Layer | Score | Files reviewed |
|---|---|---|
| Foundry unit/integration | Good (unverified in CI; see #1) | test/solidity/VaultWrapper/LiFiVaultWrapper.t.sol, test/solidity/VaultWrapper/BeaconUpgrade.t.sol (name assertion still correct), test/solidity/VaultWrapper/mocks/MockERC4626Underlying.sol |
| Deploy scripts | N/A | No deploy script changes; CREATE2 salt and init code unaffected |
What is covered: the suffix appears for the default fixture; the asset has no symbol (VW fallback) while the underlying has one; two different underlyings over the same asset give distinct names and the same symbol; the underlying has an empty-string symbol; the underlying has no symbol() function (the call reverts, which exercises the revert fallback).
Gaps — all are requested changes, every item must be addressed or explicitly accepted:
Foundry gaps:
[Low] LiFiVaultWrapper._initErc4626Metadata: oversized andbytes32underlyingsymbol()not tested (see Issue Prepare for next audit #2)[Medium] All VaultWrapper suites: not executed in CI on headd51522f(see Issue 🔧 more updates #1)
🔗 Downstream Impact
Blocks: Stacked PR #2417 (EXSC-1036, feat(vaultWrapper): add Base demo deploy config) targets dev-vault-wrapper. Its diff contains no name/symbol assertions or readSymbol usage, so no code changes are needed there. However, any Base demo wrapper deployed from #2417 will now show the "… via " name. Once #2092 is rebased onto main, #2417 must be rebased too.
Operational: Nothing is deployed on live networks yet, so no redeploy or migration is needed. Frontends, indexers and the Earn backend that display or match on the wrapper's name() should expect the longer name. Matching on the unchanged symbol() still works, but symbols are not unique per yield source, so integrators should key on the wrapper address. Audit sequencing: this delta is in-scope audit code, so make sure the audit commit pin includes d51522f before auditLog.json is populated.
QA Agent — 2026-09-24
There was a problem hiding this comment.
QA Pass (post-approval re-review) — EXSC-423. Single post-approval commit validated: FactoryInitParams initialize seeding is correct (all validations shared with owner-only setters, initializer guard present, adapter code check preserved, defaultIntegratorShareBps bounded by _setDefaultSplit); deploy ordering updated correctly (adapter before proxy). AuditRequired still blocks merge.
Wrappers over the same asset but different underlyings reported identical
name() and symbol(). The share name now appends the underlying's symbol
("LI.FI Earn USDC via sparkUSDC"); the suffix is omitted when the
underlying exposes no readable symbol. symbol() is unchanged.
EXSC-1090
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Requesting changes on 3 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 | 🟠 Medium | Process / CI | PR conflicts with main, so no forge unit tests or other pull_request gates ran on head d51522f |
| 2 | 🟢 Low | Test gap | test/solidity/VaultWrapper/LiFiVaultWrapper.t.sol |
| 3 | 🟢 Low | Code quality | New test helper _deployWrapperOver duplicates the existing _newWrapper |
1. [Medium] Conflicts with main — new tests never ran in CI
mergeable: CONFLICTING (147 ahead / 115 behind). Only CodeQL, Aikido and check-secrets ran on d51522f; unit-tests-required, signed-commits-required, audit-verification, solc-floor-build, deploy-smoke-test did not. The three new/rewritten tests (test_NameAndSymbolFallBackWhenAssetHasNoSymbol, test_NameDistinguishesYieldSourcesOverSameAsset, test_NameOmitsYieldSourceWhenUnderlyingHasNoSymbol) and the updated name() assertions (LiFiVaultWrapper.t.sol:178, :332) are unverified. Merge/rebase main, resolve conflicts, and confirm required checks are green (then rebase stacked #2417).
2. [Low] Test gap — LiFiVaultWrapper.t.sol
- Missing: underlying whose
symbol()returns >1000 bytes or burns gas — assertinitializesucceeds andbytes(name()).lengthis bounded (unit) - Missing: underlying whose
symbol()returnsbytes32(MKR-style) (unit)
3. [Low] Duplicate helper
_deployWrapperOver (LiFiVaultWrapper.t.sol:653-674) is identical to _newWrapper(address) (:676-695). Delete it and use _newWrapper in the three new tests.
💡 Once you've addressed the items above, re-apply the "Agent Review Request" label to trigger an automated re-review.
Which Linear task belongs to this PR?
Ref EXSC-423 — S15, Security review & audit support (the audited slice; ticket stays open through audit engagement, so this does not auto-close it).
Why did I implement it this way?
This is the integration PR that lands the assembled LI.FI Earn Vault Wrapper V1 subsystem (
src/VaultWrapper/) onmain, opened as one reviewable diff ahead of the external audit rather than re-deriving it from the individual S1–S14 sub-PRs.The Vault Wrapper is a standalone product per
[CONV:ARCH-VAULTWRAPPER]— not a facet, not periphery, not called by the Diamond, with nodiamondCutor shared selector/storage involvement. A factory deploys per-integrator ERC-4626 vaults as deterministic beacon proxies; each wraps a governance-curated yield source through a pluggable adapter and takes a four-type fee split between the integrator and LI.FI. It has its own governance (a dedicated 48h timelock, an emergency pauser, and an onboarding manager).Share metadata:
symbol()islf<assetSymbol>andname()also names the yield source, e.g.LI.FI Earn USDC via sparkUSDC, so wrappers over the same asset but different underlyings are distinguishable in wallets (EXSC-1090). Thevia …suffix is omitted when the underlying exposes no readable symbol; the asset symbol falls back toVW.For the full picture — architecture, contract set, roles and governance, fee model, trust assumptions, and system invariants — see the subsystem overview and the per-contract docs:
Checklist before requesting a review
Checklist for reviewer (DO NOT DEPLOY and contracts BEFORE CHECKING THIS!!!)