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