I’ve been thinking about current Soulbound token implementations and their reliance on ERC-721 as a base. Most existing solutions (like ERC-5192) essentially bolt locking mechanisms onto ERC-721, reverting transfers with custom errors. While this works, it feels architecturally wrong.
The core issue: Soulbound tokens aren’t non-fungible - they’re non-transferable. ERC-721 is built around the concept of transferability. The entire interface revolves around transferFrom, safeTransferFrom, approve, setApprovalForAll - functions that fundamentally contradict what Soulbound means.
When we inherit from ERC-721 and override these functions to revert, we’re carrying dead weight:
- Storage slots for approvals and operators that will never be used
- Larger deployment bytecode
- Higher gas costs on mint/burn due to unnecessary state management
- Increased Ethereum state growth from unused mappings
Here’s a minimal implementation that covers everything Soulbound needs:
interface IERC8129 {
event Mint(address to, uint256 id);
event Burn(address from, uint256 id);
function mint() external returns(uint256);
function burn(uint256) external;
}
interface IERC721Metadata {
function name() external view returns (string memory);
function symbol() external view returns (string memory);
function tokenURI(uint256 tokenId) external view returns (string memory);
}
contract ERC8129 is IERC8129, IERC721Metadata {
uint256 private tokenId;
string private _name;
string private _symbol;
string private _baseURI;
mapping(uint256 => address) public ownerOf;
error ErrNotAnOwner();
constructor(string memory name_, string memory symbol_, string memory baseURI_) {
_name = name_;
_symbol = symbol_;
_baseURI = baseURI_;
}
function name() external view override returns (string memory) {
return _name;
}
function symbol() external view override returns (string memory) {
return _symbol;
}
function tokenURI(uint256 _tokenId) external view override returns (string memory) {
if (ownerOf[_tokenId] == address(0)) revert ErrNotAnOwner();
return _baseURI;
}
function mint() external virtual returns (uint256 id) {
tokenId += 1;
id = tokenId;
ownerOf[id] = msg.sender;
emit Mint(msg.sender, id);
}
function burn(uint256 id) external virtual {
if (ownerOf[id] != msg.sender) revert ErrNotAnOwner();
ownerOf[id] = address(0);
emit Burn(msg.sender, id);
}
}
This drastically reduces codesize, which opens up space for storing additional data directly in the contract - like on-chain SVG metadata.
The semantic argument matters too. When you see ERC-721, you expect transferability. Soulbound tokens represent credentials, achievements, identity - things that by definition shouldn’t move between addresses. Using a transferable token standard and disabling transfers creates conceptual confusion.
Current ERC-721-based SBT implementations aren’t truly “bound to soul” - they’re transferable tokens with transfer restrictions. That’s a workaround, not a solution.
A dedicated standard would:
- Remove unused storage (approvals, operators)
- Reduce deployment bytecode significantly
- Lower gas costs for mint/revoke operations
- Decrease state growth compared to ERC-721-based implementations
- Provide clear semantics: mint once, own forever, or burn
The counterargument is ecosystem compatibility - wallets and marketplaces already understand ERC-721. But Soulbound tokens don’t need marketplace support. They’re not meant to be traded. Wallet support just needs ownerOf and metadata URI, which any minimal standard can provide.
Curious what others think - is the convenience of ERC-721 inheritance worth the architectural and efficiency tradeoffs?