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:
- AccountLayer locks its protected entry points and forwards the force-close to Symmio core.
- Core closes the position.
-
Before returning, core calls
onClosePositionso AccountLayer can remove the quote and clean up the account. - 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:
- Record the exact core address before calling it.
- Allow a cleanup callback only from that core and only when another callback is not already running.
- 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 |
forceClosePositionsettleAndForceClosePosition (deprecated)finalizeForceCloseforceCloseAndSettlePositionsUnified
|
onClosePosition |
| Final PartyA liquidation settlement |
settlePartyALiquidationsettlePartyALiquidationWithSnapshot
|
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:
onlySymmiostill 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
nonReentrantAccountLayer 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, andSymmioHookFacetatomically.LibAccountLayerUtilsandLibAccountLayerSignerare 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.