--- eip: 8216 title: Slot-Based Equipment for ERC-6551 Accounts description: An interface for equipping, unequipping, and permanently locking tokens within ERC-6551 Token Bound Accounts using named slots. author: LordThisDrip (@LordThisDrip) discussions-to: https://ethereum-magicians.org/t/erc-8216-slot-based-equipment-for-erc-6551-token-bound-accounts/28139 status: Draft type: Standards Track category: ERC created: 2026-03-28 requires: 165, 721, 1155, 6551 --- ## Abstract This EIP defines an interface for managing slot-based equipment within [ERC-6551](./eip-6551.md) Token Bound Accounts (TBAs). It allows tokens ([ERC-721](./eip-721.md) or [ERC-1155](./eip-1155.md)) to be equipped into application-defined slots, queried as a structured loadout, and optionally permanently locked. The interface includes single, batch, mint-time, and balance-only operations, allowing parent contracts to seat equipment at NFT mint time without a separate wallet-to-TBA transfer step, and exposes lock status and cached token type per slot via the SlotEntry struct, allowing frontends and marketplaces to render a complete character view in a single call. Implementations define their own unequip disposition — whether assets return to the caller's wallet (owner-bound) or remain in the TBA balance (account-bound) — enabling both tradeable-loot and character-binding patterns under one standard. ## Motivation [ERC-6551](./eip-6551.md) gives every NFT its own smart contract account, but defines no standard for what goes inside. Applications have independently built equipment, loadout, and trait systems with incompatible interfaces, making cross-application composability impossible. A standard equipment interface enables gaming characters with equippable gear, social profiles with locked credentials, composable art with swappable accessories, and universal marketplace loadout display. The mint-time and balance-only operations enable parent contracts to seat character-binding equipment at NFT mint time without an intermediate wallet round-trip, and let owners reorganize TBA-held items across slots without leaving the account. ## Specification The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174. Every compliant contract MUST implement the `IERC6551Equipment` interface and MUST register support via [ERC-165](./eip-165.md). The [ERC-165](./eip-165.md) identifier for this interface is 0xc1ef0b9e. ### Interface ```solidity // SPDX-License-Identifier: CC0-1.0 pragma solidity ^0.8.24; interface IERC6551Equipment { struct SlotEntry { bytes32 slotId; address tokenContract; uint256 tokenId; uint256 amount; bool locked; bool isERC721; } event Equipped(bytes32 indexed slotId, address indexed tokenContract, uint256 indexed tokenId, uint256 amount); event Unequipped(bytes32 indexed slotId, address indexed tokenContract, uint256 indexed tokenId, uint256 amount); event SlotLocked(bytes32 indexed slotId, address indexed tokenContract, uint256 tokenId); function equip(bytes32 slotId, address tokenContract, uint256 tokenId, uint256 amount) external; function unequip(bytes32 slotId) external; function lockSlot(bytes32 slotId) external; function equipBatch(bytes32[] calldata slotIds, address[] calldata tokenContracts, uint256[] calldata tokenIds, uint256[] calldata amounts) external; function lockSlots(bytes32[] calldata slotIds) external; function getEquipped(bytes32 slotId) external view returns (address tokenContract, uint256 tokenId, uint256 amount); function getLoadout() external view returns (SlotEntry[] memory entries); function isSlotOccupied(bytes32 slotId) external view returns (bool); function isSlotLocked(bytes32 slotId) external view returns (bool); function equipAtMint(bytes32 slotId, address tokenContract, uint256 tokenId, uint256 amount) external; function lockSlotAtMint(bytes32 slotId) external; function equipFromBalance(bytes32 slotId, address tokenContract, uint256 tokenId, uint256 amount) external; function equipAndLockAtMint(bytes32 slotId, address tokenContract, uint256 tokenId, uint256 amount) external; } ``` ### Slot Identifiers Slots are identified by bytes32 values. The RECOMMENDED convention is keccak256("slot.name"). Applications define their own slot taxonomy. Applications sharing a TBA across multiple contexts SHOULD namespace slots to avoid collisions. ### SlotEntry Fields The `isERC721` field of `SlotEntry` records the token type detected at equip time via [ERC-165](./eip-165.md) probe. Implementations MUST cache this value for the lifetime of the slot occupation and MUST NOT re-probe the token contract's interface during any subsequent operation. The reasoning is in Security Considerations under Type-shifting defense. ### Behavior equip: MUST revert if caller is not a valid signer, slot is occupied, slot is locked, or amount is zero. MUST detect token type once via ERC-165 probe and cache it on the slot entry. MUST update state before external transfers (CEI). MUST emit Equipped event. unequip: MUST revert if caller is not a valid signer, slot is empty, or slot is locked. MUST delete slot state. Implementations MUST define an unequip disposition: either transfer the asset to the caller (owner-bound disposition) or retain the asset in the TBA balance (account-bound disposition). The chosen disposition MUST be consistent across all unequip operations on a given implementation and SHOULD be documented. Implementations that retain assets in the TBA SHOULD expose equipFromBalance to allow slot reorganization without wallet round-trips. MUST emit Unequipped event. lockSlot: MUST revert if caller is not a valid signer, slot is empty, or slot is already locked. MUST set slot as permanently locked. This action is irreversible. MUST emit SlotLocked event. equipBatch: MUST revert if array lengths do not match or any individual equip would revert. All-or-nothing semantics. lockSlots: MUST revert if any individual lock would revert. All-or-nothing semantics. getLoadout: Returns an array of SlotEntry structs for every occupied slot including lock status and cached token type. equipAtMint: MUST restrict access to the parent contract — that is, the address returned as `tokenContract` by the canonical [ERC-6551](./eip-6551.md) `token()` function. MUST revert if the slot is occupied or locked, the amount is zero, or the TBA's balance of the requested asset is below `amount`. The balance condition is `balanceOf(address(this), tokenId) >= amount` for ERC-1155 and `ownerOf(tokenId) == address(this)` for ERC-721, mirroring the postfix integrity check tolerance. MUST NOT perform any token transfer — the asset is already held by the TBA. MUST emit Equipped event. lockSlotAtMint: MUST restrict access to the parent contract. Behaviorally identical to lockSlot with a different access gate. MUST emit SlotLocked event. equipFromBalance: MUST restrict access to a valid signer (NFT owner). Same balance condition and no-transfer semantics as equipAtMint. MUST emit Equipped event. equipAndLockAtMint: MUST restrict access to the parent contract. Atomically registers the slot AND locks it in a single call. The observable post-state MUST be identical to invoking equipAtMint followed by lockSlotAtMint with the same arguments. MUST emit BOTH Equipped and SlotLocked events so indexers tracking either independently are not forced to special-case the combined function. ### Execute Layer Integrity Implementations of `IERC6551Equipment` that are also [ERC-6551](./eip-6551.md) executable accounts (exposing an `execute()` function) MUST ensure that no execution can transfer out a token recorded in any equipment slot, regardless of whether that slot is locked. This protection has two parts. **Postfix integrity check.** After every successful call made via `execute()`, the implementation MUST verify, for each currently occupied slot, that the recorded token is still held by the account: for [ERC-721](./eip-721.md) tokens via `ownerOf(tokenId) == address(this)`, and for [ERC-1155](./eip-1155.md) tokens via `balanceOf(address(this), tokenId) >= amount`. If any equipped token is no longer held by the account at the conclusion of an `execute()` call, the entire execution MUST revert. Implementations SHOULD define a dedicated error type (e.g., `SlotIntegrityViolated(bytes32 slotId)`) for this revert condition. The check MUST consult the cached `isERC721` field on the slot entry rather than re-probing the token contract. The check applies uniformly to locked and unlocked slots: any token recorded as equipped is protected from being silently drained via `execute()`. **Prefix execute-target restriction.** Before the external call dispatched by `execute()`, the implementation MUST verify that the call's `to` address is not the `tokenContract` of any currently-occupied slot. If `to` matches any equipped token contract, the execution MUST revert. Implementations SHOULD define a dedicated error type (e.g., `ExecuteIntoEquippedContract(address to)`) for this revert condition. The reason this is required separately from the postfix integrity check is in Security Considerations under Approval-based bypass. Owners wishing to remove an equipped token from a slot MUST use the `unequip()` function. Under owner-bound disposition the asset transfers to the caller and the owner may re-equip or trade freely. Under account-bound disposition the asset remains in the TBA after the slot is cleared; owners wishing to move the asset to a different slot MUST use `equipFromBalance()`, and owners wishing to send the asset to an external address MUST do so via paths the prefix execute-target restriction permits — which, for contracts with a locked anchor slot, means no exit path exists. ### Ownership Transfer Semantics When the parent NFT is transferred, all equipped items remain in the TBA. Unlocked slots can be modified by the new owner. Locked slots remain locked and cannot be modified. Items recorded in locked slots cannot be drained via `execute()` because the prefix execute-target restriction blocks any call into the equipped token contract for the lifetime of the slot occupation. This makes locked slots load-bearing for character-binding patterns: an item placed into a locked slot at mint time remains custodially attached to the parent NFT across all subsequent transfers, with no extraction path. ## Rationale bytes32 slot identifiers allow arbitrary taxonomies without a central registry. Permanent locking with no unlock provides absolute trust for marketplaces and contracts. No swap function keeps the interface minimal. Batch operations reduce mint flows from many transactions to two. bool locked in SlotEntry enables single-call character rendering. CEI pattern is mandatory to prevent reentrancy via safeTransferFrom callbacks. The execute-layer postfix integrity check is required because, without it, an account owner could circumvent the lock guarantees by constructing an `execute()` call that invokes `safeTransferFrom` directly on an equipped token's contract. This would drain the underlying token from the account while leaving the on-chain equipment record intact and the slot still marked as locked. An owner could lock a "founder pack" item to advertise it as permanently bonded, sell the parent NFT at a premium based on that guarantee, then drain the item before delivery — defrauding the buyer while the on-chain lock state appears unbroken. The execute-layer check transforms slot locks from advisory metadata into enforced guarantees that downstream consumers (marketplaces, indexers, buyers) can rely on. The prefix execute-target restriction is required for a separate reason. The postfix check verifies custody at the conclusion of `execute()`, but cannot detect a call that grants a third party standing rights to drain custody after the call returns. An owner could `execute(equippedToken, setApprovalForAll(attacker, true))`, after which `attacker` has unbounded permission to call `safeTransferFrom` against the TBA on the equipped token contract — bypassing both the equipment interface and the postfix check entirely. The prefix restriction closes this path by refusing to dispatch any `execute()` call into a contract that currently appears as the `tokenContract` of any occupied slot. Approval, transfer, and arbitrary-mutation calls into equipped contracts are all blocked uniformly — the equipment interface is the only path to move equipped tokens. The unequip disposition is left to implementations rather than mandated by the spec because both patterns serve real use cases. Owner-bound disposition (transfer to caller) treats equipped items as loot — the owner's property, freely tradeable after removal. Account-bound disposition (retain in TBA) treats equipped items as identity — bound to the character, reorganizable across slots via `equipFromBalance` but never leaving the account. Combined with the prefix execute-target restriction and a locked anchor slot, account-bound disposition makes items entering the TBA permanently character-bound with no extraction path. The spec does not prefer one over the other; it defines the interface contract (slot state cleared, event emitted) and leaves the asset-movement semantic to the implementation. The mint-time equip operations (`equipAtMint`, `lockSlotAtMint`, `equipAndLockAtMint`) exist because the parent NFT contract is uniquely positioned to write character-binding equipment at mint time but has no practical signing relationship with the TBA at that moment. During the parent's `_safeMint` call the recipient passes the `IERC721.ownerOf` check that gates `onlyOwner` functions on the TBA, but the recipient is typically not the practical caller — the parent contract is mid-mint and needs to deploy the TBA, mint the founder pack, and seat the binding equipment in a single transaction. Gating these operations by parent-contract identity rather than by NFT ownership lets the mint sequence complete atomically without ownership round-tripping. The `equipAndLockAtMint` combined operation collapses one parent-to-TBA call frame and skips the redundant occupancy and lock-state SLOAD checks that the two-call sequence would otherwise re-validate. `equipFromBalance` exists to handle assets that entered the TBA via paths other than the standard wallet-to-TBA `equip` — assets minted directly to the TBA by future drops on the parent project's contract, assets received as gifts, and assets retained after `unequip`. Without it, an owner who wanted to slot a TBA-resident asset would have to extract it to their wallet and call `equip`, which the prefix execute-target restriction may not permit if the asset's contract is already represented in another occupied slot. ### Optional `amount` Packing (Discussion) Implementations of the slot-based equipment interface tend to write an entire `SlotEntry` for every equip and unequip operation. Under the layout in this amendment (`bytes32 slotId; address tokenContract; uint256 tokenId; uint256 amount; bool locked; bool isERC721`) the struct occupies four storage slots: `slotId` alone, `tokenContract + locked + isERC721` together, `tokenId` alone, `amount` alone. A future amendment MAY narrow `amount` to `uint80`, which would pack alongside `tokenContract + locked + isERC721` into a single storage slot and reduce per-equip SSTORE cost by approximately 20k gas. The trade-off is that `uint80` constrains the maximum slot amount to roughly `1.2 × 10^24`. For typical character-binding and cosmetic implementations this is far above any realistic per-slot quantity. For implementations that envision very-large-quantity ERC-1155 slots — high-supply utility tokens, dust-collected fungibles — `uint80` would be limiting. This discussion item is not normative. Implementations MAY choose either layout. Implementations that adopt the narrower `uint80` MUST clearly document the constraint and SHOULD treat the on-chain ABI shape of `getLoadout()` as a breaking change relative to the `uint256` layout. Community feedback on whether to fold this packing into a future amendment is welcomed in the discussions thread. ## Backwards Compatibility The function set added in this amendment is purely additive — `equipAtMint`, `lockSlotAtMint`, `equipFromBalance`, and `equipAndLockAtMint` do not modify the signatures of the original nine functions. The `unequip()` disposition is now implementation-defined rather than hardcoded as transfer-to-caller, which is an observable behavior change for callers that relied on the original spec's transfer guarantee, and the amended `SlotEntry` struct gains a sixth field (`isERC721`), which alters the ABI shape of `getLoadout()`'s return type. Implementations of the original spec MUST be re-compiled against the amended interface before being treated as compliant; the [ERC-165](./eip-165.md) identifier change from `0xd38f0891` to `0xc1ef0b9e` makes the interface shift detectable on-chain. ## Reference Implementation A reference implementation of the slot-based equipment interface is provided below. It demonstrates the execute-layer prefix execute-target restriction and postfix integrity check (~5-10k gas per occupied slot in each direction), the CEI-ordered equip and unequip logic, the cached `isERC721` token-type detection set once at equip time, the no-transfer balance-only equip helper shared by `equipAtMint`, `equipFromBalance`, and `equipAndLockAtMint`, and the `onlyOwner` and `onlyParentContract` access gates derived from the canonical [ERC-6551](./eip-6551.md) `token()` function. ### Interface ```solidity // SPDX-License-Identifier: CC0-1.0 pragma solidity ^0.8.24; /// @title IERC6551Equipment — Slot-Based Equipment for Token Bound Accounts /// @notice A standard interface for equipping, unequipping, and permanently /// locking tokens within ERC-6551 Token Bound Accounts using named slots. /// @dev Slots are identified by bytes32 keys, allowing any application to /// define its own slot taxonomy. The recommended convention is /// keccak256("slot."). Applications sharing a TBA across contexts /// SHOULD namespace slots to avoid collisions, e.g. /// keccak256("myapp.slot.head") vs keccak256("otherapp.slot.head"). /// /// Slots may be permanently locked, making them immutable across /// ownership transfers. Locked means locked forever — there is no /// unlock mechanism by design. /// /// The ERC-165 identifier for this interface is 0xc1ef0b9e. interface IERC6551Equipment { /// @notice Metadata describing an occupied equipment slot. /// @dev `isERC721` is cached at equip time via ERC-165 probe and is /// immutable for the lifetime of the slot occupation. /// Implementations MUST NOT re-probe the token contract's /// interface during any subsequent operation — see the /// Type-shifting defense in Security Considerations. struct SlotEntry { bytes32 slotId; address tokenContract; uint256 tokenId; uint256 amount; bool locked; bool isERC721; } event Equipped( bytes32 indexed slotId, address indexed tokenContract, uint256 indexed tokenId, uint256 amount ); event Unequipped( bytes32 indexed slotId, address indexed tokenContract, uint256 indexed tokenId, uint256 amount ); event SlotLocked( bytes32 indexed slotId, address indexed tokenContract, uint256 tokenId ); function equip(bytes32 slotId, address tokenContract, uint256 tokenId, uint256 amount) external; function unequip(bytes32 slotId) external; function lockSlot(bytes32 slotId) external; function equipBatch( bytes32[] calldata slotIds, address[] calldata tokenContracts, uint256[] calldata tokenIds, uint256[] calldata amounts ) external; function lockSlots(bytes32[] calldata slotIds) external; function getEquipped(bytes32 slotId) external view returns (address tokenContract, uint256 tokenId, uint256 amount); function getLoadout() external view returns (SlotEntry[] memory entries); function isSlotOccupied(bytes32 slotId) external view returns (bool); function isSlotLocked(bytes32 slotId) external view returns (bool); /// @notice Register an item already held in the TBA balance into a slot. /// Performs no transfer. /// @dev Implementations MUST restrict access to the parent contract /// (the `tokenContract` returned by `token()`). The balance /// condition uses `>= amount` for ERC-1155 and /// `ownerOf == address(this)` for ERC-721, matching the /// postfix integrity check tolerance. function equipAtMint( bytes32 slotId, address tokenContract, uint256 tokenId, uint256 amount ) external; /// @notice Lock a slot occupied by `equipAtMint`. /// @dev Implementations MUST restrict access to the parent contract. function lockSlotAtMint(bytes32 slotId) external; /// @notice Equip an item already held in the TBA balance into a slot. /// Performs no transfer. /// @dev Implementations MUST restrict access to the NFT owner. Same /// balance condition as `equipAtMint`. function equipFromBalance( bytes32 slotId, address tokenContract, uint256 tokenId, uint256 amount ) external; /// @notice Atomically register an item already held in the TBA balance /// into a slot AND lock it. Performs no transfer. /// @dev Implementations MUST restrict access to the parent contract. /// Implementations MUST emit BOTH `Equipped` and `SlotLocked` /// events. The post-state MUST be identical to calling /// `equipAtMint` followed by `lockSlotAtMint` with the same /// arguments. function equipAndLockAtMint( bytes32 slotId, address tokenContract, uint256 tokenId, uint256 amount ) external; } ``` ### Reference Implementation ```solidity // SPDX-License-Identifier: CC0-1.0 pragma solidity ^0.8.24; import {IERC6551Account, IERC6551Executable} from "./interfaces/IERC6551.sol"; import {IERC6551Equipment} from "./interfaces/IERC6551Equipment.sol"; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {IERC1155} from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; /// @title EquippableAccount — ERC-6551 Token Bound Account with ERC-8216 Equipment Slots /// @notice Reference implementation of the IERC6551Equipment interface, providing /// slot-based equipment management with permanent locking, mint-time and /// balance-only equip operations, and tamper-resistant enforcement at the /// execute() layer. /// @dev Amendment v1.2 (2026-04-28) /// - Adds `equipAtMint`, `lockSlotAtMint`, `equipFromBalance`, and /// `equipAndLockAtMint` for parent-contract-driven mint-time slot /// registration and owner-driven balance-only equips. /// - Caches `isERC721` on `SlotEntry` at equip time, closing the /// type-shifting attack surface where a malicious token could /// change its ERC-165 response between equip and integrity check. /// - Modifies `unequip` to retain the asset in the TBA balance, /// making it symmetric with `equipFromBalance` and enabling /// character-binding implementations. /// - Adds the prefix execute-target restriction in `execute()`, /// closing the approval-based lock-bypass. /// /// Binding (chainId, tokenContract, tokenId) is read from the proxy's /// immutable bytecode suffix appended by the canonical ERC-6551 /// Registry. Implementations MAY use a stored-binding pattern /// (constructor or initialize-time) instead, provided that the value /// returned by `token()` is identical regardless of caller and that /// the binding is set exactly once. contract EquippableAccount is IERC6551Account, IERC6551Executable, IERC6551Equipment, ERC1155Holder, ERC721Holder { // ── Storage ── uint256 private _state; mapping(bytes32 => SlotEntry) private _slots; bytes32[] private _occupiedSlots; mapping(bytes32 => uint256) private _slotIndex; // ── Constants ── /// @notice RECOMMENDED upper bound on simultaneously occupied slots. The /// execute-layer prefix and postfix checks both iterate occupied /// slots — bounding the count bounds per-execute gas. See the /// Bounded integrity check cost note in Security Considerations. uint256 public constant MAX_OCCUPIED_SLOTS = 64; // ── Errors ── error NotAuthorized(); error OnlyParentContract(); error SlotAlreadyOccupied(bytes32 slotId); error SlotEmpty(bytes32 slotId); error SlotIsLocked(bytes32 slotId); error SlotAlreadyLocked(bytes32 slotId); error SlotIntegrityViolated(bytes32 slotId); error InsufficientTBABalance(bytes32 slotId); error ExecuteIntoEquippedContract(address to); error InvalidAmount(); error ArrayLengthMismatch(); error UnsupportedOperation(uint8 op); error ExecutionFailed(bytes result); error MaxSlotsExceeded(); // ── Modifiers ── modifier onlyOwner() { if (!_isValidSigner(msg.sender)) revert NotAuthorized(); _; } /// @dev Restrict to the parent contract address read from the canonical /// 6551 `token()` function. The value is mathematically immutable /// from deploy time when bytecode-suffix binding is used (the /// canonical 6551 Registry path). Implementations that store the /// parent contract in mutable storage MUST set the binding exactly /// once at construction or initialization — see Parent-contract /// gating in Security Considerations. modifier onlyParentContract() { (, address tokenContract, ) = token(); if (msg.sender != tokenContract) revert OnlyParentContract(); _; } // ── ERC-6551 Account ── receive() external payable override {} /// @notice Read (chainId, tokenContract, tokenId) from the proxy's /// immutable bytecode suffix appended by the canonical registry. function token() public view override returns (uint256 chainId, address tokenContract, uint256 tokenId) { bytes memory footer = new bytes(0x60); assembly { extcodecopy(address(), add(footer, 0x20), 0x4d, 0x60) } return abi.decode(footer, (uint256, address, uint256)); } function state() external view override returns (uint256) { return _state; } function isValidSigner(address signer, bytes calldata) external view override returns (bytes4) { if (_isValidSigner(signer)) { return IERC6551Account.isValidSigner.selector; } return bytes4(0); } // ── ERC-6551 Execution ── /// @notice Execute an arbitrary call on behalf of the TBA. /// @dev Two-stage equipment integrity: /// (1) PREFIX — refuse to dispatch any call into a contract that /// currently appears as `tokenContract` of an occupied slot. /// Closes the approval-based bypass where execute() could be /// used to grant standing rights against equipped tokens. /// (2) POSTFIX — after the external call returns successfully, /// verify that every equipped token is still physically held /// by this account. Closes the direct-transfer bypass. function execute( address to, uint256 value, bytes calldata data, uint8 operation ) external payable override onlyOwner returns (bytes memory result) { if (operation != 0) revert UnsupportedOperation(operation); // Prefix: reject calls into any currently-equipped contract. uint256 occ = _occupiedSlots.length; for (uint256 i; i < occ; ++i) { if (to == _slots[_occupiedSlots[i]].tokenContract) { revert ExecuteIntoEquippedContract(to); } } ++_state; bool success; (success, result) = to.call{value: value}(data); if (!success) revert ExecutionFailed(result); // Postfix: verify equipment custody. _verifyEquipmentInvariant(); } // ── IERC6551Equipment — Single Operations ── function equip( bytes32 slotId, address tokenContract, uint256 tokenId, uint256 amount ) external override onlyOwner { _equip(slotId, tokenContract, tokenId, amount); } function unequip(bytes32 slotId) external override onlyOwner { _unequip(slotId); } function lockSlot(bytes32 slotId) external override onlyOwner { _lockSlot(slotId); } // ── IERC6551Equipment — Batch Operations ── function equipBatch( bytes32[] calldata slotIds, address[] calldata tokenContracts, uint256[] calldata tokenIds, uint256[] calldata amounts ) external override onlyOwner { if ( slotIds.length != tokenContracts.length || slotIds.length != tokenIds.length || slotIds.length != amounts.length ) revert ArrayLengthMismatch(); for (uint256 i; i < slotIds.length; ++i) { _equip(slotIds[i], tokenContracts[i], tokenIds[i], amounts[i]); } } function lockSlots(bytes32[] calldata slotIds) external override onlyOwner { for (uint256 i; i < slotIds.length; ++i) { _lockSlot(slotIds[i]); } } // ── IERC6551Equipment — Mint-path + balance-only operations ── function equipAtMint( bytes32 slotId, address tokenContract, uint256 tokenId, uint256 amount ) external override onlyParentContract { _equipFromTBABalance(slotId, tokenContract, tokenId, amount); } function lockSlotAtMint(bytes32 slotId) external override onlyParentContract { _lockSlot(slotId); } function equipFromBalance( bytes32 slotId, address tokenContract, uint256 tokenId, uint256 amount ) external override onlyOwner { _equipFromTBABalance(slotId, tokenContract, tokenId, amount); } function equipAndLockAtMint( bytes32 slotId, address tokenContract, uint256 tokenId, uint256 amount ) external override onlyParentContract { _equipFromTBABalance(slotId, tokenContract, tokenId, amount); _slots[slotId].locked = true; ++_state; emit SlotLocked(slotId, tokenContract, tokenId); } // ── IERC6551Equipment — Views ── function getEquipped(bytes32 slotId) external view override returns (address tokenContract, uint256 tokenId, uint256 amount) { SlotEntry memory entry = _slots[slotId]; return (entry.tokenContract, entry.tokenId, entry.amount); } function getLoadout() external view override returns (SlotEntry[] memory entries) { uint256 len = _occupiedSlots.length; entries = new SlotEntry[](len); for (uint256 i; i < len; ++i) { entries[i] = _slots[_occupiedSlots[i]]; } } function isSlotOccupied(bytes32 slotId) external view override returns (bool) { return _slotIndex[slotId] != 0; } function isSlotLocked(bytes32 slotId) external view override returns (bool) { return _slots[slotId].locked; } // ── ERC-165 ── function supportsInterface(bytes4 interfaceId) public view override(ERC1155Holder) returns (bool) { return interfaceId == type(IERC6551Account).interfaceId || interfaceId == type(IERC6551Executable).interfaceId || interfaceId == type(IERC6551Equipment).interfaceId || interfaceId == type(IERC165).interfaceId || super.supportsInterface(interfaceId); } // ── Internal: Equipment Logic ── /// @dev Standard equip with wallet-to-TBA transfer. Token type is /// detected ONCE at equip time via ERC-165 probe and cached on the /// slot entry. All later operations use the cached value — never /// re-probe. function _equip( bytes32 slotId, address tokenContract, uint256 tokenId, uint256 amount ) internal { if (_slots[slotId].locked) revert SlotIsLocked(slotId); if (_slotIndex[slotId] != 0) revert SlotAlreadyOccupied(slotId); if (amount == 0) revert InvalidAmount(); if (_occupiedSlots.length >= MAX_OCCUPIED_SLOTS) revert MaxSlotsExceeded(); bool tokenIs721 = _isERC721(tokenContract); if (tokenIs721 && amount != 1) revert InvalidAmount(); _slots[slotId] = SlotEntry({ slotId: slotId, tokenContract: tokenContract, tokenId: tokenId, amount: amount, locked: false, isERC721: tokenIs721 }); _occupiedSlots.push(slotId); _slotIndex[slotId] = _occupiedSlots.length; ++_state; if (tokenIs721) { IERC721(tokenContract).safeTransferFrom(msg.sender, address(this), tokenId); } else { IERC1155(tokenContract).safeTransferFrom(msg.sender, address(this), tokenId, amount, ""); } emit Equipped(slotId, tokenContract, tokenId, amount); } /// @dev Account-bound unequip disposition — the asset remains in the TBA /// after the slot is cleared. Symmetric with `equipFromBalance`. /// Owner-bound implementations would instead transfer the asset to /// `msg.sender` here. See Rationale for the disposition model. function _unequip(bytes32 slotId) internal { if (_slots[slotId].locked) revert SlotIsLocked(slotId); uint256 idx = _slotIndex[slotId]; if (idx == 0) revert SlotEmpty(slotId); SlotEntry memory entry = _slots[slotId]; uint256 lastIdx = _occupiedSlots.length - 1; if (idx - 1 != lastIdx) { bytes32 lastSlot = _occupiedSlots[lastIdx]; _occupiedSlots[idx - 1] = lastSlot; _slotIndex[lastSlot] = idx; } _occupiedSlots.pop(); delete _slotIndex[slotId]; delete _slots[slotId]; ++_state; // Account-bound disposition: asset stays in the TBA balance. // Owner-bound implementations would transfer here instead: // if (entry.isERC721) IERC721(...).safeTransferFrom(this, msg.sender, ...); // else IERC1155(...).safeTransferFrom(this, msg.sender, ..., ...); emit Unequipped(slotId, entry.tokenContract, entry.tokenId, entry.amount); } /// @dev Shared body of `equipAtMint`, `equipFromBalance`, and /// `equipAndLockAtMint`. Registers a slot pointing at an asset the /// TBA already holds, with no transfer. Access control is the /// responsibility of the external entry point. function _equipFromTBABalance( bytes32 slotId, address tokenContract, uint256 tokenId, uint256 amount ) internal { if (_slots[slotId].locked) revert SlotIsLocked(slotId); if (_slotIndex[slotId] != 0) revert SlotAlreadyOccupied(slotId); if (amount == 0) revert InvalidAmount(); if (_occupiedSlots.length >= MAX_OCCUPIED_SLOTS) revert MaxSlotsExceeded(); bool tokenIs721 = _isERC721(tokenContract); if (tokenIs721 && amount != 1) revert InvalidAmount(); if (tokenIs721) { if (IERC721(tokenContract).ownerOf(tokenId) != address(this)) { revert InsufficientTBABalance(slotId); } } else { if (IERC1155(tokenContract).balanceOf(address(this), tokenId) < amount) { revert InsufficientTBABalance(slotId); } } _slots[slotId] = SlotEntry({ slotId: slotId, tokenContract: tokenContract, tokenId: tokenId, amount: amount, locked: false, isERC721: tokenIs721 }); _occupiedSlots.push(slotId); _slotIndex[slotId] = _occupiedSlots.length; ++_state; emit Equipped(slotId, tokenContract, tokenId, amount); } function _lockSlot(bytes32 slotId) internal { if (_slotIndex[slotId] == 0) revert SlotEmpty(slotId); if (_slots[slotId].locked) revert SlotAlreadyLocked(slotId); _slots[slotId].locked = true; ++_state; SlotEntry memory entry = _slots[slotId]; emit SlotLocked(slotId, entry.tokenContract, entry.tokenId); } // ── Internal: Validation ── function _isValidSigner(address signer) internal view returns (bool) { (uint256 chainId, address tokenContract, uint256 tokenId) = token(); if (chainId != block.chainid) return false; return IERC721(tokenContract).ownerOf(tokenId) == signer; } function _isERC721(address tokenContract) internal view returns (bool) { try IERC165(tokenContract).supportsInterface(type(IERC721).interfaceId) returns (bool result) { return result; } catch { return false; } } /// @dev Postfix integrity check. Reads the cached `isERC721` field /// rather than re-probing the token contract — re-probing would /// open the type-shifting attack documented in Security /// Considerations. function _verifyEquipmentInvariant() internal view { uint256 len = _occupiedSlots.length; for (uint256 i; i < len; ++i) { bytes32 slotId = _occupiedSlots[i]; SlotEntry memory entry = _slots[slotId]; if (entry.isERC721) { if (IERC721(entry.tokenContract).ownerOf(entry.tokenId) != address(this)) { revert SlotIntegrityViolated(slotId); } } else { if ( IERC1155(entry.tokenContract).balanceOf(address(this), entry.tokenId) < entry.amount ) { revert SlotIntegrityViolated(slotId); } } } } } ``` ## Security Considerations Implementations MUST follow checks-effects-interactions pattern. lockSlot is permanent and irreversible. Batch operations use all-or-nothing semantics. Buyers SHOULD inspect loadout before purchasing character NFTs. Marketplaces SHOULD call supportsInterface before equipment-related calls. **Re-entrancy through the integrity check.** The execute-layer integrity check makes external calls to each equipped token's contract. A malicious token contract could attempt to re-enter the equippable account during one of these calls. Implementations MUST ensure that all state-mutating functions on the account (`equip`, `unequip`, `lockSlot`, `equipBatch`, `lockSlots`, `equipAtMint`, `lockSlotAtMint`, `equipFromBalance`, `equipAndLockAtMint`, `execute`) are gated by access control based on the parent NFT owner or parent contract identity, not on the immediate `msg.sender` of the call. This pattern ensures that re-entrant calls from a malicious token contract fail the access-control check, because the `msg.sender` of a re-entrant call is the token contract address, not the parent NFT owner or parent contract. **Type-shifting defense via cached `isERC721`.** A malicious token contract could implement [ERC-165](./eip-165.md) responses that change between calls — returning `true` for `IERC721` at equip time and `false` at integrity-check time, or vice versa. If the equippable account re-probed the token contract on every operation, the integrity check could be routed into the wrong code branch (calling `ownerOf` on a contract that the second probe identifies as ERC-1155, or vice versa), letting an asset slip past custody verification or permanently bricking the account by routing legitimate calls into reverting branches. Implementations MUST cache the token type at equip time on the `SlotEntry.isERC721` field and MUST consult only that cached value in the postfix integrity check and in any later operation that needs to know the token type. Re-probing during a state-mutating operation other than equip is forbidden by this requirement. **Approval-based bypass.** Without the prefix execute-target restriction, an account owner could `execute(equippedToken, setApprovalForAll(attacker, true))` to grant a third party standing rights to drain the equipped tokens through the equipped contract's own transfer paths. The postfix integrity check verifies custody at the conclusion of the `execute()` call, but the call that grants approval does not change custody — it changes capability. The approval persists across the `execute()` boundary; the attacker can drain at any later time, after a sale of the parent NFT, and after the original owner has lost any practical recourse. The prefix execute-target restriction closes this path by refusing to dispatch any `execute()` call into a contract that currently appears as the `tokenContract` of an occupied slot. Implementations MUST include this prefix check in any `execute()` function exposed alongside the equipment interface. The check applies uniformly to locked and unlocked slots — any occupied slot anchors its `tokenContract` against the entire surface of `execute()`-mediated mutation. **Parent-contract gating is bytecode-derived.** The `onlyParentContract` gate on `equipAtMint`, `lockSlotAtMint`, and `equipAndLockAtMint` MUST resolve the authorized address through the canonical [ERC-6551](./eip-6551.md) `token()` function — that is, from the value of `tokenContract` returned by `token()`. The canonical 6551 deployment writes this value into the proxy's immutable bytecode suffix at deploy time, so it cannot be modified after deployment. Implementations that store the parent contract address in mutable storage open a separate attack surface: any vector that lets the storage be re-written (uninitialized initializer, governance compromise, upgrade, delegatecall) would let an attacker reassign the parent and write arbitrary slot state. The bytecode-suffix path inherits its immutability from the canonical 6551 binding and requires no additional defense. Implementations MAY use a stored-binding pattern provided that `token()` returns identical values regardless of caller and provided that the binding is set exactly once at construction or initialization. **Quantity vs. provenance for ERC-1155.** The integrity check for ERC-1155 equipped tokens verifies that the account holds at least `amount` of the recorded `tokenId`. By the semantics of ERC-1155, all tokens with the same `tokenId` are fungible and indistinguishable. The check therefore provides a quantity guarantee but not a provenance guarantee — an attacker who controls both the equipped token contract and a separate exploiter contract could drain the originally-equipped tokens and replace them with newly-minted tokens of the same `tokenId` in a single transaction. Implementations and consumers SHOULD treat ERC-1155 equipment slots as providing quantity guarantees only. For provenance guarantees on individual items, use ERC-721 tokens, where each `tokenId` is unique. **Double-reservation across slots is documented behavior.** The balance check on `equipAtMint`, `equipFromBalance`, and `equipAndLockAtMint` uses `>=` per slot rather than tracking a per-asset reserved sum across slots. Two non-locked slots can therefore each claim the same `(tokenContract, tokenId)` pair for `amount = 1` when the TBA holds `1` — each slot's local check passes, and the postfix integrity check iterates per-slot and re-validates each occupied slot's recorded amount independently. This does not weaken any security guarantee: locked slots still cannot be unequipped, the prefix execute-target restriction still anchors the `tokenContract` regardless of how many slots reference it, and the postfix integrity check still detects custody loss for every slot independently. Indexers, marketplaces, and frontends rendering "free balance" on a TBA MUST aggregate amounts across all occupied slots that point at the same `(tokenContract, tokenId)` and subtract that sum from the on-chain balance. Implementations MAY add a per-asset reservation tracker if they wish to forbid double-reservation, but the spec does not require it. **Pre-equip during the parent-NFT mint callback.** The parent NFT contract may use `_safeMint` (or an analogous mint helper that fires `IERC721Receiver.onERC721Received`) to deliver the parent NFT to the recipient before the parent contract calls `equipAtMint` to seat character-binding equipment. During the receiver callback, the recipient already passes `IERC721.ownerOf` checks against the parent — they are the formal owner of the freshly-minted NFT for the duration of the callback. A contract recipient with code can use this window to pre-deploy the deterministic-address TBA, fund it from the recipient's own assets (the TBA address is computable in advance), and call `equipFromBalance` against the TBA on slots of their choosing. Slots written this way are NOT the protocol-defined character-binding slots — those are written by the parent contract's later `equipAtMint` calls, and a recipient who pre-occupies a protocol-binding slot causes the parent's outer `equipAtMint` to revert `SlotAlreadyOccupied`, atomically reverting the entire mint (a self-DoS, not an exploit). Buyers on secondary markets SHOULD call `getLoadout()` and inspect every locked slot — the protocol guarantees the protocol-binding slots are present, but does not prevent a contract minter from pre-locking arbitrary other slots with assets of their choosing. Frontends and marketplaces MAY surface a "trustworthy mint" indicator for parent NFTs whose locked slots at mint time match the protocol's expected binding-slot set. **Bounded integrity check cost.** The execute-layer prefix and postfix checks both have a gas cost that scales linearly with the number of currently occupied slots (approximately 5-10k gas per slot in each direction). For typical loadouts of 4-10 slots this overhead is negligible. For very large loadouts the overhead can become significant. Implementations SHOULD enforce a maximum slot count via a `MAX_OCCUPIED_SLOTS` constant to bound this cost; the reference implementation uses 64 as a generous upper bound that still keeps total per-execute overhead under approximately 1M gas in the worst case. Because all equip operations are gated by owner or parent-contract access control, this cost can only be incurred by parties already authorized to mutate the account — there is no third-party griefing vector. ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md).