Skip to content

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

Open
gvladika wants to merge 147 commits into
mainfrom
dev-vault-wrapper

Conversation

@gvladika

@gvladika gvladika commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

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/) on main, 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 no diamondCut or 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() is lf<assetSymbol> and name() 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). The via … suffix is omitted when the underlying exposes no readable symbol; the asset symbol falls back to VW.

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!!!)

  • I have checked that any arbitrary calls to external contracts are validated and or restricted
  • I have checked that any privileged calls (i.e. storage modifications) are validated and or restricted
  • I have ensured that any new contracts have had AT A MINIMUM 1 preliminary audit conducted on by <company/auditor>

gvladika and others added 30 commits June 15, 2026 09:23
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>
gvladika and others added 2 commits August 21, 2026 15:13
…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>

@lifi-qa-agent lifi-qa-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.maxBps fee where invested == 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 that previewDeposit(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
reednaa self-requested a review August 27, 2026 06:53

@reednaa reednaa left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment thread src/VaultWrapper/LiFiVaultWrapper.sol Outdated
Comment thread src/VaultWrapper/LiFiVaultWrapper.sol Outdated
Comment thread src/VaultWrapper/LiFiVaultWrapper.sol Outdated
Comment thread src/VaultWrapper/LiFiVaultWrapper.sol Outdated
Comment thread src/VaultWrapper/LiFiVaultWrapperFactory.sol
Comment thread src/VaultWrapper/libraries/LibVaultWrapperMath.sol
gvladika and others added 3 commits September 2, 2026 16:24
* 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>
@lifi-qa-agent

lifi-qa-agent Bot commented Sep 3, 2026 •

Copy link
Copy Markdown

🔁 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 re-review: Commit 1de5ee67ffe9 was pushed at 10:11Z after the Round 3 approval (07:50Z), causing GitHub to auto-dismiss the approval. This review analyses only the new commit.


Post-Approval Commit — 1de5ee67ffe9

Commit message: perf(VaultWrapper): give the rounding remainder to the first receiver

Summary: _payIntegrators is refactored a second time, following reednaa's suggestion at 08:24Z on the PR. The loop direction is reversed: instead of looping i=0..count-2 and paying the last receiver (index lastIndex) the remainder outside the loop, the loop now runs i=1..count-1 and pays the first receiver (index 0) the remainder outside the loop.


Logic verification

Before (fb31b9e): Loop i=0..count-2; last receiver integratorFeeReceivers[lastIndex] gets remainder.

After (1de5ee6): Loop i=1..count-1; first receiver integratorFeeReceivers[0] gets remainder.

Function body (current HEAD):

function _payIntegrators(address _token, uint256 _integratorTotal) private returns (uint256 retained) {
    uint256 count = integratorFeeReceivers.length;
    uint256 distributed;

    // every receiver but the first gets its bps share, rounded down
    for (uint256 i = 1; i < count; ++i) {
        FeeReceiver memory receiver = integratorFeeReceivers[i];
        uint256 share = _integratorTotal.mulDiv(receiver.bps, LibVaultWrapperMath.BASIS_POINT_SCALE);
        distributed += share;
        if (share == 0) continue;
        address wallet = receiver.wallet;
        if (SafeERC20.trySafeTransfer(IERC20(_token), wallet, share)) { continue; }
        retained += share;
        emit IntegratorPayoutRetained(wallet, _token, share);
    }

    // the first receiver takes the whole remainder to avoid rounding dust
    uint256 firstShare = _integratorTotal - distributed;
    if (firstShare == 0) return retained;
    address firstWallet = integratorFeeReceivers[0].wallet;
    if (SafeERC20.trySafeTransfer(IERC20(_token), firstWallet, firstShare)) { return retained; }
    retained += firstShare;
    emit IntegratorPayoutRetained(firstWallet, _token, firstShare);
}

Case analysis:

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 - 1 stack variable
  • integratorFeeReceivers[0].wallet uses PUSH0 vs. a stack-allocated index — saves ~1 safe-sub operation
  • Equivalent to fb31b9e75883 in 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

@lifi-qa-agent lifi-qa-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔶 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>
@gvladika

gvladika commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

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 unchecked-in-_payIntegrators thread. F-01 had no response until now.

That figure can't apply here anyway. This contract uses OZ v5's core ReentrancyGuard on an ERC-7201 slot (LiFiVaultWrapper.sol:9 and :61). Guard entry is a cold SLOAD plus a zero→non-zero SSTORE — on the order of 24k gas, not ~300. Adding the suggested NatSpec verbatim would have put a wrong figure in the contract right before audit.

Why nonReentrant isn't needed. The stated route — a yield-source hook calling back into setIntegratorFeeReceivers — cannot pass onlyOwner. A callback arrives with msg.sender set to the source vault or the fee token, not the owner. Round 1's variant ("integrator uses the same EOA as both admin and receiver") is not reachable either: an EOA executes no callback.

If the owner were the callback contract, there is still nothing to take. _payIntegrators fans out only the integrator's own portion and never reads or writes lifiFeeAssets/lifiFeeShares, so a rotation moves the integrator's money between the integrator's own wallets — which the owner can already do by direct call at any time. And if that rotation shrinks the set mid-distributeFees, the index cached before the loop reads out of bounds and panics, reverting the whole call rather than mispaying it. Worst case is an owner-inflicted revert: no fund loss, no state corruption.

No NatSpec added, deliberately. This thread is the record for the auditor. setFeeRate carries a nonReentrant note because there the guard is load-bearing — it is the only _accrueFees caller outside the entry/exit/distributeFees guard, so a payout hook could otherwise book fees that the in-flight distributeFees then erases. That note is not a template for documenting every setter that lacks a guard it doesn't need.

Re-triggering the review.

@gvladika gvladika added the Agent Review Request triggers QA Agent Zeus label Sep 3, 2026
@github-actions github-actions Bot added QA AI Reviewing Zeus QA review in progress and removed Agent Review Request triggers QA Agent Zeus labels Sep 3, 2026
lifi-qa-agent[bot]
lifi-qa-agent Bot previously approved these changes Sep 3, 2026

@lifi-qa-agent lifi-qa-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Comment thread src/VaultWrapper/LiFiVaultWrapper.sol Outdated
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>
lifi-qa-agent[bot]
lifi-qa-agent Bot previously approved these changes Sep 3, 2026

@lifi-qa-agent lifi-qa-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ 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>
@lifi-qa-agent

lifi-qa-agent Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

🔍 QA Review — EXSC-423

🔗 Linear Ticket · Pull Request #2092

⚠️ New commits pushed after last approval — re-analysing post-approval changes.

Review type: 🔁 Re-review (post-approval)
Baseline: QA Pass at Run #128, SHA a90554a27565 (approval 5269138237, now dismissed)
Current head: d51522f823d8
Post-approval commits: 1. d51522f823d8 (2026-09-24T12:39:56Z) feat(VaultWrapper): name the yield source in the share name (EXSC-1090). Signed and verified.

🧠 What this ticket does

EXSC-423 brings the whole Vault Wrapper V1 subsystem to main for audit. The new commit fixes a naming problem. Before it, two wrappers over the same asset but different yield sources both returned the same name() (for example "LI.FI Earn USDC"). Now name() adds the underlying vault's symbol ("LI.FI Earn USDC via sparkUSDC"). If the underlying has no readable symbol, the suffix is left off. symbol() does not change (lf<asset>, falling back to lfVW).

⚠️ Verdict: Needs Work: The contract change is correct and safe. But the PR conflicts with main, so no pull_request CI ran on the new head: no forge unit tests, audit verification or signed-commit gate. The new and changed name assertions have never been run in CI. There are also two Low items.


📋 Ticket Summary

S15 — Security review & audit support
This is the umbrella PR that integrates Vault Wrapper V1 for security review and audit. Acceptance criteria were assessed in earlier rounds (three prior QA passes). This re-review covers only the delta since a90554a.

Acceptance Criteria (delta scope):

  1. Wrapper share metadata stays correct and non-reverting for any allowlisted underlying: ✅ Met
    • Evidence: src/VaultWrapper/LiFiVaultWrapper.sol:268-282 reads both symbols through Solady MetadataReaderLib.readSymbol, which never reverts.
  2. Wrappers over the same asset with different yield sources can be told apart: ✅ Met
    • Evidence: test_NameDistinguishesYieldSourcesOverSameAsset (LiFiVaultWrapper.t.sol:351)

Post-Approval Commit Analysis: d51522f823d8

Share name construction (_initErc4626Metadata)

  • Untrusted metadata: underlying.readSymbol() uses Solady MetadataReaderLib at the pinned submodule 678c916. That call uses a 100,000-gas stipend (GAS_STIPEND_NO_GRIEF) and cuts results at 1000 bytes (STRING_LIMIT_DEFAULT). If the call reverts, returns nothing, returns an empty string or hits an EOA, the result is "". It also handles bytes32 symbols (MKR-style). A malicious or broken symbol() therefore cannot revert initialize or burn unlimited gas. ✅
  • Worst-case size: the name is at most about 11 + 1000 + 5 + 1000 bytes, roughly 64 storage slots, a one-off cost of about 1.3M gas at deploy. Underlyings must also pass the governance-owned allowedUnderlying allowlist in the factory, as the inline comment says. Acceptable. ✅
  • Empty strings: an empty underlying symbol drops the suffix. An empty asset symbol falls back to VW for both name and symbol. ✅
  • Ordering: _initErc4626Metadata reads underlying from storage. It is assigned at LiFiVaultWrapper.sol:219, before the call at :232. The NatSpec now documents this dependency. ✅
  • Immutability: the name and symbol are written once by __ERC20_init under initializer/onlyInitializing. No setter exists. ✅
  • Storage layout: unchanged. OZ ERC20 already stores the name as a string in its namespaced (ERC-7201) storage, and no new state variables were added. ✅
  • Deterministic address / salt: the factory _salt(namespace, adapter, underlying, nonce) and _proxyInitCode() do not depend on the name or symbol, so CREATE2 addresses do not change. ✅
  • ABI / types: no signature changes. The requires-types regeneration is not affected. ✅
  • Other name assertions: BeaconUpgrade.t.sol:107 still expects "LI.FI Earn AST". That is still correct because its MockERC4626Underlying has no symbol(), so the suffix is dropped. No scripts or TS tooling assert wrapper names. ✅
  • NatSpec: the initialize @dev and the _initErc4626Metadata @dev are updated and accurate. ✅

Other checks: no inline review comments since 2026-09-21T16:24Z. The only issue comment in that window is the prior QA pass. CodeRabbit review is paused.


🏷️ PR Naming — ✅ Pass

feat(EXSC-423): integrate Vault Wrapper V1 subsystem [LiFiVaultWrapper v1.0.0, …]

🔎 Ticket Discoverability — ✅ Pass

EXSC-423 is in the title and the branch context. The new commit cites EXSC-1090, a follow-up ticket landing on this umbrella branch. That is fine as long as EXSC-1090 is linked to EXSC-423 in Linear.

🛡️ Version & Audit State

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 and bytes32 underlying symbol() not tested (see Issue Prepare for next audit #2)
  • [Medium] All VaultWrapper suites: not executed in CI on head d51522f (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

lifi-qa-agent[bot]
lifi-qa-agent Bot previously approved these changes Sep 21, 2026

@lifi-qa-agent lifi-qa-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>

@lifi-qa-agent lifi-qa-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 — assert initialize succeeds and bytes(name()).length is bounded (unit)
  • Missing: underlying whose symbol() returns bytes32 (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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AuditRequired QA AI Reviewing Zeus QA review in progress requires-types Trigger Types Bindings CI (ABI/type generation for lifi-contract-types)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants