Skip to content

fix(EXSC-423): address Vault Wrapper V1 peer-review feedback - #2296

Merged
gvladika merged 6 commits into
dev-vault-wrapperfrom
fix/exsc-423-vault-wrapper-review-feedback
Sep 2, 2026
Merged

gvladika merged 6 commits into
dev-vault-wrapperfrom
fix/exsc-423-vault-wrapper-review-feedback

Conversation

@gvladika

@gvladika gvladika commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Which Linear task belongs to this PR?

Ref EXSC-423 — S15, Security review & audit support.

Why did I implement it this way?

Addresses the peer-review comments from @reednaa on #2092. Targets dev-vault-wrapper so the fixes land in the audited slice.

His review verdict was that the code is defensible with no issues found — the report was 1 INFO plus a couple of GAS notes. This PR lands the two changes that are unambiguous; the four gas threads are answered on #2092 rather than changed, with reasoning below.

1. chore: drop the dead solhint max-states-count disable

@reednaa suspected the pragma was left over from when factory was storage rather than the FACTORY immutable. Confirmed three ways:

  • solhint/lib/rules/best-practices/max-states-count.js filters on !isDeclaredConst && !isImmutable, so immutables do not count.
  • Parsing the contract with solhint's own parser gives exactly 15 counted state variables, against a rule that only trips above 15.
  • bunx solhint src/VaultWrapper/LiFiVaultWrapper.sol reports no max-states-count problem with the pragma removed.

The justification comment above it was also wrong on its own terms — it named a 16th declaration that does not exist, and shareDecimalsOffset is the 6th, not the 16th.

2. docs: underlying price-per-share is a trust assumption

Records the INFO from LibVaultWrapperMath.sol:163. The wrapper takes the source's totalAssets at face value, so a source misreporting price per share for even one block distorts the performance fee in both directions: the spike charges a fee on a gain that never existed, and because the high-water mark ratchets up-only to the spiked price, genuine later gains then accrue no fee until they exceed it. Accrual runs on deposit/withdraw and on the permissionless distributeFees, so the moment is attacker-selectable.

Root cause is always in the underlying and the only control is curation, so this is a documented accepted bound rather than a code change. The trust model previously covered "sources are curated" and "must be standard ERC-4626" but never honest price reporting.

3. docs: the beacon is 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 the factory now sits behind a TransparentUpgradeableProxy, so a timelocked upgrade could add one — and changing it after the first deploy would silently relocate every predicted address and break parity against already-deployed chains.

Also records why the CREATE2 salt preimage is deliberately unversioned (the LiFiVaultWrapperFactory.sol:375 thread): it is abi.encoded so there is no packing ambiguity, a repeated (namespace, adapter, underlying) triple makes Create2.deploy revert on the occupied address rather than colliding into one, and _nonce already serves as the disambiguator.

4. perf: copy each receiver entry into memory in the payout loop

_payIntegrators copied the whole integratorFeeReceivers array into memory up front; it now reads one entry at a time. FeeReceiver packs into a single 32-byte slot (forge inspect: wallet@slot0+off0, bps@slot0+off20), so an entry costs one SLOAD and both fields then come from memory.

Commits 2 and 4 in this branch are an earlier attempt at this and its revert — that attempt used a storage pointer, which pays a warm re-read of the same slot, and @reednaa correctly rejected it. Left in history rather than squashed so the review thread still lines up.

Measured on distributeFees (both fee pools, real contract, cold and warm — the deltas are identical in both):

receivers whole-array storage pointer per-entry (landed) hoist last receiver
1 108,115 107,912 107,992 107,756
5 220,568 220,228 220,220 —
50 1,485,700 1,483,783 1,482,807 1,479,070

Per-entry wins from five receivers up. The whole spread is under 0.1% on a call dominated by ERC-20 transfers, so this is a tidy-up, not a meaningful saving.

Two things left alone deliberately:

  • Hoisting the last receiver out of the loop only pays off if the transfer/retain/emit block is duplicated inline. Extracted into a helper it is a net regression at the cap (1,486,153 vs 1,483,783).
  • unchecked on that loop is sound — see below — but not worth swapping a compiler-enforced invariant for a prose one here.

On unchecked: the boundedness argument holds — _setIntegratorFeeReceivers is the sole writer and admits 1..50 entries whose bps sum to exactly 10000, so i is bounded, each non-last share is ≤ _integratorTotal, distributed is a sum of floors that cannot exceed it, and retained ≤ distributed. Not applied: ~260–400 gas at realistic receiver counts, in exchange for replacing a compiler-enforced invariant with a prose one in the fee-distribution loop, with the audit as the next gate.

Checklist before requesting a review

