diff --git a/.gitignore b/.gitignore index d7d4875..66e9a2f 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,13 @@ next-env.d.ts .idea bun.lockb artifacts + + +# Ignore Foundry environment variables file +/contracts/foundry-project/.env + +# Ignore Foundry cache, lib and out +/contracts/foundry-project/cache +/contracts/foundry-project/lib +/contracts/foundry-project/out + diff --git a/contracts/foundry-project/README.md b/contracts/foundry-project/README.md new file mode 100644 index 0000000..9265b45 --- /dev/null +++ b/contracts/foundry-project/README.md @@ -0,0 +1,66 @@ +## Foundry + +**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** + +Foundry consists of: + +- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). +- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. +- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. +- **Chisel**: Fast, utilitarian, and verbose solidity REPL. + +## Documentation + +https://book.getfoundry.sh/ + +## Usage + +### Build + +```shell +$ forge build +``` + +### Test + +```shell +$ forge test +``` + +### Format + +```shell +$ forge fmt +``` + +### Gas Snapshots + +```shell +$ forge snapshot +``` + +### Anvil + +```shell +$ anvil +``` + +### Deploy + +```shell +$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key +``` + +### Cast + +```shell +$ cast +``` + +### Help + +```shell +$ forge --help +$ anvil --help +$ cast --help +``` diff --git a/contracts/foundry-project/foundry.toml b/contracts/foundry-project/foundry.toml new file mode 100644 index 0000000..f0cde96 --- /dev/null +++ b/contracts/foundry-project/foundry.toml @@ -0,0 +1,9 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] + +[profile.default.dependencies] +OpenZeppelin = { git = "https://github.com/OpenZeppelin/openzeppelin-contracts.git", tag = "v4.8.0" } + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/contracts/foundry-project/package-lock.json b/contracts/foundry-project/package-lock.json new file mode 100644 index 0000000..e7a69d4 --- /dev/null +++ b/contracts/foundry-project/package-lock.json @@ -0,0 +1,17 @@ +{ + "name": "git-id-contracts", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@openzeppelin/contracts": "^5.0.2" + } + }, + "node_modules/@openzeppelin/contracts": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-5.0.2.tgz", + "integrity": "sha512-ytPc6eLGcHHnapAZ9S+5qsdomhjo6QBHTDRRBFfTxXIpsicMhVPouPgmUPebZZZGX7vt9USA+Z+0M0dSVtSUEA==" + } + } +} diff --git a/contracts/foundry-project/package.json b/contracts/foundry-project/package.json new file mode 100644 index 0000000..2eb40bf --- /dev/null +++ b/contracts/foundry-project/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@openzeppelin/contracts": "^5.0.2" + } +} diff --git a/contracts/foundry-project/remapping.txt b/contracts/foundry-project/remapping.txt new file mode 100644 index 0000000..b9a3c8f --- /dev/null +++ b/contracts/foundry-project/remapping.txt @@ -0,0 +1,2 @@ +@openzeppelin/contracts/=lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/ +@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ diff --git a/contracts/foundry-project/script/Deploy.s.sol b/contracts/foundry-project/script/Deploy.s.sol new file mode 100644 index 0000000..7f8b959 --- /dev/null +++ b/contracts/foundry-project/script/Deploy.s.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; + +contract DeployScript is Script { + function run() external { + console.log("Deploy script running..."); + + // Use the default private key and signer address + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address signerAddress = vm.envAddress("SIGNER_ADDRESS"); + + console.log("Deployer Private Key:", deployerPrivateKey); + console.log("Signer Address:", signerAddress); + + // Start broadcasting transactions + vm.startBroadcast(deployerPrivateKey); + + console.log("Starting deployment..."); + + // Stop broadcasting transactions + vm.stopBroadcast(); + + console.log("Deployment finished."); + } +} diff --git a/contracts/foundry-project/src/Controller.sol b/contracts/foundry-project/src/Controller.sol new file mode 100644 index 0000000..17e007e --- /dev/null +++ b/contracts/foundry-project/src/Controller.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import "@openzeppelin/contracts/utils/Nonces.sol"; +import "./GitID.sol"; // 确保此路径与您的GitID合约路径匹配 + +/** + * @title Controller + * @dev 此合约允许通过签名验证铸造GitID NFT。只有所有者可以设置签名者和价格,并提取资金。 + */ +contract Controller is Ownable, Nonces { + using MessageHashUtils for bytes32; + using ECDSA for bytes32; + + GitID private _gitIdContract; + address private _signer; + uint256 private _price; + uint256 private immutable _cachedChainId; + + event UserMinted(address indexed user, uint256 pricePaid, string username); + + /** + * @dev 构造函数设置GitID合约地址、签名者地址和铸造价格。 + * @param gitIdAddress_ GitID合约的地址。 + * @param signer_ 签名者的地址。 + * @param price_ 铸造价格(以wei为单位)。 + */ + constructor( + address gitIdAddress_, + address signer_, + uint price_ + ) Ownable(msg.sender) { + _gitIdContract = GitID(gitIdAddress_); + _signer = signer_; + _cachedChainId = block.chainid; + _price = price_; + } + + /** + * @dev 设置签名者的地址。只有合约所有者可以调用此函数。 + * @param signer_ 新的签名者地址。 + */ + function setSigner(address signer_) external onlyOwner { + _signer = signer_; + } + + /** + * @dev 如果签名有效且未过期,则铸造一个新的GitID NFT。非免费铸造需支付费用。 + * @param to 接收NFT的地址。 + * @param username 与NFT关联的GitHub用户名。 + * @param isFree 布尔值,指示是否为免费铸造。 + * @param deadline 签名有效的截止时间戳。 + * @param signature 由签名者生成的签名。 + */ + function mintGitID( + address to, + string memory username, + bool isFree, + uint256 deadline, + bytes memory signature + ) external payable { + // 检查签名是否过期 + require(deadline >= block.timestamp, "Expired signature"); + + // 验证签名 + require( + _verifySignature( + to, + username, + isFree, + deadline, + _cachedChainId, + _useNonce(to), + signature + ), + "Invalid or unauthorized signature" + ); + + // 如果不是免费铸造,检查支付金额 + if (!isFree) { + require(msg.value >= _price, "Invalid price"); + } + + // 铸造GitID NFT + _gitIdContract.mint(to, username); + + emit UserMinted(to, msg.value, username); + } + + /** + * @dev 验证签名的内部函数。 + * @param to 接收NFT的地址。 + * @param username 与NFT关联的GitHub用户名。 + * @param isFree 布尔值,指示是否为免费铸造。 + * @param deadline 签名有效的截止时间戳。 + * @param chainId 区块链ID。 + * @param nonces 确保签名唯一性的nonce。 + * @param signature 由签名者生成的签名。 + * @return 如果签名有效,返回true,否则返回false。 + */ + function _verifySignature( + address to, + string memory username, + bool isFree, + uint256 deadline, + uint256 chainId, + uint256 nonces, + bytes memory signature + ) private view returns (bool) { + // 生成用于签名的消息哈希 + bytes32 message = keccak256( + abi.encodePacked(to, username, isFree, deadline, chainId, nonces) + ); + + // 生成以太坊签名消息哈希 + bytes32 ethSignedMessage = message.toEthSignedMessageHash(); + + // 恢复签名者地址并验证 + return _signer == ethSignedMessage.recover(signature); + } + + /** + * @dev 返回当前的铸造价格。 + * @return 当前价格(以wei为单位)。 + */ + function getPrice() external view returns (uint256) { + return _price; + } + + /** + * @dev 设置铸造价格。只有合约所有者可以调用此函数。 + * @param price_ 新的价格(以wei为单位)。 + */ + function setPrice(uint256 price_) external onlyOwner { + _price = price_; + } + + /** + * @dev 提取合约余额到所有者地址。只有合约所有者可以调用此函数。 + */ + function withdraw() external onlyOwner { + payable(owner()).transfer(address(this).balance); + } +} diff --git a/contracts/foundry-project/src/GitID.sol b/contracts/foundry-project/src/GitID.sol new file mode 100644 index 0000000..4da52f5 --- /dev/null +++ b/contracts/foundry-project/src/GitID.sol @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +/** + * @title GitID + * @dev GitID 合约继承自 ERC721 和 Ownable,用于铸造和管理与 GitHub 用户名相关的 NFT。仅允许 Controller 合约调用铸造和销毁功能。 + */ +contract GitID is ERC721, Ownable { + // 存储地址与其 GitHub 用户名的映射 + mapping(address => string) private _addressToUsername; + + // Controller 合约的地址 + address public controller; + + // 基础 URI + string public baseURI; + + // 事件:记录 NFT 的铸造 + event Minted(address indexed to, uint256 indexed tokenId, string username); + + // 事件:记录 NFT 的销毁 + event Burned(address indexed from, uint256 indexed tokenId, string username); + + /** + * @dev 构造函数,初始化 ERC721 合约和 Ownable 合约 + */ + constructor() ERC721("GitID", "GITID") Ownable(msg.sender) {} + + /** + * @dev 设置 Controller 合约的地址,仅所有者可调用 + * @param controller_ 新的 Controller 地址 + */ + function setController(address controller_) public onlyOwner { + controller = controller_; + } + + /** + * @dev 修饰符,仅允许 Controller 调用 + */ + modifier isController() { + require(msg.sender == controller, "Caller is not the controller"); + _; + } + + /** + * @dev 铸造新的 GitID NFT,仅允许 Controller 调用 + * @param to 接收 NFT 的地址 + * @param username 与 NFT 关联的 GitHub 用户名 + */ + function mint(address to, string memory username) public isController { + require(balanceOf(to) == 0, "Address already has a GitID"); + + uint256 tokenId = getTokenIdByUsername(username); + require(_ownerOf(tokenId) == address(0), "TokenID already minted"); + + // 更新 to 的 GitHub 用户名 + _addressToUsername[to] = username; + // 铸造 Git ID + _mint(to, tokenId); + + emit Minted(to, tokenId, username); + } + + /** + * @dev 销毁旧的 GitID NFT,仅允许 Controller 调用 + * @param username 关联的 GitHub 用户名 + */ + function burn(string memory username) public isController { + uint256 tokenId = getTokenIdByUsername(username); + address from = ownerOf(tokenId); + // 删除 from 的 GitHub 用户名 + delete _addressToUsername[from]; + // 销毁 Git ID + _burn(tokenId); + + emit Burned(from, tokenId, username); + } + + /** + * @dev 禁止转移功能,只允许铸造和销毁 + * @param to 接收地址 + * @param tokenId 代币 ID + * @param auth 授权地址 + * @return 旧的所有者地址 + */ + function _update(address to, uint256 tokenId, address auth) + internal + override + returns (address) + { + address from = _ownerOf(tokenId); + if (from != address(0) && to != address(0)) { + revert("Soulbound: Transfer failed"); + } + + return super._update(to, tokenId, auth); + } + + /** + * @dev 通过用户名获取 TokenID + * @param username GitHub 用户名 + * @return 对应的 TokenID + */ + function getTokenIdByUsername(string memory username) public pure returns (uint256) { + return uint256(keccak256(abi.encodePacked(username))); + } + + /** + * @dev 通过用户名获取所有者地址 + * @param username GitHub 用户名 + * @return 对应的所有者地址 + */ + function getOwnerByUsername(string memory username) public view returns (address) { + uint256 tokenId = getTokenIdByUsername(username); + return ownerOf(tokenId); + } + + /** + * @dev 通过地址获取 GitHub 用户名 + * @param owner 所有者地址 + * @return 对应的 GitHub 用户名 + */ + function getUsernameByAddress(address owner) public view returns (string memory) { + return _addressToUsername[owner]; + } + + /** + * @dev 通过地址获取 TokenID + * @param owner 所有者地址 + * @return 对应的 TokenID + */ + function getTokenIdByAddress(address owner) public view returns (uint256) { + return getTokenIdByUsername(_addressToUsername[owner]); + } + + /** + * @dev 通过 TokenID 获取 GitHub 用户名 + * @param tokenId 代币 ID + * @return 对应的 GitHub 用户名 + */ + function getUsernameByTokenId(uint256 tokenId) public view returns (string memory) { + address owner = ownerOf(tokenId); + return _addressToUsername[owner]; + } + + /** + * @dev 通过 TokenID 获取所有者地址 + * @param tokenId 代币 ID + * @return 对应的所有者地址 + */ + function getOwnerByTokenId(uint256 tokenId) public view returns (address) { + return ownerOf(tokenId); + } + + /** + * @dev 设置基础 URI + * @return 基础 URI 字符串 + */ + function _baseURI() internal view override returns (string memory) { + return baseURI; + } + + /** + * @dev 设置新的基础 URI,仅所有者可调用 + * @param baseURI_ 新的基础 URI + */ + function setBaseURI(string memory baseURI_) external onlyOwner { + baseURI = baseURI_; + } + + /** + * @dev 返回 TokenID 的 URI + * @param tokenId 代币 ID + * @return 对应的 URI 字符串 + */ + function tokenURI(uint256 tokenId) public view override returns (string memory) { + _requireOwned(tokenId); + string memory name = getUsernameByTokenId(tokenId); + return bytes(baseURI).length > 0 ? string(abi.encodePacked(baseURI, name)) : ""; + } +} diff --git a/contracts/foundry-project/test/ControllerTest.t.sol b/contracts/foundry-project/test/ControllerTest.t.sol new file mode 100644 index 0000000..6fc2e61 --- /dev/null +++ b/contracts/foundry-project/test/ControllerTest.t.sol @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "../src/Controller.sol"; +import "../src/GitID.sol"; +import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +/** + * @title ControllerTest + * @dev 测试 Controller 和 GitID 合约的功能,包括铸造、签名验证、权限控制和资金提取。 + */ +contract ControllerTest is Test { + Controller public controller; + GitID public gitID; + address public owner = address(0xa0Ee7A142d267C1f36714E4a8F75612F20a79720); + address public user = address(0x2); + address public signer = address(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266); + uint256 public price = 0.01 ether; + string public username = "testuser"; + uint256 public deadline = block.timestamp + 1 days; + // 使用Foundry提供的私钥 + uint256 public signerPrivateKey = + uint256( + 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + ); + + /** + * @dev 设置测试环境,在每个测试函数之前运行。 + * 部署 GitID 和 Controller 合约,并设置 Controller 地址。 + */ + function setUp() public { + // 确保 owner 拥有足够的资金 + vm.deal(owner, 10 ether); + + vm.startPrank(owner); + gitID = new GitID(); + controller = new Controller(address(gitID), signer, price); + gitID.setController(address(controller)); + vm.stopPrank(); + } + + /** + * @dev 测试铸造 GitID NFT。 + * 检查铸造后所有者地址和用户名是否正确映射。 + */ + function testMintGitID() public { + vm.prank(owner); + vm.deal(user, 1 ether); + + uint256 nonce = controller.nonces(user); + bytes32 message = keccak256( + abi.encodePacked( + user, + username, + false, + deadline, + block.chainid, + nonce + ) + ); + bytes32 ethSignedMessage = MessageHashUtils.toEthSignedMessageHash( + message + ); + + // 使用签名者的私钥签名消息 + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + signerPrivateKey, + ethSignedMessage + ); + bytes memory signature = abi.encodePacked(r, s, v); + + // 调试输出 + emit log_named_address("Signer", signer); + emit log_named_bytes32("Message", message); + emit log_named_bytes32("Eth Signed Message", ethSignedMessage); + emit log_named_bytes("Signature", signature); + + // 使用用户地址调用 mintGitID 函数 + vm.prank(user); + controller.mintGitID{value: price}( + user, + username, + false, + deadline, + signature + ); + + uint256 tokenId = gitID.getTokenIdByUsername(username); + assertEq(gitID.ownerOf(tokenId), user); + assertEq(gitID.getUsernameByAddress(user), username); + } + + /** + * @dev 测试使用过期签名铸造 GitID NFT。 + * 检查是否会因签名过期而失败。 + */ + function testMintGitIDWithExpiredSignature() public { + vm.prank(owner); + vm.deal(user, 1 ether); + + uint256 expiredDeadline = block.timestamp - 1; + bytes32 message = keccak256( + abi.encodePacked( + user, + username, + false, + expiredDeadline, + block.chainid, + controller.nonces(user) + ) + ); + bytes32 ethSignedMessage = MessageHashUtils.toEthSignedMessageHash( + message + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + signerPrivateKey, + ethSignedMessage + ); + bytes memory signature = abi.encodePacked(r, s, v); + + vm.prank(user); + vm.expectRevert("Expired signature"); + controller.mintGitID{value: price}( + user, + username, + false, + expiredDeadline, + signature + ); + } + + /** + * @dev 测试使用无效签名铸造 GitID NFT。 + * 检查是否会因签名无效而失败。 + */ + function testMintGitIDWithInvalidSignature() public { + vm.prank(owner); + vm.deal(user, 1 ether); + + bytes32 message = keccak256( + abi.encodePacked( + user, + username, + false, + deadline, + block.chainid, + controller.nonces(user) + ) + ); + bytes32 ethSignedMessage = MessageHashUtils.toEthSignedMessageHash( + message + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + uint256(uint160(address(0x3))), + ethSignedMessage + ); // 使用不同的密钥签名 + bytes memory signature = abi.encodePacked(r, s, v); + + vm.prank(user); + vm.expectRevert("Invalid or unauthorized signature"); + controller.mintGitID{value: price}( + user, + username, + false, + deadline, + signature + ); + } + + /** + * @dev 测试只有所有者可以设置价格。 + * 检查非所有者调用设置价格函数时是否会失败。 + */ + function testOnlyOwnerCanSetPrice() public { + vm.prank(owner); + controller.setPrice(0.02 ether); + assertEq(controller.getPrice(), 0.02 ether); + + vm.prank(user); + vm.expectRevert( + abi.encodeWithSelector( + Ownable.OwnableUnauthorizedAccount.selector, + user + ) + ); + controller.setPrice(0.03 ether); + } + + /** + * @dev 测试提取合约中的资金。 + * 检查提取后所有者的余额是否增加。 + */ + function testWithdraw() public { + // 确保合约有足够的资金 + testMintGitID(); + uint256 ownerBalanceBefore = owner.balance; + console.log("balance", address(controller).balance); + + vm.prank(owner); + controller.withdraw(); + + uint256 ownerBalanceAfter = owner.balance; + + console.log("balanceBefore", ownerBalanceBefore); + console.log("balanceAfter", ownerBalanceAfter); + + assertEq(ownerBalanceAfter, ownerBalanceBefore + 0.01 ether); + } +} diff --git a/contracts/foundry-project/test/GitIDTest.t.sol b/contracts/foundry-project/test/GitIDTest.t.sol new file mode 100644 index 0000000..7b20fea --- /dev/null +++ b/contracts/foundry-project/test/GitIDTest.t.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "../src/GitID.sol"; + +/** + * @title GitIDTest + * @dev 测试 GitID 合约的功能,包括铸造、销毁、权限和转移限制。 + */ +contract GitIDTest is Test { + GitID public gitID; + address public controller = address(0x1234); + address public user1 = address(0x5678); + string public username = "testuser"; + + /** + * @dev 设置测试环境,在每个测试函数之前运行。 + * 部署 GitID 合约并设置 Controller 地址。 + */ + function setUp() public { + gitID = new GitID(); + gitID.setController(controller); + } + + /** + * @dev 测试铸造 GitID NFT。 + * 检查铸造后所有者地址和用户名是否正确映射。 + */ + function testMint() public { + vm.prank(controller); + gitID.mint(user1, username); + + uint256 tokenId = gitID.getTokenIdByUsername(username); + assertEq(gitID.ownerOf(tokenId), user1); + assertEq(gitID.getUsernameByAddress(user1), username); + } + + /** + * @dev 测试销毁 GitID NFT。 + * 检查销毁后 NFT 是否不存在,以及用户名是否被删除。 + */ + function testBurn() public { + vm.prank(controller); + gitID.mint(user1, username); + + uint256 tokenId = gitID.getTokenIdByUsername(username); + vm.prank(controller); + gitID.burn(username); + + vm.expectRevert(abi.encodeWithSelector(IERC721Errors.ERC721NonexistentToken.selector, tokenId)); + gitID.ownerOf(tokenId); + assertEq(gitID.getUsernameByAddress(user1), ""); + } + + /** + * @dev 测试只有 Controller 可以铸造 GitID NFT。 + * 检查非 Controller 调用铸造函数时是否会失败。 + */ + function testOnlyControllerCanMint() public { + vm.expectRevert("Caller is not the controller"); + gitID.mint(user1, username); + } + + /** + * @dev 测试只有 Controller 可以销毁 GitID NFT。 + * 检查非 Controller 调用销毁函数时是否会失败。 + */ + function testOnlyControllerCanBurn() public { + vm.prank(controller); + gitID.mint(user1, username); + + vm.expectRevert("Caller is not the controller"); + gitID.burn(username); + } + + /** + * @dev 测试禁止转移 GitID NFT。 + * 检查尝试转移 NFT 时是否会失败。 + */ + function testCannotTransfer() public { + vm.prank(controller); + gitID.mint(user1, username); + + uint256 tokenId = gitID.getTokenIdByUsername(username); + vm.expectRevert("Soulbound: Transfer failed"); + vm.prank(user1); + gitID.transferFrom(user1, address(0x9ABC), tokenId); + } + + /** + * @dev 测试设置基础 URI。 + * 检查设置后的基础 URI 是否正确。 + */ + function testSetBaseURI() public { + string memory newBaseURI = "https://api.example.com/metadata/"; + gitID.setBaseURI(newBaseURI); + assertEq(gitID.baseURI(), newBaseURI); + } + + /** + * @dev 测试获取 Token URI。 + * 检查生成的 Token URI 是否正确。 + */ + function testTokenURI() public { + vm.prank(controller); + gitID.mint(user1, username); + + string memory baseURI = "https://api.example.com/metadata/"; + gitID.setBaseURI(baseURI); + + uint256 tokenId = gitID.getTokenIdByUsername(username); + string memory expectedURI = string(abi.encodePacked(baseURI, username)); + assertEq(gitID.tokenURI(tokenId), expectedURI); + } +}