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. SequentialtokenId. -
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
tokenIdwithmapping(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:
-
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 sametokenId. -
Deterministic + invertible —
tokenIdOf(owner)andownerOf(tokenId)form a bijective pair. Any dapp can computetokenIdwithout querying the contract. -
Cross-contract isolation — XOR with
address(this)guarantees that the same address yields differenttokenIdson different contracts. -
Gas efficiency + minimal storage (consequence) — no
_ownersmapping 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
-
Full specification:
ERC-XXXX.md -
Reference implementation:
AddressDerivedSBT.sol -
ERC-8129 (inspiration): ethereum-magicians.org/t/erc-8129-non-transferable-token
-
EAS: easscan.org
-
Binance BAB: BSC
0x2B09d47D550061f995A3b5C6F0Fd58005215D7c8