Skip to content

feat(EXSC-413): S5 — pause (instance + global-read; withdrawals-always-open)#1982

Merged
mirooon merged 12 commits into
feature/exsc-409-s1-core-erc-4626-immutable-args-cwiafrom
feature/exsc-413-s5-pause-instance-global-read-withdrawals-always-open
Jul 2, 2026
Merged

feat(EXSC-413): S5 — pause (instance + global-read; withdrawals-always-open)#1982
mirooon merged 12 commits into
feature/exsc-409-s1-core-erc-4626-immutable-args-cwiafrom
feature/exsc-413-s5-pause-instance-global-read-withdrawals-always-open

Conversation

@gvladika

@gvladika gvladika commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Which Linear task belongs to this PR?

EXSC-413 — S5: Pause (instance + global-read; withdrawals-always-open)

Stacked PR. Base is feature/exsc-409-s1-core-erc-4626-immutable-args-cwia (#1992, S1). Review the incremental diff against that base; do not merge before S1 lands. Stack: S1 → S5.

Why did I implement it this way?

S5 is the clone-side pause: enforcement plus the live read of the factory-level circuit breaker. The factory's global flag and the emergency-pauser role already exist from S1 (globalPause()/globalUnpause() gated by onlyEmergencyPauser, plus globalPaused()); this PR makes instances honour that flag and adds the integrator's own per-clone pause.

  • Two pause sources, deposits-only. A clone halts inflows when its instance pause is set (paused) or the factory's global breaker is engaged. depositsPaused() = paused || factory.globalPaused(), read live so toggling the factory breaker propagates to every instance with no clone change.
  • Instance pause is integrator-only; LI.FI's only lever is the global breaker. pause() / unpause() are callable solely by the per-vault owner (the vaultWrapperAdmin, i.e. the integrator), enforced by OZ's onlyOwner. LI.FI has no per-instance pause — its authority acts only through the factory-level global circuit breaker (globalPaused), which an integrator cannot clear. This keeps the two authorities' scopes disjoint: the integrator owns its clone's pause (I28 "no LI.FI involvement" holds), and LI.FI owns the global halt (A14). (Reverts an earlier shared-flag iteration that let either authority toggle the instance flag — flagged in review as diverging from the Pause Authority Matrix / Legal Topic 3.)
  • Withdrawals always open — the load-bearing decision. The pause gate is wired into the inflow path only: deposit() / mint() revert DepositsPaused before doing anything else. It is not in the shared _beforeOperation() hook (access + accrual), which also runs on withdraw/redeem — gating there would freeze user exits during an emergency, the opposite of a circuit breaker. Self-withdraw therefore works under every pause combination.
  • maxDeposit/maxMint report 0 while paused. EIP-4626 consumers see the vault as closed and don't build deposits that would revert. The named DepositsPaused is raised at the deposit/mint entrypoints (before OZ's max check), so callers get the real reason rather than OZ's ERC4626ExceededMaxDeposit/Mint; the max* overrides remain purely for off-chain consumers reading the capacity views.
  • No separate pause module. Pause state (paused), the PauseSet event, the DepositsPaused error, the pause/unpause controls, and the depositsPaused() view all live directly in LiFiVaultWrapper. An earlier iteration used an abstract VaultWrapperPausable mixin with virtual authority/global hooks; with the wrapper as the only consumer that indirection earned nothing, so it was inlined and the mixin removed.
  • Factory interface gained globalPaused() — a view declaration already satisfied by the factory's existing public-getter state var, so no factory code change and no version bump (the subsystem is unreleased).

Tests

VaultWrapperPause.t.sol — unit tests against a MockPauseFactory that deploys the proxy (so it is the factory the instance reads back) and exposes the toggleable global flag, plus 3 end-to-end tests against the real LiFiVaultWrapperFactory:

  • deposit/mint are rejected under each pause source (instance and global) with the named DepositsPaused, including a zero-amount deposit (the entrypoint guard is unconditional);
  • withdraw/redeem succeed under instance pause, global pause, and both combined; admin role rotation (transferOwnership/acceptOwnership) also stays open under pause;
  • authority: only the owner can pause()/unpause() — a stranger cannot (OwnableUnauthorizedAccount), tested for both pause and unpause; an instance unpause() cannot clear the factory's global breaker;
  • unpause() resumes deposits; maxDeposit/maxMint go to 0 while paused and are restored after unpause; the depositsPaused() view reflects each source; PauseSet is emitted;
  • E2E: factory.globalPause() freezes an existing instance's deposits, globalUnpause() resumes them, and deploying while globally paused yields an instance frozen from birth (the documented carve-out).

