Summary
The existing atomic InstantOpen template crosses InstantLayer four times: the user funds the next virtual account and sends
the quote, then the solver locks the quote and opens the position. v0.8.6 adds an optional
InstantOpenCompact template that joins each pair, reducing the same trade to two signed operations. The
four-operation template and its individual calls remain available.
Independently, temporary batch authority now lives in EIP-1153 transient storage instead of being written to persistent fields around every batch. The trading flow stays the same while its execution overhead falls.
Two operations instead of four
The original flow separates two user actions from two solver actions. Each action travels through its own signed InstantLayer operation even though the actions in each pair always belong to the same atomic trade.
For the user actions, AccountLayer derives the next virtual account from the parent SubAccount, the requested isolation type, and the symbol. Market-direction isolation also includes LONG or SHORT. Both the margin deposit and the following quote must resolve to that same key.
The existing four-operation template
The existing InstantOpen template executed by InstantLayer.executeTemplate carries four signed
operations:
addMarginToNextVA, signed by the user and sent to the AccountLayer.sendQuote, signed by the user and routed through the AccountLayer to core.lockQuote, signed by the solver with the quote id injected from the send result.openPosition, signed by the solver with the quote id injected from the send result.
Each operation carries fixed per-operation overhead regardless of what it does:
- An
operationUsageCountread/write for replay protection, creating a slot on first use. - EIP-712 hashing over the operation calldata, plus signature verification when the signer and executor differ.
- A routing round trip through either SymmioPartyB or AccountLayer, including the applicable role checks, signer toggles and owner lookups.
- Its own signed-operation envelope, authorization payload and calldata bytes.
The compact template removes one user operation envelope and one solver operation envelope. The two combined entrypoints preserve the behavior of each pair.
Combined core entry: lockAndOpenPosition
The solver's compact operation first runs the existing lockQuote path. If the lock succeeds, it continues
directly into openPosition. Both paths are reached through self-delegatecall, so their guards, hooks, post-hook
binding checks, and events still run in the same order. The combined entrypoint is:
function lockAndOpenPosition(
uint256 quoteId,
uint256 filledAmount,
uint256 openedPrice,
SingleUpnlSig memory lockSig,
PairUpnlAndPriceSig memory upnlSig,
SolverFeeEntry[] calldata solverFees
) external whenNotPartyBOpenPositionsPaused onlyPartyB notLiquidated(quoteId)
Event order remains LockQuote, the internal open events, then OpenPosition.
InstantOpenCompact requires filledAmount == quote.quantity, so the compact template never creates a
remainder quote. InstantOpen mode is the batch context that activates InstantOpen-specific accounting and compact-template
restrictions; the execution-context section defines it in detail. When the entrypoint is used
outside that mode, partial fills retain the existing child-quote and SendQuote behavior.
A non-empty solverFees entry list is charged after the open, each entry routed to the receiver resolved for its
tag, subject to the quote's user-approved open-rate cap over the list total. The combined path uses the same solvency guard
and emits the same per-entry SolverFeeCharged events as fee-aware openPosition. Empty arrays leave
the open fee-free.
Joining lock and open removes the solver's second signed operation and the extra SymmioPartyB routing round trip. The user's two operations are compressed in the same way on the AccountLayer side.
Combined AccountLayer entry: _callWithMargin
The user's compact operation enters AccountLayer once. It resolves the next virtual account from the supplied isolation type
and symbol, transfers margin to that account, and then continues through the existing _call path. The combined
entrypoint is:
function _callWithMargin(
address account,
VirtualAccountIsolationType isolationType,
uint256 symbolId,
uint256 marginAmount,
bytes[] calldata callDatas
) external whenNotPaused nonReentrant onlyAccountOwner(account) returns (bytes[] memory)
Both steps share one owner authorization and one reentrancy guard. The margin-to-next-VA logic lives in
LibAccountLayerMargin, shared by MarginFacet.addMarginToNextVA and this function, so address
prediction and isolation validation still have one implementation.
The compact path supports the standard SubAccount-to-VA mappings only: POSITION uses a POSITION VA,
MARKET uses a MARKET VA, and MARKET_DIRECTION uses MARKET_LONG or
MARKET_SHORT according to the quote direction. A CUSTOM SubAccount is not supported by
_callWithMargin; use the separately registered InstantOpenWithCustomVA template instead.
In InstantOpenCompact, callDatas contains exactly one supported sendQuote call.
_executeCalls verifies that the quote uses the same symbol and market-direction key as the prefunded margin. A
mismatch reverts before another virtual account can use that margin. The public entrypoint also accepts other non-empty
calldata arrays, so this routing guarantee applies specifically to supported sendQuote payloads.
The compact two-operation template
The separately registered InstantOpenCompact template uses two operations:
_callWithMargin(subAccount, isolationType, symbolId, margin, [sendQuote]), signed by the user.-
lockAndOpenPosition(quoteId, filledAmount, openedPrice, lockSig, upnlSig, solverFees), signed by the solver.
One user-signed operation and one solver-signed operation, instead of two each. The quote id still flows from the first operation into the second through the template's result injection.
After the first operation sends the quote, AccountLayer returns the quote id inside its ABI-encoded bytes[]
result. Because this operation targets AccountLayer directly, the wrapper places the id at byte offset
128: [bytes[] offset][array length][element offset][element length][quoteId]. The template reads
that value with sourceOffsets: [128] and injects it into the solver's combined lock-and-open call.
Transient execution context
Compression removes two signed operation envelopes, but the operations that remain still need temporary authority. Core must know that a call is routed by InstantLayer, whether InstantOpen accounting is active, and which signer is acting. That authority exists only while the batch runs; this interval is the execution scope.
Before this optimization, core kept its mode flags and signer in GlobalAppStorage, while AccountLayer kept its
signer in AccountStorage. Every batch wrote the applicable fields on entry and cleared them on exit.
v0.8.6 moves this authority into EIP-1153 transient storage, which the EVM discards when the transaction ends or reverts. The following sections track it from batch entry through final cleanup. This build therefore requires a Cancun-compatible chain.
The authority carried by a batch
The core authority used by these batches has two pieces:
- The live context word. The routing and InstantOpen accounting flags currently in effect.
- The effective signer. The already-authorized acting identity currently installed in that diamond.
The live word and signer slots reside in the transient-storage context of the diamond using them, so every facet reached
through delegatecall sees the same values. Core's LibExecutionContext owns the word and core signer;
LibAccountLayerSigner applies the same signer model to the AccountLayer diamond. AccountLayer also keeps a
separate transient account-family scope when a delegate, rather than the owner, drives the operation; that scope constrains
the installed signer and is documented in Delegation Account Scope.
The core live context word
The core live word uses its lowest three bits for one source marker and two authority flags. Bits 3 through 255 remain zero.
| Bit | Constant | Meaning |
|---|---|---|
0 |
INSTANT_CONTEXT_ACTIVE |
Transient storage is authoritative; readers must not fall back. |
1 |
CALL_FROM_INSTANT_LAYER |
The current execution is treated as routed by InstantLayer. |
2 |
INSTANT_OPEN_MODE |
InstantOpen accounting mode is enabled. |
On the native begin/end path, packing these flags makes the live-word lifecycle two writes: one tstore to open it
and one to clear it. Signer-slot writes are separate. The historical persistent mode flags required two SSTOREs
on entry and two more on exit.
The fallback rule
The two core mode readers, isCallFromInstantLayer() and isInstantOpenMode(), consult the live
transient word first and read their persistent fields only when no transient scope is active:
function isCallFromInstantLayer() internal view returns (bool) {
uint256 context = _instantLayerContext();
if (context & INSTANT_CONTEXT_ACTIVE != 0) return context & CALL_FROM_INSTANT_LAYER != 0;
return GlobalAppStorage.layout().callFromInstantLayer;
}
Bit 0 decides which source answers the question. When it is set, the transient word is authoritative, so a clear bit 2 means
InstantOpen mode is explicitly off. When bit 0 is clear, the reader ignores the transient flags and falls back to the
persistent compatibility fields. isInstantOpenMode() follows the same structure.
isCallFromInstantLayer() therefore answers whether routing authority is effective, including a persistent
fallback. isTransientContextActive() asks the narrower question of whether a transient scope is open and reads
bit 0 only. ControlFacet.setSigner uses that narrower reader to avoid selecting transient signer storage when no
transient scope exists to own it.
The live-word panel applies the same source rule across the no-scope, routing-only, and InstantOpen states.
Why the transient signer uses two slots
Each diamond resolves an already-authorized acting identity. The installed value is not a signature cache and is not
necessarily the EIP-712 signer. configuredSigner() returns that raw installed value and may return
address(0); signer() returns the acting identity and falls back to msg.sender when no
value is installed. Guards that distinguish an installed signer from a direct caller read configuredSigner().
For PartyA operations, InstantLayer installs the canonical owner in AccountLayer and, for delegated execution, the canonical
account-family scope; AccountLayer then installs the routed sub-account or virtual account in core. For PartyB operations,
InstantLayer calls the registered PartyB contract, and core resolves that contract through the msg.sender
fallback. InstantLayer does not install a separate PartyB signer.
A transient signer needs two slots. The first holds the signer address. The second holds a simple yes-or-no flag called the
active marker. That marker answers one question for configuredSigner(): should it read the
transient value slot, or the older persistent signer field? The marker is not an identity and grants no authority by itself.
Suppose the transient signer is Alice. During the operation, the value slot contains Alice and the marker is set. Before a
hook runs, clearSignerForExternalCall() saves Alice and clears only the value slot. The state is now "marker set,
value empty." Readers continue to use the transient slot, but find no signer there, so Alice's authority is hidden instead of
falling through to the persistent signer field.
The helper also returns wasTransientScoped = true. The trusted boundary keeps that flag with Alice's saved
address so it knows where to restore her after the hook returns.
Suspending authority around hooks
At a protected hook boundary, the surrounding batch scope may span the external call, but its authority must not. Core's
LibHook clears the effective signer and suspends the core routing and InstantOpen context before the hook runs.
AccountLayer affiliate-hook routing applies the same two protections across the AccountLayer and core diamonds.
To make that suspension reversible, the trusted boundary saves the live context in a snapshot held in caller memory or a caller-specific transient slot. It carries the previous signer and its source flag separately. The snapshot is saved data, not active authority.
This full suspension is specific to hook boundaries. ERC-20 and generic external-call wrappers clear the signer only; they do not suspend the core execution context. The signer is the acting identity, so clearing it prevents the external target from inheriting the user. The remaining routing and mode bits install no identity by themselves, and entrypoint role, caller, and reentrancy checks still apply. The figure follows the full hook boundary from suspension to restoration:
When the hook returns, LibHook restores the execution context first and then restores the saved signer. Each
restoration validates the boundary state before writing the saved authority back. A failed validation reverts the entire
parent operation.
Suspension is reversible and does not end the surrounding logical batch scope. Ending is one-way final cleanup after all operations have completed and the per-operation signer has been cleared.
How a transient signer is restored after a hook
The following example uses Alice as the transient signer and follows three stages.
Before the hook, Alice is active
The transient value contains Alice and the marker is set. This tells configuredSigner() to return Alice from
transient storage: marker = 1 and value = Alice.
While the hook runs, Alice is hidden
Before calling the hook, the protocol saves Alice and remembers that she came from transient storage. It then clears the value
but leaves the marker set. The saved information is now "Alice, from transient storage," while the live state is
marker = 1 and value = empty. Because the transient value is empty, the hook cannot act as Alice.
After the hook returns, Alice is restored
The protocol first checks that the hook left no signer in either the transient value or the persistent field. If both are
empty, it writes Alice back and sets the marker. The state returns to marker = 1 and value = Alice.
Alice becomes the signer again only after this check succeeds.
If the hook left any signer address behind, the protocol does not overwrite it. The whole parent operation reverts with
ExternalCallSignerWasModified.
Example: a nested trusted call uses Bob
While Alice is hidden, trusted protocol code may temporarily install Bob as its own transient signer. When that inner call finishes, it clears Bob and the shared marker. This does not erase the outer boundary's saved note, "Alice, from transient storage." After confirming that both signer addresses are empty, the boundary sets the marker again and restores Alice.
Example: Alice came from persistent storage
In this older path, the boundary saves the note "Alice, from persistent storage," then clears the persistent field before the hook runs. Because no transient signer existed at the start, the marker must still be clear when the hook returns. A set marker means the hook path opened a transient scope and left it open, so restoration reverts. If the marker and both signer addresses are empty, Alice is written back to the persistent field.
How the snapshot is encoded
When core suspends an execution context, it copies the routing and InstantOpen values into a separate 256-bit snapshot. The snapshot remains in trusted caller memory or in a caller-specific transient slot. It is never written into the live context slot while the external call is running.
Every non-zero snapshot sets bit 255, distinguishing saved context from the 0 value that means nothing was
suspended. Bit 254 records whether the saved context came from transient storage. The remaining low bits carry the authority
that must return: routing in bit 1 and InstantOpen mode in bit 2.
A transient snapshot copies the entire live word, including its source marker in bit 0. A persistent snapshot is assembled
from the two compatibility fields and leaves bit 0 clear. Restoration chooses the destination from bit 254, so bit 0 remains
part of the saved live word rather than acting as the source discriminator. A suspended transient InstantOpen scope therefore
prints as 0xc000…0007: the high c contains bits 255 and 254, while the low 7 contains
bits 0 through 2.
To restore a transient snapshot, the library needs only the original live-word bits. INSTANT_CONTEXT_FLAGS
combines the three live constants:
INSTANT_CONTEXT_ACTIVE | CALL_FROM_INSTANT_LAYER | INSTANT_OPEN_MODE
The resulting mask has bits 0 through 2 set. snapshot & INSTANT_CONTEXT_FLAGS removes the snapshot markers in
bits 254 through 255 and leaves the original live word.
if (snapshot & EXTERNAL_CONTEXT_SNAPSHOT_TRANSIENT != 0) {
_setInstantLayerContext(snapshot & INSTANT_CONTEXT_FLAGS);
} else {
globalLayout.callFromInstantLayer = snapshot & EXTERNAL_CONTEXT_SNAPSHOT_PERSISTENT_CALL != 0;
globalLayout.instantOpenMode = snapshot & EXTERNAL_CONTEXT_SNAPSHOT_PERSISTENT_OPEN != 0;
}
Before writing a snapshot back, restoreExecutionContextAfterExternalCall requires the transient live word and
both persistent mode fields to be empty. Suspension clears the active source; transient-scope entry guards guarantee the
inactive persistent mode fields were already empty. Anything present on return was therefore installed during the hook.
Restoration fails closed instead of overwriting that state.
For the cross-contract AccountLayer path, core does not return the raw snapshot to the router. It stores the snapshot in
keccak256(abi.encode(namespace, msg.sender)), preventing the router from substituting a value, restoring another
router's snapshot, or suspending twice before restore.
The caller examples in the snapshot-slot panel use the same namespace with three illustrative addresses. Each address produces a different slot, so one router cannot reach another router's saved context.
Closing the batch scope
A batch opens one transient scope. beginInstantLayerExecution rejects a second live scope with
TransientContextAlreadyActive, preventing two batches from sharing one grant of authority.
After the final operation, the authorized router clears its per-operation signer before InstantLayer closes the scope.
endInstantLayerExecution rejects an unmatched close with TransientContextNotActive and rejects a
close while the signer is still installed with TransientSignerNotCleared. The order matters because a transient
signer would otherwise remain readable for the rest of the transaction with no active scope responsible for it.
The lifecycle panel shows the successful path from scope creation through signer cleanup and final close. At the hook
boundary, the live word becomes 0 while the trusted boundary holds the snapshot. A clean return restores the
context and signer; a callback path that leaves either kind of authority behind stops at the corresponding restoration error.
The historical persistent-field path begins from pre-existing GlobalAppStorage values and applies the same
suspend-and-restore boundary. It is distinct from both the pre-Cancun storage port and the runtime selector switch described
next.
Compatibility and pre-Cancun porting
Keeping existing deployments compatible
Existing deployments preserve their InstantLayer address because it is the EIP-712 verifying contract. Their historical selector calldata remains valid because both diamonds translate those calls internally to transient state:
-
Core.
setCallFromInstantLayer(bool)opens and closes the same EIP-1153 scope thatbeginInstantLayerExecutionandendInstantLayerExecutionuse, andsetInstantOpenMode(bool)writes the same bit as the native toggle. -
AccountLayer.
setSigner(address)installs and clears the same transient signer scope thatsetTransientSigneruses.
Translation applies to every authorized caller rather than a configured allowlist. An AccountManager is the AccountLayer
router deployed for an affiliate. AffiliateFacet grants SIGNER_SETTER_ROLE to each one it deploys,
so the caller set grows at runtime. The hasRole mapping is not enumerable, which makes a complete per-address
migration impossible. Unconditional translation keeps all authorized callers on the same storage mechanism.
Translation grants no new privilege. Existing role gates remain unchanged, and both storage mechanisms fail closed when state
from the other mechanism is already active. setCallFromInstantLayer(true) reverts if either persistent mode field
is set. A non-zero AccountLayer setSigner cannot overwrite either a persistent signer or a populated transient
signer value.
What the runtime switch changes
InstantLayer contracts deployed from the current source expose setTransientContextEnabled(bool), gated by
SETTER_ROLE and enabled by default. The flag changes only which selectors InstantLayer invokes: enabled uses the
native begin/end and transient-signer selectors; disabled uses the historical mode and signer setters. Both sequences write
transient storage in this build, although the historical selector sequence uses extra writes to toggle InstantOpen mode. This
is a selector-compatibility control, not a storage rollback. The existing public flag and setter names are retained for ABI
compatibility. Templates and signatures remain unchanged.
What a pre-Cancun build requires
The current source compiles with solc 0.8.36 for the Cancun EVM and cannot execute on a pre-Cancun chain. Current InstantLayer
batch paths initiate transient authority. Authorized direct callers can still set the persistent
instantOpenMode by calling the legacy selector outside a transient scope, and suspension/restoration continues to
protect that compatibility state. Core's legacy setSigner likewise writes the persistent core signer when no
transient execution scope is active.
A pre-Cancun deployment therefore needs a separate build targeting evmVersion "paris". Its
_transientLoad and _transientStore helpers, together with their
LibAccountLayerSigner counterparts, use a slot-keyed persistent mapping instead. The packed context lifecycle,
active bit, signer marker, and snapshot encoding remain unchanged; the PRE-CANCUN PORT block in
LibExecutionContext.sol marks the implementation boundaries that change.