EIP-5633: Composable Soulbound NFT, EIP-1155 Extension


eip: 5633
title: Composable Soulbound NFT, EIP-1155 Extension
description: Add composable soulbound property to EIP-1155 tokens
author: HonorLabs (@honorworldio)
discussions-to: EIP-5633: Composable Soulbound NFT, EIP-1155 Extension
status: Draft
type: Standards Track
category: ERC
created: 2022-09-09
requires: 165, 1155

Abstract

This standard is an extension of EIP-1155. It proposes a smart contract interface that can represent any number of soulbound and non-soulbound NFT types. Soulbound is the property of a token that prevents it from being transferred between accounts. This standard allows for each token ID to have its own soulbound property.

Motivation

The soulbound NFTs similar to World of Warcraft’s soulbound items are attracting more and more attention in the Ethereum community. In a real world game like World of Warcraft, there are thousands of items, and each item has its own soulbound property. For example, the amulate Necklace of Calisea is of soulbound property, but another low level amulate is not. This proposal provides a standard way to represent soulbound NFTs that can coexist with non-soulbound ones. It is easy to design a composable NFTs for an entire collection in a single contract.

This standard outline a interface to EIP-1155 that allows wallet implementers and developers to check for soulbound property of token ID using EIP-165. the soulbound property can be checked in advance, and the transfer function can be called only when the token is not soulbound.

Specification

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

A token type with a uint256 id is soulbound if function isSoulbound(uint256 id) returning true. In this case, all EIP-1155 functions of the contract that transfer the token from one account to another MUST throw, except for mint and burn.

// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.0;

interface IERC5633 {
  /**
   * @dev Emitted when a token type `id` is set or cancel to soulbound, according to `bounded`.
   */
  event Soulbound(uint256 indexed id, bool bounded);

  /**
   * @dev Returns true if a token type `id` is soulbound.
   */
  function isSoulbound(uint256 id) external view returns (bool);
}

Smart contracts implementing this standard MUST implement the ERC-165 supportsInterface function and MUST return the constant value true if 0x911ec470 is passed through the interfaceID argument.

Rationale

If all tokens in a contract are soulbound by default, isSoulbound(uint256 id) should return true by default during implementation.

Backwards Compatibility

This standard is fully EIP-1155 compatible.

Test Cases

Run in terminal:

npx hardhat test

Test code

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("ERC5633Demo contract", function () {

  it("InterfaceId should equals 0x911ec470", async function () {
    const [owner, addr1, addr2] = await ethers.getSigners();

    const ERC5633Demo = await ethers.getContractFactory("ERC5633Demo");

    const demo = await ERC5633Demo.deploy();
    await demo.deployed();

    expect(await demo.getInterfaceId()).equals("0x911ec470");
  });

  it("Test soulbound", async function () {
    const [owner, addr1, addr2] = await ethers.getSigners();

    const ERC5633Demo = await ethers.getContractFactory("ERC5633Demo");

    const demo = await ERC5633Demo.deploy();
    await demo.deployed();

    await demo.setSoulbound(1, true);
    expect(await demo.isSoulbound(1)).to.equal(true);
    expect(await demo.isSoulbound(2)).to.equal(false);

    await demo.mint(addr1.address, 1, 2, "0x");
    await demo.mint(addr1.address, 2, 2, "0x");

    await expect(demo.connect(addr1).safeTransferFrom(addr1.address, addr2.address, 1, 1, "0x")).to.be.revertedWith(
        "ERC5633: Soulbound, Non-Transferable"
    );
    await expect(demo.connect(addr1).safeBatchTransferFrom(addr1.address, addr2.address, [1], [1], "0x")).to.be.revertedWith(
        "ERC5633: Soulbound, Non-Transferable"
    );
    await expect(demo.connect(addr1).safeBatchTransferFrom(addr1.address, addr2.address, [1,2], [1,1], "0x")).to.be.revertedWith(
        "ERC5633: Soulbound, Non-Transferable"
    );

    await demo.mint(addr1.address, 2, 1, "0x");
    demo.connect(addr1).safeTransferFrom(addr1.address, addr2.address, 2, 1, "0x");
    demo.connect(addr1).safeBatchTransferFrom(addr1.address, addr2.address, [2], [1], "0x");

    await demo.connect(addr1).burn(addr1.address, 1, 1);
    await demo.connect(addr1).burnBatch(addr1.address, [1], [1]);
    await demo.connect(addr2).burn(addr2.address, 2, 1);
    await demo.connect(addr2).burnBatch(addr2.address, [2], [1]);
  });
});

