AccountLayer Delegated Creation & Ownership Transfer

v0.8.6 lets approved creators open SubAccounts for other owners and lets ownership move directly, while preserving hooks, permissions, and existing account state.

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.
  • owner must not be address(0).
  • accountsData must not be empty.

Behavior:

  • Uses the same internal _createSubAccount path as createSubAccounts.
  • 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 onlyAccountOwner path.

Validation:

  • newOwner must not be address(0).
  • subAccount must exist and must be an active SubAccount.
  • newOwner must differ from the current owner.

Behavior:

  • Removes subAccount from userToSubAccounts[oldOwner].
  • Adds subAccount to userToSubAccounts[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:

  1. Validate caller and newOwner.
  2. Read oldOwner, affiliate, and symmioCore.
  3. Remove the SubAccount from oldOwner's enumerable set.
  4. Add the SubAccount to newOwner's enumerable set.
  5. Set subAccounts[subAccount].owner = newOwner.
  6. Call onSubAccountOwnershipTransfer(subAccount, oldOwner, newOwner) if the affiliate registered a hook for that selector.
  7. 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 createSubAccounts remains self-service and still uses LibAccountLayerUtils.getSigner().
  • Verify createSubAccountsFor is gated by ACCOUNT_CREATOR_ROLE.
  • Verify delegated creation stores the supplied owner, not msg.sender.
  • Verify delegated creation still validates affiliate state, Symmio core whitelist, name length, isolation type, and single-VA-mode constraints through _createSubAccount.
  • Verify transferSubAccountOwnership is gated by onlyAccountOwner(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_ROLE holder.
  • 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.