Soulbound token explained, with Solidity implementation


Introduction

NFTs, or Non-Fungible Tokens, have primarily been used to represent ownership of tradeable assets. Some examples of these assets include:

  • Art and collectibles
  • Gaming items that can be bought and sold
  • Real estate (purchasing a portion of a building)

Anyone can obtain ownership of an NFT by exchanging it for coins.

While NFTs effectively represent ownership of assets, they are not well suited for representing attributes such as education or personal information, such as medical certificates.

An Ethereum account is simply a numerical identifier.

Information about the user can be obtained by examining the blockchain, such as owned coins and NFTs, but the identity of the owner is not directly available in the blockchain.

This lack of identity in the blockchain is problematic when identity information is required, such as the user’s country of residence or age (18 years or older).

Therefore, centralized web2 structures are still necessary for various tasks in the web3 environment.

For example, artists rely on platforms such as OpenSea or Twitter to prove their identity and the origin of their NFTs, so buyers can be confident they are purchasing an original piece of the artist’s work.

Additionally, centralized structures are used for KYC purposes, such as verifying the user’s identity and ensuring compliance with legislation, as in the case of buying cryptocurrencies on Binance.

Introducing soulbound tokens

Soulbound tokens can be seen as NFTs, with particular characteristics.

Untradeable

These tokens represent personal information, making it irrelevant to trade them. For instance, you cannot sell your driver’s license.

Burnable

The tokens can be destroyed by the owner, the issuer, both, or neither. It depends of what the token represents. To continue with the driving license example, if you’re a crazy driver, you may lose your license: the issuer is allowed to burn the token.

Soulbound tokens are inspired by the game World of Warcraft, where certain items are linked to the player’s character. These items cannot be sold or traded to other players, making it necessary to play the game in order to obtain them.

Privacy concerns

Even though blockchain accounts are pseudonymous (it may be possible to identify the person behind an Ethereum address), there is still a level of confidentiality.

By adding personal information to an account, the confidentiality decreases and it may become easier to identify the person if too much information is revealed. This information can also lead to discrimination.

Some possible solutions

Use multiple addresses

It’s easy to add new addresses and switch between them using a wallet like Metamask, with just a few clicks.

Dividing the identity into multiple sub-identities on different addresses can help keeping the true identity private.

Zero-knowledge proofs (ZKPs)

Zero-knowledge proofs (ZKPs) is a method to prove the validity of a statement without revealing any information beyond the statement itself. In the context of identity verification, a user could prove a part of his identity without revealing the actual information to the verifier. This can help protect the user’s privacy while still allowing them to prove their identity.

Off chain storage

Store the data off chain, and the hash of the data on chain. This allows the owner to reveal the data only when they choose to.

Implementation

There are 2 soulbound tokens standards in final state to know.

ERC 5192

https://eips.ethereum.org/EIPS/eip-5192

An extension of the NFT standard (ERC-721) that provide a minimal interface to make NFT non transferable.

ERC 5484

https://eips.ethereum.org/EIPS/eip-5484

Extends the NFT standard (ERC-721) and provide a minimal interface to authorize the burn of a token. Before issuance, both parties (the issuer and the receiver), have to agree on who has the authorization to burn this token. The burn authorization is immuable.

A complete driving license example using hardhat, with unit tests, can be found on my GitHub https://github.com/ntnprdhmm/soulbound_token_example

abstract contract SouldBoundToken is ERC721, IERC5192, IERC5484 {
  bool private isLocked;
  mapping(uint256 => bool) private _isLocked;
  mapping(uint256 => BurnAuth) private _burnAuth;
  mapping(uint256 => address) private _tokenOwners;
  mapping(uint256 => address) private _tokenIssuers;

  error ErrLocked();
  error ErrNotFound();

  constructor(
      string memory _name,
      string memory _symbol
  ) ERC721(_name, _symbol) {}

  function issue(
      address to,
      uint256 tokenId,
      bool isLocked,
      BurnAuth burnAuth
  ) internal {
      // check that the token id is not already used
      require(_tokenOwners[tokenId] == address(0));

      _safeMint(to, tokenId);

      // remember is the token is locked
      _isLocked[tokenId] = isLocked;
      // remember the burnAuth for this token
      _burnAuth[tokenId] = burnAuth;
      // remember the issuer and owner of the token
      _tokenIssuers[tokenId] = msg.sender;
      _tokenOwners[tokenId] = to;

      emit Issued(msg.sender, to, tokenId, burnAuth);
  }

  modifier checkLock() {
      if (isLocked) revert ErrLocked();
      _;
  }

  function locked(uint256 tokenId) external view returns (bool) {
      if (!_exists(tokenId)) revert ErrNotFound();
      return _isLocked[tokenId];
  }

  function burnAuth(uint256 tokenId) external view returns (BurnAuth) {
      return _burnAuth[tokenId];
  }

  function getTokenOwner(uint256 tokenId) internal view returns (address) {
      return _tokenOwners[tokenId];
  }

  function burn(uint256 tokenId) external {
      address issuer = _tokenIssuers[tokenId];
      address owner = _tokenOwners[tokenId];
      BurnAuth burnAuth = _burnAuth[tokenId];

      require(
          (burnAuth == BurnAuth.Both &&
              (msg.sender == issuer || msg.sender == owner)) ||
              (burnAuth == BurnAuth.IssuerOnly && msg.sender == issuer) ||
              (burnAuth == BurnAuth.OwnerOnly && msg.sender == owner),
          "The set burnAuth doesn't allow you to burn this token"
      );

      // Burn the token
      delete _tokenIssuers[tokenId];
      delete _tokenOwners[tokenId];
      delete _isLocked[tokenId];
      delete _burnAuth[tokenId];
      ERC721._burn(tokenId);
  }
}