test contract:

// SPDX-License-Identifier: CC0-1.0

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Burnable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

import "./ERC5633.sol";

contract ERC5633Demo is ERC1155, ERC1155Burnable, Ownable, ERC5633 {
    constructor() ERC1155("") ERC5633() {}

    function mint(address account, uint256 id, uint256 amount, bytes memory data)
        public
        onlyOwner
    {
        _mint(account, id, amount, data);
    }

    function mintBatch(address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data)
        public
        onlyOwner
    {
        _mintBatch(to, ids, amounts, data);
    }

    function setSoulbound(uint256 id, bool soulbound) 
        public
        onlyOwner 
    {
        _setSoulbound(id, soulbound);
    }

    // The following functions are overrides required by Solidity.
    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(ERC1155, ERC5633)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
    
    function _beforeTokenTransfer(address operator, address from, address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data)
        internal
        override(ERC1155, ERC5633)
    {
        super._beforeTokenTransfer(operator, from, to, ids, amounts, data);
    }

    function getInterfaceId() public view returns (bytes4) {
        return type(IERC5633).interfaceId;
    }
}

Reference Implementation

// SPDX-License-Identifier: CC0-1.0

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "./IERC5633.sol";

/**
 * @dev Extension of ERC1155 that adds soulbound property per token id.
 *
 */
abstract contract ERC5633 is ERC1155, IERC5633 {
    mapping(uint256 => bool) private _soulbounds;
    
    /// @dev See {IERC165-supportsInterface}.
    function supportsInterface(bytes4 interfaceId) public view virtual override(ERC1155) returns (bool) {
        return interfaceId == type(IERC5633).interfaceId || super.supportsInterface(interfaceId);
    }

    /**
     * @dev Returns true if a token type `id` is soulbound.
     */
    function isSoulbound(uint256 id) public view virtual returns (bool) {
        return _soulbounds[id];
    }

    function _setSoulbound(uint256 id, bool soulbound) internal {
        _soulbounds[id] = soulbound;
        emit Soulbound(id, soulbound);
    }

    /**
     * @dev See {ERC1155-_beforeTokenTransfer}.
     */
    function _beforeTokenTransfer(
        address operator,
        address from,
        address to,
        uint256[] memory ids,
        uint256[] memory amounts,
        bytes memory data
    ) internal virtual override {
        super._beforeTokenTransfer(operator, from, to, ids, amounts, data);

        for (uint256 i = 0; i < ids.length; ++i) {
            if (isSoulbound(ids[i])) {
                require(
                    from == address(0) || to == address(0),
                    "ERC5633: Soulbound, Non-Transferable"
                );
            }
        }
    }
}

Security Considerations

There are no security considerations related directly to the implementation of this standard.

Copyright

Copyright and related rights waived via CC0.

I’ve thought of a similar protocol before. In addition to games, there are many other application scenarios. For example, in crypto city, the property rights of citizens’ houses and vehicles are applicable to non-soulbound NFTs, while educational experience and citizen points can be used as soulbound NFTs.

EIP-5192: Minimal Soulbound NFTs is now final. Maybe this interface can be adopted for EIP-1155 to solve your use case?

1 Like

Thanks! I think it’s a good idea, I’ll check out EIP-5192 and see if it’s possible.

2 Likes

Thanks for reply. It’s a pretty good application scenario.

