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

ERC: Slot-Based Equipment for ERC-6551 Token Bound Accounts

been working on an equipment interface for 6551 accounts, not here to put labels on what it should or shouldn’t be used for but the core problem felt obvious enough to write a standard around it. nine functions, three events, one struct. no catalog infrastructure, no framework dependency, just the primitive.

PR | Reference Implementation | Live Frontend


Abstract

a standard interface (IERC6551Equipment) for managing slot-based equipment within ERC-6551 Token Bound Accounts. tokens (ERC-721 or ERC-1155) can be equipped into application-defined slots, queried as a structured loadout, and optionally permanently locked, i.e. enabling on-chain identity, provenance, and composable character systems.

ERC-165 interface ID: 0xd38f0891

Motivation

6551 gives NFTs wallets but says nothing about what goes inside them, and every project that wants characters with loadouts ends up writing their own slot logic from scratch, none of it compatible with anything else. been stress testing this framework for a while now through what felt like an infinite iteration process and it felt right to formalize a standard around it; the interoperability part only works if everyone’s equipment interface is compatible, that’s what this solves.

use cases this enables:

  • gaming: characters with equippable gear slots where identity traits are permanently locked at mint and fashion items are freely swappable

  • social/identity: profiles with locked verification credentials and swappable display badges

  • art: composable NFTs with locked base layers and swappable frames or accessories

  • marketplaces: universal loadout display via a single getLoadout() call, distinguishing permanent traits from tradeable gear

Specification

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;
    }

    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);
}

Slot Identifiers

slots are bytes32: applications define their own taxonomy via keccak256("slot.head"), keccak256("slot.weapon"), whatever makes sense for the context. no central registry, no opinion on what slots should exist; the standard defines the mechanism not the taxonomy.

applications sharing a TBA across multiple contexts SHOULD namespace slots to avoid collisions, e.g. keccak256("myapp.slot.head") vs keccak256("otherapp.slot.head").

Behavior

equip: MUST revert if caller is not a valid signer, slot is occupied, slot is locked, or amount is zero. MUST update state before external transfers (CEI). MUST transfer the token into the TBA via safeTransferFrom. MUST emit Equipped event.

unequip: MUST revert if caller is not a valid signer, slot is empty, or slot is locked. MUST delete state before external transfers (CEI). MUST transfer the token back to the caller. MUST emit Unequipped event.

lockSlot: MUST revert if caller is not a valid signer, slot is empty, or already locked. MUST set slot as permanently locked. this is irreversible, there is no unlockSlot. MUST emit SlotLocked event.

equipBatch: MUST revert if array lengths do not match or any individual equip would revert. all-or-nothing semantics. MUST emit Equipped for each slot.

lockSlots: MUST revert if any individual lock would revert. all-or-nothing semantics. MUST emit SlotLocked for each slot.

getEquipped: returns token contract, token ID, and amount for a given slot. returns (address(0), 0, 0) if empty.

getLoadout: returns an array of SlotEntry structs for every occupied slot. lock status included via bool locked. returns empty array if nothing equipped.

isSlotOccupied / isSlotLocked: boolean views. a locked slot is always occupied, i.e. you cannot lock an empty slot.

Ownership Transfer Semantics

when the parent NFT is transferred to a new owner:

  • all equipped items remain in the TBA

  • unlocked slots can be modified by the new owner (equip/unequip)

  • locked slots remain locked forever: the new owner inherits the locks

  • the new owner CAN equip new items to empty slots and lock additional unlocked slots

i.e. a character’s identity (locked traits) travels with it across sales, while the new owner can customize the character’s gear (unlocked slots).

Rationale

the design decisions that survived the iteration process and why they matter:

locking is permanent: this one i care about a lot. lockSlot() is irreversible, no unlock, no timelock, no admin override. if isSlotLocked() returns true it returns true forever, across ownership transfers, across time. the new owner inherits the lock. this is the whole point: provable on-chain immutable identity. you lock your character’s core traits at mint and they become the character forever. if you want something swappable, don’t lock it.

CEI is mandatory not recommended: equip() and unequip() trigger safeTransferFrom which invokes receiver callbacks; the spec requires state updates before external calls. went through the process of catching this in our own implementation and fixing it, felt important enough to make it a MUST in the spec rather than leaving it as a suggestion.

getLoadout() returns everything in one call: all occupied slots, lock status included in the struct via bool locked. frontends and marketplaces get the complete picture without N separate view calls. this was a deliberate struct change, adding locked to SlotEntry so one call gives you the full character.

batch operations exist because mint flows demand it: equipping 12 traits and locking 5 identity slots shouldn’t cost 17 transactions. equipBatch() + lockSlots() brings it down to 2. all-or-nothing semantics matching the pattern from 1155.

