diff --git a/tips/tip-1038.md b/tips/tip-1038.md new file mode 100644 index 0000000000..362fd5ffce --- /dev/null +++ b/tips/tip-1038.md @@ -0,0 +1,542 @@ +--- +id: TIP-1038 +title: Enshrined Sub-Accounts +description: Protocol-native parent-child account linking with fund-pull authorization, enabling headless wallet onboarding and deferred custody handoff. +authors: Georgios Konstantopoulos +status: Draft +related: TIP-1000, TIP-1011, TIP-1022, IAccountKeychain.sol +protocolVersion: TBD +--- + +# TIP-1038: Enshrined Sub-Accounts + +## Abstract + +This TIP introduces **sub-accounts**: a protocol-native mechanism for linking independent Tempo accounts into a parent-child hierarchy. A parent account can pull funds from its sub-accounts and revoke their access keys. Sub-accounts retain their own address, nonce, balance, and signing keys — they are fully functional standalone accounts that can be linked post-hoc. This enables a headless wallet onboarding flow where an agent starts with a raw private key and later hands off custody to a passkey-based parent account. + +## Motivation + +### The Agent Wallet Problem + +The current account model forces a choice: either start with a passkey (good security, bad for headless agents) or start with a private key (good for agents, no recovery path). There is no way to bridge between the two after the fact. + +The desired UX is: + +1. Agent does `cast wallet new` → gets a standalone wallet with a private key +2. Agent calls `tempo wallet claim` or uses headless onramping → funds arrive +3. Agent does things — sends transactions, interacts with contracts +4. Later, user does `tempo wallet login` → opens a webpage, signs with a passkey, links the agent wallet as a sub-account +5. User can now pull remaining funds from the agent wallet, or revoke the agent's access keys if compromised + +This flow is impossible today because: + +- The agent's account address is permanently bound to its private key +- There is no protocol-level relationship between two independent accounts +- No account can pull funds from another account's balance +- The keychain only manages keys within a single account, not across accounts + +### Why Not Just Use Access Keys? + +Access keys (TIP-1011, `IAccountKeychain`) solve a different problem: delegating scoped permissions from one account to secondary signing keys. The agent wallet flow requires **two separate accounts** because: + +- The agent needs its own address for receiving funds before any parent exists +- The agent may interact with contracts that check `msg.sender` — it needs to be the canonical sender +- Multiple agents may exist simultaneously, each with their own balance and transaction history +- The parent relationship is established *after* the agent has already been operating independently + +### Why Not Use TIP-1022 Virtual Addresses? + +TIP-1022 virtual addresses solve deposit forwarding — many addresses routing inbound funds to one master. But virtual addresses have no independent state: no nonce, no balance, no ability to originate transactions. An agent needs a real account. + +### Comparison + +| Capability | Access Keys | Virtual Addresses (TIP-1022) | Sub-Accounts (this TIP) | +|---|---|---|---| +| Independent address | ❌ Same account | ❌ No state | ✅ Own address | +| Independent balance | ❌ Shared | ❌ None | ✅ Own balance | +| Can originate transactions | ❌ Signs for parent | ❌ Cannot transact | ✅ Full EOA | +| Relationship timing | At key creation | At registration | Post-hoc | +| Parent can pull funds | N/A (same balance) | N/A (auto-forwarded) | ✅ | +| Parent can revoke child's access keys | N/A | N/A | ✅ | + +## Assumptions + +1. **Mutual consent for linking.** Both the parent and child must co-sign the link operation. Neither side can unilaterally claim the other. This prevents griefing (e.g., an attacker linking a victim's account as a sub-account to drain it). + +2. **Single parent per child.** A sub-account has at most one parent. This avoids conflicting pull-fund claims and simplifies the authorization model. A parent may have many sub-accounts. + +3. **Existing keychain unchanged.** This TIP does not modify the root key / access key distinction within a single account. The keychain continues to manage keys for individual accounts; sub-accounts are a separate, orthogonal primitive. + +4. **TIP-20 integration required.** Fund pulling operates through the TIP-20 transfer path, so it must integrate with TIP-403 policy checks and TIP-1015 authorization on the child's token balance. + +5. **Account creation cost unaffected.** Sub-accounts are real accounts and incur the standard 250,000 gas account creation cost (TIP-1000) on first use. + +6. **Access-key revocation only.** The parent can revoke *access keys* on the child's keychain, but cannot revoke or replace the child's root key. True root-authority migration (replacing the original private key) is out of scope for this TIP and deferred to a future proposal. + +--- + +# Specification + +## Sub-Account Registry Precompile + +A new precompile deployed at `0xaAAAaaAA00000000000000000000000000000001` manages parent-child account relationships. + +### Storage Layout + +Each child account maps to a single 32-byte storage slot: + +``` +slot = keccak256(abi.encode(childAddress, REGISTRY_SLOT)) +value = parentAddress | flags + ^^^^^^^^^^^^ ^^^^^ + 20 bytes 12 bytes +``` + +| Field | Bytes | Description | +|-------|-------|-------------| +| `parentAddress` | 20 | The registered parent. `address(0)` if unlinked. | +| `flags` | 12 | Bitfield. Bit 0: `canPullFunds`. Bit 1: `canRevokeAccessKeys`. Remaining bits reserved, MUST be zero. | + +Additional storage: + +``` +linkNonce[parent] → uint64 // Consumed on each successful link, prevents replay +isParent[address] → bool // True if the address has any linked children +``` + +On-chain child enumeration is intentionally omitted. Indexers and wallets SHOULD reconstruct the parent-child graph from `SubAccountLinked` / `SubAccountUnlinked` events. + +### Interface + +```solidity +interface ISubAccountRegistry { + // ──────────────────── Structs ──────────────────── + + /// @notice Permissions the parent has over the sub-account + struct SubAccountPermissions { + bool canPullFunds; // Parent can transfer TIP-20 tokens from child + bool canRevokeAccessKeys; // Parent can revoke access keys on child's keychain + } + + /// @notice Sub-account relationship info + struct SubAccountInfo { + address parent; + SubAccountPermissions permissions; + bool isLinked; + } + + // ──────────────────── Events ──────────────────── + + /// @notice Emitted when a sub-account link is established + event SubAccountLinked( + address indexed parent, + address indexed child, + bool canPullFunds, + bool canRevokeAccessKeys + ); + + /// @notice Emitted when a sub-account link is removed + event SubAccountUnlinked( + address indexed parent, + address indexed child + ); + + /// @notice Emitted when a parent pulls funds from a sub-account + event FundsPulled( + address indexed parent, + address indexed child, + address indexed token, + uint256 amount + ); + + // ──────────────────── Errors ──────────────────── + + /// @notice The child account already has a parent + error AlreadyLinked(); + + /// @notice The child account is not linked to any parent + error NotLinked(); + + /// @notice The caller is not the parent of the specified child + error NotParent(); + + /// @notice The parent does not have the required permission + error PermissionDenied(); + + /// @notice Cannot link an account to itself + error SelfLink(); + + /// @notice Cannot link to/from an account that is already part of a sub-account hierarchy + error NestedSubAccount(); + + /// @notice Zero address provided where a valid address is required + error ZeroAddress(); + + /// @notice The token address is not a valid TIP-20 token + error InvalidToken(); + + // ──────────────── Link Management ─────────────── + + /// @notice Link a child account to a parent account. + /// @dev This function MUST be called by the child account (the account being linked). + /// The child signs the transaction, and `parentAddress` specifies the parent. + /// The parent must co-sign via a `SubAccountAuthorization` included in the + /// transaction envelope (see Transaction Integration). + /// + /// The protocol validates both signatures at transaction validation time: + /// - The child's signature is the standard transaction signature (tx.caller == child) + /// - The parent's signature is in the SubAccountAuthorization (recovered signer == parent) + /// + /// Reverts with AlreadyLinked if the child already has a parent. + /// Reverts with SelfLink if parentAddress == msg.sender. + /// Reverts with NestedSubAccount if parentAddress is itself a sub-account, + /// or if the child currently has any sub-accounts of its own (isParent == true). + /// Reverts with ZeroAddress if parentAddress is address(0). + /// + /// @param parentAddress The parent account to link to + /// @param permissions The permissions granted to the parent + function linkAccount( + address parentAddress, + SubAccountPermissions calldata permissions + ) external; + + /// @notice Unlink a sub-account. Can be called by either the parent or the child. + /// @dev When called by the child, the child regains full independence. + /// When called by the parent, the parent releases the child. + /// Both directions are permitted to avoid locking either party. + /// + /// After unlinking, the parent can no longer pull funds or revoke keys. + /// The child's existing keys and balances are unaffected. + /// + /// Reverts with NotLinked if the child is not linked. + /// Reverts with NotParent if called by an address that is neither + /// the parent nor the child. + /// + /// @param childAddress The sub-account to unlink + function unlinkAccount(address childAddress) external; + + // ──────────────── Fund Operations ─────────────── + + /// @notice Pull TIP-20 tokens from a sub-account to the parent. + /// @dev MUST only be callable by the parent of the specified child. + /// MUST check that the parent has `canPullFunds` permission. + /// `token` MUST be a valid TIP-20 token address (0x20c0... prefix). + /// Executes an internal TIP-20 precompile transfer from child to parent, + /// subject to standard TIP-403 / TIP-1015 authorization on both + /// the child (sender) and parent (recipient). + /// + /// Reverts with NotLinked if the child is not linked. + /// Reverts with NotParent if msg.sender is not the parent. + /// Reverts with PermissionDenied if canPullFunds is false. + /// Reverts with InvalidToken if token is not a TIP-20 address. + /// Reverts with standard TIP-20 errors if the transfer fails + /// (insufficient balance, policy rejection, etc.). + /// + /// @param childAddress The sub-account to pull funds from + /// @param token The TIP-20 token address (must have 0x20c0... prefix) + /// @param amount The amount to pull + function pullFunds( + address childAddress, + address token, + uint256 amount + ) external; + + // ──────────────── Key Management ──────────────── + + /// @notice Revoke an access key on a sub-account's keychain. + /// @dev MUST only be callable by the parent of the specified child. + /// MUST check that the parent has `canRevokeAccessKeys` permission. + /// Calls through to the new system-only keychain hook + /// `systemRevokeKey(account, keyId)` on the AccountKeychain precompile. + /// + /// This allows a parent to revoke a compromised agent access key + /// without needing the agent's cooperation. + /// + /// NOTE: This can only revoke access keys registered in the child's + /// keychain. It CANNOT revoke or replace the child's root key. + /// Root-authority migration is out of scope for this TIP. + /// + /// Reverts with NotLinked if the child is not linked. + /// Reverts with NotParent if msg.sender is not the parent. + /// Reverts with PermissionDenied if canRevokeAccessKeys is false. + /// Reverts with standard AccountKeychain errors if the key + /// does not exist or is already revoked. + /// + /// @param childAddress The sub-account whose access key to revoke + /// @param keyId The key ID to revoke on the child's keychain + function revokeChildAccessKey( + address childAddress, + address keyId + ) external; + + // ──────────────── View Functions ──────────────── + + /// @notice Get sub-account information for a child address + /// @param childAddress The account to query + /// @return info Sub-account relationship info (parent is address(0) if unlinked) + function getSubAccountInfo(address childAddress) + external view returns (SubAccountInfo memory info); + + /// @notice Get the parent of a child account + /// @param childAddress The account to query + /// @return The parent address, or address(0) if unlinked + function getParent(address childAddress) + external view returns (address); + + /// @notice Get the current link nonce for a parent address + /// @dev Used by off-chain signers to construct SubAccountAuthorization + /// @param parentAddress The parent account + /// @return The next expected nonce + function getLinkNonce(address parentAddress) + external view returns (uint64); +} +``` + +### Constants + +| Name | Value | Description | +|------|-------|-------------| +| `SUB_ACCOUNT_REGISTRY_ADDRESS` | `0xaAAAaaAA00000000000000000000000000000001` | Precompile address for the sub-account registry | + +## Transaction Integration + +### `SubAccountAuthorization` + +Linking requires mutual consent. A new optional field `sub_account_authorization` is added to the Tempo transaction envelope, appended as a trailing RLP field after `key_authorization`: + +``` +SubAccountAuthorization { + child_address: Address, + parent_address: Address, + permissions: SubAccountPermissions, + nonce: uint64, // Parent's link nonce (consumed on success) + deadline: uint64, // Unix timestamp after which this authorization expires + signature: PrimitiveSignature, // Parent's root-key signature +} +``` + +The parent signs with a `PrimitiveSignature` (not a `TempoSignature` with keychain indirection). This avoids the need to validate the parent's keychain state during authorization recovery and matches the pattern used by `KeyAuthorization`. + +The authorization payload that the parent signs is: + +``` +payload = keccak256(abi.encodePacked( + "TEMPO_SUB_ACCOUNT_LINK", + chain_id, + SUB_ACCOUNT_REGISTRY_ADDRESS, + bytes4(keccak256("linkAccount(address,(bool,bool))")), // function selector + child_address, + parent_address, + canPullFunds, + canRevokeAccessKeys, + nonce, + deadline +)) +``` + +The payload commits to the registry address, function selector, both addresses, exact permissions, nonce, and deadline. This prevents: + +- **Replay after unlink**: the nonce is consumed on successful link; reusing the same authorization reverts +- **Permission mismatch**: the signed permissions must exactly match the calldata permissions +- **Cross-chain replay**: `chain_id` is included +- **Stale authorizations**: `deadline` provides an expiry window + +### Validation + +At transaction validation time (`handler.rs`), when a `SubAccountAuthorization` is present: + +1. Verify `block.timestamp <= deadline`; reject with `SubAccountAuthorizationExpired` if expired +2. Recover the signer from the authorization's `PrimitiveSignature` +3. Verify the recovered signer equals `parent_address` +4. Verify `child_address` equals `tx.caller` (the transaction sender) +5. Verify `nonce` equals `linkNonce[parent_address]`; reject with `SubAccountAuthorizationNonceMismatch` if wrong +6. Verify the transaction calls `linkAccount` on `SUB_ACCOUNT_REGISTRY_ADDRESS` with matching `parentAddress` and `permissions`; reject with `SubAccountAuthorizationCallMismatch` if calldata does not match +7. If validation passes, increment `linkNonce[parent_address]` and allow the call to proceed +8. If validation fails at any step, reject the transaction with the appropriate error + +### RLP Encoding + +``` +TempoTransaction RLP: + [...existing fields..., key_authorization?, sub_account_authorization?] + +SubAccountAuthorization RLP: + [child_address, parent_address, [canPullFunds, canRevokeAccessKeys], nonce, deadline, signature_bytes] +``` + +Pre-fork nodes encountering the extra trailing field will reject the transaction. Post-fork nodes MUST accept transactions with or without the field. + +### Intrinsic Gas + +Transactions containing a `SubAccountAuthorization` incur additional intrinsic gas: + +| Component | Gas | +|-----------|-----| +| Signature recovery (secp256k1) | 3,000 | +| Signature recovery (P256/WebAuthn) | 3,450 | +| Authorization validation | 2,100 | + +## TIP-20 Integration + +### `pullFunds` Transfer Path + +When `pullFunds(child, token, amount)` is called: + +1. Verify the caller is the registered parent of `child` +2. Verify `canPullFunds` permission is set +3. Verify `token` is a valid TIP-20 token address (bytes `[0:12]` match the `0x20c0...` prefix); revert with `InvalidToken` otherwise +4. Execute an internal TIP-20 precompile transfer from `child` to `parent`: + - Apply standard TIP-403 / TIP-1015 **sender** authorization on `child` + - Apply standard TIP-403 / TIP-1015 **recipient** authorization on `parent` + - Debit `child`'s balance, credit `parent`'s balance +5. Emit `FundsPulled(parent, child, token, amount)` + +The sub-account registry precompile is allowlisted as a system caller in the TIP-20 precompile, authorizing it to invoke the internal transfer path on behalf of the child. This matches the existing pattern where protocol-level callers (e.g., `systemTransferFrom`) have special privileges. + +### Authorization Semantics + +TIP-403 policies on the child's account apply normally to `pullFunds` transfers. If the child has a policy restricting outbound transfers (e.g., a blocklist), the pull is rejected. Similarly, if the parent is not authorized to receive the token, the pull is rejected. This preserves the invariant that TIP-403 policies are always enforced regardless of transfer origin. + +## AccountKeychain Integration + +### New System Hook: `systemRevokeKey` + +A new system-only function is added to the AccountKeychain precompile: + +```solidity +/// @notice Revoke an access key on any account. System-only. +/// @dev MUST only be callable by the SubAccountRegistry precompile address. +/// Bypasses the standard `ensure_admin_caller` root-key check. +/// Can only revoke access keys (keys registered via authorizeKey), +/// NOT the account's root key (which is not in the keychain). +/// @param account The account whose keychain to modify +/// @param keyId The access key to revoke +function systemRevokeKey(address account, address keyId) external; +``` + +This function: + +- Is callable **only** by `SUB_ACCOUNT_REGISTRY_ADDRESS`; any other caller reverts with `UnauthorizedCaller` +- Bypasses `ensure_admin_caller` (no root-key requirement) +- Can only revoke keys that exist in `keys[account][keyId]` — it cannot affect the root key, which is not registered in the keychain +- Follows the same revocation semantics as `revokeKey`: sets `isRevoked = true`, emits `KeyRevoked` + +### `revokeChildAccessKey` Path + +When `revokeChildAccessKey(child, keyId)` is called on the sub-account registry: + +1. Verify the caller is the registered parent of `child` +2. Verify `canRevokeAccessKeys` permission is set +3. Call `AccountKeychain.systemRevokeKey(child, keyId)` + +The parent cannot *add* keys to the child's keychain — only revoke access keys. This is a deliberate asymmetry: adding keys to someone else's account is a much larger security surface than revoking them. + +### Scope Limitation + +This TIP does **not** provide a way to revoke or replace the child's root key (the original private key). The root key is not a registered keychain entry and has no revocation state. True root-authority migration — where a passkey fully replaces a private key as the account's root authority — requires deeper changes to the account model and is deferred to a future TIP. + +In the agent wallet flow, this means: +- The parent **can** revoke any access keys the agent registered +- The parent **can** pull all funds out of the agent's account +- The parent **cannot** disable the agent's original private key from signing transactions + +If the agent's private key is compromised, the parent should pull all funds immediately. The compromised key can still sign empty transactions (at the agent's cost), but cannot move funds the parent has already withdrawn. + +## Depth Restriction + +Sub-account links are limited to depth 1. `linkAccount` MUST revert with `NestedSubAccount` if: + +- `parentAddress` is itself a sub-account of another account, **or** +- `childAddress` currently has any sub-accounts of its own (`isParent[childAddress] == true`) + +This prevents: + +- Multi-level fund-pull chains (A pulls from B pulls from C) +- Circular linking (A → B → A) +- Unbounded traversal during authorization checks + +The `isParent` flag is set to `true` when an account first has a child linked, and cleared when its last child is unlinked. + +## Gas Costs + +| Operation | Estimated Gas | +|-----------|---------------| +| `linkAccount` | ~50,000 (2 SSTORE + nonce increment + isParent update) | +| `unlinkAccount` | ~25,000 (1 SSTORE clear + isParent update) | +| `pullFunds` | ~30,000 + TIP-20 transfer cost | +| `revokeChildAccessKey` | ~25,000 + keychain revocation cost | +| `getSubAccountInfo` | ~2,600 (1 SLOAD + decode) | +| `getParent` | ~2,600 (1 SLOAD) | +| `getLinkNonce` | ~2,600 (1 SLOAD) | + +## Backward Compatibility + +This TIP requires a **hardfork** due to: + +1. **New transaction envelope field**: `sub_account_authorization` is appended as a trailing RLP field after `key_authorization`. Pre-fork nodes will reject transactions containing it. +2. **New precompile**: `ISubAccountRegistry` at `0xaAAAaaAA00000000000000000000000000000001`. Pre-fork, calls to this address hit an empty account. +3. **New AccountKeychain hook**: `systemRevokeKey` is a new privileged function on the existing keychain precompile. +4. **New TIP-20 system caller**: The sub-account registry is allowlisted for internal transfers. + +Post-fork, existing transactions without `sub_account_authorization` are unaffected. The new precompile starts with empty state. All features MUST be gated behind hardfork activation. + +Pre-fork blocks MUST be replayed with old semantics to preserve state root consistency. + +--- + +# Invariants + +## Core Invariants + +1. **Mutual consent.** A sub-account link MUST only be created when both the child (via `tx.caller`) and the parent (via `SubAccountAuthorization` signature) have explicitly signed. Neither party can unilaterally create a link. + +2. **Single parent.** A child account MUST have at most one parent at any time. Attempting to link an already-linked child MUST revert with `AlreadyLinked`. + +3. **No nesting.** If account A is a sub-account of B, then A MUST NOT be allowed to become a parent. If account C already has children, C MUST NOT be allowed to become a sub-account. `linkAccount` MUST enforce both directions. + +4. **No replay.** Each `SubAccountAuthorization` MUST include a nonce and deadline. The nonce MUST be consumed on successful link. A reused nonce MUST cause the transaction to be rejected. An expired deadline MUST cause the transaction to be rejected. + +5. **Call binding.** The signed `SubAccountAuthorization` payload MUST commit to the registry address, function selector, child address, parent address, and exact permissions. A mismatch between the signed authorization and the actual calldata MUST cause the transaction to be rejected. + +6. **Permission enforcement.** `pullFunds` MUST revert if `canPullFunds` is false. `revokeChildAccessKey` MUST revert if `canRevokeAccessKeys` is false. Permissions are immutable for the lifetime of a link. + +7. **Unlink from either side.** Both the parent and the child MUST be able to call `unlinkAccount`. After unlinking, all parent privileges (fund pulling, key revocation) MUST immediately cease. + +8. **TIP-403 policy preservation.** `pullFunds` transfers MUST be subject to the same TIP-403 / TIP-1015 sender and recipient authorization checks as any other TIP-20 transfer. The sub-account link does not override token-level policies. + +9. **TIP-20 token validation.** `pullFunds` MUST reject non-TIP-20 token addresses. Only native TIP-20 tokens (with the `0x20c0...` address prefix) are valid targets. + +10. **No balance mutation on link/unlink.** `linkAccount` and `unlinkAccount` MUST NOT move any tokens. They only modify registry state. + +11. **Child independence.** A linked sub-account MUST continue to originate transactions, sign with its own keys, and manage its own keychain (via its root key) independently. The parent's `revokeChildAccessKey` is an *additional* revocation path, not a replacement for the child's root key authority. + +12. **Access-key revocation only.** `revokeChildAccessKey` MUST only revoke keys registered in the child's keychain via `authorizeKey`. It MUST NOT affect the child's root key, which has no keychain entry. + +13. **Atomic revert.** If any step of `pullFunds` or `revokeChildAccessKey` fails (insufficient balance, policy rejection, key not found), the entire operation MUST revert with no state changes. + +## Test Cases + +1. **Happy path link**: Child calls `linkAccount` with valid `SubAccountAuthorization` from parent → link established, events emitted +2. **Double link**: Child already linked → reverts with `AlreadyLinked` +3. **Self link**: `parentAddress == msg.sender` → reverts with `SelfLink` +4. **Nested link (parent is child)**: Parent is itself a sub-account → reverts with `NestedSubAccount` +5. **Nested link (child is parent)**: Child already has children → reverts with `NestedSubAccount` +6. **Missing authorization**: `linkAccount` called without `SubAccountAuthorization` → transaction rejected at validation +7. **Wrong parent signature**: Authorization signed by a different key → transaction rejected at validation +8. **Permission mismatch**: Signed permissions differ from calldata → rejected with `SubAccountAuthorizationCallMismatch` +9. **Expired authorization**: `deadline < block.timestamp` → rejected with `SubAccountAuthorizationExpired` +10. **Replayed authorization**: Same nonce used after unlink+relink attempt → rejected with `SubAccountAuthorizationNonceMismatch` +11. **Pull funds**: Parent calls `pullFunds` → child balance decreases, parent balance increases, `FundsPulled` emitted +12. **Pull funds without permission**: `canPullFunds` is false → reverts with `PermissionDenied` +13. **Pull funds not parent**: Non-parent calls `pullFunds` → reverts with `NotParent` +14. **Pull funds insufficient balance**: Amount exceeds child's balance → reverts with TIP-20 error +15. **Pull funds with TIP-403 sender block**: Child has outbound policy blocking transfers → reverts +16. **Pull funds with TIP-403 recipient block**: Parent not authorized to receive token → reverts +17. **Pull funds invalid token**: Non-TIP-20 address → reverts with `InvalidToken` +18. **Revoke child access key**: Parent calls `revokeChildAccessKey` → key revoked on child's keychain, `KeyRevoked` emitted +19. **Revoke child access key without permission**: `canRevokeAccessKeys` is false → reverts with `PermissionDenied` +20. **Unlink by child**: Child calls `unlinkAccount` → link removed, parent can no longer pull +21. **Unlink by parent**: Parent calls `unlinkAccount` → link removed +22. **Pull after unlink**: Parent calls `pullFunds` after unlink → reverts with `NotLinked` +23. **Relink after unlink**: Child unlinks, then links to a new parent with fresh nonce → succeeds +24. **Child independence**: Linked child can still send transactions, manage its own keychain → unaffected