No new tests: the Solidity changes are deleting an inert lint pragma and a storage-access refactor with identical semantics, whose fan-out, last-receiver remainder and retain-failed-payout paths are already covered in VaultWrapperDistribution.t.sol. The full VaultWrapper suite (377 tests) passes unchanged. The gas probes used for the measurements above were throwaway and are not committed.

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 4 commits September 1, 2026 15:50
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>
…ory 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>
…sumption

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>
_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>
@coderabbitai

coderabbitai Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

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

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

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: bbb6aeec-b66a-4055-afa9-0e3a445adafe

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

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

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

Comment thread src/VaultWrapper/LiFiVaultWrapper.sol Outdated
uint256 distributed;

for (uint256 i; i < count; ++i) {
FeeReceiver storage receiver = integratorFeeReceivers[i];

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.

Needs to be memory. Sorry for the confusion here. This reads the storage slot twice incurring additional gas costs compared to prior. 😅

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep reverted to memory! 9ae8ace

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.

No.....

You have 2 options:
Either you do:

FeeReceiver[] storage receivers = integratorFeeReceivers;

...

FeeReceiver memory receiver = receivers[i]

or straight

FeeReceiver memory receiver = integratorFeeReceivers[I]

This is a nit anyway. 😅

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in d24c14bd8 , went with your second option

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.

I can't believe that the storage pointer version is cheaper at lower runs…

…ot 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>
@gvladika gvladika changed the title fix(EXSC-423): address Vault Wrapper V1 peer-review feedback fix(EXSC-423): address Vault Wrapper V1 peer-review feedback (docs + dead lint pragma) Sep 2, 2026
@gvladika
gvladika marked this pull request as ready for review September 2, 2026 13:56
@github-actions github-actions Bot added the requires-types Trigger Types Bindings CI (ABI/type generation for lifi-contract-types) label Sep 2, 2026
@lifi-action-bot

Copy link
Copy Markdown
Collaborator

🤖 GitHub Action: Security Alerts Review 🔍

🟢 Dismissed Security Alerts with Comments
The following alerts were dismissed with proper comments:

🟢 View Alert - File: src/VaultWrapper/LiFiVaultWrapper.sol
🔹 Performing a narrowing downcast may result in silent overflow due to bit truncation. For more information, visit: http://detectors.olympixdevsectools.com/article/web3-vulnerability/unsafe-downcast
🔹 Dismiss Reason: False positive
🔹 Dismiss Comment: Cast cannot truncate: values are fee-counter remainders from _distributeFeePool, each <= its input, and inputs are the fee counters that _splitFee saturates at uint128 max. Numeric-narrowing class tracked under EXSC-617.

🟢 View Alert - File: src/VaultWrapper/LiFiVaultWrapper.sol
🔹 Performing a narrowing downcast may result in silent overflow due to bit truncation. For more information, visit: http://detectors.olympixdevsectools.com/article/web3-vulnerability/unsafe-downcast
🔹 Dismiss Reason: False positive
🔹 Dismiss Comment: Cast cannot truncate: values are fee-counter remainders from _distributeFeePool, each <= its input, and inputs are the fee counters that _splitFee saturates at uint128 max. Numeric-narrowing class tracked under EXSC-617.

🟢 View Alert - File: src/VaultWrapper/LiFiVaultWrapper.sol
🔹 Performing a narrowing downcast may result in silent overflow due to bit truncation. For more information, visit: http://detectors.olympixdevsectools.com/article/web3-vulnerability/unsafe-downcast
🔹 Dismiss Reason: False positive
🔹 Dismiss Comment: Cast cannot truncate: values are fee-counter remainders from _distributeFeePool, each <= its input, and inputs are the fee counters that _splitFee saturates at uint128 max. Numeric-narrowing class tracked under EXSC-617.

🟢 View Alert - File: src/VaultWrapper/LiFiVaultWrapper.sol
🔹 Performing a narrowing downcast may result in silent overflow due to bit truncation. For more information, visit: http://detectors.olympixdevsectools.com/article/web3-vulnerability/unsafe-downcast
🔹 Dismiss Reason: False positive
🔹 Dismiss Comment: Cast cannot truncate: values are fee-counter remainders from _distributeFeePool, each <= its input, and inputs are the fee counters that _splitFee saturates at uint128 max. Numeric-narrowing class tracked under EXSC-617.

🟢 View Alert - File: src/VaultWrapper/LiFiVaultWrapper.sol
🔹 Performing a narrowing downcast may result in silent overflow due to bit truncation. For more information, visit: http://detectors.olympixdevsectools.com/article/web3-vulnerability/unsafe-downcast
🔹 Dismiss Reason: False positive
🔹 Dismiss Comment: Guarded downcast: the line immediately before, if (decoded > type(uint8).max) revert AssetDecimalsUnavailable();, proves decoded fits in uint8, so uint8(decoded) cannot truncate. SafeCast would only swap our domain error for OZ's generic one.

🟢 View Alert - File: src/VaultWrapper/LiFiVaultWrapper.sol
🔹 Making an external call without a gas budget may consume all of the transaction's gas, causing it to revert. For more information, visit: http://detectors.olympixdevsectools.com/article/web3-vulnerability/call-without-gas-budget
🔹 Dismiss Reason: False positive
🔹 Dismiss Comment: view staticcall reading the asset decimals() at initialize: no state to corrupt, no reentrancy surface, result fully validated (ok, length>=32, value<=uint8 max) before use. A gas cap would risk spuriously reverting tokens with a costlier decimals().

🟢 View Alert - File: src/VaultWrapper/LiFiVaultWrapper.sol
🔹 Using uninitialized state variables may lead to unexpected behavior. For more information, visit: http://detectors.olympixdevsectools.com/article/web3-vulnerability/uninitialized-state-variable
🔹 Dismiss Reason: False positive
🔹 Dismiss Comment: integratorFeeReceivers is set write-once in initialize via _setIntegratorFeeReceivers; impl locked with _disableInitializers() and factory deploys+initializes atomically, so no path reads it uninitialized. Same finding as dismissed #1772.

🟢 View Alert - File: src/VaultWrapper/LiFiVaultWrapper.sol
🔹 Reentrant functions which emit events after making an external call may lead to out-of-order events. For more information, visit: http://detectors.olympixdevsectools.com/article/web3-vulnerability/reentrancy-events
🔹 Dismiss Reason: False positive
🔹 Dismiss Comment: Event follows the external resolveAsset call, but initialize is single-shot (OZ initializer guard) and called by the factory within the deploy tx; the adapter is governance-approved. No reentrancy surface. Same finding as dismissed #1763.

🟢 View Alert - File: src/VaultWrapper/LiFiVaultWrapper.sol
🔹 Performing a narrowing downcast may result in silent overflow due to bit truncation. For more information, visit: http://detectors.olympixdevsectools.com/article/web3-vulnerability/unsafe-downcast
🔹 Dismiss Reason: False positive
🔹 Dismiss Comment: Bounds-checked downcast: guard returns type(uint128).max when _delta > type(uint128).max - accrued, so accrued + _delta always fits uint128 here (cast is exact). SafeCast intentionally avoided: _saturatingAddUint128 must saturate, not revert.

🟢 View Alert - File: src/VaultWrapper/LiFiVaultWrapper.sol
🔹 Reentrant functions which emit events after making an external call may lead to out-of-order events. For more information, visit: http://detectors.olympixdevsectools.com/article/web3-vulnerability/reentrancy-events
🔹 Dismiss Reason: False positive
🔹 Dismiss Comment: Fee booking event follows the adapter withdrawal by necessity: the booked amount includes the adapter overage only known after the call. withdraw/redeem entrypoints are nonReentrant; adapter is governance-approved.

🟢 View Alert - File: src/VaultWrapper/LiFiVaultWrapper.sol
🔹 Reentrant functions which emit events after making an external call may lead to out-of-order events. For more information, visit: http://detectors.olympixdevsectools.com/article/web3-vulnerability/reentrancy-events
🔹 Dismiss Reason: False positive
🔹 Dismiss Comment: AssetFeeCharged follows the token pull and adapter delegatecall, but deposit/mint entrypoints are nonReentrant and the adapter is governance-approved, so events cannot be reordered by reentrancy.

🟢 View Alert - File: src/VaultWrapper/LiFiVaultWrapper.sol
🔹 Allowing delegated calls to arbitrary addresses may result in execution of untrusted code. For more information, visit: http://detectors.olympixdevsectools.com/article/web3-vulnerability/arbitrary-delegatecall
🔹 Dismiss Reason: Won't fix
🔹 Dismiss Comment: adapter is set write-once in initialize to a factory/governance-approved address with no post-init setter, so the delegatecall target is not arbitrary. The delegatecall adapter is the intended design ([CONV:VW-ADAPTERS]); statelessness enforced by review/audit.

🟢 View Alert - File: src/VaultWrapper/LiFiVaultWrapper.sol
🔹 Making an external call without a gas budget may consume all of the transaction's gas, causing it to revert. For more information, visit: http://detectors.olympixdevsectools.com/article/web3-vulnerability/call-without-gas-budget
🔹 Dismiss Reason: Won't fix
🔹 Dismiss Comment: The governance-approved adapter must run the full deposit/withdraw into the yield source via delegatecall; the target is not attacker-controlled, so gas-griefing does not apply and full gas forwarding is intended. Capping gas would truncate legitimate yield-source interactions.

✅ No unresolved security alerts! 🎉

…t 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>
@gvladika gvladika changed the title fix(EXSC-423): address Vault Wrapper V1 peer-review feedback (docs + dead lint pragma) fix(EXSC-423): address Vault Wrapper V1 peer-review feedback Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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.

3 participants