The Problem It Solves
Previously, each frontend deployed its own MultiAccount contract, which Symmio registered individually. Upgrades required every frontend to redeploy. Affiliate onboarding was manual. Implementations diverged. Users were locked into rigid account structures.
AccountLayer consolidates all of that into one system: a single Diamond proxy, a standardized affiliate registry, virtual accounts with deterministic addresses, and configurable isolation strategies.
Account Hierarchy
Accounts are organized into two layers.
---
config:
theme: default
layout: dagre
---
flowchart TB
A(["User (EOA)"]) --> n1(["SubAccount (1)"]) & n2(["SubAccount (2)"])
n2 --> n3(["Virtual Account (21)"]) & n4(["Virtual Account (22)"])
n1 --> n5(["Virtual Account (11)"]) & n6(["Virtual Account (12)"])
SubAccounts are the user's top-level organizational unit. Each one is bound to a specific frontend (affiliate), a Symmio core instance, and an isolation type chosen at creation. A user might have one SubAccount for conservative trades and another for aggressive strategies.
Virtual Accounts (VAs) sit below SubAccounts and provide position isolation. Each VA is an independent trading address on the Symmio core with its own balance, allocated margin, and positions. A liquidation in one VA cannot touch another. When a VA's last position closes, its funds are automatically swept back to the parent SubAccount and the VA address is recycled for future use.
Both SubAccounts and VAs are virtual addresses — deterministic CREATE2-style addresses with no deployed contract behind them. The AccountLayer manages their state centrally.
Fund Flow
User ──deposit──► SubAccount ──addMargin──► Virtual Account
SubAccount ◄──auto-sweep── Virtual Account (on last position close)
- User to SubAccount — The user deposits collateral, credited to the SubAccount's balance on the Symmio core.
-
SubAccount to VA — The user manually transfers margin to a VA before trading. This can target an
existing VA (
addMargin) or pre-fund the predicted next VA address (addMarginToNextVA) before it exists. -
VA to SubAccount — Automatic when the VA's last position closes. Users can also manually pull margin back
via
removeMargin, which requires a UPNL signature to prove the VA remains solvent.
Isolation Types
The isolation type, chosen when creating a SubAccount, determines how VAs are created and managed during trading.
POSITION — One VA per Trade
Every quote gets its own VA. Maximum isolation: each position has separate margin, and a liquidation on one cannot affect any other. When the position closes, the VA is destroyed and funds return to the SubAccount.
graph LR
SA[SubAccount] --> VA1[VA: BTC Long #1]
SA --> VA2[VA: ETH Short #2]
SA --> VA3[VA: BTC Short #3]
MARKET — One VA per Market
Positions on the same market share a VA. All BTC-USD trades go to one VA, all ETH-USD trades to another. Different markets remain fully isolated.
graph LR
SA[SubAccount] --> VA1["VA: BTC-USD<br/>(all BTC trades)"]
SA --> VA2["VA: ETH-USD<br/>(all ETH trades)"]
Without Single VA Mode (the default), each sendQuote creates a new VA even if one already exists
for that market. With Single VA Mode enabled, subsequent quotes for the same market route to the existing active VA. This
setting can be toggled when no VAs are active on the SubAccount.
MARKET_DIRECTION — One VA per Market + Direction
Like MARKET, but also separates by direction. All BTC longs share one VA; all BTC shorts share another.
graph LR
SA[SubAccount] --> VA1["VA: BTC Long"]
SA --> VA2["VA: BTC Short"]
SA --> VA3["VA: ETH Long"]
CUSTOM — Full Manual Control
No automatic VA creation. Trades can execute directly through the SubAccount (no isolation), or through manually created VAs with any configuration the user chooses. VAs that track quoteIds are still automatically cleaned up when their last position closes.
The Trading Flow
Here is how a trade moves through the system, end to end:
-
Pre-fund — The user transfers margin to the target VA address. For a new trade, this means calling
addMarginToNextVAwith the predicted next VA address (which theViewFacet.predictNextVirtualAccountAddresshelper can compute). For an existing VA,addMarginworks. -
Send quote — The user submits a
sendQuotethrough_call(). The AccountLayer intercepts it: - On a non-CUSTOM SubAccount: creates or reuses a VA based on the isolation type, routes the quote through it, and tracks the quoteId. - On a CUSTOM SubAccount: executes directly, no VA involved. - On a VA directly: enforces isolation rules (POSITION allows max 1 quote; MARKET/MARKET_LONG/MARKET_SHORT enforce symbolId and direction match), then executes and tracks the quoteId. -
Position opens — The Symmio core calls
onOpenPosition(a no-op on the AccountLayer side). -
Position closes — The Symmio core calls
onClosePosition. The AccountLayer removes the quoteId from the VA. If the VA has no remaining quoteIds: - All remaining margin is deallocated (viazeroUpnlDeallocate— zero unrealized PnL, no signature needed) - All funds are transferred back to the parent SubAccount - The VA address is pushed into a recycling pool - The VA deletion hook fires (if configured)
Cancellations and liquidations are also handled automatically. See Virtual Account Lifecycle Hooks for the full flow including partial closes, liquidation deferral, and settlement recovery.
The user never manages VA lifecycle manually (unless using CUSTOM isolation). Funding is always manual.
Affiliate System
Affiliates are frontends and integrators that bring users to the protocol. The AccountLayer manages their full lifecycle: registration, approval, fee splitting, hooks, and delegated actions.
Registration
- Anyone can request registration, providing a name, brand color, admin address, fee stakeholder configuration, and which Symmio core(s) to use.
-
A protocol
APPROVER_ROLEholder reviews and approves. On approval, an AccountManager proxy is deployed (deterministic CREATE2), a fee distributor address is generated, and the affiliate is registered on each allowed Symmio core. - The affiliate is active. Users can create SubAccounts under it.
State transitions: NONE -> PENDING -> ACTIVE <-> PAUSED. Pending registrations can be cancelled or
rejected, which deletes the data entirely (resetting to NONE).
The AccountManager
Each affiliate gets an AccountManager contract whose primary purpose is migration convenience. It provides a
nearly identical API to the old MultiAccount contracts — create accounts, deposit, withdraw, execute trades, list accounts --
so frontends can swap contract addresses with minimal code changes. Under the hood, it authenticates the caller (by
temporarily setting globalSigner on the AccountLayer) and proxies the call through. There is no reason to use it
directly beyond backward compatibility.
Fee Distribution
Trading fees accrue under a virtual fee distributor address on the Symmio core. When an affiliate registers, they configure
stakeholders — a list of addresses and percentage shares (e.g., frontend operator 70%, referral partner 20%,
protocol 10%). All shares including symmioShare must sum to exactly 1e18.
Any stakeholder receiver or DISTRIBUTOR_ROLE holder can trigger fee claims. The system withdraws accumulated fees
from the core, splits them according to configured shares, and transfers each portion to the respective recipient.
Fee changes are two-step: the affiliate admin requests an update, then a protocol approver confirms it.
Hooks
Affiliates can attach hook contracts to account lifecycle events:
| Event | Trigger |
|---|---|
| Account creation | A new SubAccount is created |
| VA creation | A new VA is created (auto or manual) |
| VA deletion | A VA is cleaned up (last position closed) |
| SubAccount deletion | A SubAccount is removed |
| Call execution | _call is invoked on an account (fires after all calls complete) |
Hooks preserve the customization power frontends had when they owned their MultiAccount contracts — NFT minting on signup,
cashback, auto-binding to a PartyB, analytics tracking. During execution, hooks can
call back into the Symmio core via CoreFacet.executeForAccount(callData), gated by two
conditions: hookContext.isActive must be true (only during hook execution), and the function selector must be in
hookAllowedSelectors[affiliate] (controlled by SETTER_ROLE).
Audit note: Hooks are unsandboxed external calls. A malicious hook can revert and block the operation
(griefing) or consume excessive gas. The signer clearing mechanism (see Security below) prevents impersonation, and
hookAllowedSelectors limits callback scope, but hooks can still affect liveness.
Delegated Actions
Affiliates can execute protocol-level operations through callAsAffiliate, which lets the affiliate admin (or
authorized operators) call whitelisted functions on the Symmio core with the affiliate's identity. Useful for administrative
tasks like fee configuration.
Express Deposits
Express deposits let affiliates split user deposits to maintain instant-withdrawal liquidity. A deposit is divided by the affiliate's configured express rate: the real portion goes to the Symmio core as collateral, the virtual portion goes to a Virtual Provider that credits the user immediately. See Express Deposit for details.
Security Model
The Global Signer Pattern
The AccountLayer authenticates users through a globalSigner variable instead of relying on
msg.sender directly. This is the most security-critical mechanism in the system:
sequenceDiagram
participant U as User (EOA)
participant AM as AccountManager
participant AL as AccountLayer
U->>AM: call withSigner()
AM->>AM: globalSigner = msg.sender
AM->>AL: delegate call
AL->>AL: getSigner() returns globalSigner (the user)
AL->>AL: execute operation
AL-->>AM: return
AM->>AM: globalSigner = address(0)
AM-->>U: return
function getSigner() internal view returns (address) {
address signer = AccountStorage.layout().globalSigner;
return signer == address(0) ? msg.sender : signer;
}
Signer Clearing
Every external call — hooks, ERC20 transfers, virtual provider callbacks — is wrapped to prevent callback attacks:
function safeExternalCall(address target, bytes memory data) internal {
address previousSigner = ahLayout.globalSigner;
ahLayout.globalSigner = address(0); // Clear before external call
(bool success, bytes memory reason) = target.call(data);
ahLayout.globalSigner = previousSigner; // Restore after
}
The same pattern is used in LibAccountLayerSafeERC20 for token transfers and in callHook() (with the
additional step of setting/clearing HookContext).
Without this, a malicious hook or token contract could re-enter the AccountLayer during an external call and, since
getSigner() would still return the user's address, execute operations as the user.
Access Control
| Role | Controls |
|---|---|
DEFAULT_ADMIN_ROLE |
Can grant/revoke any role and assign role admins. Does not implicitly hold other roles. |
APPROVER_ROLE |
Activate affiliates, approve fee updates |
SETTER_ROLE |
Core whitelists, hook/call allowed selectors, AccountManager implementation, fee receiver |
PAUSER_ROLE |
Pause operations (affiliate admins can also pause their own affiliate) |
UNPAUSER_ROLE |
Unpause operations |
SIGNER_SETTER_ROLE |
Set globalSigner — granted to each AccountManager during affiliate approval |
DEPLOYER_ROLE |
Defined but unused; AccountManager deployment is handled inside approveAffiliate |
DISTRIBUTOR_ROLE |
Trigger fee distribution |
INSTANT_LAYER_ROLE |
Bypass ownership checks for InstantLayer batched execution |
Role admins are managed via setRoleAdmin(user, role, status). A DEFAULT_ADMIN_ROLE holder is
implicitly a role admin for all roles.
Reentrancy Protection
A custom reentrancy guard uses a dedicated storage slot
(keccak256("diamond.standard.storage.accountlayer.reentrancy")). The nonReentrant modifier covers
primary entry points: _call, createSubAccounts, deleteSubAccount,
createCustomVirtualAccount, deposits, and MarginFacet functions. Administrative functions rely on access control
instead. SymmioHookFacet.onClosePosition uses nonReentrant; onCancelQuote does not.
Legacy Migration
Frontends on the old MultiAccount system can import existing accounts into the AccountLayer as SubAccounts with CUSTOM isolation. Ownership is preserved (only the actual owner can import), double-import is prevented, and imported accounts gain access to all AccountLayer features.
Architecture Reference
Facets
| Facet | Responsibility |
|---|---|
| CoreFacet | SubAccount/VA lifecycle, deposits, trade execution routing, hook callbacks |
| AffiliateFacet | Affiliate registration, fee config, hooks, operators, express config |
| MarginFacet | Margin transfers between SubAccounts and VAs, emergency recovery |
| ViewFacet | Read-only queries for accounts, affiliates, addresses, predictions |
| ControlFacet | Role management, pause control, core whitelisting, allowed selectors |
| SymmioHookFacet | Receives callbacks from Symmio core for automatic VA cleanup (see VA Lifecycle Hooks) |
Storage Layout
| Storage Contract | Slot Key | Contents |
|---|---|---|
AccountStorage |
keccak256("diamond.standard.storage.accountlayer.account") |
SubAccount/VA data, nonces, globalSigner, AccountManager bytecode |
AffiliateStorage |
keccak256("diamond.standard.storage.accountlayer.affiliate") |
Affiliate configs, fee details, hooks, operators, hookContext |
AccountLayerStorage |
keccak256("diamond.standard.storage.accountlayer") |
RBAC (hasRole, roleAdmins), globalPaused |
| Reentrancy Guard | keccak256("diamond.standard.storage.accountlayer.reentrancy") |
Reentrancy status flag |
Deterministic Address Generation
SubAccounts, VAs, and fee distributors are virtual addresses (no deployed contracts). AccountManagers are real CREATE2
deployments. All follow the CREATE2 formula: keccak256(0xff, deployer, salt, initCodeHash).
// SubAccount: deployer=affiliate, salt=keccak256(user, globalNonce), initCodeHash=keccak256("ACC_V1")
address(uint160(uint256(keccak256(abi.encodePacked(
bytes1(0xff), affiliate, keccak256(abi.encodePacked(user, nonce)), keccak256("ACC_V1")
)))))
// Virtual Account: deployer=parentAccount, salt=keccak256(nonce), initCodeHash=keccak256("VACC_V1")
address(uint160(uint256(keccak256(abi.encodePacked(
bytes1(0xff), parentAccount, keccak256(abi.encodePacked(nonce)), keccak256("VACC_V1")
)))))
// AccountManager (real deployment): deployer=AccountLayer diamond
bytes32 salt = keccak256(abi.encodePacked(keccak256("ACM_V1"), registrant, name));
bytes32 initCodeHash = keccak256(abi.encodePacked(implementation, abi.encode(diamond)));
// Fee Distributor: deployer=affiliate, salt=keccak256(globalNonce), initCodeHash=keccak256("VFD_V1")
// Note: nonce comes from AccountStorage.globalNonce (shared with SubAccount generation)
address(uint160(uint256(keccak256(abi.encodePacked(
bytes1(0xff), affiliate, keccak256(abi.encodePacked(nonce)), keccak256("VFD_V1")
)))))
Cross-Contract Permissions
| Role on Symmio Core | Why |
|---|---|
SIGNER_ADMIN_ROLE |
Set signer context when executing operations on behalf of SubAccounts/VAs |
AFFILIATE_MANAGER_ROLE |
Register affiliates and set fee collectors on the core |
BALANCE_SETTLER_ROLE |
Transfer funds between VAs and parent SubAccounts during cleanup |
The InstantLayer requires INSTANT_LAYER_ROLE on the AccountLayer to bypass ownership checks during batched
template execution.