[ERC-Idea] Address-Derived Non-Transferable Token (Soulbound Token)

, ,

Update Log

2026-05-09: Initial draft, seeking community feedback.


Summary

I propose a minimal ERC for soulbound tokens where tokenId is deterministically derived from the owner’s address via XOR with the contract address:

tokenId = uint256(uint160(owner)) ^ uint160(address(this))

Protocol-level guarantee: one address = exactly one tokenId. It is mathematically impossible to have two tokens for one address within the same contract — this is a property of the function, not a runtime check.

Determinism: same owner → always the same tokenId.

Invertibility: ownerOf is computed via inverse XOR, no storage.

No transferFrom, approve, setApprovalForAll. Full draft specification: ERC-XXXX.md.


I’d love to get the community’s feedback on this approach, especially from:

  • Authors of ERC-5192, ERC-5484, ERC-4973, and ERC-8129

  • Teams building soulbound / identity token systems (Binance BAB, 0xKYC, kycDAO, Octan, Galxe, POAP)

  • Wallet and indexer developers

  • Anyone who has strong opinions about SBT standards


Motivation

Over the last 4 years, four main approaches to SBTs have emerged in the Ethereum ecosystem:

  • ERC-5192 (Minimal Soulbound NFTs, Final) — extends ERC-721, inherits all unused storage: _owners, _balances, _tokenApprovals, _operatorApprovals. Sequential tokenId.

  • ERC-4973 (Account-Bound Tokens, Review) — does not implement transfer interface, but still uses mapping(uint256 => address). EIP-712 signature for minting.

  • ERC-5484 (Consensual Soulbound Tokens, Final) — ERC-721 extension + BurnAuth enum. Sequential tokenId.

  • ERC-8129 (Non-Transferable Token, Draft) — standalone without transfer functions, but sequential tokenId with mapping(uint256 => address).

Common problem: none of these standards provide a protocol-level “one address → one token” guarantee. ERC-5192/5484 do not prevent multiple mints to the same address without an explicit check. Neither does ERC-8129. This is the gap our proposal fills.

Real-World Adoption

This is not an abstract discussion. Several real-world projects already issue one-per-address SBTs and face the lack of a suitable standard:

Project Users Standard 1:1 Mechanism
Binance BAB 855k+ mints Custom ERC-721 soulbound Backend-level (KYC UID)
0xKYC 1k+ Custom ERC-721 soulbound Deterministic UUID
kycDAO Production Custom ERC-721 soulbound Contract-level
Octan 1ID (Reputation SBT) Production Custom ERC-721 soulbound Contract-level

All four use full ERC-721 — _owners, _balances, _tokenApprovals, _operatorApprovals — even though transfer is locked. Binance BAB spends ~80k gas per mint (full ERC-721 + proxy). Our approach: ~33k gas, ~2.5x cheaper.


Core Idea

// Derivation (bidirectional, invertible)
tokenId = uint256(uint160(owner)) ^ uint160(address(this));
owner  = address(uint160(tokenId ^ uint160(address(this))));

// Storage: one mapping for existence, nothing else
mapping(uint256 => bool) private _isMinted;

// ownerOf — computed, not stored
function ownerOf(uint256 tokenId) public view returns (address) {
    require(_isMinted[tokenId], "not minted");
    return address(uint160(tokenId ^ uint160(address(this))));
}

Key properties in priority order:

  1. Protocol-level 1:1 — XOR is an injection on uint160. The same address always produces the same tokenId, and a different address can never produce the same tokenId.

  2. Deterministic + invertibletokenIdOf(owner) and ownerOf(tokenId) form a bijective pair. Any dapp can compute tokenId without querying the contract.

  3. Cross-contract isolation — XOR with address(this) guarantees that the same address yields different tokenIds on different contracts.

  4. Gas efficiency + minimal storage (consequence) — no _owners mapping means: mint writes 1 storage slot, burn deletes 1 slot. No approval cleanup, no balances.


Key Design Decisions

1. Protocol-level one-per-address

The main problem with existing SBTs: they don’t guarantee 1:1 at the protocol level. ERC-5192, ERC-5484, ERC-8129 do not prevent multiple mints to the same address — an explicit check is required. BAB, 0xKYC, kycDAO, Octan — all implement this custom.

XOR-derived tokenId makes this a mathematical invariant:

f(owner) = uint256(uint160(owner)) ^ uint160(address(this))

  • Injection on uint160 — different owners → always different tokenIds

  • Determinism — same owner → always the same tokenId

  • No require, no storage reads for verification

This is not a side benefit of the protocol — it is its fundamental property. Gas efficiency and minimal storage are natural consequences.

2. XOR instead of keccak256

  • Invertibility: owner can be recovered from tokenId without storage or events

  • Gas: XOR costs ~3 gas, keccak256 costs ~30+ gas

  • Bijection: XOR with a constant is a bijection on uint160

