InstantOpen Gas Optimization

v0.8.6 lowers atomic InstantOpen gas in two independent ways. It adds an opt-in two-operation InstantOpenCompact template beside the existing four-operation template, while EIP-1153 transient storage removes persistent writes for temporary batch authority. The focused compact-template benchmark measures roughly 126k gas saved and enforces a 120k regression floor.

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:

  1. addMarginToNextVA, signed by the user and sent to the AccountLayer.
  2. sendQuote, signed by the user and routed through the AccountLayer to core.
  3. lockQuote, signed by the solver with the quote id injected from the send result.
  4. 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 operationUsageCount read/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:

  1. _callWithMargin(subAccount, isolationType, symbolId, margin, [sendQuote]), signed by the user.
  2. 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:

  1. The live context word. The routing and InstantOpen accounting flags currently in effect.
  2. 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.

Core live execution-context word
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:

The batch scope spans the hook call; its authority does not Authority is cleared before control leaves trusted code and restored only after validation.

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 that beginInstantLayerExecution and endInstantLayerExecution use, and setInstantOpenMode(bool) writes the same bit as the native toggle.
  • AccountLayer. setSigner(address) installs and clears the same transient signer scope that setTransientSigner uses.

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.