ERC-6551 Standard: Token Bound Accounts (TBA)
Introduction
NFTs were originally created to represent ownership of digital or physical assets, like collectibles. However, they were limited to tracking ownership of an ID and its associated metadata; they could not own other NFTs or ERC-20 tokens, nor interact with DeFi protocols like regular Ethereum accounts. ERC-6551 changes this by giving every NFT its own smart contract account, known as the Token Bound Account (TBA). TBAs can:- Hold assets like ERC-20 tokens, ETH, ERC-721, ERC-1155, and any other token your wallet accepts
- Execute transactions on-chain when initiated by the NFT owner (EOA)
- Maintain a transaction history like any other Ethereum account
- Each NFT is assigned one or more deterministic TBA addresses, computed by the registry contract.
- The TBA functions as a smart contract account controlled by the NFT owner.
- All actions are executed through the TBA.
- The transaction history and assets are tied to the TBA.
Prerequisites
This article assumes the reader is familiar with the following:- ERC-721 standard
- EIP-1167 minimal proxy standard
- EIP-1014 (
CREATE2
) - ERC-1271 signature validation method for contracts
Key Features of the ERC-6551 Standard
The ERC-6551 pattern implicitly maps NFTs to smart contract addresses by using the deterministic address mechanism ofCREATE2
.
Specifically, NFTs are uniquely identified by the tuple (chainId
, tokenContract
, tokenId
). The drawback to using only these parameters is that each NFT would be limited to a single account.
In some cases, an NFT may require multiple TBAs, just like regular Ethereum users who have separate wallets for different purposes (hot/cold storage). This is where a salt
parameter comes in.
Each unique salt value helps produce a different TBA for the same NFT, allowing a single NFT to manage multiple TBAs. This extends the tuple to: (chainId
, tokenContract
, tokenId
, salt
).
Recall that the address CREATE2
deploys is computed as follows:
predictedAddress = address(uint160(uint256(keccak256(abi.encodePacked(
bytes1(0xff),
deployer,
salt,
keccak256(_initCode)
)))));
Since the deployer’s address affects the CREATE2
address computation, all TBAs must come from a common deployer (factory). Otherwise, it would be necessary to track which factory deployed which TBA.
However, this requirement creates a challenge: for predictable addressing, TBAs deployed from the same factory need to have identical initialization bytecode (_initCode). Embedding different custom logic into each TBA would alter the initialization bytecode and break address predictability.
To solve this, each TBA is deployed as a custom EIP-1167 minimal proxy clone that extends its bytecode to include additional immutable data (chainId
, tokenContract
, tokenId
, and salt
) for account binding while delegating execution to a separate implementation contract. This maintains consistent initialization code for address predictability while allowing TBAs from the same factory to use different implementations.
TBA Bytecode Structure
Each TBA deployed from the factory has the following bytecode structure: [EIP-1167 Minimal Proxy] ├── Header (10 bytes) – Standard proxy initialization ├── Implementation (20 bytes) – Target contract address └── Footer (15 bytes) – Delegation logic [Immutable Account Data] (data that binds the TBA to a specific NFT) ├── Salt (32 bytes) – For create2 address derivation ├── ChainID (32 bytes) – chain identifier where NFT exists ├── TokenContract (32 bytes) – NFT contract address └── TokenID (32 bytes) – NFT identifier For instance, the bytecode structure will look as follows:// ERC-1167 Proxy Section
363d3d373d3d3d363d73 // Header - copy calldata
bebebebebebebebebebebebebebebebebebebebe //Implementation
5af43d82803e903d91602b57fd5bf3 // Footer - delegate call section
// Immutable Data Section
0000...0000 // Salt (32 bytes of zeros)
0000...0001 // ChainID (1 for Ethereum mainnet)
cfcf...cfcf // NFT contract address
0000...007b // TokenID (123 in hex)
When an NFT owner interacts with their TBA, the TBA (proxy clone) maintains its own state (such as accumulated NFTs or in-game assets) while delegating logic to the implementation contract it uses.
Given this proxy architecture, one additional parameter is required: the implementation
address. Consequently, the complete tuple for identifying the TBA address becomes (implementation
, salt
, chainId
, tokenContract
, tokenId
).
This identification method, combined with CREATE2
, allows anyone to compute a TBA’s address on demand without storing mappings. As a result, it eliminates storage costs while ensuring each TBA has a unique, predictable address.
Registry Contract
The canonical registry serves as the factory that deploys these TBAs. Since TBAs delegate execution, their implementation must first be deployed. To create a TBA, the registry’screateAccount()
function takes all tuple parameters (implementation
, salt
, chainId
, tokenContract
, tokenId
) and deploys a proxy clone.

