When a user wants to open a position, they send a quote on-chain. Then, partyB, upon seeing that quote, can lock it and open the position. The issue we encountered was that sometimes it took a few seconds for partyB to receive the contract events and be notified about the new quote.
The only way to address this problem was for the user to contact partyB directly off-chain and send the quote to them
manually, enabling partyB to submit the quote to the contract on behalf of the user. To support this in previous versions, we
introduced a "delegateAccess" feature, where the user could grant partyB permission to call a specific
function (such as sendQuote) on their behalf.
However, this approach required the user to fully trust partyB, since partyB technically had the ability to misuse access and potentially steal the user's funds. In practice, this was not a major concern, as partyBs within Symmio are trusted entities—but if a partyB were to be hacked, users could still be exposed to significant risk.
With the introduction of the Instant Layer, we allow users to grant one-time, specific allowances to partyBs instead of broad, indefinite permissions. Essentially, a user can sign a specific call with defined parameters and provide that signature to partyB. PartyB can then prepare their own corresponding signature, and both signatures can be submitted together to the Instant Layer for batched execution.
Apart from signing a single callData and handing it over to Party B for execution, the user can also delegate
access to a set of selectors to another signer if needed. This action can be performed either through a transaction or by
signing a message (and then passing that message to someone for execution).
Yes, this delegation feature is somewhat similar to what we had in previous versions, but now it comes with a much cleaner API, and the user can complete it using only a signature.
Together, these improvements enable Symmio frontends to implement something powerful: during each user session, they can generate a key pair locally in the user's browser and, for example, rotate it daily. At the start of each session, the user grants access to the newly created address, allowing all subsequent actions and signatures to be performed by that key pair within the browser.
Compared to the old approach, the user no longer needs to trust Party B for this access delegation.
Apart from executing a batch of Symmio calls from their respective signatures using
executeBatch method, we have also introduced templates in the instant layer. These templates
come in handy when the execution of one operation depends on the output of a previous one.
A prime example of this in Symmio contracts is executing sendQuote, then lockQuote, and finally
openPosition — all within a single transaction. In this flow, the quote ID (a parameter required by the second
and third operations) is obtained from the result of the first operation or from a view call inserted after it to retrieve the
latest quote ID.
To handle this, partyB places a placeholder value (such as 0) in the lockQuote and
openPosition calldata. The contract then automatically replaces these placeholders with the correct values during
execution.
Only admins can create templates, while operators — possessing both user and partyB
signatures — can select a template and pass the corresponding signatures for such workflows to the
executeTemplate method.
Technical Implementation Details
Core Data Structures
Account Structure
The Instant Layer uses an Account struct to represent both PartyA and PartyB accounts:
struct Account {
address addr; // Trading account address (PartyA) or PartyB contract address
bool isPartyB; // true for PartyB, false for PartyA accounts
}
-
For PartyA accounts:
isPartyB = false, andaddris the trading account address (sub-account, virtual account, or a legacy MultiAccount account). Ownership is resolved throughAccountLayer.ownerOf(addr). -
For PartyB accounts:
isPartyB = true, andaddris the PartyB contract address.
Replay Attack Protection
Every operation includes a ReplayAttackHeader to prevent replay attacks:
struct ReplayAttackHeader {
uint256 nonce; // 0 for salt-only, >0 for sequential execution
uint256 deadline; // Unix timestamp for expiration
bytes32 salt; // Unique salt for operation uniqueness
}
Nonce Management Strategy:
-
nonce = 0(Salt-only mode):Operations can be executed in any order, as long as each uses a unique
salt. No sequential tracking is performed. -
nonce > 0(Sequential mode):Operations must be executed in order. The contract enforces that the provided nonce equals the account's current nonce + 1.
Important:
Nonces are tracked per signerAccount.addr, meaning each trading account (PartyA or PartyB) maintains its own independent nonce counter. This allows different accounts
belonging to the same user to sign and execute operations concurrently without interference.
Signature Structures
SignedOperation
The primary structure for batched operations:
struct SignedOperation {
address signer; // Address that created the signature
address target; // Contract to call (must be whitelisted)
bytes callData; // Encoded function call to execute
Account signerAccount; // Account context
FlexField[] flexFields; // Modifiable regions in calldata (empty for standard ops)
uint256 maxUses; // Max execution count (1=single-use, 0=unlimited)
ReplayAttackHeader replayAttackHeader;
}
For standard operations, flexFields is empty and maxUses is 1. See the
Flex Fields section for details on partial calldata modification.
Self-Execution (Signature Skip)
When the transaction sender (msg.sender) is the same as the operation's signer,
signature verification is skipped. This optimization allows authorized parties to execute their own
operations without needing to sign them.
How It Works:
-
The contract first verifies authorization:
- For PartyB: Checks that the signer is a registered PartyB
- For PartyA: Checks that the signer is the account owner or has a valid delegation
-
If
signer == msg.sender, the signature check is bypassed becausemsg.senderalready proves identity at the EVM level. -
An empty signature (
"0x"or[]) can be provided for self-executed operations.
Use Cases:
-
PartyB Direct Execution: A PartyB operator can call
executeBatchdirectly, including their own operations without signatures while still providing signatures for user operations. - Account Owner Execution: An account owner can execute their own operations directly without signing.
- Delegated Signer Execution: A delegated signer with proper permissions can execute operations directly.
Example:
// PartyB executing their own operation - no signature needed
const partyBOperation = {
signer: partyBAddress, // Same as msg.sender
target: symmioCoreAddress,
callData: encodedLockQuote,
signerAccount: { addr: partyBAddress, isPartyB: true },
flexFields: [],
maxUses: 1,
replayAttackHeader: { nonce: 0n, deadline: 0n, salt: generateSalt() },
};
// User operation still needs a signature
const userOperation = {
signer: userAddress,
target: symmioCoreAddress,
callData: encodedSendQuote,
signerAccount: { addr: userAccountAddress, isPartyB: false },
flexFields: [],
maxUses: 1,
replayAttackHeader: { nonce: 0n, deadline: 0n, salt: generateSalt() },
};
// PartyB (as msg.sender) calls executeBatch
await instantLayer.executeBatch(
[userOperation, partyBOperation],
[userSignature, "0x"], // Empty signature for PartyB's own operation
[{ values: [] }, { values: [] }], // No flex fills
[[], []], // No flex filler signatures
);
Security Note:
Authorization is still fully enforced before the signature skip:
- PartyB must be registered with InstantLayer
- PartyA signers must be account owners or have valid delegations
- Replay attack protection (salts, nonces, hashes) still applies
Target and Whitelisting
-
If
targetis the Symmio core, the call is routed throughAccountLayer._call(...)so the core sees the correct trading account viasetSigner. -
If
targetis not Symmio, the Instant Layer callstargetdirectly. Only whitelisted targets can be called. -
For external targets,
msg.senderis the Instant Layer contract (the signature is what authorizes the call). - Return data from external targets will be dropped.
What Users Should Sign:
- The user creates a hash of their operation using EIP-712 standard
-
The hash includes:
- Their address as
signer - The exact
targetandcallDatathey want to execute - Their account information
- Replay protection parameters
- Their address as
Example User Signature Creation (JavaScript):
const domain = {
name: "SymmioInstantLayer",
version: "1",
chainId: chainId,
verifyingContract: instantLayerAddress,
};
const types = {
SignedOperation: [
{ name: "signer", type: "address" },
{ name: "target", type: "address" },
{ name: "callData", type: "bytes" },
{ name: "signerAccount", type: "Account" },
{ name: "flexFields", type: "FlexField[]" },
{ name: "maxUses", type: "uint256" },
{ name: "replayAttackHeader", type: "ReplayAttackHeader" },
],
Account: [
{ name: "addr", type: "address" },
{ name: "isPartyB", type: "bool" },
],
FlexField: [
{ name: "offset", type: "uint256" },
{ name: "length", type: "uint256" },
{ name: "authorizedFlexFiller", type: "address" },
],
ReplayAttackHeader: [
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
{ name: "salt", type: "bytes32" },
],
};
const userOperation = {
signer: userAddress,
target: symmioCoreAddress, // or a whitelisted external target
callData: encodedSendQuote, // e.g., symmio.sendQuote(...) encoded
signerAccount: {
addr: userAccountAddress, // sub-account / virtual-account / legacy account
isPartyB: false,
},
flexFields: [], // empty for standard operations
maxUses: 1, // single-use
replayAttackHeader: {
nonce: 0, // or current nonce + 1 for sequential
deadline: Math.floor(Date.now() / 1000) + 3600, // 1 hour expiry
salt: ethers.utils.randomBytes(32),
},
};
const signature = await signer._signTypedData(domain, types, userOperation);
Method Specifications
executeBatch
Executes multiple operations from different signers in a single transaction.
function executeBatch(
SignedOperation[] calldata signedOps,
bytes[] calldata signatures,
FlexFill[] calldata fills,
bytes[][] calldata flexFillerSignatures
) external nonReentrant onlyRole(OPERATOR_ROLE);
Parameters:
signedOps/signatures— operations and their EIP-712 signatures (as before)-
fills— oneFlexFillper operation. For standard operations (no flex fields), pass{ values: [] } -
flexFillerSignatures— onebytes[]per operation, with one signature per flex field. Pass[]for standard operations or"0x"for fields where the flex filler ismsg.sender
Signature Requirements:
- Each operation requires a corresponding entry in the
signaturesarray - If
signedOps[i].signer == msg.sender, thensignatures[i]can be empty ("0x") - Otherwise, a valid EIP-712 signature is required
executeTemplate
Executes a predefined template with dynamic parameter replacement.
function executeTemplate(
uint256 templateId,
SignedOperation[] calldata signedOps,
bytes[] calldata signatures,
FlexFill[] calldata fills,
bytes[][] calldata flexFillerSignatures
) external nonReentrant onlyRole(OPERATOR_ROLE);
The fills and flexFillerSignatures parameters work the same as in executeBatch. When a
template step has both flex fields and result injection, flex fills are applied first, then template result injection
overwrites.
Template Structure:
struct Template {
string name;
Operation[] operations;
bool active;
}
struct Operation {
uint256[] insertionPoints;
uint256[] sourceIndices;
}
Placeholder Replacement System: Templates support automatic value replacement using placeholders:
- Placeholders are 32-byte zero values in the callData (only 32 byte values are now supported)
- The contract replaces them with actual values from previous operation results
- Common pattern:
sendQuotereturns quoteId →lockQuoteuses that quoteId
Delegation System
DelegationInfo Structure
struct DelegationInfo {
Account account;
address delegatedSigner;
bytes4[] selectors;
uint256 expiryTimestamp;
}
Creating Delegations
Option 1: Via Transaction
function grantDelegation(DelegationInfo calldata info);
Option 2: Via Signature (Gasless)
struct SignedDelegation {
DelegationInfo delegationInfo;
ReplayAttackHeader replayAttackHeader;
}
function grantBatchDelegationBySig(SignedDelegation calldata signedDelegation, bytes calldata signature);
EIP-712 Encoding Note:
When signing a DelegationInfo struct, the bytes4[] selectors field is encoded using a custom array
hashing method. Each selector is right-padded to 32 bytes, all padded values are concatenated, and the entire concatenation is
hashed once.
Formally:
keccak256(abi.encodePacked(bytes32(selector1), bytes32(selector2), ...))
This is the same behavior implemented in _hashBytes4Array inside the contract, ensuring consistent EIP-712
signature verification.
Security Mechanisms
1. Replay Protection
- Salt mechanism: Each operation must have a unique salt
- Nonce mechanism: Optional sequential execution enforcement
- Deadline enforcement: Operations expire after specified timestamp
- Hash tracking: Used operation hashes are permanently recorded
2. Contract Registration and Whitelists
- PartyB contracts must be registered via
registerPartyBs(...). -
The
AccountLayeraddress must be set viasetAccountLayer(...)(and the Instant Layer must haveINSTANT_LAYER_ROLEon the AccountLayer to callAccountLayer._call). -
targetcontracts must be whitelisted viasetTargetWhitelist(...)(Symmio is whitelisted by default).
3. Self-Execution Security
When signer == msg.sender, signature verification is skipped, but all other security checks remain:
- Authorization validation (PartyB registration or account ownership/delegation)
- Replay attack protection via hash tracking
- Nonce enforcement for sequential operations
- Deadline validation
4. Delegation Revoke System
For delegations, a time-delayed revocation system is implemented:
- Revocations are initiated via
initiateRevokeDelegation() - They become active after
revocationCooldownseconds - Finally,
finalizeRevokeDelegation()permanently removes the delegation
Flex Fields
Flex fields allow a user to sign an operation while designating specific byte ranges of the calldata as modifiable by authorized parties. This enables use cases like:
-
A TPSL service modifying the
amountorclosePriceparameter in a close request - A solver updating the Muon signature in a
sendQuotebefore execution - A service choosing the exact parameters at execution time while the user constrains which function is called and which parameters are fixed
How It Works
The user signs a SignedOperation where certain calldata regions are marked as flex fields. Each flex field
specifies a byte offset, length, and an authorizedFlexFiller address — the party allowed to fill in that region.
The user's EIP-712 signature covers the calldata as-is (the placeholder values in flex regions don't matter) plus the flex
field definitions, so the user explicitly commits to which parts are modifiable and who can
modify them.
At execution time, the operator provides FlexFill values for each flex field. The contract verifies flex filler
authorization and injects the fill values before executing the call.
Data Structures
struct FlexField {
uint256 offset; // Byte offset after 4-byte selector
uint256 length; // Bytes to replace (typically 32)
address authorizedFlexFiller; // Who can provide the fill value
}
struct FlexFill {
bytes[] values; // One entry per FlexField, each must match field.length
}
Flex Filler Authorization
Each flex field has its own authorizedFlexFiller. Different fields can have different flex fillers, allowing
multiple parties to control different parameters of the same operation.
When flex filler == msg.sender: No additional signature is needed. Pass "0x" as the flex filler
signature for that field.
When flex filler != msg.sender: The flex filler must sign a FlexFillAuth message authorizing the
specific fill value:
// What the flex filler signs (EIP-712)
struct FlexFillAuth {
bytes32 opHash; // Hash of the SignedOperation
uint256 fieldIndex; // Which flex field this fill is for
bytes value; // The actual fill value
}
The flex filler's signature authorizes a specific value for a specific field of a specific operation. For multi-use
operations, the same signature can be reused across executions (since the value and operation are the same). The
maxUses cap and deadline provide the usage controls.
Multi-Use Operations
All operations use the maxUses field uniformly for replay protection:
maxUses = 1— single-use (default for standard operations)maxUses = N(N > 1) — can be executed up to N timesmaxUses = 0— unlimited executions until deadline expires
Once signed, an operation is valid until its maxUses count is reached or the deadline passes. There
is no cancellation mechanism — if you signed it, the flex filler can use it within the agreed bounds. This ensures trust: a
flex filler (e.g., a solver) can rely on the authorization without risk of it being pulled out from under them.
Example: TPSL Service with Flex Fields
Instead of delegating the entire requestToClosePosition selector to a TPSL bot, the user can sign a specific
close operation with the amount as a flex field:
The requestToClosePosition signature is:
function requestToClosePosition(
uint256 quoteId, // offset 0
uint256 closePrice, // offset 32
uint256 quantityToClose, // offset 64
OrderType orderType, // offset 96
uint256 deadline // offset 128
)
All parameters are static, so the ABI encoding is straightforward — each is a 32-byte slot at a fixed offset.
// User signs: "close quote 42, but let the TPSL bot choose the amount"
const closeCallData = symmio.interface.encodeFunctionData(
"requestToClosePosition",
[42, triggerPrice, 0, 1, deadline], // quantityToClose=0 is a placeholder
);
const operation = {
signer: userAddress,
target: symmioCoreAddress,
callData: closeCallData,
signerAccount: { addr: userAccount, isPartyB: false },
flexFields: [
{
offset: 64, // quantityToClose is the 3rd param = 2 * 32
length: 32,
authorizedFlexFiller: tpslBotAddress,
},
],
maxUses: 0, // unlimited until deadline
replayAttackHeader: {
nonce: 0n,
deadline: BigInt(Math.floor(Date.now() / 1000) + 86400), // 24h
salt: generateSalt(),
},
};
const userSig = await userSigner.signTypedData(domain, types, operation);
When the TP/SL threshold is hit, the bot provides the fill value and its flex filler signature:
// Bot determines the close amount
const closeAmount = ethers.AbiCoder.defaultAbiCoder().encode(["uint256"], [quantityToClose]);
// Bot signs the fill authorization
const opHash = await instantLayer.getOperationHash(operation);
const fillAuthSig = await botSigner.signTypedData(
domain,
{
FlexFillAuth: [
{ name: "opHash", type: "bytes32" },
{ name: "fieldIndex", type: "uint256" },
{ name: "value", type: "bytes" },
],
},
{ opHash, fieldIndex: 0, value: closeAmount },
);
// Operator submits
await instantLayer.executeBatch([operation], [userSig], [{ values: [closeAmount] }], [[fillAuthSig]]);
Example: Solver Updating Muon Signature in sendQuote
The sendQuoteWithAffiliate signature is:
function sendQuoteWithAffiliate(
address[] memory partyBsWhiteList, // offset 0 (dynamic -- offset pointer)
uint256 symbolId, // offset 32
PositionType positionType, // offset 64
OrderType orderType, // offset 96
uint256 price, // offset 128
uint256 quantity, // offset 160
uint256 cva, // offset 192
uint256 lf, // offset 224
uint256 partyAmm, // offset 256
uint256 partyBmm, // offset 288
uint256 maxFundingRate, // offset 320
uint256 deadline, // offset 352
address affiliate, // offset 384
SingleUpnlAndPriceSig memory upnlSig // offset 416 (dynamic -- offset pointer)
)
The upnlSig is a dynamic struct (contains bytes fields), so offset 416 holds an
offset pointer to the struct's actual data in the ABI tail. The struct's exact byte position and length
depend on the preceding dynamic parameters (e.g., partyBsWhiteList array length).
For flex fields on dynamic parameters, the integrator must calculate the exact byte range of the encoded struct data in the specific calldata being signed. Since both the user and solver see the full calldata, this is straightforward to compute off-chain:
// Encode the full sendQuote calldata with a placeholder Muon sig
const callData = partyAFacet.interface.encodeFunctionData("sendQuoteWithAffiliate", [
partyBsWhiteList,
symbolId,
positionType,
orderType,
price,
quantity,
cva,
lf,
partyAmm,
partyBmm,
maxFundingRate,
deadline,
affiliate,
placeholderUpnlSig,
]);
// Calculate where the upnlSig data actually starts in the encoded calldata.
// The offset pointer at byte 416 (after selector) contains the start position.
// Read it from the encoded calldata:
const upnlSigOffsetPointer = 416;
const upnlSigDataStart = Number(
ethers.AbiCoder.defaultAbiCoder().decode(
["uint256"],
"0x" + callData.slice(2 + 8 + upnlSigOffsetPointer * 2, 2 + 8 + (upnlSigOffsetPointer + 32) * 2),
)[0],
);
const upnlSigDataLength = callData.length / 2 - 4 - upnlSigDataStart; // rest of calldata
const operation = {
signer: userAddress,
target: symmioCoreAddress,
callData: callData,
signerAccount: { addr: userAccount, isPartyB: false },
flexFields: [
{
offset: upnlSigDataStart,
length: upnlSigDataLength,
authorizedFlexFiller: solverAddress,
},
],
maxUses: 1,
replayAttackHeader: { nonce: 0n, deadline: 0n, salt: generateSalt() },
};
The solver encodes a fresh Muon signature with the same byte length and provides it as the fill value. All other quote parameters remain exactly as the user signed them.
Important: The replacement value must have the exact same byte length as the original. For
SingleUpnlAndPriceSig, this means the fresh Muon sig must produce the same ABI-encoded length (samereqIdandgatewaySignaturebyte lengths).
Related Documentation
For practical integration examples, see:
- InstantLayer PartyB Integration Guide — Detailed code examples for PartyB integrators
- InstantLayer Service Integration Guide — TPSL, Trigger Market, and Session Key integration