diff --git a/contracts/interfaces/ITokenHandleRegistry.sol b/contracts/interfaces/ITokenHandleRegistry.sol index 474d91a..6694c56 100644 --- a/contracts/interfaces/ITokenHandleRegistry.sol +++ b/contracts/interfaces/ITokenHandleRegistry.sol @@ -21,15 +21,16 @@ interface ITokenHandleRegistry { * @custom:permissions LensHub * * @param handleId ID of the .lens namespace handle NFT - * @param tokenId ID of the Lens Protocol Profile NFT + * @param profileId ID of the Lens Protocol Profile NFT */ - function migrationLink(uint256 handleId, uint256 tokenId) external; + function migrationLink(uint256 handleId, uint256 profileId) external; /** * @notice Links a handle NFT with a profile NFT. * Linking means a connection between the two NFTs is created, and the handle NFT can be used to resolve the profile * NFT or vice versa. - * @custom:permissions Caller must own both NFTs. + * @custom:permissions Both NFTs must be owned by the same address, and caller must be the owner of profile or its + * approved DelegatedExecutor. * * @dev In the first version of the registry, the NFT contracts are hard-coded: * - Handle is hard-coded to be of the .lens namespace @@ -38,13 +39,13 @@ interface ITokenHandleRegistry { * function might be deprecated and replaced with a new one accepting addresses of the handle and token contracts. * * @param handleId ID of the .lens namespace handle NFT - * @param tokenId ID of the Lens Protocol Profile NFT + * @param profileId ID of the Lens Protocol Profile NFT */ - function link(uint256 handleId, uint256 tokenId) external; + function link(uint256 handleId, uint256 profileId) external; /** * @notice Unlinks a handle NFT from a profile NFT. - * @custom:permissions Called can be the owner of either of the NFTs. + * @custom:permissions Caller can be the owner of either of the NFTs. * * @dev In the first version of the registry, the contracts are hard-coded: * - Handle is hard-coded to be of the .lens namespace @@ -53,9 +54,9 @@ interface ITokenHandleRegistry { * function might be deprecated and replaced with a new one accepting addresses of the handle and token contracts. * * @param handleId ID of the .lens namespace handle NFT - * @param tokenId ID of the Lens Protocol Profile NFT + * @param profileId ID of the Lens Protocol Profile NFT */ - function unlink(uint256 handleId, uint256 tokenId) external; + function unlink(uint256 handleId, uint256 profileId) external; /** * @notice Resolves a handle NFT to a profile NFT. diff --git a/contracts/libraries/constants/Types.sol b/contracts/libraries/constants/Types.sol index 4704632..8805be8 100644 --- a/contracts/libraries/constants/Types.sol +++ b/contracts/libraries/constants/Types.sol @@ -73,7 +73,7 @@ library Types { /** * @notice A struct containing the necessary information to reconstruct an EIP-712 typed data signature. * - * @param signer The address of the signer. + * @param signer The address of the signer. Specially needed as a parameter to support EIP-1271. * @param v The signature's recovery parameter. * @param r The signature's r parameter. * @param s The signature's s parameter. diff --git a/contracts/misc/ProfileCreationProxy.sol b/contracts/misc/ProfileCreationProxy.sol index 6a85a53..9cc48d3 100644 --- a/contracts/misc/ProfileCreationProxy.sol +++ b/contracts/misc/ProfileCreationProxy.sol @@ -61,7 +61,7 @@ contract ProfileCreationProxy is ImmutableOwnable { uint256 profileId = ILensHub(LENS_HUB).createProfile(createProfileParams); uint256 handleId = LENS_HANDLES.mintHandle(address(this), handle); - TOKEN_HANDLE_REGISTRY.link({handleId: handleId, tokenId: profileId}); + TOKEN_HANDLE_REGISTRY.link({handleId: handleId, profileId: profileId}); // Transfer the handle & profile to the destination LENS_HANDLES.transferFrom(address(this), destination, handleId); diff --git a/contracts/namespaces/TokenHandleRegistry.sol b/contracts/namespaces/TokenHandleRegistry.sol index e318281..e6fbc5a 100644 --- a/contracts/namespaces/TokenHandleRegistry.sol +++ b/contracts/namespaces/TokenHandleRegistry.sol @@ -2,13 +2,16 @@ pragma solidity ^0.8.18; -import {IERC721} from '@openzeppelin/contracts/token/ERC721/IERC721.sol'; +import {IERC1271} from '@openzeppelin/contracts/interfaces/IERC1271.sol'; import {ITokenHandleRegistry} from 'contracts/interfaces/ITokenHandleRegistry.sol'; import {RegistryTypes} from 'contracts/namespaces/constants/Types.sol'; +import {Types} from 'contracts/libraries/constants/Types.sol'; +import {Errors} from 'contracts/libraries/constants/Errors.sol'; import {RegistryErrors} from 'contracts/namespaces/constants/Errors.sol'; import {RegistryEvents} from 'contracts/namespaces/constants/Events.sol'; import {ILensHub} from 'contracts/interfaces/ILensHub.sol'; import {ILensHandles} from 'contracts/interfaces/ILensHandles.sol'; +import {Typehash} from 'contracts/namespaces/constants/Typehash.sol'; /** * @title TokenHandleRegistry @@ -20,24 +23,13 @@ contract TokenHandleRegistry is ITokenHandleRegistry { // First version of TokenHandleRegistry only works with Lens Profiles and .lens namespace. address immutable LENS_HUB; address immutable LENS_HANDLES; + bytes4 internal constant EIP1271_MAGIC_VALUE = 0x1626ba7e; // Using _handleHash(Handle) and _tokenHash(Token) as keys given that structs cannot be used as them. mapping(bytes32 handle => RegistryTypes.Token token) handleToToken; mapping(bytes32 token => RegistryTypes.Handle handle) tokenToHandle; - modifier onlyHandleOwner(uint256 handleId, address transactionExecutor) { - if (IERC721(LENS_HANDLES).ownerOf(handleId) != transactionExecutor) { - revert RegistryErrors.NotHandleOwner(); - } - _; - } - - modifier onlyTokenOwner(uint256 tokenId, address transactionExecutor) { - if (IERC721(LENS_HUB).ownerOf(tokenId) != transactionExecutor) { - revert RegistryErrors.NotTokenOwner(); - } - _; - } + mapping(address signer => uint256 nonce) public nonces; constructor(address lensHub, address lensHandles) { LENS_HUB = lensHub; @@ -47,47 +39,145 @@ contract TokenHandleRegistry is ITokenHandleRegistry { // Lens V1 to Lens V2 migration function // WARNING: It is able to link the Token and Handle even if they're not in the same wallet. // But it is designed to be only called from LensHub migration function, which assures that they are. - function migrationLink(uint256 handleId, uint256 tokenId) external { + function migrationLink(uint256 handleId, uint256 profileId) external { if (msg.sender != LENS_HUB) { revert RegistryErrors.OnlyLensHub(); } - _link( + _executeLinkage( RegistryTypes.Handle({collection: LENS_HANDLES, id: handleId}), - RegistryTypes.Token({collection: LENS_HUB, id: tokenId}) + RegistryTypes.Token({collection: LENS_HUB, id: profileId}) ); } /// @inheritdoc ITokenHandleRegistry - function link( + function link(uint256 handleId, uint256 profileId) external { + _link(handleId, profileId, msg.sender); + } + + function linkWithSig(uint256 handleId, uint256 profileId, Types.EIP712Signature calldata signature) external { + _validateLinkSignature(signature, handleId, profileId); + _link(handleId, profileId, signature.signer); + } + + function _link(uint256 handleId, uint256 profileId, address transactionExecutor) private { + // Handle and profile must be owned by the same address. + // Caller should be the owner of the profile or one of its approved delegated executors. + address profileOwner = ILensHub(LENS_HUB).ownerOf(profileId); + if (profileOwner != ILensHandles(LENS_HANDLES).ownerOf(handleId)) { + revert RegistryErrors.HandleAndTokenNotInSameWallet(); + } + if ( + transactionExecutor != profileOwner || + !ILensHub(LENS_HUB).isDelegatedExecutorApproved(profileId, transactionExecutor) + ) { + revert RegistryErrors.DoesNotHavePermissions(); + } + _executeLinkage( + RegistryTypes.Handle({collection: LENS_HANDLES, id: handleId}), + RegistryTypes.Token({collection: LENS_HUB, id: profileId}) + ); + } + + function _validateLinkSignature( + Types.EIP712Signature calldata signature, uint256 handleId, - uint256 tokenId - ) external onlyTokenOwner(tokenId, msg.sender) onlyHandleOwner(handleId, msg.sender) { - _link( - RegistryTypes.Handle({collection: LENS_HANDLES, id: handleId}), - RegistryTypes.Token({collection: LENS_HUB, id: tokenId}) + uint256 profileId + ) internal { + _validateRecoveredAddress( + _calculateDigest( + keccak256( + abi.encode(Typehash.LINK, handleId, profileId, nonces[signature.signer]++, signature.deadline) + ) + ), + signature ); } + function _validateUnlinkSignature( + Types.EIP712Signature calldata signature, + uint256 handleId, + uint256 profileId + ) internal { + _validateRecoveredAddress( + _calculateDigest( + keccak256( + abi.encode(Typehash.UNLINK, handleId, profileId, nonces[signature.signer]++, signature.deadline) + ) + ), + signature + ); + } + + /** + * @dev Wrapper for ecrecover to reduce code size, used in meta-tx specific functions. + */ + function _validateRecoveredAddress(bytes32 digest, Types.EIP712Signature calldata signature) private view { + if (signature.deadline < block.timestamp) revert Errors.SignatureExpired(); + // If the expected address is a contract, check the signature there. + if (signature.signer.code.length != 0) { + bytes memory concatenatedSig = abi.encodePacked(signature.r, signature.s, signature.v); + if (IERC1271(signature.signer).isValidSignature(digest, concatenatedSig) != EIP1271_MAGIC_VALUE) { + revert Errors.SignatureInvalid(); + } + } else { + address recoveredAddress = ecrecover(digest, signature.v, signature.r, signature.s); + if (recoveredAddress == address(0) || recoveredAddress != signature.signer) { + revert Errors.SignatureInvalid(); + } + } + } + + /** + * @dev Calculates EIP712 digest based on the current DOMAIN_SEPARATOR. + * + * @param hashedMessage The message hash from which the digest should be calculated. + * + * @return bytes32 A 32-byte output representing the EIP712 digest. + */ + function _calculateDigest(bytes32 hashedMessage) private view returns (bytes32) { + return keccak256(abi.encodePacked('\x19\x01', calculateDomainSeparator(), hashedMessage)); + } + + function calculateDomainSeparator() internal view returns (bytes32) { + return + keccak256( + abi.encode( + Typehash.EIP712_DOMAIN, + keccak256('TokenHandleRegistry'), + keccak256('1'), + block.chainid, + address(this) + ) + ); + } + /// @inheritdoc ITokenHandleRegistry - function unlink(uint256 handleId, uint256 tokenId) external { - // We revert here only in the case if both tokens exists and the caller is not the owner of any of them + function unlink(uint256 handleId, uint256 profileId) external { + _unlink(handleId, profileId, msg.sender); + } + + function unlinkWithSig(uint256 handleId, uint256 profileId, Types.EIP712Signature calldata signature) external { + _validateUnlinkSignature(signature, handleId, profileId); + _unlink(handleId, profileId, signature.signer); + } + + function _unlink(uint256 handleId, uint256 profileId, address transactionExecutor) private { if ( ILensHandles(LENS_HANDLES).exists(handleId) && - ILensHandles(LENS_HANDLES).ownerOf(handleId) != msg.sender && - ILensHub(LENS_HUB).exists(tokenId) && - ILensHub(LENS_HUB).ownerOf(tokenId) != msg.sender + ILensHandles(LENS_HANDLES).ownerOf(handleId) != transactionExecutor && + ILensHub(LENS_HUB).exists(profileId) && + (ILensHub(LENS_HUB).ownerOf(profileId) != transactionExecutor || + !ILensHub(LENS_HUB).isDelegatedExecutorApproved(profileId, transactionExecutor)) ) { revert RegistryErrors.NotHandleNorTokenOwner(); } - RegistryTypes.Handle memory handle = RegistryTypes.Handle({collection: LENS_HANDLES, id: handleId}); RegistryTypes.Token memory tokenPointedByHandle = handleToToken[_handleHash(handle)]; - // We check if the tokens are (were) linked for the case if some of them doesn't exist - if (tokenPointedByHandle.id != tokenId) { + if (tokenPointedByHandle.id != profileId) { revert RegistryErrors.NotLinked(); } - _unlink(handle, tokenPointedByHandle); + _executeUnlinkage(handle, tokenPointedByHandle); } /// @inheritdoc ITokenHandleRegistry @@ -104,11 +194,11 @@ contract TokenHandleRegistry is ITokenHandleRegistry { } /// @inheritdoc ITokenHandleRegistry - function getDefaultHandle(uint256 tokenId) external view returns (uint256) { - if (!ILensHub(LENS_HUB).exists(tokenId)) { + function getDefaultHandle(uint256 profileId) external view returns (uint256) { + if (!ILensHub(LENS_HUB).exists(profileId)) { revert RegistryErrors.DoesNotExist(); } - uint256 defaultHandleId = _resolveTokenToHandle(RegistryTypes.Token({collection: LENS_HUB, id: tokenId})).id; + uint256 defaultHandleId = _resolveTokenToHandle(RegistryTypes.Token({collection: LENS_HUB, id: profileId})).id; if (defaultHandleId == 0 || !ILensHandles(LENS_HANDLES).exists(defaultHandleId)) { return 0; } @@ -131,7 +221,7 @@ contract TokenHandleRegistry is ITokenHandleRegistry { return tokenToHandle[_tokenHash(token)]; } - function _link(RegistryTypes.Handle memory handle, RegistryTypes.Token memory token) internal { + function _executeLinkage(RegistryTypes.Handle memory handle, RegistryTypes.Token memory token) internal { _deleteTokenToHandleLinkageIfAny(handle); handleToToken[_handleHash(handle)] = token; @@ -157,7 +247,7 @@ contract TokenHandleRegistry is ITokenHandleRegistry { } } - function _unlink(RegistryTypes.Handle memory handle, RegistryTypes.Token memory token) internal { + function _executeUnlinkage(RegistryTypes.Handle memory handle, RegistryTypes.Token memory token) internal { delete handleToToken[_handleHash(handle)]; // tokenToHandle is removed too, as the first version linkage is one-to-one. delete tokenToHandle[_tokenHash(token)]; diff --git a/contracts/namespaces/constants/Errors.sol b/contracts/namespaces/constants/Errors.sol index bb170ae..e7f0c65 100644 --- a/contracts/namespaces/constants/Errors.sol +++ b/contracts/namespaces/constants/Errors.sol @@ -3,12 +3,13 @@ pragma solidity >=0.6.0; library RegistryErrors { - error NotHandleOwner(); - error NotTokenOwner(); error NotHandleNorTokenOwner(); error OnlyLensHub(); error NotLinked(); error DoesNotExist(); + error DoesNotHavePermissions(); + error HandleAndTokenNotInSameWallet(); + error SignatureInvalid(); } library HandlesErrors { diff --git a/contracts/namespaces/constants/Typehash.sol b/contracts/namespaces/constants/Typehash.sol new file mode 100644 index 0000000..87929d1 --- /dev/null +++ b/contracts/namespaces/constants/Typehash.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT + +pragma solidity >=0.6.0; + +library Typehash { + + bytes32 constant EIP712_DOMAIN = keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'); + + bytes32 constant LINK = keccak256('Link(uint256 handleId,uint256 profileId,uint256 nonce,uint256 deadline)'); + + bytes32 constant UNLINK = keccak256('Unlink(uint256 handleId,uint256 profileId,uint256 nonce,uint256 deadline)'); +} diff --git a/test/namespaces/TokenHandleRegistryTest.t.sol b/test/namespaces/TokenHandleRegistryTest.t.sol index faac06b..3d5d3c7 100644 --- a/test/namespaces/TokenHandleRegistryTest.t.sol +++ b/test/namespaces/TokenHandleRegistryTest.t.sol @@ -42,7 +42,7 @@ contract TokenHandleRegistryTest is BaseTest { vm.prank(initialHandleHolder); lensHandles.transferFrom(initialHandleHolder, otherAddress, handleId); - vm.expectRevert(RegistryErrors.NotTokenOwner.selector); + vm.expectRevert(RegistryErrors.DoesNotHavePermissions.selector); vm.prank(otherAddress); tokenHandleRegistry.link(handleId, profileId); @@ -58,7 +58,7 @@ contract TokenHandleRegistryTest is BaseTest { vm.prank(initialProfileHolder); hub.transferFrom(initialProfileHolder, otherAddress, profileId); - vm.expectRevert(RegistryErrors.NotHandleOwner.selector); + vm.expectRevert(RegistryErrors.DoesNotHavePermissions.selector); vm.prank(otherAddress); tokenHandleRegistry.link(handleId, profileId);