what intentionally doesn’t exist and why:

  • no unlockSlot(): permanence is the feature not a limitation

  • no swap(): unequip then equip, multicall via execute() if you need atomicity

  • no getLockedSlots(): getLoadout() covers it, events handle indexing

  • no opinion on which slots should exist or what they mean

Prior Art

aware of ERC-6220 (composable NFTs utilizing equippable parts) from RMRK. different approach, different scope. 6220 extends ERC-721 directly, requires a Catalog system with pre-defined parts, equippable address whitelists, and 5+ interfaces across the RMRK suite; designed for visual composability, i.e. assembling rendered images from component parts. this ERC extends ERC-6551 specifically, requires zero extra infrastructure beyond the TBA itself, and focuses on ownership composability: tokens physically transfer into the character’s wallet. the key primitive that doesn’t exist in 6220 or anywhere else i’ve found is permanent slot locking.

the question of equipping on 6551 has come up before in the original ERC-6551 discussion thread (post #128) where 6220 was suggested as a solution. 6220 doesn’t extend 6551: it extends 721 directly and requires the RMRK catalog infrastructure. this ERC is the native answer to that question.

EIP-4973 (account-bound tokens) uses “equip/unequip” terminology but in a different context: showing or hiding a soulbound credential, not placing transferable tokens into named slots. 4973 makes the token immovable. this ERC makes the slot immovable: different primitive, different use case.

ERC-7635 (multi-fungible token) proposes a new token standard with built-in slots but replaces the existing token model entirely. this ERC works within the existing 721 + 1155 + 6551 ecosystem: no new token type, just an interface for what’s already deployed.

ERC-998 (composable top-down NFTs) predates 6551, handles parent-child nesting but doesn’t define named slots, lock semantics, or batch operations. ERC-4883 (composable SVG NFTs) is visual-only with no slot logic.

if i’m missing prior art that covers this exact surface, i.e. slot-based equipment with permanent locking for TBAs, genuinely want to know.

Backwards Compatibility

no backwards compatibility issues. extends ERC-6551 Token Bound Accounts with new functionality. existing TBAs are unaffected. accounts implementing this interface are fully compatible with ERC-721 and ERC-1155 token standards.

Reference Implementation

27 foundry tests covering core flow, locking, batch ops, ownership transfer, CEI verification, and ERC-165. deployed on Base Sepolia with a live frontend consuming the interface at phantoma.io: demonstrates getLoadout() rendering with lock status display, equipment management across both injected and embedded wallet providers, and supportsInterface detection.

repo: GitHub - LordThisDrip/erc-equipment: Slot-based equipment interface for ERC-6551 Token Bound Accounts · GitHub

Security Considerations

checks-effects-interactions: equip(), unequip(), and equipBatch() involve external calls via safeTransferFrom which invoke receiver callbacks. implementations MUST update slot state before performing any external token transfers.

irreversible locking: lockSlot() is permanent and cannot be undone. frontends SHOULD present a clear confirmation dialog. tokens locked in slots are permanently committed to the TBA.

batch atomicity: equipBatch() and lockSlots() use all-or-nothing semantics. implementations MUST validate all array lengths match before processing.

ownership transfer: when a parent NFT is transferred, the new owner gains control of all unlocked slots but inherits all locks. buyers SHOULD inspect the loadout before purchasing.

ERC-165 detection: marketplaces and contracts SHOULD call supportsInterface(0xd38f0891) to verify equipment support before making equipment-related calls.

Copyright

Copyright and related rights waived via CC0.

Spec update + critical bug fix in reference implementation

A foundation review of the ERC-8216 reference implementation surfaced a critical lock-bypass vulnerability that the spec did not normatively prevent. Both the reference implementation and the spec text have been patched. PR #1645 has been updated.

The bug

The lock guarantee in ERC-8216 that a slot marked as locked cannot have its underlying token removed; was bypassable via the inherited execute() function from ERC-6551. An owner could construct an execute() call invoking safeTransferFrom directly on an equipped token’s contract, draining the underlying token from the account while leaving the on-chain equipment record intact and the slot still marked as locked.

The attack scenario that motivated the fix: an owner equips and locks a “founder pack” item to advertise it as permanently bonded, sells the parent NFT at a premium based on that guarantee, then drains the item via execute() before delivery. The buyer pays for a locked loadout that no longer exists. Marketplaces, indexers, and any consumer reading getLoadout() or isSlotLocked() would receive accurate-looking but materially false information.

The original 27-test suite had no test for this attack vector. The closest test (test_TransferPreservesLock) verified that a new owner could not call unequip() on a locked slot post-transfer, but said nothing about execute().

The fix

Reference implementation (LordThisDrip/erc-equipment commit bf9728e):

A new internal function _verifyEquipmentInvariant() is called at the end of every execute() call. It iterates _occupiedSlots and verifies, for each one, that the recorded token is still held by the account; ownerOf for ERC-721, balanceOf for ERC-1155. If any equipped token is missing, the entire execution reverts with SlotIntegrityViolated(slotId). Gas cost is approximately 5–10k per occupied slot.

Two additional fixes ship in the same commit:

  • initialize() access control (defense-in-depth): added an immutable REGISTRY set in the constructor and a msg.sender == REGISTRY check at the top of initialize(). Not exploitable in the current atomic-clone deploy flow, but defends against any future deployment pattern that separates clone creation from initialization.

  • Cleaner errors for ERC-721 with amount > 1: added a symmetric _isERC1155() detector and explicit type detection in _equip(), replacing the previous opaque revert from the ERC-1155 fallthrough path.

Test suite: 27 → 37 passing tests. The new tests include test_RevertExecuteBypassesLockedSlot as the primary regression for Bug #1, plus four supporting tests covering unlocked-slot integrity, multi-slot loadouts, the no-false-positive case for transferring genuinely-unequipped tokens, and three tests for the type-detection fixes. All 37 pass on the patched contract; the Bug #1 test fails on the unpatched contract (verifiable by commenting out the call to _verifyEquipmentInvariant() and re-running).

Spec text update

PR #1645 has been updated with commit 459dc6e4 containing:

New ### Execute Layer Integrity subsection under Specification → Behavior, mandating the post-execute verification with normative MUST language for both ERC-721 and ERC-1155 cases.

Rationale paragraph explaining the lock-bypass attack and why making the integrity check normative (rather than leaving it as an implementation detail) is necessary for the standard’s lock guarantees to be meaningful.

Updated Reference Implementation section with the test count, the canonical repo link, and the commit hash.

Three new Security Considerations paragraphs from a Pass 2 adversarial stress test (see below):

  1. Re-entrancy through the integrity check: implementations MUST gate state-mutating functions on the parent NFT owner, not on the immediate msg.sender, so that re-entrant calls from a malicious token contract during ownerOf/balanceOf cannot escalate. The reference implementation achieves this via _isValidSigner checking IERC721(tokenContract).ownerOf(tokenId) == signer. Implementations relying on tx.origin or constructor-set owners would be vulnerable and are non-conforming.

  2. Quantity vs. provenance for ERC-1155: balanceOf provides a quantity guarantee but not a provenance guarantee. An attacker controlling both the equipped token contract and a separate exploiter contract could drain originally-equipped tokens and replace them with newly-minted tokens of the same tokenId in a single transaction; the integrity check would observe the correct balance and pass. Whether this is meaningful depends on whether the ERC-1155 token has off-chain semantics distinguishing individual units. Implementations and consumers SHOULD treat ERC-1155 slots as quantity-only. For provenance guarantees, use ERC-721.

  3. Bounded integrity check cost: gas scales linearly with occupied slot count (~5–10k per slot). Negligible for typical loadouts (4–10 slots), significant for very large loadouts (hundreds). Implementations SHOULD consider whether to enforce a maximum slot count. Because all equip operations are owner-gated, this is not a third-party griefing vector — only self-inflicted.

Pass 2 adversarial stress test summary

After the Pass 1 patch was verified clean with 37/37 tests passing, the patched implementation was reviewed across six adversarial attack angles:

# Angle Result
1 Re-entrancy through _verifyEquipmentInvariant() Safe via onlyOwner access control based on parent NFT owner. Spec note added.
2 ERC-1155 partial-balance edge (drain + replace mid-tx) Inherent ERC-1155 semantic limitation, not a code bug. Spec note added.
3 _state increment ordering and revert atomicity Clean. EVM tx atomicity guarantees rollback on revert.
4 Gas griefing via 50+ slot loadout self-DOS Self-affecting only, not a vulnerability. Spec note added.
5 isValidSigner chain-id during integrity check Safe. Cross-chain protection happens upstream at execute() entry.
6 address(this) ERC-165 fall-through for non-token contracts Test passes for the right reason (try/catch on missing function).

No new exploitable vulnerabilities were found. Three of the six angles produced spec-level documentation that has been incorporated.

Backwards compatibility

The interface ID remains 0xd38f0891. No functions, events, or struct fields are added or removed. The only normative changes are the new MUST requirement on execute() integrity for implementations that combine IERC6551Equipment with IERC6551Executable, and the security considerations. Existing implementations that omit the integrity check are non-conforming under the updated spec and should be updated.