Skip to content
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 MetadataGenerator #12

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/openzeppelin-contracts
85 changes: 85 additions & 0 deletions src/XPNFTToken.sol
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 {
Copy link
Collaborator

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?

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)));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you elaborate on why we're doing it this way again?

Copy link
Collaborator

Choose a reason for hiding this comment

The 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.
This means every account automatically "owns" this NFT with most of them having 0 XP

}

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);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice

}
}
6 changes: 6 additions & 0 deletions src/interfaces/INFTMetadataGenerator.sol
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);
}
36 changes: 36 additions & 0 deletions src/nft-metadata-generators/BaseNFTMetadataGenerator.sol
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);
}
28 changes: 28 additions & 0 deletions src/nft-metadata-generators/NFTMetadataGeneratorSVG.sol
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)));
}
}
24 changes: 24 additions & 0 deletions src/nft-metadata-generators/NFTMetadataGeneratorURL.sol
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));
}
}
2 changes: 1 addition & 1 deletion test/RewardsStreamer.t.sol
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
// 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";

contract RewardsStreamerTest is Test {
MockToken rewardToken;

Check warning on line 9 in test/RewardsStreamer.t.sol

View workflow job for this annotation

GitHub Actions / lint

Explicitly mark visibility of state
MockToken stakingToken;

Check warning on line 10 in test/RewardsStreamer.t.sol

View workflow job for this annotation

GitHub Actions / lint

Explicitly mark visibility of state
RewardsStreamer public streamer;

address admin = makeAddr("admin");

Check warning on line 13 in test/RewardsStreamer.t.sol

View workflow job for this annotation

GitHub Actions / lint

Explicitly mark visibility of state
address alice = makeAddr("alice");

Check warning on line 14 in test/RewardsStreamer.t.sol

View workflow job for this annotation

GitHub Actions / lint

Explicitly mark visibility of state
address bob = makeAddr("bob");

Check warning on line 15 in test/RewardsStreamer.t.sol

View workflow job for this annotation

GitHub Actions / lint

Explicitly mark visibility of state
address charlie = makeAddr("charlie");

Check warning on line 16 in test/RewardsStreamer.t.sol

View workflow job for this annotation

GitHub Actions / lint

Explicitly mark visibility of state
address dave = makeAddr("dave");

Check warning on line 17 in test/RewardsStreamer.t.sol

View workflow job for this annotation

GitHub Actions / lint

Explicitly mark visibility of state

function setUp() public {
rewardToken = new MockToken("Reward Token", "RT");
Expand Down
3 changes: 1 addition & 2 deletions test/RewardsStreamerMP.t.sol
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
// 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;

Check warning on line 9 in test/RewardsStreamerMP.t.sol

View workflow job for this annotation

GitHub Actions / lint

Explicitly mark visibility of state
MockToken stakingToken;

Check warning on line 10 in test/RewardsStreamerMP.t.sol

View workflow job for this annotation

GitHub Actions / lint

Explicitly mark visibility of state
RewardsStreamerMP public streamer;

address admin = makeAddr("admin");

Check warning on line 13 in test/RewardsStreamerMP.t.sol

View workflow job for this annotation

GitHub Actions / lint

Explicitly mark visibility of state
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address charlie = makeAddr("charlie");
Expand Down
95 changes: 95 additions & 0 deletions test/XPNFTToken.t.sol
Original file line number Diff line number Diff line change
@@ -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\",",

Check failure on line 36 in test/XPNFTToken.t.sol

View workflow job for this annotation

GitHub Actions / lint

Line length must be no more than 120 but current length is 146
"\"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);
}
}
18 changes: 18 additions & 0 deletions test/mocks/MockMetadataGenerator.sol
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);
}
}
62 changes: 62 additions & 0 deletions test/nft-metadata-generators/NFTMetadataGeneratorSVG.t.sol
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>");
}
}
Loading
Loading