createAccount()
function is what creates the specialized proxy bytecode by combining the EIP-1167 pattern with NFT binding data in memory:

Registry Contract Functions
The registry must implementIERC6551Registry
which defines two main functions:
createAccount()
Creates a token bound account (TBA) for a specific NFT.
createAccount()
simply returns the computed account address without calling CREATE2
. It does this by checking for existing code at the computed address using iszero(extcodesize(computed))
as highlighted with the red arrow below; if there’s no code (size = 0), it means no account exists yet, so the registry creates a new TBA at that exact address, then createAccount()
returns the address of the newly created TBA on line 121. However, if code already exists (size > 0), it returns the existing account’s address without deploying a new one.

account()
Computes and returns the deterministic address of a TBA for a given NFT without creating it.
AccountCreated() Event
The registry is required to emit one event,ERC6551AccountCreated
, which is emitted only when createAccount()
successfully creates a new TBA:

Registry Contract Deployment
The ERC-6551 registry contract is deployed at a fixed address (0x000000006551c19487814612e58FE06813775758
) across all EVM compatible chains.
Notably, the “6551” part of the address was intentionally chosen as a vanity pattern by iterating through different salts until the desired address was obtained.
A list of deployed registry addresses on various chains can be found here.
Cross-chain Registry Deployment Mechanism
To ensure the registry contract is deployed at the same address across all chains, the registry uses Nick’s factory: Nick’s factory is aCREATE2
factory contract deployed at the same address (0x4e59b44847b379578588920cA78FbF26c0B4956C
) on all chains. It makes deterministic contract deployment across chains possible.
Nick’s factory uses CREATE2
to deploy other contracts at predictable addresses where the deployed address is computed from: the factory’s address, a salt value, and the contract’s bytecode (in this case, the registry).
Thus, for the registry contract to be deployed at a consistent address across all chains, Nick’s factory must exist at address 0x4e59b44847b379578588920cA78FbF26c0B4956C
on all chains.
To deploy the registry on a chain where it hasn’t been deployed yet, submit the following deployment transaction:
{
"to": "0x4e59b44847b379578588920ca78fbf26c0b4956c",
"value": "0x0",
"data": "0x0000000000000000000000000000000000000000fd8eb4e1dca713016c518e31608060405234801561001057600080fd5b5061023b806100206000396000f3fe608060405234801561001057600080fd5b50600436106100365760003560e01c8063246a00211461003b5780638a54c52f1461006a575b600080fd5b61004e6100493660046101b7565b61007d565b6040516001600160a01b03909116815260200160405180910390f35b61004e6100783660046101b7565b6100e1565b600060806024608c376e5af43d82803e903d91602b57fd5bf3606c5285605d52733d60ad80600a3d3981f3363d3d373d3d3d363d7360495260ff60005360b76055206035523060601b60015284601552605560002060601b60601c60005260206000f35b600060806024608c376e5af43d82803e903d91602b57fd5bf3606c5285605d52733d60ad80600a3d3981f3363d3d373d3d3d363d7360495260ff60005360b76055206035523060601b600152846015526055600020803b61018b578560b760556000f580610157576320188a596000526004601cfd5b80606c52508284887f79f19b3655ee38b1ce526556b7731a20c8f218fbda4a3990b6cc4172fdf887226060606ca46020606cf35b8060601b60601c60005260206000f35b80356001600160a01b03811681146101b257600080fd5b919050565b600080600080600060a086880312156101cf57600080fd5b6101d88661019b565b945060208601359350604086013592506101f46060870161019b565b94979396509194608001359291505056fea2646970667358221220ea2fe53af507453c64dd7c1db05549fa47a298dfb825d6d11e1689856135f16764736f6c63430008110033",
}
Understanding the registry’s deployment transaction:
to
:0x4e59b44847b379578588920ca78fbf26c0b4956c
(Nick’s factory address)value
:0x0
(No ETH sent with the deployment)data
: Consists of the first 32 bytes which represent the salt parameter:0x0000000000000000000000000000000000000000fd8eb4e1dca713016c518e31
, and remaining bytes which is the registry contract’s bytecode.
TBA Interfaces
To be compliant with the ERC-6551 standard, every token bound account (TBA) implementation must implementIERC6551Account
for account identification and authorization, IERC6551Executable
for operation execution, IERC165
for interface detection, and IERC1271
for signature validation.
The functions in each interface—IERC6551Account
,IERC6551Executable
, andIERC165
along withIERC1271
, are explained individually. Their integration will be addressed in the “How These Interfaces Work Together” subsection.
Account Interface (IERC6551Account)
When a TBA interacts with other contracts or protocols, those contracts and protocols need a standardized way to verify important information like: which NFT this TBA is bound to, is the transaction signer authorized, and what’s the current execution state (nonce) of the TBA. TheIERC6551Account
interface defines a set of core functions to address the aforementioned verification needs:

