From 06ce81966a2c66ad3f6a0a7996c86b9b87664fae Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 15 Feb 2024 16:20:30 +0800 Subject: [PATCH] feat: erc5192 for soulbound document --- src/OwnableDocumentStore.sol | 39 +++- src/interfaces/IERC5192.sol | 20 +++ src/interfaces/IOwnableDocumentStoere.sol | 4 +- .../IOwnableDocumentStoreErrors.sol | 2 + test/CommonTest.t.sol | 6 +- test/OwnableDocumentStore.t.sol | 167 ++++++++++++++---- 6 files changed, 194 insertions(+), 44 deletions(-) create mode 100644 src/interfaces/IERC5192.sol diff --git a/src/OwnableDocumentStore.sol b/src/OwnableDocumentStore.sol index 92cd4a8..64c7b07 100644 --- a/src/OwnableDocumentStore.sol +++ b/src/OwnableDocumentStore.sol @@ -8,14 +8,17 @@ import {IDocumentStore} from "./interfaces/IDocumentStore.sol"; import "./base/DocumentStoreAccessControl.sol"; import "./interfaces/IOwnableDocumentStoere.sol"; import "./interfaces/IOwnableDocumentStoreErrors.sol"; +import "./interfaces/IERC5192.sol"; contract OwnableDocumentStore is DocumentStoreAccessControl, ERC721Upgradeable, + IERC5192, IOwnableDocumentStoreErrors, IOwnableDocumentStore { mapping(uint256 => bool) private _revoked; + mapping(uint256 => bool) private _locked; constructor(string memory name_, string memory symbol_, address initAdmin) { initialize(name_, symbol_, initAdmin); @@ -38,10 +41,6 @@ contract OwnableDocumentStore is return super.name(); } - function _isRevoked(uint256 tokenId) internal view returns (bool) { - return _revoked[tokenId]; - } - function isActive(bytes32 document) public view nonZeroDocument(document) returns (bool) { uint256 tokenId = uint256(document); address owner = _ownerOf(tokenId); @@ -54,10 +53,16 @@ contract OwnableDocumentStore is revert ERC721NonexistentToken(tokenId); } - function issue(address to, bytes32 document) public nonZeroDocument(document) onlyRole(ISSUER_ROLE) { + function issue(address to, bytes32 document, bool lock) public nonZeroDocument(document) onlyRole(ISSUER_ROLE) { uint256 tokenId = uint256(document); if (!_isRevoked(tokenId)) { _mint(to, tokenId); + if (lock) { + _locked[tokenId] = true; + emit Locked(tokenId); + } else { + emit Unlocked(tokenId); + } } else { revert DocumentIsRevoked(document); } @@ -96,9 +101,33 @@ contract OwnableDocumentStore is return interfaceId == type(IDocumentStore).interfaceId || interfaceId == type(IOwnableDocumentStore).interfaceId || + interfaceId == type(IERC5192).interfaceId || super.supportsInterface(interfaceId); } + function locked(uint256 tokenId) public view returns (bool) { + if (tokenId == 0) { + revert ZeroDocument(); + } + return _isLocked(tokenId); + } + + function _isRevoked(uint256 tokenId) internal view returns (bool) { + return _revoked[tokenId]; + } + + function _isLocked(uint256 tokenId) internal view returns (bool) { + return _locked[tokenId]; + } + + function _update(address to, uint256 tokenId, address auth) internal virtual override returns (address) { + address from = super._update(to, tokenId, auth); + if (_isLocked(tokenId) && (from != address(0) && to != address(0))) { + revert DocumentLocked(bytes32(tokenId)); + } + return from; + } + modifier nonZeroDocument(bytes32 document) { uint256 tokenId = uint256(document); if (tokenId == 0) { diff --git a/src/interfaces/IERC5192.sol b/src/interfaces/IERC5192.sol new file mode 100644 index 0000000..53a8774 --- /dev/null +++ b/src/interfaces/IERC5192.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity >=0.8.23 <0.9.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/src/interfaces/IOwnableDocumentStoere.sol b/src/interfaces/IOwnableDocumentStoere.sol index 8df31e3..45bec97 100644 --- a/src/interfaces/IOwnableDocumentStoere.sol +++ b/src/interfaces/IOwnableDocumentStoere.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity >=0.8.23 <0.9.0; -import {IDocumentStore} from "./IDocumentStore.sol"; +import "./IDocumentStore.sol"; interface IOwnableDocumentStore is IDocumentStore { - function issue(address to, bytes32 documentRoot) external; + function issue(address to, bytes32 documentRoot, bool locked) external; } diff --git a/src/interfaces/IOwnableDocumentStoreErrors.sol b/src/interfaces/IOwnableDocumentStoreErrors.sol index 131e7c6..53616ef 100644 --- a/src/interfaces/IOwnableDocumentStoreErrors.sol +++ b/src/interfaces/IOwnableDocumentStoreErrors.sol @@ -9,4 +9,6 @@ interface IOwnableDocumentStoreErrors { error ZeroDocument(); error DocumentIsRevoked(bytes32 document); + + error DocumentLocked(bytes32 document); } diff --git a/test/CommonTest.t.sol b/test/CommonTest.t.sol index ed86b78..f807a26 100644 --- a/test/CommonTest.t.sol +++ b/test/CommonTest.t.sol @@ -245,11 +245,9 @@ abstract contract OwnableDocumentStoreCommonTest is CommonTest { super.setUp(); vm.startPrank(owner); - documentStore = new OwnableDocumentStore(storeName, storeSymbol, owner); documentStore.grantRole(documentStore.ISSUER_ROLE(), issuer); documentStore.grantRole(documentStore.REVOKER_ROLE(), revoker); - vm.stopPrank(); } } @@ -270,8 +268,8 @@ abstract contract OwnableDocumentStore_Initializer is OwnableDocumentStoreCommon recipients[1] = vm.addr(5); vm.startPrank(issuer); - documentStore.issue(recipients[0], documents[0]); - documentStore.issue(recipients[1], documents[1]); + documentStore.issue(recipients[0], documents[0], false); + documentStore.issue(recipients[1], documents[1], true); vm.stopPrank(); } } diff --git a/test/OwnableDocumentStore.t.sol b/test/OwnableDocumentStore.t.sol index 5acccc9..f80448b 100644 --- a/test/OwnableDocumentStore.t.sol +++ b/test/OwnableDocumentStore.t.sol @@ -45,7 +45,7 @@ contract OwnableDocumentStore_issue_Test is OwnableDocumentStoreCommonTest { vm.assume(document != bytes32(0)); vm.prank(issuer); - documentStore.issue(recipient, document); + documentStore.issue(recipient, document, false); } function testIssueAsNonIssuerRevert(bytes32 document) public { @@ -62,7 +62,7 @@ contract OwnableDocumentStore_issue_Test is OwnableDocumentStoreCommonTest { ); vm.prank(nonIssuer); - documentStore.issue(recipient, document); + documentStore.issue(recipient, document, false); } function testIssueAsRevokerRevert(bytes32 document) public { @@ -77,102 +77,138 @@ contract OwnableDocumentStore_issue_Test is OwnableDocumentStoreCommonTest { ); vm.prank(revoker); - documentStore.issue(recipient, document); + documentStore.issue(recipient, document, false); } function testIssueToRecipient(bytes32 document) public { vm.assume(document != bytes32(0)); vm.prank(issuer); - documentStore.issue(recipient, document); + documentStore.issue(recipient, document, false); address docOwner = documentStore.ownerOf(uint256(document)); assertTrue(docOwner == recipient, "Document owner is not the recipient"); } + function testIssueUnlockedDocument(bytes32 document) public { + vm.assume(document != bytes32(0)); + vm.expectEmit(true, false, false, true); + + emit IERC5192.Unlocked(uint256(document)); + + vm.prank(issuer); + documentStore.issue(recipient, document, false); + + bool isLocked = documentStore.locked(uint256(document)); + assertFalse(isLocked, "Document should not be locked"); + } + + function testIssueLockedDocument(bytes32 document) public { + vm.assume(document != bytes32(0)); + vm.expectEmit(true, false, false, true); + + emit IERC5192.Locked(uint256(document)); + + vm.prank(issuer); + documentStore.issue(recipient, document, true); + + bool isLocked = documentStore.locked(uint256(document)); + assertTrue(isLocked, "Document should be locked"); + } + function testIssueToZeroRecipientRevert(bytes32 document) public { vm.assume(document != bytes32(0)); vm.expectRevert(abi.encodeWithSelector(IERC721Errors.ERC721InvalidReceiver.selector, address(0))); vm.prank(issuer); - documentStore.issue(address(0), document); + documentStore.issue(address(0), document, false); } function testIssueToZeroDocumentRevert() public { vm.expectRevert(abi.encodeWithSelector(IOwnableDocumentStoreErrors.ZeroDocument.selector)); vm.prank(issuer); - documentStore.issue(recipient, bytes32(0)); + documentStore.issue(recipient, bytes32(0), false); } function testIssueAlreadyIssuedDocumentToSameRecipientRevert(bytes32 document) public { vm.assume(document != bytes32(0)); vm.prank(issuer); - documentStore.issue(recipient, document); + documentStore.issue(recipient, document, false); vm.expectRevert(abi.encodeWithSelector(IERC721Errors.ERC721InvalidSender.selector, address(0))); vm.prank(issuer); - documentStore.issue(recipient, document); + documentStore.issue(recipient, document, false); } function testIssueAlreadyIssuedDocumentToDifferentRecipientRevert(bytes32 document) public { vm.assume(document != bytes32(0)); vm.prank(issuer); - documentStore.issue(recipient, document); + documentStore.issue(recipient, document, false); vm.expectRevert(abi.encodeWithSelector(IERC721Errors.ERC721InvalidSender.selector, address(0))); vm.prank(issuer); - documentStore.issue(vm.addr(69), document); + documentStore.issue(vm.addr(69), document, false); } function testIssueRevokedDocumentToSameRecipientRevert(bytes32 document) public { vm.assume(document != bytes32(0)); vm.startPrank(owner); - documentStore.issue(recipient, document); + documentStore.issue(recipient, document, false); documentStore.revoke(document); vm.stopPrank(); vm.expectRevert(abi.encodeWithSelector(IOwnableDocumentStoreErrors.DocumentIsRevoked.selector, document)); vm.prank(issuer); - documentStore.issue(recipient, document); + documentStore.issue(recipient, document, false); } function testIssueRevokedDocumentToDifferentRecipientRevert(bytes32 document) public { vm.assume(document != bytes32(0)); vm.startPrank(owner); - documentStore.issue(recipient, document); + documentStore.issue(recipient, document, false); documentStore.revoke(document); vm.stopPrank(); vm.expectRevert(abi.encodeWithSelector(IOwnableDocumentStoreErrors.DocumentIsRevoked.selector, document)); vm.prank(issuer); - documentStore.issue(vm.addr(69), document); + documentStore.issue(vm.addr(69), document, false); } } contract OwnableDocumentStore_revoke_Test is OwnableDocumentStore_Initializer { - bytes32 public targetDocument; + bytes32 public unlockedDocument; + bytes32 public lockedDocument; function setUp() public override { super.setUp(); - targetDocument = documents[0]; + unlockedDocument = documents[0]; + lockedDocument = documents[1]; } - function testRevokeAsRevokerSuccess() public { + function testRevokeUnlockedDocumentAsRevokerSuccess() public { vm.prank(revoker); - documentStore.revoke(targetDocument); + documentStore.revoke(unlockedDocument); - assertTrue(documentStore.isRevoked(targetDocument), "Document is should be revoked"); + assertTrue(documentStore.isRevoked(unlockedDocument), "Document is should be revoked"); assertEq(documentStore.balanceOf(recipients[0]), 0); } + function testRevokeLockedDocumentAsRevokerSuccess() public { + vm.prank(revoker); + documentStore.revoke(lockedDocument); + + assertTrue(documentStore.isRevoked(lockedDocument), "Document is should be revoked"); + assertEq(documentStore.balanceOf(recipients[1]), 0); + } + function testRevokeAsIssuerRevert() public { vm.expectRevert( abi.encodeWithSelector( @@ -183,7 +219,7 @@ contract OwnableDocumentStore_revoke_Test is OwnableDocumentStore_Initializer { ); vm.prank(issuer); - documentStore.revoke(targetDocument); + documentStore.revoke(unlockedDocument); } function testRevokeAsNonRevokerRevert() public { @@ -198,7 +234,7 @@ contract OwnableDocumentStore_revoke_Test is OwnableDocumentStore_Initializer { ); vm.prank(nonRevoker); - documentStore.revoke(targetDocument); + documentStore.revoke(unlockedDocument); } function testRevokeZeroDocumentRevert() public { @@ -219,21 +255,21 @@ contract OwnableDocumentStore_revoke_Test is OwnableDocumentStore_Initializer { function testRevokeAlreadyRevokedDocumentRevert() public { vm.prank(revoker); - documentStore.revoke(targetDocument); + documentStore.revoke(unlockedDocument); - vm.expectRevert(abi.encodeWithSelector(IERC721Errors.ERC721NonexistentToken.selector, targetDocument)); + vm.expectRevert(abi.encodeWithSelector(IERC721Errors.ERC721NonexistentToken.selector, unlockedDocument)); vm.prank(revoker); - documentStore.revoke(targetDocument); + documentStore.revoke(unlockedDocument); } function testRevokeDocumentOwnerIsZeroRevert() public { vm.prank(revoker); - documentStore.revoke(targetDocument); + documentStore.revoke(unlockedDocument); - vm.expectRevert(abi.encodeWithSelector(IERC721Errors.ERC721NonexistentToken.selector, targetDocument)); + vm.expectRevert(abi.encodeWithSelector(IERC721Errors.ERC721NonexistentToken.selector, unlockedDocument)); - documentStore.ownerOf(uint256(targetDocument)); + documentStore.ownerOf(uint256(unlockedDocument)); } } @@ -296,13 +332,17 @@ contract OwnableDocumentStore_isRevoked_Test is OwnableDocumentStore_Initializer contract OwnableDocumentStore_isActive_Test is OwnableDocumentStore_Initializer { function testIsActive() public { assertTrue(documentStore.isActive(documents[0]), "Document should be active"); + assertTrue(documentStore.isActive(documents[1]), "Document should be active"); } function testIsActiveRevertedDocument() public { - vm.prank(revoker); + vm.startPrank(revoker); documentStore.revoke(documents[0]); + documentStore.revoke(documents[1]); + vm.stopPrank(); assertFalse(documentStore.isActive(documents[0]), "Document should not be active"); + assertFalse(documentStore.isActive(documents[1]), "Document should not be active"); } function testIsActiveNotIssuedDocumentRevert() public { @@ -321,11 +361,21 @@ contract OwnableDocumentStore_isActive_Test is OwnableDocumentStore_Initializer } contract OwnableDocumentStore_transfer_Test is OwnableDocumentStore_Initializer { - function testTransferFromToNewRecipient() public { + bytes32 public unlockedDocument; + bytes32 public lockedDocument; + + function setUp() public override { + super.setUp(); + + unlockedDocument = documents[0]; + lockedDocument = documents[1]; + } + + function testTransferFromUnlockedDocumentToNewRecipient() public { vm.prank(recipients[0]); - documentStore.transferFrom(recipients[0], recipients[1], uint256(documents[0])); + documentStore.transferFrom(recipients[0], recipients[1], uint256(unlockedDocument)); - address docOwner = documentStore.ownerOf(uint256(documents[0])); + address docOwner = documentStore.ownerOf(uint256(unlockedDocument)); uint256 balanceOfRecipient0 = documentStore.balanceOf(recipients[0]); uint256 balanceOfRecipient1 = documentStore.balanceOf(recipients[1]); @@ -334,11 +384,61 @@ contract OwnableDocumentStore_transfer_Test is OwnableDocumentStore_Initializer assertEq(balanceOfRecipient1, 2); } - function testTransferFromToZeroRevert() public { + function testTransferFromLockedDocumentToNewRecipientRevert() public { + vm.expectRevert(abi.encodeWithSelector(IOwnableDocumentStoreErrors.DocumentLocked.selector, lockedDocument)); + + vm.prank(recipients[1]); + documentStore.transferFrom(recipients[1], recipients[0], uint256(lockedDocument)); + } + + function testTransferFromUnlockedDocumentToZeroRevert() public { vm.expectRevert(abi.encodeWithSelector(IERC721Errors.ERC721InvalidReceiver.selector, address(0))); vm.prank(recipients[0]); - documentStore.transferFrom(recipients[0], address(0), uint256(documents[0])); + documentStore.transferFrom(recipients[0], address(0), uint256(unlockedDocument)); + } + + function testTransferFromLockedDocumentToZeroRevert() public { + vm.expectRevert(abi.encodeWithSelector(IERC721Errors.ERC721InvalidReceiver.selector, address(0))); + + vm.prank(recipients[1]); + documentStore.transferFrom(recipients[1], address(0), uint256(lockedDocument)); + } +} + +contract OwnableDocumentStore_locked_Test is OwnableDocumentStoreCommonTest { + address public recipient; + + function setUp() public override { + super.setUp(); + + recipient = vm.addr(6969); + } + + function testLockedWithUnlockedDocument(bytes32 document) public { + vm.assume(document != bytes32(0)); + + vm.prank(issuer); + documentStore.issue(recipient, document, false); + + bool isLocked = documentStore.locked(uint256(document)); + assertFalse(isLocked, "Document should not be locked"); + } + + function testLockedWithLockedDocument(bytes32 document) public { + vm.assume(document != bytes32(0)); + + vm.prank(issuer); + documentStore.issue(recipient, document, true); + + bool isLocked = documentStore.locked(uint256(document)); + assertTrue(isLocked, "Document should be locked"); + } + + function testLockedWithZeroDocument() public { + vm.expectRevert(abi.encodeWithSelector(IOwnableDocumentStoreErrors.ZeroDocument.selector)); + + documentStore.locked(uint256(bytes32(0))); } } @@ -346,6 +446,7 @@ contract OwnableDocumentStore_supportsInterface_Test is OwnableDocumentStoreComm function testSupportsInterface() public { assertTrue(documentStore.supportsInterface(type(IOwnableDocumentStore).interfaceId)); assertTrue(documentStore.supportsInterface(type(IDocumentStore).interfaceId)); + assertTrue(documentStore.supportsInterface(type(IERC5192).interfaceId)); assertTrue(documentStore.supportsInterface(type(IERC721Metadata).interfaceId)); assertTrue(documentStore.supportsInterface(type(IAccessControl).interfaceId)); }