-
Notifications
You must be signed in to change notification settings - Fork 0
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
feature: add XPNFTToken with metadata generators #12
Changes from all commits
4c7caf8
f71a69d
ec76274
e50f304
90c1897
e69d1ca
ea38ac9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
// SPDX-License-Identifier: MIT | ||
pragma solidity ^0.8.26; | ||
|
||
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; | ||
import { INFTMetadataGenerator } from "./interfaces/INFTMetadataGenerator.sol"; | ||
|
||
interface IERC20 { | ||
function balanceOf(address account) external view returns (uint256); | ||
} | ||
|
||
contract XPNFTToken is Ownable { | ||
error XPNFT__TransferNotAllowed(); | ||
error XPNFT__InvalidTokenId(); | ||
|
||
IERC20 public xpToken; | ||
INFTMetadataGenerator public metadataGenerator; | ||
|
||
string private name = "XPNFT"; | ||
string private symbol = "XPNFT"; | ||
|
||
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); | ||
|
||
modifier onlyValidTokenId(uint256 tokenId) { | ||
if (tokenId > type(uint160).max) { | ||
revert XPNFT__InvalidTokenId(); | ||
} | ||
_; | ||
} | ||
|
||
constructor(address xpTokenAddress, address _metadataGenerator) Ownable(msg.sender) { | ||
xpToken = IERC20(xpTokenAddress); | ||
metadataGenerator = INFTMetadataGenerator(_metadataGenerator); | ||
} | ||
|
||
function setMetadataGenerator(address _metadataGenerator) external onlyOwner { | ||
metadataGenerator = INFTMetadataGenerator(_metadataGenerator); | ||
} | ||
|
||
function mint() external { | ||
emit Transfer(msg.sender, msg.sender, uint256(uint160(msg.sender))); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you elaborate on why we're doing it this way again? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah I see, so an account's address is essentially the ID for the token. |
||
} | ||
|
||
function balanceOf(address) external pure returns (uint256) { | ||
return 1; | ||
} | ||
|
||
function ownerOf(uint256 tokenId) external pure onlyValidTokenId(tokenId) returns (address) { | ||
address owner = address(uint160(tokenId)); | ||
return owner; | ||
} | ||
|
||
function safeTransferFrom(address, address, uint256, bytes calldata) external pure { | ||
revert XPNFT__TransferNotAllowed(); | ||
} | ||
|
||
function safeTransferFrom(address, address, uint256) external pure { | ||
revert XPNFT__TransferNotAllowed(); | ||
} | ||
|
||
function transferFrom(address, address, uint256) external pure { | ||
revert XPNFT__TransferNotAllowed(); | ||
} | ||
|
||
function approve(address, uint256) external pure { | ||
revert XPNFT__TransferNotAllowed(); | ||
} | ||
|
||
function setApprovalForAll(address, bool) external pure { | ||
revert XPNFT__TransferNotAllowed(); | ||
} | ||
|
||
function getApproved(uint256) external pure returns (address) { | ||
return address(0); | ||
} | ||
|
||
function isApprovedForAll(address, address) external pure returns (bool) { | ||
return false; | ||
} | ||
|
||
function tokenURI(uint256 tokenId) external view onlyValidTokenId(tokenId) returns (string memory) { | ||
address account = address(uint160(tokenId)); | ||
uint256 balance = xpToken.balanceOf(account); | ||
return metadataGenerator.generate(account, balance); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Very nice |
||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
// SPDX-License-Identifier: MIT | ||
pragma solidity ^0.8.26; | ||
|
||
interface INFTMetadataGenerator { | ||
function generate(address account, uint256 balance) external view returns (string memory); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
// SPDX-License-Identifier: MIT | ||
pragma solidity ^0.8.26; | ||
|
||
import { Base64 } from "@openzeppelin/contracts/utils/Base64.sol"; | ||
import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; | ||
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; | ||
import { INFTMetadataGenerator } from "../interfaces/INFTMetadataGenerator.sol"; | ||
|
||
abstract contract BaseNFTMetadataGenerator is INFTMetadataGenerator, Ownable { | ||
address public nft; | ||
|
||
constructor(address _nft) Ownable(msg.sender) { | ||
nft = _nft; | ||
} | ||
|
||
function generate(address account, uint256 balance) external view returns (string memory) { | ||
string memory baseName = "XPNFT Token "; | ||
string memory baseDescription = "This is a XPNFT token for address "; | ||
|
||
string memory propName = string(abi.encodePacked(baseName, Strings.toHexString(account))); | ||
string memory propDescription = string( | ||
abi.encodePacked(baseDescription, Strings.toHexString(account), " with balance ", Strings.toString(balance)) | ||
); | ||
|
||
string memory image = generateImageURI(account, balance); | ||
|
||
bytes memory json = abi.encodePacked( | ||
"{\"name\":\"", propName, "\",\"description\":\"", propDescription, "\",\"image\":\"", image, "\"}" | ||
); | ||
|
||
string memory jsonBase64 = Base64.encode(json); | ||
return string(abi.encodePacked("data:application/json;base64,", jsonBase64)); | ||
} | ||
|
||
function generateImageURI(address account, uint256 balance) internal view virtual returns (string memory); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
// SPDX-License-Identifier: MIT | ||
pragma solidity ^0.8.26; | ||
|
||
import { Base64 } from "@openzeppelin/contracts/utils/Base64.sol"; | ||
import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; | ||
import { BaseNFTMetadataGenerator } from "./BaseNFTMetadataGenerator.sol"; | ||
|
||
contract NFTMetadataGeneratorSVG is BaseNFTMetadataGenerator { | ||
string public imagePrefix = ""; | ||
string public imageSuffix = ""; | ||
|
||
constructor(address nft, string memory _imagePrefix, string memory _imageSuffix) BaseNFTMetadataGenerator(nft) { | ||
imagePrefix = _imagePrefix; | ||
imageSuffix = _imageSuffix; | ||
} | ||
|
||
function setImageStrings(string memory _imagePrefix, string memory _imageSuffix) external onlyOwner { | ||
imagePrefix = _imagePrefix; | ||
imageSuffix = _imageSuffix; | ||
} | ||
|
||
function generateImageURI(address, uint256 balance) internal view override returns (string memory) { | ||
string memory text = Strings.toString(balance / 1e18); | ||
bytes memory svg = abi.encodePacked(imagePrefix, text, imageSuffix); | ||
|
||
return string(abi.encodePacked("data:image/svg+xml;base64,", Base64.encode(svg))); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
// SPDX-License-Identifier: MIT | ||
pragma solidity ^0.8.26; | ||
|
||
import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; | ||
import { BaseNFTMetadataGenerator } from "./BaseNFTMetadataGenerator.sol"; | ||
|
||
contract NFTMetadataGeneratorURL is BaseNFTMetadataGenerator { | ||
string public urlPrefix; | ||
string public urlSuffix; | ||
|
||
constructor(address nft, string memory _urlPrefix, string memory _urlSuffix) BaseNFTMetadataGenerator(nft) { | ||
urlPrefix = _urlPrefix; | ||
urlSuffix = _urlSuffix; | ||
} | ||
|
||
function setURLStrings(string memory _urlPrefix, string memory _urlSuffix) external onlyOwner { | ||
urlPrefix = _urlPrefix; | ||
urlSuffix = _urlSuffix; | ||
} | ||
|
||
function generateImageURI(address account, uint256) internal view override returns (string memory) { | ||
return string(abi.encodePacked(urlPrefix, Strings.toHexString(account), urlSuffix)); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
// SPDX-License-Identifier: MIT | ||
pragma solidity ^0.8.26; | ||
|
||
import { Test, console } from "forge-std/Test.sol"; | ||
import { Base64 } from "@openzeppelin/contracts/utils/Base64.sol"; | ||
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; | ||
import { MockToken } from "./mocks/MockToken.sol"; | ||
import { XPNFTToken } from "../src/XPNFTToken.sol"; | ||
import { MockMetadataGenerator } from "./mocks/MockMetadataGenerator.sol"; | ||
|
||
contract XPNFTTokenTest is Test { | ||
MockToken erc20Token; | ||
MockMetadataGenerator metadataGenerator; | ||
XPNFTToken nft; | ||
|
||
address alice = makeAddr("alice"); | ||
|
||
function setUp() public { | ||
erc20Token = new MockToken("Test", "TEST"); | ||
metadataGenerator = new MockMetadataGenerator(address(erc20Token), "https://test.local/"); | ||
nft = new XPNFTToken(address(erc20Token), address(metadataGenerator)); | ||
|
||
address[1] memory users = [alice]; | ||
for (uint256 i = 0; i < users.length; i++) { | ||
erc20Token.mint(users[i], 10e18); | ||
} | ||
} | ||
|
||
function addressToId(address addr) internal pure returns (uint256) { | ||
return uint256(uint160(addr)); | ||
} | ||
|
||
function testTokenURI() public view { | ||
bytes memory expectedMetadata = abi.encodePacked( | ||
"{\"name\":\"XPNFT Token 0x328809bc894f92807417d2dad6b7c998c1afdac6\",", | ||
// solhint-disable-next-line | ||
"\"description\":\"This is a XPNFT token for address 0x328809bc894f92807417d2dad6b7c998c1afdac6 with balance 10000000000000000000\",", | ||
"\"image\":\"https://test.local/0x328809bc894f92807417d2dad6b7c998c1afdac6\"}" | ||
); | ||
string memory metadata = nft.tokenURI(addressToId(alice)); | ||
assertEq(metadata, string(abi.encodePacked("data:application/json;base64,", Base64.encode(expectedMetadata)))); | ||
} | ||
|
||
function testSetMetadataGenerator() public { | ||
MockMetadataGenerator newMetadataGenerator = | ||
new MockMetadataGenerator(address(erc20Token), "https://new-test.local/"); | ||
|
||
nft.setMetadataGenerator(address(newMetadataGenerator)); | ||
|
||
assertEq(address(nft.metadataGenerator()), address(newMetadataGenerator)); | ||
} | ||
|
||
function testSetMetadataGeneratorRevert() public { | ||
MockMetadataGenerator newMetadataGenerator = | ||
new MockMetadataGenerator(address(erc20Token), "https://new-test.local/"); | ||
|
||
vm.prank(alice); | ||
vm.expectPartialRevert(Ownable.OwnableUnauthorizedAccount.selector); | ||
nft.setMetadataGenerator(address(newMetadataGenerator)); | ||
} | ||
|
||
function testTransferNotAllowed() public { | ||
vm.expectRevert(XPNFTToken.XPNFT__TransferNotAllowed.selector); | ||
nft.transferFrom(alice, address(0), addressToId(alice)); | ||
} | ||
|
||
function testSafeTransferNotAllowed() public { | ||
vm.expectRevert(XPNFTToken.XPNFT__TransferNotAllowed.selector); | ||
nft.safeTransferFrom(alice, address(0), addressToId(alice)); | ||
} | ||
|
||
function testSafeTransferWithDataNotAllowed() public { | ||
vm.expectRevert(XPNFTToken.XPNFT__TransferNotAllowed.selector); | ||
nft.safeTransferFrom(alice, address(0), addressToId(alice), ""); | ||
} | ||
|
||
function testApproveNotAllowed() public { | ||
vm.expectRevert(XPNFTToken.XPNFT__TransferNotAllowed.selector); | ||
nft.approve(address(0), addressToId(alice)); | ||
} | ||
|
||
function testSetApprovalForAllNotAllowed() public { | ||
vm.expectRevert(XPNFTToken.XPNFT__TransferNotAllowed.selector); | ||
nft.setApprovalForAll(address(0), true); | ||
} | ||
|
||
function testGetApproved() public view { | ||
address approved = nft.getApproved(addressToId(alice)); | ||
assertEq(approved, address(0)); | ||
} | ||
|
||
function testIsApprovedForAll() public view { | ||
bool isApproved = nft.isApprovedForAll(alice, address(0)); | ||
assertFalse(isApproved); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
// SPDX-License-Identifier: MIT | ||
pragma solidity ^0.8.26; | ||
|
||
import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; | ||
import { BaseNFTMetadataGenerator } from "../../src/nft-metadata-generators/BaseNFTMetadataGenerator.sol"; | ||
|
||
contract MockMetadataGenerator is BaseNFTMetadataGenerator { | ||
string private _baseURI; | ||
|
||
constructor(address nft, string memory baseURI) BaseNFTMetadataGenerator(nft) { | ||
_baseURI = baseURI; | ||
} | ||
|
||
function generateImageURI(address account, uint256) internal view override returns (string memory) { | ||
bytes memory uri = abi.encodePacked(_baseURI, Strings.toHexString(account)); | ||
return string(uri); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
// SPDX-License-Identifier: MIT | ||
pragma solidity ^0.8.26; | ||
|
||
import { Test } from "forge-std/Test.sol"; | ||
import { Base64 } from "@openzeppelin/contracts/utils/Base64.sol"; | ||
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; | ||
import { MockToken } from "../mocks/MockToken.sol"; | ||
import { NFTMetadataGeneratorSVG } from "../../src/nft-metadata-generators/NFTMetadataGeneratorSVG.sol"; | ||
|
||
contract NFTMetadataGeneratorSVGTest is Test { | ||
MockToken private erc20Token; | ||
NFTMetadataGeneratorSVG private metadataGenerator; | ||
|
||
address private alice = makeAddr("alice"); | ||
|
||
function setUp() public { | ||
erc20Token = new MockToken("Test", "TEST"); | ||
metadataGenerator = new NFTMetadataGeneratorSVG(address(erc20Token), "<svg>", "</svg>"); | ||
|
||
erc20Token.mint(alice, 10e18); | ||
} | ||
|
||
function testGenerateMetadata() public view { | ||
string memory expectedName = "XPNFT Token 0x328809bc894f92807417d2dad6b7c998c1afdac6"; | ||
string memory expectedDescription = | ||
// solhint-disable-next-line | ||
"This is a XPNFT token for address 0x328809bc894f92807417d2dad6b7c998c1afdac6 with balance 10000000000000000000"; | ||
string memory encodedImage = Base64.encode(abi.encodePacked("<svg>10</svg>")); | ||
string memory expectedImage = string(abi.encodePacked("data:image/svg+xml;base64,", encodedImage)); | ||
|
||
bytes memory expectedMetadata = abi.encodePacked( | ||
"{\"name\":\"", | ||
expectedName, | ||
"\",", | ||
"\"description\":\"", | ||
expectedDescription, | ||
"\",", | ||
"\"image\":\"", | ||
expectedImage, | ||
"\"}" | ||
); | ||
|
||
string memory metadata = metadataGenerator.generate(alice, 10e18); | ||
assertEq(metadata, string(abi.encodePacked("data:application/json;base64,", Base64.encode(expectedMetadata)))); | ||
} | ||
|
||
function testSetImageStrings() public { | ||
assertEq(metadataGenerator.imagePrefix(), "<svg>"); | ||
assertEq(metadataGenerator.imageSuffix(), "</svg>"); | ||
|
||
metadataGenerator.setImageStrings("<new-svg>", "</new-svg>"); | ||
|
||
assertEq(metadataGenerator.imagePrefix(), "<new-svg>"); | ||
assertEq(metadataGenerator.imageSuffix(), "</new-svg>"); | ||
} | ||
|
||
function testSetImageStringsRevert() public { | ||
vm.prank(alice); | ||
vm.expectPartialRevert(Ownable.OwnableUnauthorizedAccount.selector); | ||
metadataGenerator.setImageStrings("<new-svg>", "</new-svg>"); | ||
} | ||
} |
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.
Just a nitpick: shall we make this
Ownable2step
?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.
I opened another PR for that, #51