token()
– Reads the embedded data from the calling account’s bytecode and returns a tuple containing the NFT (chainId, contract address, tokenId) that is bound to the account.isValidSigner()
– Validates if an address is authorized to sign for the account.state()
– Returns a uint256 value that tracks the account’s operation nonce.
ERC-165 Interface Detection

IERC6551Account
and IERC6551Executable
interfaces before interacting with them.
ERC-1271 Signature Validation Interface

isValidSignature()
function that checks if the signature is valid for the NFT owner, returning 0x1626ba7e
if valid or bytes4(0)
if invalid.
0x1626ba7e
is derived from the function selector of isValidSignature(bytes32,bytes)
, computed as:
keccak256("isValidSignature(bytes32,bytes)")
Then, the first 4 bytes (bytes4) of the hash are taken as the return value.

Execution Interface (IERC6551Executable)

IERC6551Executable
consists of a single required function execute()
that enables TBA implementations to perform low-level operations when called by a valid signer.
The interface has no mandatory events to be emitted.
Through execute()
, TBA implementations define which operations they support. The operation
parameter is a uint8
value that signals which low-level action should be performed:
0 = CALL // Regular calls (sending ETH, interacting with contracts)
1 = DELEGATECALL // Execute code from another contract in TBA's context
2 = CREATE // Deploy new contracts
3 = CREATE2 // Deploy contracts with deterministic addresses
Consider a scenario where an NFT owner wants their TBA to deposit ERC-20 tokens into a protocol. This requires the NFT owner to call execute()
on the TBA for each operation, which then delegates to the implementation contract to perform the transactions.
Approving tokens:
execute(
to: tokenContract, // ERC20 token address
value: 0, // No ETH sent
data: abi.encodeWithSignature(
"approve(address,uint256)",
spender, // Protocol address to approve
amount // Amount to approve
),
operation: 0 // CALL operation
);
Depositing into protocol:
execute(
to: protocol address,
value: 0, // no ETH sent
data: depositData,// encoded deposit() call
operation: 0 // operation: CALL
);
Or for simply sending ETH:
execute(
to: recipient,
value: 2 ether,
data: "", // empty for ETH transfer
operation: 0 // operation: CALL
);
Each operation must be called by a valid signer and must include proper error handling.
The IERC6551Executable
interface is flexible in that instead of enforcing a specific way to handle transactions, it only requires that TBA implementations clearly signal (through ERC-165
) which execution interface they support, whether it’s the standard IERC6551Executable
interface or their own custom execution mechanism.
How These Interfaces Work Together
The reference implementation underscores howIERC6551Account
, IERC6551Executable
, IERC165
, and ERC-1271
work together to let TBAs execute transactions and verify that a message was signed by their NFT owner (EOA). These interfaces make up the core of how a TBA functions, as shown in the reference implementation:

isValidSignature()
(from ERC-1271) validates that a signature is valid for the owner of the bound NFT

- It checks if the
owner()
(EOA) of the TBA created the signature. The reference implementation uses OpenZeppelin’s SignatureChecker for this verification.
isValidSigner()
(from IERC6551Account) checks if a given address (caller) is authorized to execute transactions on behalf of the TBA

- It verifies the address against the NFT’s owner. This is used primarily in the
execute()
function (from IERC6551Executable) to validate the caller.