The existing LiFiVaultWrapper.t.sol (whose factory is the test contract) carries a globalPaused() shim so its deposits still resolve.

Checklist before requesting a review

  • I have performed a self-review of my code
  • This pull request is as small as possible and only tackles one problem
  • I have run /pr-ready (local CodeRabbit) on this branch and resolved (or explicitly documented) all findings — see .agents/commands/pr-ready.md
  • I have added tests that cover the functionality / test the bug
  • For new facets: I have checked all points from this list: https://www.notion.so/lifi/New-Facet-Contract-Checklist-157f0ff14ac78095a2b8f999d655622e
  • I have updated any required documentation

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>

Maintainer note (propagation merge). This branch was updated by merging the latest S1 (#1992), which brings: the Ownable2Step admin refactor, the InitializedVaultWrapperConfigured event rename, the enforceLibAssetRouting + solc_floor CI carve-outs for the VaultWrapper subsystem, the IYieldAdapter version reset to 1.0.0, and the latest main (via dev-vault-wrapper). Local /pr-ready was bypassed for this push (PR_READY_OK=1) because the diff is mechanical propagation of already-reviewed S1 and upstream main code; CI CodeRabbit still runs as the safety net. All 126 VaultWrapper tests pass and the solc_floor (0.8.17) build is green.

@coderabbitai

coderabbitai Bot commented Jun 25, 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: Pro

Run ID: bc55165c-3a75-4d63-a604-3db90c4cd6e4

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
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/exsc-413-s5-pause-instance-global-read-withdrawals-always-open

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.

gvladika and others added 3 commits June 26, 2026 16:26
S5. Add VaultWrapperPausable abstract mixin with two independent instance
flags (emergencyPaused via the LI.FI emergency multisig read live from the
factory, integratorPaused via the vault admin) plus a live
factory.globalPaused() read. The pause guard is wired into deposit/mint
only; withdraw/redeem stay structurally open under every pause
combination. _requireNotPaused was removed from the shared _beforeOperation
(which previously gated exits too) and replaced by _requireDepositsNotPaused
on inflows — fixing the latent freeze-on-exit defect.

ILiFiVaultWrapperFactory gains globalPaused()/emergencyPauser() view
declarations (already satisfied by the factory's public getters).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial review gaps: add a real-factory end-to-end suite proving
factory.globalPause() freezes a live instance's deposits (and unpause
resumes), plus the documented carve-out that a deploy while globally
paused yields a frozen-from-birth instance. Cover mint() under integrator
and global pause, and withdraw-open under global-pause-alone. Blank line
after pragma in LiFiVaultWrapper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Append-only upgrade gap so the pause mixin can gain state without shifting
the storage of the contracts that inherit it, consistent with the wrapper's
own gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gvladika gvladika force-pushed the feature/exsc-413-s5-pause-instance-global-read-withdrawals-always-open branch from b0e15d6 to f83fbb7 Compare June 26, 2026 14:31
@gvladika gvladika changed the base branch from feature/exsc-408-s11-access-interfaces-reference-adapter to feature/exsc-409-s1-core-erc-4626-immutable-args-cwia June 26, 2026 14:31
gvladika and others added 7 commits June 29, 2026 09:45
… breaker

Removes the wrapper-level emergency pause (story A13): the LI.FI emergency
authority now halts an instance's deposits only via the factory-level global
circuit breaker (A14), never per-instance. Integrator pause (I28) and the
withdrawals-always-open guarantee (U20) are unchanged.

- VaultWrapperPausable: drop emergencyPaused/emergencyPause(Unpause)/
  EmergencyPauseSet/NotEmergencyPauser/onlyEmergencyPauser/_emergencyPauseAuthority;
  depositsPaused() = integratorPaused || _globalPaused().
- Wrapper: drop the _emergencyPauseAuthority() override (no factory.emergencyPauser()
  read).
- ILiFiVaultWrapperFactory: drop the now-unused emergencyPauser() view (keep
  globalPaused()); the factory's own NotEmergencyPauser error is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iew 1+3)

- maxDeposit/maxMint return 0 while any pause source is engaged, so EIP-4626
  consumers see the vault as closed and don't build deposits that revert.
- Move the pause gate from the deposit/mint entrypoints into the single _deposit
  chokepoint both route through, gating every current and future inflow by
  construction.

Consequence: with max* == 0, OZ's deposit/mint reject a non-zero amount with
ERC4626ExceededMaxDeposit/Mint before _deposit runs; the _deposit guard
(DepositsPaused) is the backstop for zero-amount / non-standard inflows that
clear the max check. Tests updated accordingly (+ blank-line fix per
CONV:BLANKLINES). 123/123 VaultWrapper tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…into feature/exsc-413-s5-pause-instance-global-read-withdrawals-always-open

# Conflicts:
#	src/VaultWrapper/LiFiVaultWrapper.sol
…ble mixin

Dissolve the VaultWrapperPausable mixin into LiFiVaultWrapper. The mixin's
indirection (integrator-pause-authority hook, requireDepositsNotPaused helper,
global-paused hook, custom NotIntegratorAdmin modifier/error) added no value
now that the wrapper is the only consumer.

- pause()/unpause() replace integratorPause()/integratorUnpause(), gated by OZ's
  existing onlyOwner (the authority was always owner()).
- Pause check inlined at the _deposit chokepoint; global breaker read inlined
  into depositsPaused().
- Storage bool integratorPaused -> paused (packs with integratorShareBps);
  event IntegratorPauseSet -> PauseSet.

Behaviour unchanged: dual gate (max*==0 + _deposit guard) on inflows, exits
always open (U20). Unreleased v1.0.0, so the storage-layout shift is a no-op.
124/124 VaultWrapper tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The instance `paused` flag is now toggled by either the per-vault owner
(integrator) or the factory's emergency pauser, in either direction — a single
shared flag, so one authority can lift the other's pause. Adds an
`onlyPauseAuthority` modifier (owner() or factory.emergencyPauser(), read live)
and a `NotPauseAuthority` error; re-adds `emergencyPauser()` to the factory
interface so the wrapper can read it. The factory-level global breaker is
unchanged. 127/127 VaultWrapper tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hten pause bodies

Move the modifier above the functions into a dedicated Modifiers section and
drop the blank line before the emit in pause()/unpause().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the pause guard from the _deposit chokepoint to the deposit()/mint()
entrypoints, reverting the named DepositsPaused before super runs (code-review).
Previously maxDeposit/maxMint==0 made OZ revert ERC4626ExceededMaxDeposit/Mint
first, so DepositsPaused was unreachable on the normal path and callers saw a
misleading capacity error. maxDeposit/maxMint still return 0 for EIP-4626
consumers. Also: PauseSet NatSpec now names both pause authorities; the
LiFiVaultWrapper.t.sol factory stub gained emergencyPauser(). 127/127 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gvladika gvladika marked this pull request as ready for review June 30, 2026 13:57
@github-actions github-actions Bot added the requires-types Trigger Types Bindings CI (ABI/type generation for lifi-contract-types) label Jun 30, 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
🔹 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: Proxy state, not constructor-set. pendingVaultWrapperAdmin is deliberately zero until a two-step transfer starts; zero == no pending transfer, and acceptVaultWrapperAdmin() gates on msg.sender == pendingVaultWrapperAdmin so zero authorizes no one. Confirmed by review.

🟢 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: initialize() is initializer-guarded (single-shot), called atomically by the factory; its only external call is the view resolveAsset() before the emit. No reentrancy vector, event ordering benign. Same pattern as the dismissed deploy() alert. Confirmed by security review.

🟢 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: initialize is initializer-guarded (single-shot); a re-entrant call reverts with InvalidInitialization. The only pre-emit external call is the adapter resolveAsset view. No reentrancy surface, so out-of-order events cannot occur.

🟢 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: _withdraw is reachable only via withdraw/redeem, both nonReentrant; shares are burned (effects) before the adapter interaction (CEI), and Withdraw is emitted last per OZ ERC-4626. Reentrancy is prevented, so out-of-order events cannot occur.

🟢 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! 🎉

@lifi-action-bot

lifi-action-bot commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Test Coverage Report

Line Coverage: 90.20% (3602 / 3993 lines)
Function Coverage: 93.63% ( 574 / 613 functions)
Branch Coverage: 73.80% ( 665 / 901 branches)
Test coverage (90.20%) is above min threshold (89%). Check passed.

Comment thread src/VaultWrapper/LiFiVaultWrapper.sol Outdated
instance = LiFiVaultWrapper(_deploy(0));
}

