Delegation Account Scope

A delegation was enforced against the account owner rather than the delegated account, so v0.8.6 confines a delegated operation to the account family it was granted over.

Summary

A delegation names one account and one set of selectors. Before v0.8.6 it was enforced as one owner and one set of selectors, which is a wider grant than the user agreed to.

A delegate approved for SubAccount A could run the delegated selectors against any other SubAccount held by the same owner. Approving a bot to trade one account effectively approved it across every account that user held.

An account family is one named SubAccount together with its Virtual Accounts. v0.8.6 confines a delegated operation to the family the delegation was granted over. Anything else is rejected with AccountOutOfScope and the whole operation reverts, so no balance or ownership change lands.

What changed

Before v0.8.6, InstantLayer executed a delegated operation as the account owner. The AccountLayer then checked ownership of whichever account the call named, and an owner owns all of their SubAccounts, so that check passed for every one of them. The delegation was checked against A while the call was allowed against B.

After v0.8.6, the session carries the delegated account family alongside the signer. Ownership is checked as before, and a second check rejects any account outside that family.

Nothing about granting or revoking a delegation changed. Existing delegations keep working, and they now reach only the account they already named.

What a delegation covers

The grant covers the named SubAccount together with its Virtual Accounts.

scope = virtualAccount.isExists ? virtualAccount.parentAccount : account

allowed(target) = owner(target) == signer AND canonical(target) == scope

Virtual Accounts are inside the grant because a SubAccount and its VAs are one trading unit. Margin moves between them in normal use: addMargin is called on a Virtual Account but draws the collateral from its parent. A delegate that could not reach Virtual Accounts could not trade the account it was approved for.

The grant stops there. A second SubAccount of the same owner, and any Virtual Account beneath it, is outside the delegation even though one person owns both.

The scope resolves through the same rule the delegation itself is keyed by, so the enforced family and the granted family are the same set. A delegation recorded against a Virtual Account and a delegation recorded against its parent are one grant, not two.

How the scope is set

InstantLayer is the only caller that opens a confined session. When it executes an operation whose signer is not the account owner, it resolves the delegation key for the named account and installs it alongside the owner.

An owner-signed operation needs no confinement and uses a zero scope. A delegated operation resolves the named account to its canonical SubAccount family. The administrator-controlled transientContextEnabled flag selects either the native begin/end selector protocol and setTransientSignerScoped, or the historical setter-compatible protocol and setSignerScoped. This is a configured protocol choice, not automatic capability detection. Both routes require the upgraded AccountLayer scoped-signer methods and install the same owner and scope for this transaction.

address scope = signedOp.signer == owner
    ? address(0)
    : _canonicalDelegator(signedOp.signerAccount.addr);

if (usesNativeContextSelectors) {
    IAccountLayerDiamond(accountLayer).setTransientSignerScoped(owner, scope);
} else {
    IAccountLayerDiamond(accountLayer).setSignerScoped(owner, scope);
}

The scope applies to both routes InstantLayer uses: operations targeting Symmio, which are wrapped through _call, and direct AccountLayer calls such as addMargin or transferSubAccountOwnership.

The session is cleared with the signer when the operation finishes. Both setSigner and setTransientSigner install a zero scope, so either matching clear path removes the confinement before the next operation. Callers that do not execute through an InstantLayer delegation never open a scoped session and are unaffected. This includes AccountManager, direct users, and protocol operations.

ViewFacet.getSignerScope reports the active scope, or address(0) when the session is unconfined. Because the scope is opened and cleared inside one transaction, it reads zero between transactions and is only meaningful to an observer inside the call.

AccountOutOfScope is raised inside the AccountLayer, so a caller going through InstantLayer receives it wrapped as OperationFailed(index, reason) with the scope rejection as the inner reason, rather than as a top-level revert.