_isValidSigner()
which only checks that the given address is the owner of the NFT, but additional verification logic can be included to authorize other addresses besides the owner.
For NFT owners who want to interact with their TBAs, or for decentralized exchanges and marketplaces integrating with TBAs, the workflow typically looks like:
- Direct Transaction:
- User (NFT owner) sends a transaction to their TBA calling
execute()
- The
execute()
function verifies that the caller is authorized via_isValidSigner(msg.sender)
- If authorized, the TBA performs the requested operation e.g. transferring ERC-20 tokens
state
is incremented in storage after every execution, preventing replay attacks.
- User (NFT owner) sends a transaction to their TBA calling
- Signature-Based:
Note that this requires protocols to implement ERC-1271 support, which wasn’t widely adopted when many DEXs were designed and may not support the contract signature verification method natively.
- User signs a message with their wallet (which owns the NFT) containing transaction details (e.g., “Trade 100 USDC for 0.05 ETH from my TBA”)
- DEX calls
isValidSignature()
on the TBA, using the message hash and the signature from the NFT owner as arguments, to verify both:- The signature’s validity
- And that the signature was produced by the current owner of the NFT bound to the TBA, confirming they have authority over the TBA’s account
- If verified, the DEX knows the transaction is authorized, but to actually execute the trade, the TBA must grant token approvals to the DEX via the
execute()
call
execute(
to: tokenContract, // USDC token address
value: 0, // No ETH sent
data: abi.encodeWithSignature(
"approve(address,uint256)",
spender, // Protocol address to approve
100 // Amount to approve
),
operation: 0 // CALL operation
);
The IERC6551Account
, IERC6551Executable
, and ERC-1271
interfaces collectively provide a standardized way for TBAs to verify signatures and execute authorized transactions, making them compatible with both direct function calls and protocols that use ERC-1271 signature verification.
How to Create a TBA for an NFT
To create a TBA, follow these simple steps:- Create and deploy an implementation contract.
- Interact with the registry at
0x000000006551c19487814612e58FE06813775758
by callingcreateAccount()
and passing in the necessary parameters:(implementation, salt, chainId, tokenContract, tokenId)
.- If you already have a suitable deployed implementation contract, you can pass its address as the
implementation
argument directly without creating a new one.
- If you already have a suitable deployed implementation contract, you can pass its address as the
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/introspection/IERC165.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/interfaces/IERC1271.sol";
import "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";
interface IERC6551Account {
receive() external payable;
function token() external view returns (uint256 chainId, address tokenContract, uint256 tokenId);
function state() external view returns (uint256);
function isValidSigner(address signer, bytes calldata context) external view returns (bytes4 magicValue);
}
interface IERC6551Executable {
function execute(address to, uint256 value, bytes calldata data, uint8 operation)
external payable returns (bytes memory);
}
contract ERC6551Account is IERC165, IERC1271, IERC6551Account, IERC6551Executable {
uint256 public state;
/// @notice Mapping to store addresses permitted to act on behalf of the account
mapping(address => bool) public isPermitted;
receive() external payable {}
function execute(address to, uint256 value, bytes calldata data, uint8 operation)
external payable virtual returns (bytes memory result) {
require(_isValidSigner(msg.sender), "Invalid signer");
require(operation == 0, "Only call operations are supported");
++state;
bool success;
(success, result) = to.call{value: value}(data);
if (!success) {
assembly {
revert(add(result, 32), mload(result))
}
}
}
/// @notice Allows the NFT owner to grant or revoke permission for another address
function setPermission(address user, bool permitted) external {
require(msg.sender == owner(), "Only owner can set permissions");
require(user != msg.sender, "Cannot set permission for yourself");
isPermitted[user] = permitted;
}
function isValidSigner(address signer, bytes calldata) external view virtual returns (bytes4) {
return _isValidSigner(signer) ? IERC6551Account.isValidSigner.selector : bytes4(0);
}
function isValidSignature(bytes32 hash, bytes memory signature)
external view virtual returns (bytes4 magicValue) {
return SignatureChecker.isValidSignatureNow(owner(), hash, signature)
? IERC1271.isValidSignature.selector
: bytes4(0);
}
function supportsInterface(bytes4 interfaceId) public view virtual returns (bool) {
return interfaceId == type(IERC165).interfaceId
|| interfaceId == type(IERC6551Account).interfaceId
|| interfaceId == type(IERC6551Executable).interfaceId;
}
function token() public view virtual returns (uint256, address, uint256) {
bytes memory footer = new bytes(0x60);
assembly {
extcodecopy(address(), add(footer, 0x20), 0x4d, 0x60)
}
return abi.decode(footer, (uint256, address, uint256));
}
function owner() public view virtual returns (address) {
(uint256 chainId, address tokenContract, uint256 tokenId) = token();
return (chainId == block.chainid) ? IERC721(tokenContract).ownerOf(tokenId) : address(0);
}
function _isValidSigner(address signer) internal view virtual returns (bool) {
return signer == owner() || isPermitted[signer];
}
}
The following are the new additions to the reference implementation contract:
mapping(address => bool) isPermitted
: Tracks which addresses have permissions to act on behalf of the accountsetPermission(address user, bool permitted)
: External function that lets the NFT owner grant or revoke permissions- An updated
_isValidSigner()
function that now checks whether the signer is either the NFT owner or an authorized address.
- Clone this repository:
git clone https://github.com/Sayrarh/ERC-6551-Reference-Implementation.git
- Install dependencies:
npm install
- Create your
.env
file by copying the contents of.env.example
and filling in your API keys - Go to the
scripts/interaction.ts
file and replacenftContractAddress
with your actual NFT address, along with thenfttokenId
, andchainId
parameters

