-
Notifications
You must be signed in to change notification settings - Fork 5.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add EIP: Cross-Contract Hierarchical NFT #7638
Closed
Closed
Changes from 3 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,247 @@ | ||
--- | ||
eip: <TBA> | ||
title: Cross-Contract Hierarchical NFT | ||
description: An extension of ERC-721 to maintain hierarchical relationship between tokens from different contracts. | ||
author: Ming Jiang (@minkyn), Zheng Han (@hanbsd), Fan Yang (@fayang) | ||
discussions-to: <URL> | ||
status: Draft | ||
type: Standards Track | ||
category: ERC | ||
created: 2023-08-24 | ||
requires: 721 | ||
--- | ||
|
||
## Abstract | ||
|
||
This standard is an extension of [ERC-721](./eip-721.md). It proposes a way to maintain hierarchical relationship between tokens from different contracts. This standard provides an interface to query the parent tokens of an NFT or whether the parent relation exists between two NFTs. | ||
|
||
## Motivation | ||
|
||
Some NFTs want to generate derivative assets as new NFTs. For example, a 2D NFT image would like to publish its 3D model as a new derivative NFT. An NFT may also be derived from multiple parent NFTs. Such cases include a movie NFT featuring multiple characters from other NFTs. This standard is proposed to record such hierarchical relationship between derivative NFTs. | ||
|
||
Existing [ERC-6150](./eip-6150.md) introduces a similar feature, but it only builds hierarchy between tokens within the same contract. More than often we need to create a new NFT collection with the derivative tokens. Therefore the cross-contract relationship establishment is required. | ||
|
||
## Specification | ||
|
||
Solidity interface available at [IERCXXXX.sol](../assets/eip-nft_hierarchy/contracts/IERCXXXX.sol): | ||
|
||
```solidity | ||
/// @notice The struct used to reference a token in an NFT contract | ||
struct Token { | ||
address collection; | ||
uint256 id; | ||
} | ||
|
||
interface IERCXXXX { | ||
|
||
/// @notice Emitted when the parent tokens for an NFT is updated | ||
event UpdateParentTokens(uint256 indexed tokenId); | ||
|
||
/// @notice Get the parent tokens of an NFT | ||
/// @param tokenId The NFT to get the parent tokens for | ||
/// @return An array of parent tokens for this NFT | ||
function parentTokensOf(uint256 tokenId) external view returns (Token[] memory); | ||
|
||
/// @notice Check if another token is a parent of an NFT | ||
/// @param tokenId The NFT to check its parent for | ||
/// @param otherToken Another token to check as a parent or not | ||
/// @return Whether `otherToken` is a parent of `tokenId` | ||
function isParentToken(uint256 tokenId, Token memory otherToken) external view returns (bool); | ||
|
||
} | ||
``` | ||
|
||
## Rationale | ||
|
||
This standard differs from [ERC-6150](./eip-6150.md) in mainly two aspects: supporting cross-contract token reference, and allowing multiple parents. But we try to keep the naming consistent overall. | ||
|
||
In addition, we didn't include `child` relation in the interface. An original NFT exists before its derivative NFTs. Therefore we know what parent tokens to include when minting derivative NFTs, but we wouldn't know the children tokens when minting the original NFT. If we have to record the children, that means whenever we mint a derivative NFT, we need to call on its original NFT to add it as a child. However, those two NFTs may belong to different contracts and thus require different write permissions, making it impossible to combine the two operations into a single transaction in practice. As a result, we decide to only record the `parent` relation from the derivative NFTs. | ||
|
||
## Backwards Compatibility | ||
|
||
No backwards compatibility issues found. | ||
|
||
## Test Cases | ||
|
||
Test cases available available at: [ERCXXXX.test.ts](../assets/eip-nft_hierarchy/test/ERCXXXX.test.ts): | ||
|
||
```typescript | ||
import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers"; | ||
import { expect } from "chai"; | ||
import { ethers } from "hardhat"; | ||
|
||
const NAME = "NAME"; | ||
const SYMBOL = "SYMBOL"; | ||
const TOKEN_ID = 1234; | ||
|
||
const PARENT_1_COLLECTION = "0xDEAdBEEf00000000000000000123456789ABCdeF"; | ||
const PARENT_1_ID = 8888; | ||
const PARENT_1_TOKEN = { collection: PARENT_1_COLLECTION, id: PARENT_1_ID }; | ||
|
||
const PARENT_2_COLLECTION = "0xBaDc0ffEe0000000000000000123456789aBCDef"; | ||
const PARENT_2_ID = 9999; | ||
const PARENT_2_TOKEN = { collection: PARENT_2_COLLECTION, id: PARENT_2_ID }; | ||
|
||
describe("ERCXXXX", function () { | ||
|
||
async function deployContractFixture() { | ||
const [deployer, owner] = await ethers.getSigners(); | ||
|
||
const contract = await ethers.deployContract("ERCXXXX", [NAME, SYMBOL], deployer); | ||
await contract.mint(owner, TOKEN_ID); | ||
|
||
return { contract, owner }; | ||
} | ||
|
||
describe("Functions", function () { | ||
it("Should not set parent tokens if not owner or approved", async function () { | ||
const { contract } = await loadFixture(deployContractFixture); | ||
|
||
await expect(contract.setParentTokens(TOKEN_ID, [PARENT_1_TOKEN])) | ||
.to.be.revertedWith("ERCXXXX: caller is not owner or approved"); | ||
}); | ||
|
||
it("Should correctly query token without parents", async function () { | ||
const { contract } = await loadFixture(deployContractFixture); | ||
|
||
expect(await contract.parentTokensOf(TOKEN_ID)).to.have.lengthOf(0); | ||
|
||
expect(await contract.isParentToken(TOKEN_ID, PARENT_1_TOKEN)).to.equal(false); | ||
}); | ||
|
||
it("Should set parent tokens and then update", async function () { | ||
const { contract, owner } = await loadFixture(deployContractFixture); | ||
|
||
await contract.connect(owner).setParentTokens(TOKEN_ID, [PARENT_1_TOKEN]); | ||
|
||
let parentTokens = await contract.parentTokensOf(TOKEN_ID); | ||
expect(parentTokens).to.have.lengthOf(1); | ||
expect(parentTokens[0].collection).to.equal(PARENT_1_COLLECTION); | ||
expect(parentTokens[0].id).to.equal(PARENT_1_ID); | ||
|
||
expect(await contract.isParentToken(TOKEN_ID, PARENT_1_TOKEN)).to.equal(true); | ||
expect(await contract.isParentToken(TOKEN_ID, PARENT_2_TOKEN)).to.equal(false); | ||
|
||
await contract.connect(owner).setParentTokens(TOKEN_ID, [PARENT_2_TOKEN]); | ||
|
||
parentTokens = await contract.parentTokensOf(TOKEN_ID); | ||
expect(parentTokens).to.have.lengthOf(1); | ||
expect(parentTokens[0].collection).to.equal(PARENT_2_COLLECTION); | ||
expect(parentTokens[0].id).to.equal(PARENT_2_ID); | ||
|
||
expect(await contract.isParentToken(TOKEN_ID, PARENT_1_TOKEN)).to.equal(false); | ||
expect(await contract.isParentToken(TOKEN_ID, PARENT_2_TOKEN)).to.equal(true); | ||
}); | ||
|
||
it("Should burn and clear parent tokens", async function () { | ||
const { contract, owner } = await loadFixture(deployContractFixture); | ||
|
||
await contract.connect(owner).setParentTokens(TOKEN_ID, [PARENT_1_TOKEN, PARENT_2_TOKEN]); | ||
await contract.burn(TOKEN_ID); | ||
|
||
await expect(contract.parentTokensOf(TOKEN_ID)).to.be.revertedWith("ERCXXXX: query for nonexistent token"); | ||
await expect(contract.isParentToken(TOKEN_ID, PARENT_1_TOKEN)).to.be.revertedWith("ERCXXXX: query for nonexistent token"); | ||
await expect(contract.isParentToken(TOKEN_ID, PARENT_2_TOKEN)).to.be.revertedWith("ERCXXXX: query for nonexistent token"); | ||
|
||
await contract.mint(owner, TOKEN_ID); | ||
|
||
expect(await contract.parentTokensOf(TOKEN_ID)).to.have.lengthOf(0); | ||
expect(await contract.isParentToken(TOKEN_ID, PARENT_1_TOKEN)).to.equal(false); | ||
expect(await contract.isParentToken(TOKEN_ID, PARENT_2_TOKEN)).to.equal(false); | ||
}); | ||
}); | ||
|
||
describe("Events", function () { | ||
it("Should emit event when set parent tokens", async function () { | ||
const { contract, owner } = await loadFixture(deployContractFixture); | ||
|
||
await expect(contract.connect(owner).setParentTokens(TOKEN_ID, [PARENT_1_TOKEN, PARENT_2_TOKEN])) | ||
.to.emit(contract, "UpdateParentTokens").withArgs(TOKEN_ID); | ||
}); | ||
}); | ||
|
||
}); | ||
``` | ||
|
||
## Reference Implementation | ||
|
||
Reference implementation available at: [ERCXXXX.sol](../assets/eip-nft_hierarchy/contracts/ERCXXXX.sol): | ||
|
||
```solidity | ||
// SPDX-License-Identifier: CC0-1.0 | ||
|
||
pragma solidity ^0.8.0; | ||
|
||
import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; | ||
|
||
import "./IERCXXXX.sol"; | ||
|
||
contract ERCXXXX is ERC721, IERCXXXX { | ||
|
||
mapping(uint256 => Token[]) private _parentTokens; | ||
mapping(uint256 => mapping(address => mapping(uint256 => bool))) private _isParentToken; | ||
|
||
constructor( | ||
string memory name, string memory symbol | ||
) ERC721(name, symbol) {} | ||
|
||
function supportsInterface( | ||
bytes4 interfaceId | ||
) public view virtual override returns (bool) { | ||
return interfaceId == type(IERCXXXX).interfaceId || super.supportsInterface(interfaceId); | ||
} | ||
|
||
function parentTokensOf( | ||
uint256 tokenId | ||
) public view virtual override returns (Token[] memory) { | ||
require(_exists(tokenId), "ERCXXXX: query for nonexistent token"); | ||
return _parentTokens[tokenId]; | ||
} | ||
|
||
function isParentToken( | ||
uint256 tokenId, | ||
Token memory otherToken | ||
) public view virtual override returns (bool) { | ||
require(_exists(tokenId), "ERCXXXX: query for nonexistent token"); | ||
return _isParentToken[tokenId][otherToken.collection][otherToken.id]; | ||
} | ||
|
||
function setParentTokens( | ||
uint256 tokenId, Token[] memory parentTokens | ||
) public virtual { | ||
require(_isApprovedOrOwner(_msgSender(), tokenId), "ERCXXXX: caller is not owner or approved"); | ||
_clear(tokenId); | ||
for (uint256 i = 0; i < parentTokens.length; i++) { | ||
_parentTokens[tokenId].push(parentTokens[i]); | ||
_isParentToken[tokenId][parentTokens[i].collection][parentTokens[i].id] = true; | ||
} | ||
emit UpdateParentTokens(tokenId); | ||
} | ||
|
||
function _burn( | ||
uint256 tokenId | ||
) internal virtual override { | ||
super._burn(tokenId); | ||
_clear(tokenId); | ||
} | ||
|
||
function _clear( | ||
uint256 tokenId | ||
) private { | ||
Token[] storage parentTokens = _parentTokens[tokenId]; | ||
for (uint256 i = 0; i < parentTokens.length; i++) { | ||
delete _isParentToken[tokenId][parentTokens[i].collection][parentTokens[i].id]; | ||
} | ||
delete _parentTokens[tokenId]; | ||
} | ||
|
||
} | ||
``` | ||
|
||
## Security Considerations | ||
|
||
No security considerations found. | ||
|
||
## Copyright | ||
|
||
Copyright and related rights waived via [CC0](../LICENSE.md). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
// SPDX-License-Identifier: CC0-1.0 | ||
|
||
pragma solidity ^0.8.0; | ||
|
||
import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; | ||
|
||
import "./IERCXXXX.sol"; | ||
|
||
contract ERCXXXX is ERC721, IERCXXXX { | ||
|
||
mapping(uint256 => Token[]) private _parentTokens; | ||
mapping(uint256 => mapping(address => mapping(uint256 => bool))) private _isParentToken; | ||
|
||
constructor( | ||
string memory name, string memory symbol | ||
) ERC721(name, symbol) {} | ||
|
||
function supportsInterface( | ||
bytes4 interfaceId | ||
) public view virtual override returns (bool) { | ||
return interfaceId == type(IERCXXXX).interfaceId || super.supportsInterface(interfaceId); | ||
} | ||
|
||
function parentTokensOf( | ||
uint256 tokenId | ||
) public view virtual override returns (Token[] memory) { | ||
require(_exists(tokenId), "ERCXXXX: query for nonexistent token"); | ||
return _parentTokens[tokenId]; | ||
} | ||
|
||
function isParentToken( | ||
uint256 tokenId, | ||
Token memory otherToken | ||
) public view virtual override returns (bool) { | ||
require(_exists(tokenId), "ERCXXXX: query for nonexistent token"); | ||
return _isParentToken[tokenId][otherToken.collection][otherToken.id]; | ||
} | ||
|
||
function setParentTokens( | ||
uint256 tokenId, Token[] memory parentTokens | ||
) public virtual { | ||
require(_isApprovedOrOwner(_msgSender(), tokenId), "ERCXXXX: caller is not owner or approved"); | ||
_clear(tokenId); | ||
for (uint256 i = 0; i < parentTokens.length; i++) { | ||
_parentTokens[tokenId].push(parentTokens[i]); | ||
_isParentToken[tokenId][parentTokens[i].collection][parentTokens[i].id] = true; | ||
} | ||
emit UpdateParentTokens(tokenId); | ||
} | ||
|
||
function _burn( | ||
uint256 tokenId | ||
) internal virtual override { | ||
super._burn(tokenId); | ||
_clear(tokenId); | ||
} | ||
|
||
function _clear( | ||
uint256 tokenId | ||
) private { | ||
Token[] storage parentTokens = _parentTokens[tokenId]; | ||
for (uint256 i = 0; i < parentTokens.length; i++) { | ||
delete _isParentToken[tokenId][parentTokens[i].collection][parentTokens[i].id]; | ||
} | ||
delete _parentTokens[tokenId]; | ||
} | ||
|
||
// For test only | ||
function mint( | ||
address to, uint256 tokenId | ||
) public virtual { | ||
_mint(to, tokenId); | ||
} | ||
|
||
// For test only | ||
function burn( | ||
uint256 tokenId | ||
) public virtual { | ||
_burn(tokenId); | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
// SPDX-License-Identifier: CC0-1.0 | ||
|
||
pragma solidity ^0.8.0; | ||
|
||
/// @notice The struct used to reference a token in an NFT contract | ||
struct Token { | ||
address collection; | ||
uint256 id; | ||
} | ||
|
||
interface IERCXXXX { | ||
|
||
/// @notice Emitted when the parent tokens for an NFT is updated | ||
event UpdateParentTokens(uint256 indexed tokenId); | ||
|
||
/// @notice Get the parent tokens of an NFT | ||
/// @param tokenId The NFT to get the parent tokens for | ||
/// @return An array of parent tokens for this NFT | ||
function parentTokensOf(uint256 tokenId) external view returns (Token[] memory); | ||
|
||
/// @notice Check if another token is a parent of an NFT | ||
/// @param tokenId The NFT to check its parent for | ||
/// @param otherToken Another token to check as a parent or not | ||
/// @return Whether `otherToken` is a parent of `tokenId` | ||
function isParentToken(uint256 tokenId, Token memory otherToken) external view returns (bool); | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import { HardhatUserConfig } from "hardhat/config"; | ||
import "@nomicfoundation/hardhat-toolbox"; | ||
|
||
const config: HardhatUserConfig = { | ||
solidity: { | ||
version: "0.8.21", | ||
settings: { | ||
optimizer: { | ||
enabled: true, | ||
runs: 200, | ||
}, | ||
}, | ||
}, | ||
}; | ||
|
||
export default config; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Assigning next sequential EIP/ERC number.
EIP/ERC numbering changed to sequential from 7500 and is no longer the PR number.
Please also update the filename.