Before v0.8.6, SubAccount creation was mostly self-service: the signer created accounts for itself. Protocol-run onboarding, migration, or automation services could not prepare an account for a target owner without acting as that owner.
v0.8.6 keeps the existing self-service path unchanged and adds a narrow delegated path. A caller with
ACCOUNT_CREATOR_ROLE can call createSubAccountsFor(owner, affiliate, accountsData), and the created
SubAccounts are owned by the supplied owner, not by the caller.
The supplied affiliate must already be active. Delegated creation uses the same affiliate validation and
onAccountCreation hook behavior as self-service creation.
The same release also adds direct ownership transfer for existing SubAccounts. Instead of treating transfer like an
empty-account migration, transferSubAccountOwnership(subAccount, newOwner) moves the stored owner and owner
indexes while keeping the same SubAccount, Virtual Accounts, balances, positions, and affiliate state attached to the same
addresses.
The existing self-service SubAccount creation path, virtual account address derivation, Symmio core balance accounting, and virtual account lifecycle logic do not change.
Summary
Delegated creation uses the same internal creation path as normal SubAccount creation, but writes the supplied owner into storage. Ownership transfer updates the stored owner and the owner-to-SubAccount indexes.
Since Virtual Account ownership resolves through the parent SubAccount, all Virtual Accounts under the transferred SubAccount become controlled by the new owner automatically.
Affiliate hooks can observe ownership transfers through
onSubAccountOwnershipTransfer(subAccount, oldOwner, newOwner).
Changed interfaces
CoreFacet.createSubAccountsFor
function createSubAccountsFor(
address owner,
address affiliate,
SubAccountCreationData[] memory accountsData
) external returns (address[] memory);
Access control:
- Caller must have
ACCOUNT_CREATOR_ROLE. ownermust not beaddress(0).accountsDatamust not be empty.
Behavior:
- Uses the same internal
_createSubAccountpath ascreateSubAccounts. - The SubAccount owner is set to the supplied
owner, not the caller. - The SubAccount is added to
userToSubAccounts[owner]. - The existing
SubAccountCreated(account, owner, affiliate, name)event is emitted. - The existing
onAccountCreation(owner, subAccount, metadata)affiliate hook is called if registered.
CoreFacet.transferSubAccountOwnership
AccountLayer distinguishes the raw caller, msg.sender, from the effective signer whose authority it applies. A
trusted router may install the owner as that signer for one transaction, together with the account-family scope granted to a
delegate. See Delegation Account Scope.
function transferSubAccountOwnership(address subAccount, address newOwner) external;
Access control:
-
The effective AccountLayer signer must be the current owner of
subAccount; the raw caller may be that owner or a trusted router acting under a valid scoped signer session. - Ownership and delegated account scope are checked through the existing
onlyAccountOwnerpath.
Validation:
newOwnermust not beaddress(0).subAccountmust exist and must be an active SubAccount.newOwnermust differ from the current owner.
Behavior:
- Removes
subAccountfromuserToSubAccounts[oldOwner]. - Adds
subAccounttouserToSubAccounts[newOwner]. - Updates
AccountStorage.subAccounts[subAccount].owner. - Calls the affiliate transfer hook after storage is updated.
- Emits
SubAccountOwnershipTransferred(subAccount, oldOwner, newOwner).
IAccountLayerHook.onSubAccountOwnershipTransfer
function onSubAccountOwnershipTransfer(
address subAccount,
address oldOwner,
address newOwner
) external;
The hook is called after the owner field and enumerable indexes have been updated. If the hook queries
ownerOf(subAccount), it observes newOwner.
If the hook reverts, the entire transfer reverts, including the owner/index updates. This matches the existing AccountLayer lifecycle hook behavior for SubAccount creation and deletion.
Storage impact
The change does not add a second ownership model. AccountLayer already stores SubAccount ownership and owner indexes, so delegated creation and transfer reuse those fields.
mapping(address => SubAccountData) subAccounts;
mapping(address => EnumerableSet.AddressSet) userToSubAccounts;
Delegated creation writes the supplied owner into the existing SubAccount record. Ownership transfer updates
subAccounts[subAccount].owner and moves the SubAccount address from the old owner's index set to the new owner's
index set.
The new creator permission also reuses existing AccountLayer role storage:
mapping(address => mapping(bytes32 => bool)) hasRole;
Ownership resolution
SubAccounts store their owner directly:
SubAccountData.owner
Virtual Accounts do not store their own owner. Their owner is resolved through the parent SubAccount with
LibAccountLayerUtils.resolveAccountOwner(account).
Because Virtual Accounts resolve ownership through the parent, changing the SubAccount owner also changes effective control of all current, inactive, and future Virtual Accounts under that SubAccount, without rewriting VA storage.
Balances, allocations, pending quotes, open positions, and reusable VA pools stay attached to the same SubAccount and VA addresses. Only owner resolution changes.
Implications:
- The new owner can execute all owner-gated operations for the SubAccount.
- The old owner loses direct owner-gated access immediately.
- Owner-gated Virtual Account actions follow the parent SubAccount owner after transfer.
- Existing InstantLayer delegations intentionally remain active after the transfer. See the security note below.
This is an ownership transfer, and it does not require an empty account.
Hook ordering and revert semantics
The transfer sequence is:
- Validate caller and
newOwner. - Read
oldOwner,affiliate, andsymmioCore. - Remove the SubAccount from
oldOwner's enumerable set. - Add the SubAccount to
newOwner's enumerable set. - Set
subAccounts[subAccount].owner = newOwner. -
Call
onSubAccountOwnershipTransfer(subAccount, oldOwner, newOwner)if the affiliate registered a hook for that selector. - Emit
SubAccountOwnershipTransferred.
The hook is deliberately called after storage mutation so downstream affiliate systems can read the final owner state. Solidity transaction atomicity means any hook revert rolls back the storage mutation and event emission.
The existing hook context protection still applies. LibAccountLayerUtils.callHook clears whichever AccountLayer
signer mechanism is active (persistent or transient) before external hook execution and restores it afterward. When an
InstantLayer core context is active, it suspends and later restores that context as well. The hook therefore cannot inherit
the transferring user's authorization.
Security considerations
Role-granted account creation
ACCOUNT_CREATOR_ROLE can create SubAccounts for arbitrary non-zero owners under active affiliates. This role
should be granted only to trusted automation, onboarding, migration, or protocol-operated services.
The role does not grant ownership over the created accounts. The created SubAccount is controlled by the supplied
owner.
Direct ownership transfer
Ownership transfer is immediate and does not require recipient acceptance. A typo in newOwner transfers control
to that address atomically; there is no second transaction in which the recipient confirms the transfer.
The function does not require the SubAccount to be empty. A user can transfer an account with balances, allocated margin, pending quotes, open positions, and active Virtual Accounts. Treat this as intentional behavior.
Existing InstantLayer delegations
Ownership transfer intentionally does not clear InstantLayer delegations for the SubAccount or for Virtual Accounts that resolve to that SubAccount. Delegations are stored by canonical account, delegated signer, and selector; they are not bound to the owner that granted them.
This means a delegated signer approved before the transfer may still be able to execute the delegated selector after the SubAccount owner changes. During InstantLayer execution, AccountLayer resolves the current owner and sets that owner as the effective signer, so the delegated call can pass owner-gated AccountLayer checks unless the delegation has been revoked or expired.
A SubAccount transfer also carries its active InstantLayer delegations as outstanding approvals. Reconstruct the candidate
delegate and selector list from
DelegationGranted events or the integration's own records, then confirm active entries with
getActiveDelegations. For anything that should not survive the transfer, call
initiateRevokeDelegation. After the configured cooldown, finalizeRevokeDelegation removes the grant.
The grant remains active during that cooldown. A safe handoff must wait for the cooldown and confirm the grant is inactive,
preferably by finalizing the revocation, before transferring ownership. Do not read "old owner loses direct access" as "all
previously granted delegated access is removed."
Affiliate hook liveness
A registered transfer hook can revert and block ownership transfer for SubAccounts under that affiliate. This mirrors existing hook liveness risk for account creation and deletion. Affiliates should use simple, reliable hooks for lifecycle observability and keep heavier logic off-chain where possible.
No Symmio core balance movement
Transfer only changes AccountLayer ownership metadata and indexes. It does not call into the Symmio core to move balances, deallocate collateral, settle positions, or alter quote ownership. The same virtual addresses continue to hold the same Symmio-side balances and positions.
Events
SubAccountOwnershipTransferred
event SubAccountOwnershipTransferred(
address indexed account,
address indexed oldOwner,
address indexed newOwner
);
Indexers should update the owner of account from oldOwner to newOwner. They should also
treat all Virtual Accounts whose parent is account as effectively owned by newOwner from the same
transaction onward.
Existing SubAccountCreated semantics remain unchanged for self-service creation. For role-gated delegated
creation, the owner argument is the supplied target owner.
Implementation review checklist
-
Verify
createSubAccountsremains self-service and still usesLibAccountLayerUtils.getSigner(). - Verify
createSubAccountsForis gated byACCOUNT_CREATOR_ROLE. - Verify delegated creation stores the supplied
owner, notmsg.sender. -
Verify delegated creation still validates affiliate state, Symmio core whitelist, name length, isolation type, and
single-VA-mode constraints through
_createSubAccount. - Verify
transferSubAccountOwnershipis gated byonlyAccountOwner(subAccount). - Verify zero-address and same-owner transfers revert.
- Verify owner indexes are updated consistently with
SubAccountData.owner. - Verify Virtual Account owner resolution changes through the parent SubAccount without mutating VA storage.
- Verify pre-transfer InstantLayer delegations remain active for the transferred SubAccount/VA selectors unless explicitly revoked or expired.
- Verify hook execution observes the final owner and reverts the whole transfer on failure.
- Verify signer clearing still applies during the new hook callback.
Tested behaviors
The AccountLayer behavior tests cover:
- Successful delegated creation by an
ACCOUNT_CREATOR_ROLEholder. - Rejection of delegated creation by a caller without the role.
- Rejection of delegated creation to
address(0). - Owner/index correctness after delegated creation.
- Successful direct ownership transfer.
- Old owner access loss and new owner access gain.
- Virtual Account ownership resolution through transferred parent SubAccounts.
- Rejection of non-owner, zero-owner, and same-owner transfers.
- Affiliate hook invocation and encoded arguments.
- Hook visibility of final owner state.
- Hook revert rolling back the transfer.