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(""));
+ 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(), "");
+
+ 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");
+ }
+}