3. Standalone instead of ERC-721 extension

ERC-8129 has already convincingly shown: inheriting ERC-721 just to block transfers is an architectural compromise. You pay for storage and bytecode that will never be used.

Our standard defines its own minimal interface. At the same time, it maintains compatibility with ERC-5192 (Locked event, locked()) and ERC-721Metadata (name, symbol, tokenURI) for wallet detection.

4. Self-mint without EIP-712 consent

ERC-4973 introduces EIP-712 signatures for minting. This solves unsolicited minting but complicates implementation.

Our approach: minting rights are determined by the deployer’s access control. The default implementation does not restrict mint(), but the deployer can:

  • Restrict with require(to == msg.sender) — self-mint only

  • Add a MINTER_ROLE — issuer-based mint

  • Add signature verification — gasless mint

5. Burn — reference implementation

In the reference implementation, burn() is only accessible to the token owner. The standard does not specify access control for burn — the specific implementation may allow the issuer, both, or neither (analogous to BurnAuth in ERC-5484).


Gas Comparison

Operation This standard ERC-8129 (est.) ERC-5192 / ERC-5484 (est.) BAB (BSC, observed)
Mint 32,905 ~45k ~50k+ ~80k+ (incl. proxy)
Burn 8,006 ~40k ~45k+
BalanceOf (minted) 9,618 ~15k ~23k ~23k
OwnerOf (minted) 10,389 ~23k ~23k ~23k
tokenIdOf 7,417 N/A N/A N/A

Measurements: vm.startSnapshotGas in Foundry (solc 0.8.35, optimizer 200).

What this means in practice:

At BAB scale (855k mints), the difference between ~80k and ~33k gas amounts to:

  • BSC gas price ~3 gwei → savings of ~0.00014 BNB ($0.04) per mint

  • 855k mints → ~$34k savings on minting alone

Gas efficiency is not the primary goal — it is a direct consequence of protocol-level 1:1 and minimal storage.


Specification Summary

Full specification: ERC-XXXX.md

Core interface (IERCXXXX):

interface IERCXXXX is IERC165, IERC5192, IERC721Core, IERC721Metadata {
    error AlreadyMinted();
    error NotMinted();
    error NotAuthorized();

    function mint(address to) external returns (uint256 tokenId);
    function burn(uint256 tokenId) external;
    function tokenIdOf(address owner) external view returns (uint256 tokenId);
}

Required interfaces: IERC165 (supportsInterface), IERC5192 (Locked event, locked()), IERC721Core (Transfer event, balanceOf, ownerOf), IERC721Metadata (name, symbol, tokenURI).

Reference implementation: AddressDerivedSBT.sol — ~30 lines of logic, ~5KB bytecode.


Comparison with Existing Standards

Feature ERC-5192 / ERC-5484 ERC-4973 ERC-8129 This standard
Token ID Sequential keccak256(EIP-712) Sequential XOR(owner, contract)
Storage mapping(uint256 => address) + approvals mapping(uint256 => address) mapping(uint256 => address) mapping(uint256 => bool)
1:1 guarantee Explicit check Not enforced Not enforced Protocol-level
ownerOf Storage read Storage read Storage read Computed
Minting Restricted EIP-712 consent Issuer-only Implementation-defined
Transfer Present but reverted Absent Absent Absent
ERC-5192 compat Native / No No No Yes
Real-world adoption BAB (custom ERC-721) TBD
Gas (mint/burn) ~50k / ~45k ~55k / ~50k ~45k / ~40k 33k / 8k

Open Questions

1. Issuer-based mint — allow it or self-mint only?

Currently mint(address to) has no access control. Options:

  • A: implementation-defined (maximum flexibility)

  • B: require(to == msg.sender) in core spec

  • C: both (mint() for issuer + mintSelf() for the user)

My bias: A. But BAB and other KYC use cases show that issuer-based mint is the dominant pattern for SBTs. Maybe a MintApproval modifier in the core spec is needed?

2. Burn — is it needed in the core interface?

Currently burn() is present. If an EOA is compromised, the token is lost forever. ERC-4337 smart wallets solve key rotation without burn.

Alternative: remove burn entirely. The token lives forever (or until the contract dies).

3. EAS / attestations — will they make SBT standards unnecessary?

Gitcoin Passport (2.3M+ Passports) migrated from SBT to EAS and Sign Protocol. EAS offers: schema registry, off-chain data, revocability without SBT overhead.

One view: SBTs remain relevant where a combination of on-chain ownership (ownerOf), indexer-compatible events (Transfer), and ERC-165/5192 detection is needed. But is on-chain ownership worth the overhead when attestation schemas exist? This needs community discussion.


Links