AccountLayer Force-Close and Liquidation Fix

Force-closing a position or finishing a PartyA liquidation through AccountLayer no longer fails during account cleanup.

What was broken

Some force-close and PartyA liquidation-settlement transactions worked when called directly on Symmio core but reverted when sent through AccountLayer. The close or settlement reached its final cleanup step, then the entire transaction failed.

The failed step was a callback: after core finishes an operation, it calls AccountLayer to update quote tracking and try to delete an empty Virtual Account. AccountLayer was already locked while waiting for core to return, so it treated this expected cleanup call as reentrancy and rejected it.

How the failure happened

Consider a force-close sent through AccountLayer._call:

  1. AccountLayer locks its protected entry points and forwards the force-close to Symmio core.
  2. Core closes the position.
  3. Before returning, core calls onClosePosition so AccountLayer can remove the quote and clean up the account.
  4. The old guard sees that AccountLayer is already locked and rejects the callback.
sequenceDiagram
    actor Caller
    participant AL as AccountLayer
    participant Core as Symmio core

    Caller->>AL: Route force-close
    AL->>Core: Close the position
    Core->>AL: Clean up the closed position
    AL--xCore: Old guard rejects the callback
    Core--xCaller: Whole transaction reverts

The callback reverted with ReentrancyGuardReentrantCall. Core reported that callback failure as HookReverted, which caused the original force-close or liquidation settlement to revert as well.

What changed

AccountLayer now remembers the address of the core it is calling. Until that core call returns, the same core may call back to run account cleanup. No other caller passes this callback check, and the main AccountLayer lock remains active throughout the operation.

The new flow is:

  1. Record the exact core address before calling it.
  2. Allow a cleanup callback only from that core and only when another callback is not already running.
  3. Restore the previous core address as soon as the outbound call returns.

The shared callCore helper opens and closes this narrow callback window. The cleanup functions onClosePosition, onCancelQuote, and onLiquidationSettled now use nonReentrantCallback to apply the rule.

Quote cancellation already worked because onCancelQuote previously had no reentrancy guard. It now uses the same callback-aware guard, so cancellation stays live while nested callbacks are also blocked.

Fixed operations

Regression tests cover every known AccountLayer-routed operation that reaches the affected cleanup callbacks:

Flow Covered operations Cleanup callback
Force-close forceClosePosition
settleAndForceClosePosition (deprecated)
finalizeForceClose
forceCloseAndSettlePositionsUnified
onClosePosition
Final PartyA liquidation settlement settlePartyALiquidation
settlePartyALiquidationWithSnapshot
onLiquidationSettled

The same boundary covers every AccountLayer route that calls core and can receive one of these cleanup callbacks. That includes ordinary routed calls through _call, margin-prefunded calls through _callWithMargin, hook-requested core calls through executeForAccount, affiliate-authorized calls through callAsAffiliate, and affiliate fee withdrawal.

What stays protected

This is not a general exception to AccountLayer's reentrancy guard. It allows only the cleanup call needed to finish the core operation:

  • onlySymmio still rejects callers that are not whitelisted core contracts.
  • During a routed operation, the callback must come from the exact core AccountLayer called.
  • A callback cannot open another protected callback while it is running.
  • Ordinary nonReentrant AccountLayer functions remain locked until the outer operation finishes.
  • The same core may make another callback only after the previous callback has returned.

The recorded core address and callback-active marker use EIP-1153 transient storage, so they last only for the current transaction. If cleanup makes another outbound call to an allowed core, push-and-restore handling temporarily records that core and restores the previous core address afterward. The active-callback marker still blocks a second protected callback until the first one returns.

Upgrade notes

  • No caller changes are required. Bots and integrations keep the same AccountLayer gateway, core selector, calldata, and return decoding.
  • There is no external ABI, event, or persistent storage-layout change, and no state migration is required.
  • Rebuild and replace CoreFacet, AffiliateFacet, MarginFacet, and SymmioHookFacet atomically. LibAccountLayerUtils and LibAccountLayerSigner are internal libraries whose changed code is inlined into those facet artifacts; they are not separately deployed. New callback code paired with old forwarding cannot identify an allowed core. New forwarding paired with old callback code still hits the original reentrancy failure.