npx hardhat run scripts/interaction.ts --network sepolia
to interact with the contracts:


EIP-1167 Proxy Section
Header (10 bytes) -> 0x363d3d373d3d3d363d
Implementation Address (20 bytes) -> 311e822a099fae1ef8fc961ddf61fafd5392e7a9 (Implementation Address)
Footer (15 bytes) -> 5af43d82803e903d91602b57fd5bf3
Immutable Data Section (account binding)
salt (32 bytes) -> 0000000000000000000000000000000000000000000000000000000000000000
chainID (32 bytes) -> 00000000000000000000000000000000000000000000000000000000aa36a7 // chainID -> 11155111 (sepolia)
tokenContract (32 bytes) -> 0000000000000000000000006b57b7edf751829dfb2aeccf578d6d24c33a45a2 (NFT contract address)
tokenID (32 bytes) -> 0000000000000000000000000000000000000000000000000000000000000001 //token ID -> 1
The full bytecode of the TBA shows that the newly created TBA at 0x97212622cbdb6f1aa96c4abceaebb2b1b47d2bbe
forwards calls to the implementation contract at 0x311e822a099fae1ef8fc961ddf61fafd5392e7a9
with the TBA bound to the NFT:
- NFT contract address:
0x6b57b7edf751829dfb2aeccf578d6d24c33a45a2
- tokenID: 1
- chainID: 11155111 (Sepolia testnet)
- salt: 0 (default salt)
token()
function is called, it reads the TBA’s bytecode:


chainId
, tokenContract
, tokenId
) uniquely identify the NFT the account is bound to, making it easy for contracts or applications to determine the associated NFT.
Handling NFT Transfer
When an NFT with a TBA is transferred, ownership of the TBA automatically transfers as well. This occurs because the TBA’s ownership is dynamically determined by the current NFT owner through theowner()
function.

- The TBA retains all its assets (ETH, tokens, NFTs)
- Control of the TBA changes to the new NFT owner
- The previous owner loses all access to the assets owned by the TBA once the NFT is transferred unless those assets have been transferred out beforehand, as illustrated in the gaming example.
The implementation used in this section is a simple one. The authors of EIP-6551 have also provided a reference upgradable implementation in their GitHub repository, allowing you to upgrade the TBA’s logic by pointing it to a new implementation contract when needed.
Preventing Ownership Cycles in TBAs
The upgradable implementation include safeguards against ownership cycles, which can occur when a chain of TBA ownership forms a loop. Ownership cycles create situations where no account has the authority to initiate transactions because each TBA needs permission from an owner (EOA) that’s trapped in the cycle. This can be illustrated as follows:
- Each TBA needs its NFT owner’s (EOA’s) permission to operate
- When the EOA transfers the NFT into one of the TBAs in the cycle, it loses all control because it effectively relinquishes ownership.
- In a cycle, no other party can initiate transactions since the EOA no longer owns any NFT in the chain
- All assets become permanently locked with no way to break the cycle
TBA Fraud Mitigation
NFT marketplaces will need to implement safeguards to prevent fraudulent actions by malicious TBA owners. Consider a scenario in which Bob owns a character NFT with a TBA that holds 0.5 ETH, 2 dragon NFTs, and $50 USDC from gameplay rewards. Bob lists the character NFT on a marketplace for 1 ETH, including all the assets in the TBA as part of the deal. Alice, who wants to skip starting the game from scratch, agrees to buy the NFT. Before the sale is finalized, Bob quickly withdraws the $50 USDC from the TBA. When the transaction is complete, Alice receives the character NFT with only 0.5 ETH and 2 dragon NFTs in the TBA, while Bob ends up with 1 ETH from the sale and the $50 USDC he withdrew. To address fraudulent acts like this, NFT marketplaces need to implement protections at the marketplace level, and contracts implementing this standard should also include safeguards. Some mitigation strategies include:- Attaching a list of asset commitments to marketplace orders (such as particular ERC-20 balances, NFTs, etc.) and voiding the offer if committed assets are removed before fulfillment.
- Having the marketplace temporarily take ownership of the NFT during the listing period (rather than just approval rights), which prevents the seller from manipulating the TBA’s assets.