diff --git a/lib/forge-std b/lib/forge-std index 1714bee..035de35 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 1714bee72e286e73f76e320d110e0eaf5c4e649d +Subproject commit 035de35f5e366c8d6ed142aec4ccb57fe2dd87d4 diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts index dbb6104..6325009 160000 --- a/lib/openzeppelin-contracts +++ b/lib/openzeppelin-contracts @@ -1 +1 @@ -Subproject commit dbb6104ce834628e473d2173bbc9d47f81a9eec3 +Subproject commit 632500967504310a07f9d2c70ad378cf53be0109 diff --git a/src/XPNFTToken.sol b/src/XPNFTToken.sol new file mode 100644 index 0000000..9240441 --- /dev/null +++ b/src/XPNFTToken.sol @@ -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))); + } + + 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); + } +} diff --git a/src/interfaces/INFTMetadataGenerator.sol b/src/interfaces/INFTMetadataGenerator.sol new file mode 100644 index 0000000..25701aa --- /dev/null +++ b/src/interfaces/INFTMetadataGenerator.sol @@ -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); +} diff --git a/src/nft-metadata-generators/BaseNFTMetadataGenerator.sol b/src/nft-metadata-generators/BaseNFTMetadataGenerator.sol new file mode 100644 index 0000000..60b115f --- /dev/null +++ b/src/nft-metadata-generators/BaseNFTMetadataGenerator.sol @@ -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); +} diff --git a/src/nft-metadata-generators/NFTMetadataGeneratorSVG.sol b/src/nft-metadata-generators/NFTMetadataGeneratorSVG.sol new file mode 100644 index 0000000..3dd659f --- /dev/null +++ b/src/nft-metadata-generators/NFTMetadataGeneratorSVG.sol @@ -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))); + } +} diff --git a/src/nft-metadata-generators/NFTMetadataGeneratorURL.sol b/src/nft-metadata-generators/NFTMetadataGeneratorURL.sol new file mode 100644 index 0000000..e84d8d2 --- /dev/null +++ b/src/nft-metadata-generators/NFTMetadataGeneratorURL.sol @@ -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)); + } +} diff --git a/test/RewardsStreamer.t.sol b/test/RewardsStreamer.t.sol index 9ed3a2c..32c15a1 100644 --- a/test/RewardsStreamer.t.sol +++ b/test/RewardsStreamer.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.26; -import { Test, console } from "forge-std/Test.sol"; +import { Test } from "forge-std/Test.sol"; import { RewardsStreamer } from "../src/RewardsStreamer.sol"; import { MockToken } from "./mocks/MockToken.sol"; diff --git a/test/RewardsStreamerMP.t.sol b/test/RewardsStreamerMP.t.sol index ee3c4e7..d83ecfc 100644 --- a/test/RewardsStreamerMP.t.sol +++ b/test/RewardsStreamerMP.t.sol @@ -1,10 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.26; -import { Test, console } from "forge-std/Test.sol"; +import { Test } from "forge-std/Test.sol"; import { RewardsStreamerMP } from "../src/RewardsStreamerMP.sol"; import { MockToken } from "./mocks/MockToken.sol"; -import "forge-std/console.sol"; contract RewardsStreamerMPTest is Test { MockToken rewardToken; diff --git a/test/XPNFTToken.t.sol b/test/XPNFTToken.t.sol new file mode 100644 index 0000000..c0c5b57 --- /dev/null +++ b/test/XPNFTToken.t.sol @@ -0,0 +1,95 @@ +// 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\",", + "\"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); + } +} diff --git a/test/mocks/MockMetadataGenerator.sol b/test/mocks/MockMetadataGenerator.sol new file mode 100644 index 0000000..cbaed9a --- /dev/null +++ b/test/mocks/MockMetadataGenerator.sol @@ -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); + } +} diff --git a/test/nft-metadata-generators/NFTMetadataGeneratorSVG.t.sol b/test/nft-metadata-generators/NFTMetadataGeneratorSVG.t.sol new file mode 100644 index 0000000..e725b1e --- /dev/null +++ b/test/nft-metadata-generators/NFTMetadataGeneratorSVG.t.sol @@ -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), "", ""); + + 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("10")); + 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(), ""); + assertEq(metadataGenerator.imageSuffix(), ""); + + metadataGenerator.setImageStrings("", ""); + + assertEq(metadataGenerator.imagePrefix(), ""); + assertEq(metadataGenerator.imageSuffix(), ""); + } + + function testSetImageStringsRevert() public { + vm.prank(alice); + vm.expectPartialRevert(Ownable.OwnableUnauthorizedAccount.selector); + metadataGenerator.setImageStrings("", ""); + } +} diff --git a/test/nft-metadata-generators/NFTMetadataGeneratorURL.t.sol b/test/nft-metadata-generators/NFTMetadataGeneratorURL.t.sol new file mode 100644 index 0000000..70118d3 --- /dev/null +++ b/test/nft-metadata-generators/NFTMetadataGeneratorURL.t.sol @@ -0,0 +1,52 @@ +// 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 { NFTMetadataGeneratorURL } from "../../src/nft-metadata-generators/NFTMetadataGeneratorURL.sol"; + +contract NFTMetadataGeneratorURLTest is Test { + MockToken private erc20Token; + NFTMetadataGeneratorURL private metadataGenerator; + + address private alice = makeAddr("alice"); + + function setUp() public { + erc20Token = new MockToken("Test", "TEST"); + metadataGenerator = new NFTMetadataGeneratorURL(address(erc20Token), "http://test.local/images/", ".jpg"); + + erc20Token.mint(alice, 10e18); + } + + function testGenerateMetadata() public view { + string memory expectedMetadata = "data:application/json;base64,"; + bytes memory json = abi.encodePacked( + "{\"name\":\"XPNFT Token 0x328809bc894f92807417d2dad6b7c998c1afdac6\",", + // solhint-disable-next-line + "\"description\":\"This is a XPNFT token for address 0x328809bc894f92807417d2dad6b7c998c1afdac6 with balance 10000000000000000000\",", + "\"image\":\"http://test.local/images/0x328809bc894f92807417d2dad6b7c998c1afdac6.jpg\"}" + ); + expectedMetadata = string(abi.encodePacked(expectedMetadata, Base64.encode(json))); + + string memory metadata = metadataGenerator.generate(alice, erc20Token.balanceOf(alice)); + assertEq(metadata, expectedMetadata); + } + + function testSetBaseURL() public { + string memory newURLPrefix = "http://new-test.local/images/"; + string memory newURLSuffix = ".png"; + + metadataGenerator.setURLStrings(newURLPrefix, newURLSuffix); + + assertEq(metadataGenerator.urlPrefix(), newURLPrefix); + assertEq(metadataGenerator.urlSuffix(), newURLSuffix); + } + + function testSetBaseURLRevert() public { + vm.prank(alice); + vm.expectPartialRevert(Ownable.OwnableUnauthorizedAccount.selector); + metadataGenerator.setURLStrings("http://new-test.local/images/", ".png"); + } +}