From d856d6cde4780fa165c382ebdde4739ac36b9b47 Mon Sep 17 00:00:00 2001 From: Tomi_Ohl Date: Wed, 15 Nov 2023 16:20:23 +0100 Subject: [PATCH 1/3] Implement EIP-5192 --- contracts/BasicGuildRewardNFT.sol | 2 ++ contracts/interfaces/IBasicGuildRewardNFT.sol | 4 ---- contracts/interfaces/IERC5192.sol | 20 +++++++++++++++++++ contracts/token/SoulboundERC721.sol | 17 ++++++++++++++-- 4 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 contracts/interfaces/IERC5192.sol diff --git a/contracts/BasicGuildRewardNFT.sol b/contracts/BasicGuildRewardNFT.sol index 21e0188..1445342 100644 --- a/contracts/BasicGuildRewardNFT.sol +++ b/contracts/BasicGuildRewardNFT.sol @@ -67,6 +67,8 @@ contract BasicGuildRewardNFT is _safeMint(receiver, tokenId); + emit Locked(tokenId); + emit Claimed(receiver, tokenId); } diff --git a/contracts/interfaces/IBasicGuildRewardNFT.sol b/contracts/interfaces/IBasicGuildRewardNFT.sol index fdce32d..fd6e076 100644 --- a/contracts/interfaces/IBasicGuildRewardNFT.sol +++ b/contracts/interfaces/IBasicGuildRewardNFT.sol @@ -76,8 +76,4 @@ interface IBasicGuildRewardNFT { /// @notice Error thrown when the supplied signature is invalid. error IncorrectSignature(); - - /// @notice Error thrown when trying to query info about a token that's not (yet) minted. - /// @param tokenId The queried id. - error NonExistentToken(uint256 tokenId); } diff --git a/contracts/interfaces/IERC5192.sol b/contracts/interfaces/IERC5192.sol new file mode 100644 index 0000000..bb25f04 --- /dev/null +++ b/contracts/interfaces/IERC5192.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.0; + +interface IERC5192 { + /// @notice Emitted when the locking status is changed to locked. + /// @dev If a token is minted and the status is locked, this event should be emitted. + /// @param tokenId The identifier for a token. + event Locked(uint256 tokenId); + + /// @notice Emitted when the locking status is changed to unlocked. + /// @dev If a token is minted and the status is unlocked, this event should be emitted. + /// @param tokenId The identifier for a token. + event Unlocked(uint256 tokenId); + + /// @notice Returns the locking status of an Soulbound Token + /// @dev SBTs assigned to zero address are considered invalid, and queries + /// about them do throw. + /// @param tokenId The identifier for an SBT. + function locked(uint256 tokenId) external view returns (bool); +} diff --git a/contracts/token/SoulboundERC721.sol b/contracts/token/SoulboundERC721.sol index 4f1a580..9a46b18 100644 --- a/contracts/token/SoulboundERC721.sol +++ b/contracts/token/SoulboundERC721.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.19; /* solhint-disable max-line-length */ +import { IERC5192 } from "../interfaces/IERC5192.sol"; import { ERC721Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; import { IERC721Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721Upgradeable.sol"; import { ERC721EnumerableUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol"; @@ -14,7 +15,11 @@ import { IERC721EnumerableUpgradeable } from "@openzeppelin/contracts-upgradeabl /// @notice Allowance and transfer-related functions are disabled. /// @dev Inheriting from upgradeable contracts here - even though we're using it in a non-upgradeable way, /// we still want it to be initializable -contract SoulboundERC721 is ERC721Upgradeable, ERC721EnumerableUpgradeable { +contract SoulboundERC721 is ERC721Upgradeable, ERC721EnumerableUpgradeable, IERC5192 { + /// @notice Error thrown when trying to query info about a token that's not (yet) minted. + /// @param tokenId The queried id. + error NonExistentToken(uint256 tokenId); + /// @notice Error thrown when a function's execution is not possible, because this is a soulbound NFT. error Soulbound(); @@ -28,7 +33,15 @@ contract SoulboundERC721 is ERC721Upgradeable, ERC721EnumerableUpgradeable { function supportsInterface( bytes4 interfaceId ) public view virtual override(ERC721EnumerableUpgradeable, ERC721Upgradeable) returns (bool) { - return interfaceId == type(IERC721EnumerableUpgradeable).interfaceId || super.supportsInterface(interfaceId); + return + interfaceId == type(IERC5192).interfaceId || + interfaceId == type(IERC721EnumerableUpgradeable).interfaceId || + super.supportsInterface(interfaceId); + } + + function locked(uint256 tokenId) external view returns (bool) { + if (!_exists(tokenId)) revert NonExistentToken(tokenId); + return true; } function approve( From 057a7fcaeeb272f85c80cd57a22112bd138ac121 Mon Sep 17 00:00:00 2001 From: Tomi_Ohl Date: Wed, 15 Nov 2023 16:20:53 +0100 Subject: [PATCH 2/3] Add tests --- test/BasicGuildRewardNFT.spec.ts | 7 +++++++ test/SoulboundERC721.spec.ts | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/test/BasicGuildRewardNFT.spec.ts b/test/BasicGuildRewardNFT.spec.ts index 59cb5fa..8be80ab 100644 --- a/test/BasicGuildRewardNFT.spec.ts +++ b/test/BasicGuildRewardNFT.spec.ts @@ -201,6 +201,13 @@ describe("BasicGuildRewardNFT", () => { expect(await nft.ownerOf(tokenId)).to.eq(wallet0.address); }); + it("should emit Locked event when minting", async () => { + const tokenId = await nft.totalSupply(); + await expect(nft.claim(wallet0.address, sampleUserId, sampleSignature, { value: fee + price })) + .to.emit(nft, "Locked") + .withArgs(tokenId); + }); + it("should emit Claimed event", async () => { await expect( nft.claim(wallet0.address, sampleUserId, sampleSignature, { diff --git a/test/SoulboundERC721.spec.ts b/test/SoulboundERC721.spec.ts index 539c609..3d22d75 100644 --- a/test/SoulboundERC721.spec.ts +++ b/test/SoulboundERC721.spec.ts @@ -53,4 +53,11 @@ describe("SoulboundERC721", () => { nft["safeTransferFrom(address,address,uint256,bytes)"](wallet0.address, randomWallet.address, 0, ethers.ZeroHash) ).to.be.revertedWithCustomError(BasicGuildRewardNFT, "Soulbound"); }); + + it("should have a locked function that throws for not minted tokens", async () => { + const tokenId = 1; + await expect(nft.locked(tokenId)) + .to.be.revertedWithCustomError(BasicGuildRewardNFT, "NonExistentToken") + .withArgs(tokenId); + }); }); From 5467f7bb41effacd80a87769342c24133149b32c Mon Sep 17 00:00:00 2001 From: Tomi_Ohl Date: Wed, 15 Nov 2023 16:21:45 +0100 Subject: [PATCH 3/3] Update docs --- .../interfaces/IBasicGuildRewardNFT.md | 14 ----- docs/contracts/interfaces/IERC5192.md | 60 +++++++++++++++++++ docs/contracts/token/SoulboundERC721.md | 33 ++++++++++ 3 files changed, 93 insertions(+), 14 deletions(-) create mode 100644 docs/contracts/interfaces/IERC5192.md diff --git a/docs/contracts/interfaces/IBasicGuildRewardNFT.md b/docs/contracts/interfaces/IBasicGuildRewardNFT.md index 97e1835..c366281 100644 --- a/docs/contracts/interfaces/IBasicGuildRewardNFT.md +++ b/docs/contracts/interfaces/IBasicGuildRewardNFT.md @@ -220,17 +220,3 @@ error IncorrectSignature() Error thrown when the supplied signature is invalid. -### NonExistentToken - -```solidity -error NonExistentToken(uint256 tokenId) -``` - -Error thrown when trying to query info about a token that's not (yet) minted. - -#### Parameters - -| Name | Type | Description | -| ---- | ---- | ----------- | -| tokenId | uint256 | The queried id. | - diff --git a/docs/contracts/interfaces/IERC5192.md b/docs/contracts/interfaces/IERC5192.md new file mode 100644 index 0000000..97fb0c1 --- /dev/null +++ b/docs/contracts/interfaces/IERC5192.md @@ -0,0 +1,60 @@ +# IERC5192 + +## Functions + +### locked + +```solidity +function locked( + uint256 tokenId +) external returns (bool) +``` + +Returns the locking status of an Soulbound Token + +SBTs assigned to zero address are considered invalid, and queries +about them do throw. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `tokenId` | uint256 | The identifier for an SBT. | + +## Events + +### Locked + +```solidity +event Locked( + uint256 tokenId +) +``` + +Emitted when the locking status is changed to locked. + +If a token is minted and the status is locked, this event should be emitted. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `tokenId` | uint256 | The identifier for a token. | +### Unlocked + +```solidity +event Unlocked( + uint256 tokenId +) +``` + +Emitted when the locking status is changed to unlocked. + +If a token is minted and the status is unlocked, this event should be emitted. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `tokenId` | uint256 | The identifier for a token. | + diff --git a/docs/contracts/token/SoulboundERC721.md b/docs/contracts/token/SoulboundERC721.md index e2a0b7a..7ab6946 100644 --- a/docs/contracts/token/SoulboundERC721.md +++ b/docs/contracts/token/SoulboundERC721.md @@ -41,6 +41,25 @@ See {IERC165-supportsInterface}. | :--- | :--- | :---------- | | `interfaceId` | bytes4 | | +### locked + +```solidity +function locked( + uint256 tokenId +) external returns (bool) +``` + +Returns the locking status of an Soulbound Token + +SBTs assigned to zero address are considered invalid, and queries +about them do throw. + +#### Parameters + +| Name | Type | Description | +| :--- | :--- | :---------- | +| `tokenId` | uint256 | The identifier for an SBT. | + ### approve ```solidity @@ -169,6 +188,20 @@ Still used for minting/burning. ## Custom errors +### NonExistentToken + +```solidity +error NonExistentToken(uint256 tokenId) +``` + +Error thrown when trying to query info about a token that's not (yet) minted. + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| tokenId | uint256 | The queried id. | + ### Soulbound ```solidity