Contrary to EIP-5192, 5633 solves the problem with well-written and thought-out code. The solution proposed here in 5633 is much better and should and could stand alone.

I would prefer if every soul-bound interface is not itself soul-bound to an EIP that is a less-than-ideal developer, user, aggregate, and analyst experience.

For all soul-bound proposals that address the existing token specification of ERC1155s, 5633 is the best thought-out and most realistic.

Very big supporter of this implementation, thank you, @HonorLabs and I look forward to seeing this EIP progress!

The act of this coming in an extension is a great choice. Yet, with 1155s, they carry one distinct nuance in that batch transfers are a default supported function which means now batchTransferFrom has 2 very costly for-loops to run.

Is there an existing reference implementation of adding this functionality directly into the transfer loop to illustrate that cost impact does not “have to be” high?

3 Likes

kekw

 event Soulbound(uint256 indexed id, bool bounded);

the past form of “bind” is “bound”

2 Likes

The verbiage of an EIP can easily be updated :slight_smile:

For verbosity, 5633 serves the problem better than 5192 by removing two specific events that create a long runway for usage and adoption issues.

Based on the fact that wording is your only note of resolve here, I presume you are also in support of 5633?

In this case, all EIP-1155 functions of the contract that transfer the token from one account to another MUST throw, except for mint and burn.

Does this mean a soulbound token can always be burned?

The EIP-5633 implementation overrides function _beforeTokenTransfer of OZ but OZ uses this function in e.g. burn, so that’d mean the reference implementation and the specification are divergent: openzeppelin-contracts/contracts/token/ERC1155/ERC1155.sol at master · OpenZeppelin/openzeppelin-contracts · GitHub

While antithetical to the idea of a psuedonym-bound token, constant forfeiture prevents tokens from becoming scarlet letters as well as absolves all need of token-breaking standards such a consensual minting.

Generally in EIPs, when something is not included in a must, that means it is a CAN such as:

In this case, all EIP-1155 functions of the contract that transfer the token from one account to another MUST throw, except for mint and burn that CAN throw depending on implementation details.

2 Likes

We are very glad you like this proposal, it’s an honor. Our intention is to propose a protocol that is more practical in the real world. Gas cost analysis and optimization is an important task in our next update.

We implement Non-Transferable logic in the function _beforeTokenTransfer, because it’s a most suitable place, the function is called by the three main functional logic: burn, transfer and mint.

User CAN decide whether the token is burnable or not depends on the implementation.

Very precise description, thank you! :clap: :clap:

What are your thoughts in explicitly making it EIP-721 compatible as well? I don’t love the idea of two separate standards for EIP-721 and EIP-1155 when it can serve both.

2 Likes

I think so too. I posted the proposal that is strongly inspired by EIP-5192 and have a similar interfaces. Please take a look.

1 Like

Since EIP5192 is already final it doesn’t make much sense to have both a locked and a isSoulbound function that do the same thing.

1 Like

If all tokens in a contract are soulbound by default, isSoulbound(uint256 id) should return true by default during implementation.

I think the function should throw if the id does not exist using the same pattern as other standards.

The problem with this proposal, as well as with EIP5192, is that they assume that the status of the token switches from transferable to non-transferable and vice versa following a transaction that emits an event. However, this may not always be the case. In the gaming industry, for example, an NFT’s status can change many times a day without any transactions, simply due to changes in context. As a result, the most reliable way to determine whether an NFT is transferable or not is to call a view function.
It is not feasible to implement an interface that requires an event in such scenarios.
So, I believe that events should be removed from this interface. Additionally, I think that the name of the function should be more direct, such as isTransferable, however I could survive to different name :slight_smile:

@HonorLabs I’d be happy to adjust EIP-5633 to use the same nomenclature as EIP-5192 (function locked and Locked and Unlocked events and send a PR on GitHub. Is that interesting to you?

3 Likes

I have the same problem as @sullof. Is removing the event an option for you?