function test_GlobalPauseFromFactoryFreezesInstanceDeposits() public {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit [400-solidity-tests.md]: testRevert_ is required for any test that calls vm.expectRevert (this one does, on the DepositsPaused expectation below). Rename to testRevert_GlobalPauseFromFactoryFreezesInstanceDeposits for consistency with the rest of the suite (the MockPauseFactory tests above already follow this).

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.

Fixed in e479d86b6 — renamed to testRevert_GlobalPauseFromFactoryFreezesInstanceDeposits.

Comment thread test/solidity/VaultWrapper/VaultWrapperPause.t.sol Outdated
@lifi-qa-agent

lifi-qa-agent Bot commented Jul 1, 2026

Copy link
Copy Markdown

🔍 QA Review — EXSC-413 · S5 — Pause (instance + global-read; withdrawals-always-open)

Re-review · Prior CHANGES_REQUESTED: 2026-07-01 · This re-review: 2026-07-02
PR: #1982 · feature/exsc-413-s5-pause-instance-global-read-withdrawals-always-openfeature/exsc-409-s1-core-erc-4626-immutable-args-cwia · HEAD: e479d86b6
Ticket: EXSC-413
Reviewer: lifi-qa-agent[bot] · Date: 2026-07-02


What this PR does

EXSC-413 (S5) implements the per-instance and global-circuit-breaker pause layer for LiFiVaultWrapper. The deposit/mint path reverts under either pause source; withdrawals and redemptions are unconditionally open; the integrator (owner) controls the instance flag exclusively; LI.FI's only lever is the factory-level globalPaused() read live in depositsPaused(). A dedicated Foundry test file (VaultWrapperPause.t.sol) covers all stated scenarios plus an E2E suite against the real factory.


Resolution status — all 7 prior items

# Severity Item Status
1 🔴 High Shared-flag pause design divergence ✅ Fixed
2 🟠 Medium V2 reward-injection seam missing ✅ Fixed
3 🟠 Medium Role-rotation-under-pause test missing ✅ Fixed
4 🟡 Low testRevert_ naming violations (×2) ✅ Fixed
5 🟡 Low testRevert_StrangerCannotUnpause missing ✅ Fixed
6 🟡 Low Preview consistency + vm.expectEmit emitter ✅ Fixed
7 🟡 Low pause/unpause/depositsPaused absent from ILiFiVaultWrapper ✅ Accepted (deferred)

Detailed verification

Item 1 — Shared-flag design [High] → ✅ Fixed

The redesign is correct and clean. paused is now an integrator-only flag gated by onlyOwner; pause() and unpause() carry the onlyOwner modifier exclusively. LI.FI has zero per-instance pause capability — its only lever is ILiFiVaultWrapperFactory(factory).globalPaused(), read live in depositsPaused(). The two sources are OR-ed (return paused || ILiFiVaultWrapperFactory(factory).globalPaused()), perfectly matching the "disjoint authority" spec in I28/U20. The old onlyPauseAuthority / NotPauseAuthority / emergencyPauser() path is removed. The NatDoc on paused explicitly states "LI.FI has no per-instance pause" — good defensive documentation against future drift.

Item 2 — V2 reward-injection seam [Medium] → ✅ Fixed

TODO(pause): comment added in the "Fee / access blueprint" seam block:

/// TODO(pause): any future inflow entrypoint (e.g. the V2 reward-injection path) must
///      gate on `if (depositsPaused()) revert DepositsPaused();` like `deposit`/`mint`.

Placement is logical — it lives where future inflow entrypoints will be added. Satisfies AC-05.

Item 3 — Role-rotation-under-pause test [Medium] → ✅ Fixed

test_OwnershipTransferOpenUnderPause sets both instance and global pause simultaneously, calls transferOwnership(newAdmin) as vaultAdmin, then acceptOwnership() as newAdmin, and asserts wrapper.owner() == newAdmin. Solid coverage of AC-09 under worst-case combined pause state.

Item 4 — testRevert_ naming violations [Low] → ✅ Fixed

Both E2E tests correctly renamed to:

  • testRevert_GlobalPauseFromFactoryFreezesInstanceDeposits
  • testRevert_DeployWhileGloballyPausedYieldsFrozenInstance

Both use vm.expectRevert and now follow the repo's [CONV:TESTS] convention.

Item 5 — testRevert_StrangerCannotUnpause [Low] → ✅ Fixed

testRevert_StrangerCannotUnpause is present alongside testRevert_StrangerCannotPause. Revert selector is correctly encoded with OwnableUpgradeable.OwnableUnauthorizedAccount.selector and the stranger address. Both pause and unpause paths are now fully covered.

Item 6 — Preview consistency + vm.expectEmit emitter [Low] → ✅ Fixed

Preview: NatSpec block added above previewDeposit/previewMint citing EIP-4626 §3 — previews MUST NOT account for deposit limits; maxDeposit/maxMint are the pause-aware limit views. Technically correct and properly documented.

Emitter: test_PauseSettersEmitEvents now uses:

vm.expectEmit(true, true, true, true, address(wrapper));

Confirmed correct — fifth argument is the emitter address per Foundry convention.

Item 7 — Interface gap in ILiFiVaultWrapper [Low] → ✅ Accepted

Deferred to an interface-cleanup ticket. Acceptable per the original finding's explicit note. ILiFiVaultWrapperFactory correctly received globalPaused() view as needed by this implementation.


New issues introduced by changes

No new blockers or high-severity issues found. Three informational observations:

Observation A — _expectMintReverts uses asset count as share count [Low / Test quality, non-blocking]

_expectMintReverts calls wrapper.mint(_amount, _from) where _amount = 1_000e18 is denominated in assets. mint(uint256 shares, ...) takes shares, not assets. At inception (totalSupply = 0, virtual offset applied) the 1:1 ratio makes this work, but the semantic mismatch could produce silent coverage drift as share price changes in future tests. Suggested: add an inline comment // shares == assets at inception (1:1 ratio) or use wrapper.previewDeposit(_amount) for the share conversion. Non-blocking — pause enforcement correctness is unaffected.

Observation B — test_IntegratorUnpauseCannotClearGlobalPause validity confirmed

The test correctly verifies that calling wrapper.unpause() (which clears the instance paused flag) does not clear the factory's global circuit breaker. depositsPaused() remains true because factory.globalPaused() is still active. Confirmed working as intended — no issue.

Observation C — integratorShareBps: type(uint16).max in E2E _deploy [Info / S8 scope]

The E2E test helper passes type(uint16).max (65535) as integratorShareBps. Since initialize reverts for values ≥ 10_000, the factory's deploy must apply validation or capping before calling initialize — confirmed by the developer's claim of 126/126 passing tests. Worth verifying explicitly in S8's review when LiFiVaultWrapperFactory.deploy lands.


Acceptance criteria coverage

AC Description Status
AC-01 Instance deposit pause controlled by integrator; LI.FI global CB separate
AC-02 Integrator can unilaterally pause/unpause own clone
AC-03 factory.globalPaused() read live on deposit
AC-04 Withdrawals-always-open structural guarantee
AC-05 Pause covers deposits (+ V2 reward injection seam)
AC-06 deposit()/mint() revert when paused
AC-07 withdraw() succeeds under every pause combo
AC-08 Integrator vs LI.FI authority separation (disjoint ownership)
AC-09 Self-withdraw + role-rotation bypass
AC-10 Unit tests for all stated scenarios

Security assessment

Check Result
Reentrancy on pause/unpause No external calls — onlyOwner modifier + boolean set. No nonReentrant needed. ✅
factory.globalPaused() call in depositsPaused() Calls factory (write-once at init, cannot be changed). Not exploitable. ✅
Withdrawals-always-open withdraw()/redeem() have no pause guard. _beforeOperation() is shared (access + fees only) and contains no pause check. ✅
Integrator cannot freeze LI.FI global CB unpause() sets paused = false only — doesn't touch factory.globalPaused(). Confirmed by test_IntegratorUnpauseCannotClearGlobalPause. ✅
Storage layout / upgrade safety bool paused packs with uint16 integratorShareBps in one slot (3 bytes total). No new slot consumed; __gap[50] unchanged. ✅
Authority disjoint property Integrator: onlyOwner on instance flag. LI.FI: factory-owned global CB, no per-instance handle. Cross-lift impossible by design. ✅

Test coverage summary

VaultWrapperPauseTest (unit):

Scenario Covered
Deposit/mint revert under integrator pause
Deposit/mint revert under global pause
Zero-amount deposit rejected while paused
Withdraw/redeem open under integrator pause
Withdraw/redeem open under global pause
Redeem open under combined (both) pause
Role rotation (transfer + accept) under combined pause
Integrator unpause does not clear global pause
Stranger cannot pause
Stranger cannot unpause
Unpause resumes deposits
depositsPaused() reflects each source independently
PauseSet event with correct emitter
maxDeposit/maxMint = 0 under each pause source; restore after unpause

VaultWrapperGlobalPauseE2ETest (integration, real factory):

Scenario Covered
Global pause freezes instance deposits
Global unpause resumes instance deposits
Deploy while globally paused → frozen from birth

LiFiVaultWrapper.t.sol update: existing test contract now exposes globalPaused() external pure returns (false) shim so S1 tests continue passing after depositsPaused() begins calling the factory. Clean separation.

Coverage rating: Comprehensive


Downstream impact

EXSC-421 (S12 — Multi-actor scenario tests): S12 depends on this PR's authority model being stable. The disjoint-authority design (integrator-only instance pause, factory-only global CB) is cleaner for multi-actor scenarios — no cross-lift to model. S12 authors should adopt MockPauseFactory.setGlobalPaused as the test helper pattern for the global-CB arm.

EXSC-417 (S8 — Factory): S8 owns the globalPaused() implementation and the LI.FI emergency-pauser role. This PR only reads the interface. ILiFiVaultWrapperFactory.globalPaused() is correctly added as a view stub. S8 must implement the matching storage flag, globalPause()/globalUnpause() functions, and role gating. The E2E tests in this PR already exercise the S8 factory path (via LiFiVaultWrapperFactory) and should remain green when S8 lands.

Pre-merge-to-main reminder: @custom:version 1.0.0 must be bumped and audit/auditLog.json must be updated when the S1→S5 stack merges to main. These are process gates enforced by versionControlAndAuditCheck.yml — they do not run on stacked PRs (base ≠ main).


Process items

/pr-ready unchecked: The PR template's CodeRabbit pre-flight checkbox remains unchecked. This does not block QA approval, but the team convention is to run /pr-ready and resolve findings before marking ready for review. Please confirm CodeRabbit was run or tick the box.

requires-types label: Present — indicates expected TypeScript task files are not included. If companion TypeScript tasks are out of scope for this Solidity-only PR, remove the label. If they are in scope, they remain outstanding before the stack merges to main.


✅ Verdict: Pass

All 7 previously flagged items are resolved (6 fixed in code, 1 accepted as deferred with explicit product justification). The design change for item 1 correctly implements the disjoint-authority model and improves the spec alignment. Three informational observations are noted — none are blocking. The implementation satisfies all 10 acceptance criteria.

Severity Count Items
🔴 High 1 (resolved) Shared-flag design — ✅ Fixed
🟠 Medium 2 (resolved) V2 seam, role-rotation test — ✅ Fixed
🟡 Low 4 (resolved) Convention ×2, test gap ×1, preview+emitter — ✅ Fixed; interface deferred ✅ Accepted

Posted by lifi-qa-agent[bot] · EXSC-413 · QA Agent SDK · 2026-07-02

@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 7 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 🔴 High Design Shared-flag pause design may diverge from canonical two-primitive spec — confirm spec alignment
2 🟠 Medium Code V2 reward injection entrypoint has no pause-guard seam or TODO comment
3 🟠 Medium Test gap test/solidity/VaultWrapper/VaultWrapperPause.t.sol (existing)
4 🟡 Low Convention Two E2E tests use vm.expectRevert but named test_ not testRevert_
5 🟡 Low Test gap test/solidity/VaultWrapper/VaultWrapperPause.t.sol (existing)
6 🟡 Low Code previewDeposit/previewMint inconsistency under pause + missing vm.expectEmit emitter
7 🟡 Low Documentation pause()/unpause()/depositsPaused() absent from ILiFiVaultWrapper

1. [High] Shared-flag pause design may diverge from canonical two-primitive spec

The implementation uses a single shared bool paused flag that both the integrator (owner) and the LI.FI emergency pauser can toggle in either direction — including lifting the other authority's pause. Reviewer mirooon references a "canonical Pause Authority Matrix" and "Legal Brief Topic 3" that define the instance pause as two separate primitives with authority-mirrored unpause. The test test_IntegratorCanLiftEmergencyPause documents this cross-lift as intentional, but if the spec requires isolation between the two authority levels, the implementation needs two separate booleans, a revised modifier, a revised depositsPaused(), and updated tests. Resolution of this item directly affects EXSC-421 (S12) test design.

Required: Spec owner to confirm in a PR comment whether (a) the shared-flag model is an accepted V1 simplification or (b) two separate storage flags are required. If (b), update LiFiVaultWrapper.sol and VaultWrapperPause.t.sol accordingly.

2. [Medium] V2 reward injection seam missing

The ticket scope states "pause covers deposits (+ V2 reward injection)". No such function exists yet, and no TODO comment exists to signal that the future entrypoint must gate on depositsPaused().

Required: Add // TODO(pause): V2 reward injection entrypoint must check: if (depositsPaused()) revert DepositsPaused(); at the appropriate extension point in src/VaultWrapper/LiFiVaultWrapper.sol.

3. [Medium] Test gap — VaultWrapperPause.t.sol

The ticket specifies "bypass cases keep working (self-withdraw, fee sweep, role rotation)". Role rotation is untested.

  • Missing: test_OwnershipTransferOpenUnderPause — verify transferOwnership + acceptOwnership succeed while paused = true and factory.globalPaused = true

4. [Low] testRevert_ naming violations (×2)

Two tests in VaultWrapperGlobalPauseE2ETest call vm.expectRevert but are named test_ not testRevert_, violating [CONV:TESTS] (400-solidity-tests.md):

  • test_GlobalPauseFromFactoryFreezesInstanceDepositstestRevert_GlobalPauseFromFactoryFreezesInstanceDeposits
  • test_DeployWhileGloballyPausedYieldsFrozenInstancetestRevert_DeployWhileGloballyPausedYieldsFrozenInstance

5. [Low] Test gap — VaultWrapperPause.t.sol

  • Missing: testRevert_StrangerCannotUnpause — symmetric to testRevert_StrangerCannotPause; both pause() and unpause() carry onlyPauseAuthority, only one side is tested.

6. [Low] previewDeposit/previewMint inconsistency under pause + missing emitter

previewDeposit()/previewMint() return positive share estimates when depositsPaused() is true, while deposit()/mint() revert. EIP-4626 requires previews to reflect what the actual call would do. Add a NatSpec note (or revert when paused); add test_PreviewDepositReturnsBehaviourWhenPaused.

Also: test_PauseSettersEmitEvents calls vm.expectEmit(true, true, true, true) without the 5th emitter arg — should be vm.expectEmit(true, true, true, true, address(wrapper)).

7. [Low] Pause interface gap in ILiFiVaultWrapper

pause(), unpause(), depositsPaused(), and PauseSet are absent from ILiFiVaultWrapper. Callers holding an interface reference cannot access these without a cast. Process note for the interface-cleanup ticket — acceptable to defer if that is planned.


💡 Once you've addressed the items above, re-apply the "Agent Review Request" label to trigger an automated re-review.

…obal breaker

Per review (mirooon / QA): the shared instance-pause flag diverged from the
authoritative Pause Authority Matrix + Legal Topic 3. New model: the integrator
(owner) is the sole authority over the instance `paused` flag; LI.FI's only
lever over a clone is the factory-level global circuit breaker. This makes I28
'no LI.FI involvement' hold and removes the cross-lift.

- pause()/unpause() gated by OZ onlyOwner; drop onlyPauseAuthority modifier and
  NotPauseAuthority error; drop emergencyPauser() from the factory interface (no
  longer read by the wrapper). depositsPaused() = paused || factory.globalPaused().

Also addresses review nits:
- rename two E2E tests to testRevert_ (call vm.expectRevert) [400-solidity-tests]
- add testRevert_StrangerCannotUnpause; add test_OwnershipTransferOpenUnderPause
- add address(wrapper) emitter to test_PauseSettersEmitEvents
- NatSpec: previews intentionally ignore pause per EIP-4626; TODO(pause) seam for
  the future V2 reward-injection inflow

126/126 VaultWrapper tests pass; solhint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gvladika

gvladika commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Addressed in e479d86b6:

# Item Resolution
1 Shared-flag design divergence [High] Fixed (design change). Instance pause is now integrator-only (onlyOwner); LI.FI has no per-instance pause — its only lever is the factory-level global breaker. Scopes are disjoint, so I28 "no LI.FI involvement" holds and there is no cross-lift. Dropped the shared flag / onlyPauseAuthority / NotPauseAuthority / factory emergencyPauser().
2 V2 reward-injection seam [Medium] Added TODO(pause): note stating any future inflow entrypoint must gate on if (depositsPaused()) revert DepositsPaused();.
3 Role-rotation-under-pause test [Medium] Added test_OwnershipTransferOpenUnderPause (transfer + accept while paused and globalPaused).
4 testRevert_ naming (×2) [Low] Fixed — both E2E tests renamed to testRevert_.
5 testRevert_StrangerCannotUnpause [Low] Added (stranger reverts OwnableUnauthorizedAccount; pause + unpause both covered).
6 preview consistency + emitter [Low] Preview: NatSpec note added — per EIP-4626 previews MUST NOT account for deposit limits, so previewDeposit/previewMint intentionally ignore pause; maxDeposit/maxMint are the pause-aware limit views. Emitter: fixedvm.expectEmit(true,true,true,true,address(wrapper)).
7 Interface gap in ILiFiVaultWrapper [Low] Deferred to the interface-cleanup ticket, as the finding noted is acceptable.

126/126 VaultWrapper tests pass; solhint clean. Re-applying the Agent Review Request label for re-review.

Note: versionControlAndAuditCheck version bump + auditLog.json entry are tracked as pre-merge-to-main actions for the whole S1→S5 stack (this stacked PR targets S1, so that CI does not run here).

@gvladika gvladika added the Agent Review Request triggers QA Agent Zeus label Jul 2, 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 Jul 2, 2026
…into feature/exsc-413-s5-pause-instance-global-read-withdrawals-always-open

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

Re-review complete — all 7 CHANGES_REQUESTED items resolved; EXSC-413 S5 pause module approved at HEAD e479d86.

@mirooon mirooon merged commit 8bd4bc7 into feature/exsc-409-s1-core-erc-4626-immutable-args-cwia Jul 2, 2026
31 checks passed
@mirooon mirooon deleted the feature/exsc-413-s5-pause-instance-global-read-withdrawals-always-open branch July 2, 2026 13:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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.

3 participants