ERC-6956 Asset-bound NFT (Asset-bound Non-Fungible Tokens)
Review · NFTs & multi-token · created 2023-04-29
By attaching a unique identifier called an anchor to a physical or digital asset and binding it 1:1 with an NFT, and having an oracle prove that it actually holds the asset, the token can be moved without requiring the existing owner's signature.
Why it is needed
When ERC-721 is used to represent an off-chain asset like a physical good, the identifier of the asset is usually written into the metadata, but since the metadata mostly lives off-chain and can change, the link between the token and the asset stays weak. Storing a hash on-chain to fix it in place then makes it impossible to update metadata that is supposed to change, such as traits or play time, and there have been incidents where the external storage that tokenURI points to simply disappeared. Direction is the bigger problem. Even if ownership of the physical item changes hands, if the existing NFT owner does not cooperate or loses access to their account, there is no way to move on-chain ownership. Actually holding the asset does not translate into on-chain ownership, so the token cannot keep up with the life cycle of an asset whose owner may change thousands of times.
What to implement
event AnchorApproval(address indexed owner, address approved, bytes32 indexed anchor, uint256 tokenId);: Fires when an attestation changes or reconfirms the approved address of an anchor token.event AnchorTransfer(address indexed from, address indexed to, bytes32 indexed anchor, uint256 tokenId);: Fires together with the ERC-721 Transfer whenever ownership of the NFT bound to an anchor changes in any way, leaving a history from the anchor's perspective.event AttestationUse(address indexed to, bytes32 indexed anchor, bytes32 indexed attestationHash, uint256 totalUsedAttestationsForAnchor);: Signals that an attestation has been used, so that an attestation with the same hash will not be accepted again.event OracleUpdate(address indexed oracle, bool indexed trusted);: Fires when the trust status of an oracle address changes. Only attestations from an oracle whose latest event is trust (true) are valid.function anchorByToken(uint256 tokenId) external view returns (bytes32 anchor);: Returns the anchor bound 1:1 to the token ID. Returns 0x0 if it is not an anchor token.function tokenByAnchor(bytes32 anchor) external view returns (uint256 tokenId);: Returns the token ID bound 1:1 to the anchor. Returns 0 if no token exists yet.function attestationsUsedByAnchor(bytes32 anchor) view external returns (uint256 attestationUses);: Returns the number of attestations already used for state changes on a given anchor.function decodeAttestationIfValid(bytes memory attestation, bytes memory data) external view returns (address to, bytes32 anchor, bytes32 attestationHash);: If the attestation is valid, decodes and returns the to address, anchor, and attestation hash. Fails if it has already been used, the signer is not a trusted oracle, or it is outside the validity period.function burnAuthorization() external view returns(Authorization burnAuth);: Reports whether burn authorization belongs to the asset, the owner, or the issuer.function approveAuthorization() external view returns(Authorization approveAuth);: Reports whether approval authorization belongs to the asset, the owner, or the issuer.function transferAnchor(bytes memory attestation) external;: Transfers the NFT bound to the anchor to attestation.to using only the attestation, with no additional data.function transferAnchor(bytes memory attestation, bytes memory data) external;: Changes ownership of the NFT bound to the attestation's anchor to the to address. It is a permissionless function that anyone can submit, and mints a new token if one does not already exist.function approveAnchor(bytes memory attestation) external;: Sets the approved address of the anchor token using only the attestation, with no additional data.function approveAnchor(bytes memory attestation, bytes memory data) external;: Approves the token bound to the anchor to attestation.to. Behaves the same as ERC-721.approve and fails if the asset does not have approval authorization.function burnAnchor(bytes memory attestation) external;: Burns the anchor token using only the attestation, with no additional data.function burnAnchor(bytes memory attestation, bytes memory data) external;: Burns the token bound to the anchor. Fails if the asset does not have burn authorization.function attestationLimit(bytes32 anchor) external view returns (uint256 limit);: (AttestationLimited extension) Returns the limit on attestation usage per anchor. If no per-anchor setting exists, the global limit applies.function attestationUsagesLeft(bytes32 anchor) external view returns (uint256 nrTransfersLeft);: (AttestationLimited extension) Returns the number of attestation uses remaining for the anchor.function floating(bytes32 anchor) external view returns (bool);: (Floatable extension) Reports whether the anchor token can be transferred without an attestation.function float(bytes32 anchor, FloatState newState) external;: (Floatable extension) Turns floating status on or off per anchor, or resets it to the global default. Unauthorized calls fail.function anchorValid(bytes32 anchor, bytes32[] memory proof) external view returns (bool isValid);: (ValidAnchors extension) Verifies via Merkle proof whether the anchor is a valid anchor on this contract.
In depth
1:1 binding between anchor and token
The anchor is a value that uniquely identifies the off-chain asset, and the contract must have a bidirectional mapping of anchorByToken(tokenId) and tokenByAnchor(anchor), with at most one token per anchor. tokenId=0 and anchor=0x0 are defined as invalid, which takes advantage of the property that Solidity mappings return 0 for a nonexistent key, letting the contract skip a separate existence-check state variable to save gas. Transfers with a batch size greater than 1 are prohibited, and every transfer must emit an AnchorTransfer(from, to, anchor, tokenId) event. For tokenURI(tokenId), an anchor-based URI of the form baseURI/anchor is recommended, since the mapping between anchor and tokenId is unknown before the anchor is first used. Even for mass-produced items, each individual asset has its own ownership history, so an ERC-721 extension was chosen rather than a multi-token standard.
Attestation format and verification
An attestation ABI-encodes the to address, the anchor, the signing time attestationTime, the validity start time validStartTime, and the validity end time validEndTime, and attaches a 65-byte ETH signature that the oracle created over attestationHash = keccak256([to, anchor, attestationTime, validStartTime, validEndTime]). decodeAttestationIfValid(attestation, data) decodes this and returns to, anchor, and attestationHash, but must fail if the signature is not from a trusted oracle, if it is outside the validity period, or if the same attestationHash has already been used. Since this function does not change state, it can also be used solely to check validity without consuming the attestation. Any operation that changes state must mark the attestation as used within the same transaction (it is recommended to store the used attestationHash), increment attestationsUsedByAnchor by 1, and emit an AttestationUse event.
Permissionless transfer, approval, and burn
transferAnchor, approveAnchor, and burnAnchor are all permissionless functions. Neither the current owner (from) nor the recipient (to) needs to sign, anyone can submit the transaction, and authorization comes solely from an attestation signed by the oracle. transferAnchor follows the same behavior and events as ERC-721.safeTransferFrom, except that it mints a new NFT if tokenByAnchor(anchor) is 0, and fails if attestation.to is already the owner. Approval and burn authorization are each set to one of the combinations in the Authorization enum: OWNER (token owner), ISSUER (issuing contract), or ASSET (attestation), and can be queried via burnAuthorization() and approveAuthorization(). This design ensures that even if a token ends up in an arbitrary account whose private key is unknown, or in a contract that does not support ERC-721, approval and burning remain possible through proof of the asset alone.
Three optional extensions
The AttestationLimited extension caps the number of attestation uses per anchor. It implements attestationLimit(anchor) and attestationUsagesLeft(anchor), and fixes the direction in which the limit can be updated to one of IMMUTABLE, INCREASE_ONLY, DECREASE_ONLY, or FLEXIBLE, exposed via attestationLimitPolicy(). The Floatable extension allows a token to be temporarily floated. When floating(anchor) is true, that token can be transferred through the normal ERC-721 method (isApprovedOrOwner approval) without an attestation, and the authority to start and stop floating is kept separate via floatStartAuthorization and floatStopAuthorization. The ValidAnchors extension records the set of valid anchors on-chain as a Merkle tree root hash and verifies them with anchorValid(anchor, proof). Only the maintainer can update the root, so even if the oracle is compromised, it cannot slip in a new anchor.
Locked by default, compatible with ERC-721
Anchors must be unreleased by default, and ordinary ERC-721 transfers must all fail for tokens whose anchor has not been released. In other words, by default a token can only move via attestation. Adding the Floatable extension turns this into familiar ERC-721 behavior with an attestation authorization mechanism layered on top, making it fully compatible with ERC-721. Since the commonly used _beforeTokenTransfer() hook is called for every transfer, including attestation-based ones, checking lock extensions such as ERC-5192 or ERC-6982 in this same hook can block both ordinary transfers and attestation-based ones. For marketplaces, it is also recommended to add interfaces that signal transferability, such as ERC-6454 and ERC-5484.
Additional requirements for physical assets
When the asset is physical, the oracle must prove physical proximity (typically a few millimeters to a few meters) between the input device specifying the to address and the physical anchor, and the method used must be resistant to attempts to clone or reproduce the anchor and to spoofing (presentation attacks). It must also be implemented under the assumption that whoever specifies the to address could be a malicious party trying to obtain a false attestation without ever having had access to the physical item. The physical asset itself must have an anchor serving as a unique identifier (hashing the serial number is recommended over plaintext) along with a security feature or marking proving physical presence, and anchor technologies with non-clonable security properties are recommended. Technologies that are easily copied, physically or digitally, such as barcodes or ordinary NFC chips, are not recommended.
A proposal still under review
This proposal is in Review status, so the interface or requirements may change during review, and it is advisable to check the latest source before implementing. The ERC-165 identifier for the standard interface is 0xa9cf7635, AttestationLimited is 0x75a2e933, Floatable is 0xf82773f7, and ValidAnchors is 0x051c9bd8. The source provides a minimal implementation covering only the standard interface, a full implementation supporting all three extensions, a TypeScript example for generating attestations, and tests for both configurations.
Things to watch out for
- Stealing the asset also takes the NFT: Because this standard mirrors control of the asset directly onto the token, someone who steals the asset can take the NFT too once they prove control. The oracle only attests to control, not to whether someone is the rightful owner. However, the moment the perpetrator uses the anchor, an on-chain address tied to the act is exposed, which can serve as a lead for investigation, and combined with a locking mechanism, attestation-based actions can be blocked temporarily or permanently.
- Gas-sponsor fund-drain attack: Because attestation-based actions are permissionless, it is common for the asset issuer to cover gas costs on behalf of users. If a malicious user repeatedly obtains oracle proofs under different account addresses and passes them to the sponsor, the sponsor's funds can be drained, halting the system. The AttestationLimited extension defends against this by capping the number of uses per anchor.
- Hash serial numbers before use: If sequential identifiers are used in plaintext, the minimum and maximum numbers alone can be used to estimate how many assets have been released to the market, exposing confidential information such as sales volume by product line. Hashing or encrypting the serial number to obscure the actual quantity is the simplest countermeasure.
- Do not fully trust the oracle: Since the oracle knows the Merkle tree of valid anchors, a compromised oracle could maliciously alter the tree. On-chain anchor verification should be kept, and only the maintainer, not the oracle, should be able to update the tree, so that even if the oracle is breached, it cannot add new anchors. The oracle and maintainer accounts should be managed separately off-chain so that a breach of one system does not compromise both.
- A salt leaf to guard against Merkle tree reconstruction: Once every anchor's NFT has been minted, all anchors become visible on-chain, making it possible to reconstruct the Merkle tree. It is recommended to include one salt leaf in the tree for which the oracle will never issue an attestation.