EIP.tools

EIP.tools

⚠️ DraftStandards Track: ERC

ERC-8216: Slot-Based Equipment for ERC-6551 Accounts

An interface for equipping, unequipping, and permanently locking tokens within ERC-6551 Token Bound Accounts using named slots.

Authors
Created2026-03-28
Discussion Linkhttps://ethereum-magicians.org/t/erc-8216-slot-based-equipment-for-erc-6551-token-bound-accounts/28139
Requires

Markdown

https://raw.githubusercontent.com/LordThisDrip/ERC...

EIP-GPT summary

Contents
AbstractMotivationSpecificationInterfaceSlot IdentifiersSlotEntry FieldsBehaviorExecute Layer IntegrityOwnership Transfer SemanticsRationaleOptional amount Packing (Discussion)Backwards CompatibilityReference ImplementationInterfaceReference ImplementationSecurity ConsiderationsCopyright

Abstract

This EIP defines an interface for managing slot-based equipment within ERC-6551 Token Bound Accounts (TBAs). It allows tokens (ERC-721 or ERC-1155) 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 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.

The ERC-165 identifier for this interface is 0xc1ef0b9e.

Interface

// 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 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 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 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 tokens via ownerOf(tokenId) == address(this), and for ERC-1155 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 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 token() function.

Interface

// 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.<name>"). 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

// 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 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 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 and related rights waived via CC0.

EIP.tools

EIP.tools

Search, read, and map Ethereum improvement proposals, ERCs, RIPs, and CAIPs from one focused interface.

Farcaster
by @apoorveth