From a2ee5e2344c6e822b54b4cfcd5c07d81e292e2ae Mon Sep 17 00:00:00 2001 From: geovgy Date: Mon, 4 Nov 2024 16:59:49 -0500 Subject: [PATCH] Create and test ERC1155 nft contract --- remappings.txt | 1 + script/Counter.s.sol | 19 -- src/Counter.sol | 14 -- src/EIPAuthorReward.sol | 251 ++++++++++++++++++++++++ test/Counter.t.sol | 24 --- test/fuzz/FuzzEIPAuthorReward.t.sol | 94 +++++++++ test/unit/EIPAuthorReward.t.sol | 92 +++++++++ test/utils/base/BaseTest.sol | 32 +++ test/utils/mock/MockEIPAuthorReward.sol | 24 +++ 9 files changed, 494 insertions(+), 57 deletions(-) create mode 100644 remappings.txt delete mode 100644 script/Counter.s.sol delete mode 100644 src/Counter.sol create mode 100644 src/EIPAuthorReward.sol delete mode 100644 test/Counter.t.sol create mode 100644 test/fuzz/FuzzEIPAuthorReward.t.sol create mode 100644 test/unit/EIPAuthorReward.t.sol create mode 100644 test/utils/base/BaseTest.sol create mode 100644 test/utils/mock/MockEIPAuthorReward.sol diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..2b69fdf --- /dev/null +++ b/remappings.txt @@ -0,0 +1 @@ +@openzeppelin/contracts=lib/openzeppelin-contracts/contracts \ No newline at end of file diff --git a/script/Counter.s.sol b/script/Counter.s.sol deleted file mode 100644 index cdc1fe9..0000000 --- a/script/Counter.s.sol +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Script, console} from "forge-std/Script.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterScript is Script { - Counter public counter; - - function setUp() public {} - - function run() public { - vm.startBroadcast(); - - counter = new Counter(); - - vm.stopBroadcast(); - } -} diff --git a/src/Counter.sol b/src/Counter.sol deleted file mode 100644 index aded799..0000000 --- a/src/Counter.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; - } - - function increment() public { - number++; - } -} diff --git a/src/EIPAuthorReward.sol b/src/EIPAuthorReward.sol new file mode 100644 index 0000000..8f1f75c --- /dev/null +++ b/src/EIPAuthorReward.sol @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { ERC1155 } from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { Pausable } from "@openzeppelin/contracts/utils/Pausable.sol"; +import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import { Initializable } from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; + +contract EIPAuthorReward is + ERC1155, + EIP712, + Ownable, + Pausable, + ReentrancyGuard +{ + string private _name; + string private _symbol; + uint256 private _supply; + // keccak256(author) => id of network upgrade => claimed + mapping(bytes32 author => mapping(uint256 id => bool)) private _claimed; + mapping(uint256 id => string uri) private _uris; + + string private constant SIGNING_DOMAIN = "EIP Author Reward"; + string private constant SIGNATURE_VERSION = "1"; + bytes32 private constant CLAIMABLE_TYPE_HASH = keccak256("Claimable(uint256 id,address to,string author)"); + + /** + * @dev Struct containing the claimable information. + * + * @param id - token ID of network upgrade + * @param to - address of recipient + * @param author - github username of EIP author + */ + struct Claimable { + uint256 id; + address to; + string author; + } + + /** + * @dev Emitted when a claim is made. + * + * @param id - token ID of network upgrade + * @param to - address of recipient + * @param author - github username of EIP author + */ + event Claimed( + uint256 indexed id, + address indexed to, + string indexed author + ); + + /** + * @dev Emitted when the URI for a token is updated. + * + * @param id - token ID of network upgrade + * @param uri - URI to set + */ + event MetadataUpdated( + uint256 indexed id, + string indexed uri + ); + + error InvalidSignature(); + + error AlreadyClaimed(bytes32 author, uint256 id); + + /** + * @dev Initializes the contract. + * + * @param owner - address of owner + * @param name_ - name of the contract + * @param symbol_ - symbol of the contract + */ + constructor( + address owner, + string memory name_, + string memory symbol_ + ) + Ownable(owner) + ERC1155("") + EIP712(SIGNING_DOMAIN, SIGNATURE_VERSION) + { + require(bytes(name_).length > 0, "Reward: no name"); + require(bytes(symbol_).length > 0, "Reward: no symbol"); + _name = name_; + _symbol = symbol_; + } + + /** + * @dev Mints token to msg.sender. Assumes msg.sender is the author of the EIP. + * + * @param id - token ID of network upgrade + * @param author - github username of EIP author + * @param signature - signature of the author + * + * Note: author can only claim once per token ID. + */ + function claim( + uint256 id, + string calldata author, + bytes calldata signature + ) external whenNotPaused nonReentrant { + _claimMint( + Claimable({ + id: id, + to: msg.sender, + author: author + }), + signature + ); + } + + /** + * @dev Mints token to recipient address that has not claimed before + * + * @param claimable - struct containing the claimable information + * @param signature - signature of the author + * + * Note: author can only claim once per token ID. + */ + function claim(Claimable calldata claimable, bytes calldata signature) external whenNotPaused nonReentrant { + _claimMint(claimable, signature); + } + + /** + * @dev Returns a boolean to indicate if account has been used to mint token + * + * @param author - github username of EIP author + * @param id - token ID of network upgrade + * + * Note: Only owner can call this view function. + */ + function claimed(string calldata author, uint256 id) + external + view + returns (bool) + { + return _claimed[keccak256(abi.encodePacked(author))][id]; + } + + /** + * @dev Returns supply of tokens + */ + function supply() external view returns (uint256) { + return _supply; + } + + /** + * @dev Returns the name of the reward. + */ + function name() public view returns (string memory) { + return _name; + } + + /** + * @dev Returns the symbol of the reward. + */ + function symbol() public view returns (string memory) { + return _symbol; + } + + /** + * @dev Returns the URI for a given token ID. + */ + function uri(uint256 id) public override view returns (string memory) { + return _uris[id]; + } + + /** + * @dev Sets the URI for a given token ID. + * + * Note: Only owner can call this function. + * + * @param id - token ID of network upgrade + * @param uri_ - URI to set + */ + function setUri(uint256 id, string calldata uri_) external onlyOwner { + _uris[id] = uri_; + emit MetadataUpdated(id, uri_); + } + + /** + * @dev Pauses claims on the contract. + * + * Note: Only owner can call this function. + */ + function pause() public onlyOwner { + _pause(); + } + + /** + * @dev Unpauses claims on the contract. + * + * Note: Only owner can call this function. + */ + function unpause() public onlyOwner { + _unpause(); + } + + /** + * @dev Internal function to claim and mint token. + */ + function _claimMint(Claimable memory claimable, bytes calldata signature) internal { + bytes32 author = keccak256(abi.encodePacked(claimable.author)); + if (_claimed[author][claimable.id]) { + revert AlreadyClaimed(author, claimable.id); + } + bytes32 hash = _hashClaimableStruct(claimable); + if (!_isValidSignature(owner(), hash, signature)) { + revert InvalidSignature(); + } + _claimed[author][claimable.id] = true; + _supply++; + _mint(claimable.to, claimable.id, 1, ""); + emit Claimed(claimable.id, claimable.to, claimable.author); + } + + /** + * @dev Internal function to hash the claimable struct. + */ + function _hashClaimableStruct(Claimable memory _claimable) + internal + view + returns (bytes32) + { + bytes32 structHash = keccak256( + abi.encode( + CLAIMABLE_TYPE_HASH, + _claimable.id, + address(_claimable.to), + keccak256(abi.encodePacked(_claimable.author)) + ) + ); + return _hashTypedDataV4(structHash); + } + + /** + * @dev Internal function to validate the signature. + */ + function _isValidSignature( + address signer, + bytes32 hash, + bytes calldata signature + ) internal view returns (bool) { + return SignatureChecker.isValidSignatureNow(signer, hash, signature); + } +} \ No newline at end of file diff --git a/test/Counter.t.sol b/test/Counter.t.sol deleted file mode 100644 index 54b724f..0000000 --- a/test/Counter.t.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test, console} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - counter.setNumber(0); - } - - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); - } - - function testFuzz_SetNumber(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), x); - } -} diff --git a/test/fuzz/FuzzEIPAuthorReward.t.sol b/test/fuzz/FuzzEIPAuthorReward.t.sol new file mode 100644 index 0000000..319bab8 --- /dev/null +++ b/test/fuzz/FuzzEIPAuthorReward.t.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {BaseTest} from "../utils/base/BaseTest.sol"; +import {EIPAuthorReward} from "../../src/EIPAuthorReward.sol"; +import {MockEIPAuthorReward} from "../utils/mock/MockEIPAuthorReward.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +contract FuzzEIPAuthorRewardTest is BaseTest { + MockEIPAuthorReward internal reward; + + uint256 internal ownerPrivateKey = 11111111; + address internal owner; + + function setUp() public { + owner = vm.addr(ownerPrivateKey); + reward = new MockEIPAuthorReward(owner); + assertEq(reward.name(), "Mock EIP Author Reward"); + assertEq(reward.symbol(), "MER"); + assertEq(reward.owner(), owner); + } + + function test_revert_claim_alreadyClaimed(address attempter) public { + uint256 id = 1; + string memory author = "author"; + address recipient = vm.addr(3); + EIPAuthorReward.Claimable memory claimable = EIPAuthorReward.Claimable({ + id: id, + author: author, + to: recipient + }); + + bytes memory signature = _signAndExecuteClaim( + reward, + claimable, + ownerPrivateKey + ); + assertEq(reward.balanceOf(recipient, id), 1); + + bytes32 authorHash = keccak256(abi.encodePacked(author)); + vm.expectRevert(abi.encodeWithSelector(EIPAuthorReward.AlreadyClaimed.selector, authorHash, id)); + reward.claim(claimable, signature); + + assertEq(reward.balanceOf(attempter, id), 0); + } + + function test_revert_claim_invalidSignature(uint32 nonOwnerPrivateKey) public { + vm.assume(nonOwnerPrivateKey > 0 && nonOwnerPrivateKey != ownerPrivateKey); + + uint256 id = 1; + string memory author = "author"; + address recipient = vm.addr(3); + EIPAuthorReward.Claimable memory claimable = EIPAuthorReward.Claimable({ + id: id, + author: author, + to: recipient + }); + bytes memory signature = _signClaimable( + reward, + claimable, + nonOwnerPrivateKey + ); + + vm.expectRevert(abi.encodeWithSelector(EIPAuthorReward.InvalidSignature.selector)); + reward.claim(claimable, signature); + + assertEq(reward.balanceOf(recipient, id), 0); + } + + function test_revert_setUri_notOwner(address nonOwner) public { + vm.assume(nonOwner != owner); + vm.startPrank(nonOwner); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, nonOwner)); + reward.setUri(1, "uri"); + } + + function test_revert_pause_notOwner(address nonOwner) public { + vm.assume(nonOwner != owner); + vm.startPrank(nonOwner); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, nonOwner)); + reward.pause(); + } + + function test_revert_unpause_notOwner(address nonOwner) public { + vm.assume(nonOwner != owner); + + vm.prank(owner); + reward.pause(); + + vm.startPrank(nonOwner); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, nonOwner)); + reward.unpause(); + } +} diff --git a/test/unit/EIPAuthorReward.t.sol b/test/unit/EIPAuthorReward.t.sol new file mode 100644 index 0000000..9456728 --- /dev/null +++ b/test/unit/EIPAuthorReward.t.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {BaseTest} from "../utils/base/BaseTest.sol"; +import {EIPAuthorReward} from "../../src/EIPAuthorReward.sol"; +import {MockEIPAuthorReward} from "../utils/mock/MockEIPAuthorReward.sol"; + +contract EIPAuthorRewardTest is BaseTest { + MockEIPAuthorReward internal reward; + + uint256 internal ownerPrivateKey = 11111111; + address internal owner; + + function setUp() public { + owner = vm.addr(ownerPrivateKey); + reward = new MockEIPAuthorReward(owner); + assertEq(reward.name(), "Mock EIP Author Reward"); + assertEq(reward.symbol(), "MER"); + assertEq(reward.owner(), owner); + } + + function test_claim() public { + // Mint to recipient + uint256 id = 1; + string memory author = "author"; + address recipient = vm.addr(3); + EIPAuthorReward.Claimable memory claimable = EIPAuthorReward.Claimable({ + id: id, + author: author, + to: recipient + }); + bytes memory signature = _signClaimable( + reward, + claimable, + ownerPrivateKey + ); + + vm.expectEmit(); + emit EIPAuthorReward.Claimed(id, recipient, author); + reward.claim(claimable, signature); + + assertEq(reward.balanceOf(recipient, id), 1); + } + + function test_claim_asMsgSender() public { + // Mint to msg.sender + uint256 id = 1; + address sender = vm.addr(3); + bytes memory signature = _signClaimable( + reward, + EIPAuthorReward.Claimable({ + id: id, + author: "author", + to: sender + }), + ownerPrivateKey + ); + + vm.prank(sender); + reward.claim(id, "author", signature); + + assertEq(reward.balanceOf(sender, id), 1); + } + + function test_claimed() public { + _signAndExecuteClaim( + reward, + EIPAuthorReward.Claimable({ + id: 1, + author: "author", + to: vm.addr(2) + }), + ownerPrivateKey + ); + assertTrue(reward.claimed("author", 1)); + } + + function test_setUri() public { + uint256 id = 1; + string memory newUri = "newUri"; + + vm.startPrank(owner); + + vm.expectEmit(); + emit EIPAuthorReward.MetadataUpdated(id, newUri); + reward.setUri(id, newUri); + + vm.stopPrank(); + + assertEq(reward.uri(id), newUri); + } +} diff --git a/test/utils/base/BaseTest.sol b/test/utils/base/BaseTest.sol new file mode 100644 index 0000000..4cea1b8 --- /dev/null +++ b/test/utils/base/BaseTest.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {EIPAuthorReward} from "../../../src/EIPAuthorReward.sol"; +import {MockEIPAuthorReward} from "../mock/MockEIPAuthorReward.sol"; + +contract BaseTest is Test { + function _signClaimable( + MockEIPAuthorReward _reward, + EIPAuthorReward.Claimable memory claimable, + uint256 privateKey + ) internal view returns (bytes memory) { + bytes32 hash = _reward.hashClaimableStruct(claimable); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, hash); + return abi.encodePacked(r, s, v); + } + + function _signAndExecuteClaim( + MockEIPAuthorReward _reward, + EIPAuthorReward.Claimable memory claimable, + uint256 ownerPrivateKey + ) internal returns (bytes memory signature) { + vm.prank(vm.addr(ownerPrivateKey)); + signature = _signClaimable( + _reward, + claimable, + ownerPrivateKey + ); + _reward.claim(claimable, signature); + } +} diff --git a/test/utils/mock/MockEIPAuthorReward.sol b/test/utils/mock/MockEIPAuthorReward.sol new file mode 100644 index 0000000..7d1810a --- /dev/null +++ b/test/utils/mock/MockEIPAuthorReward.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {EIPAuthorReward} from "../../../src/EIPAuthorReward.sol"; + +contract MockEIPAuthorReward is EIPAuthorReward { + constructor(address owner) EIPAuthorReward(owner, "Mock EIP Author Reward", "MER") {} + + function hashClaimableStruct(Claimable memory _claimable) + public + view + returns (bytes32) + { + return super._hashClaimableStruct(_claimable); + } + + function isValidSignature( + address signer, + bytes32 hash, + bytes calldata signature + ) public view returns (bool) { + return super._isValidSignature(signer, hash, signature); + } +} \ No newline at end of file