diff --git a/.gitignore b/.gitignore index 27048d2..85198aa 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,3 @@ docs/ # Dotenv file .env - -remappings.txt diff --git a/.gitmodules b/.gitmodules index e80ffd8..c5abb46 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/openzeppelin/openzeppelin-contracts +[submodule "lib/solmate"] + path = lib/solmate + url = https://github.com/transmissions11/solmate diff --git a/lib/solmate b/lib/solmate new file mode 160000 index 0000000..97bdb20 --- /dev/null +++ b/lib/solmate @@ -0,0 +1 @@ +Subproject commit 97bdb2003b70382996a79a406813f76417b1cf90 diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..1cc3266 --- /dev/null +++ b/remappings.txt @@ -0,0 +1,2 @@ +@openzeppelin/=lib/openzeppelin-contracts/contracts/ +@solmate/=lib/solmate/src/ \ No newline at end of file diff --git a/src/InfernalRiftAbove.sol b/src/InfernalRiftAbove.sol index bcdc0b8..c54b0b1 100644 --- a/src/InfernalRiftAbove.sol +++ b/src/InfernalRiftAbove.sol @@ -1,79 +1,103 @@ // SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.26; /* solhint-disable private-vars-leading-underscore */ /* solhint-disable var-name-mixedcase */ /* solhint-disable func-param-name-mixedcase */ -pragma solidity ^0.8.0; - import {IERC721Metadata} from "@openzeppelin/token/ERC721/extensions/IERC721Metadata.sol"; import {ERC2981} from "@openzeppelin/token/common/ERC2981.sol"; +import {IERC2981} from "@openzeppelin/interfaces/IERC2981.sol"; import {IInfernalPackage} from "./interfaces/IInfernalPackage.sol"; import {IRoyaltyRegistry} from "./interfaces/IRoyaltyRegistry.sol"; import {IInfernalRiftAbove} from "./interfaces/IInfernalRiftAbove.sol"; +import {IInfernalRiftBelow} from "./interfaces/IInfernalRiftBelow.sol"; import {ICrossDomainMessenger} from "./interfaces/ICrossDomainMessenger.sol"; import {IOptimismPortal} from "./interfaces/IOptimismPortal.sol"; import {InfernalRiftBelow} from "./InfernalRiftBelow.sol"; + contract InfernalRiftAbove is IInfernalPackage, IInfernalRiftAbove { - uint256 constant BPS_MULTIPLIER = 10000; + uint constant internal BPS_MULTIPLIER = 10000; - address immutable PORTAL; - address immutable L1_CROSS_DOMAIN_MESSENGER; - address immutable ROYALTY_REGISTRY; - address INFERNAL_RIFT_BELOW; + IOptimismPortal immutable public PORTAL; + address immutable public L1_CROSS_DOMAIN_MESSENGER; + IRoyaltyRegistry immutable public ROYALTY_REGISTRY; + address public INFERNAL_RIFT_BELOW; error RiftBelowAlreadySet(); error NotCrossDomainMessenger(); error CrossChainSenderIsNotRiftBelow(); + error CollectionNotERC2981Compliant(); + error CallerIsNotRoyaltiesReceiver(address _caller, address _receiver); constructor(address _PORTAL, address _L1_CROSS_DOMAIN_MESSENGER, address _ROYALTY_REGISTRY) { - PORTAL = _PORTAL; + PORTAL = IOptimismPortal(_PORTAL); L1_CROSS_DOMAIN_MESSENGER = _L1_CROSS_DOMAIN_MESSENGER; - ROYALTY_REGISTRY = _ROYALTY_REGISTRY; + ROYALTY_REGISTRY = IRoyaltyRegistry(_ROYALTY_REGISTRY); } - function setInfernalRiftBelow(address a) external { + /** + * Allows the {InfernalRiftBelow} contract to be set. + * + * @dev This contract address cannot be updated if a non-zero address already set. + * + * @param _infernalRiftBelow Address of the {InfernalRiftBelow} contract + */ + function setInfernalRiftBelow(address _infernalRiftBelow) external { if (INFERNAL_RIFT_BELOW != address(0)) { revert RiftBelowAlreadySet(); } - INFERNAL_RIFT_BELOW = a; + + INFERNAL_RIFT_BELOW = _infernalRiftBelow; } + /** + * Sends ERC721 tokens from the L1 chain to L2. + * + * @param collectionAddresses Addresses of collections returning from L2 + * @param idsToCross Array of tokenIds, with the first iterator referring to collectionAddress + * @param recipient The recipient of the tokens on L2 + * @param gasLimit The maximum amount of gas to spend in transaction + */ function crossTheThreshold( address[] calldata collectionAddresses, - uint256[][] calldata idsToCross, + uint[][] calldata idsToCross, address recipient, uint64 gasLimit ) external payable { // Set up payload - uint256 numCollections = collectionAddresses.length; + uint numCollections = collectionAddresses.length; Package[] memory package = new Package[](numCollections); + // Cache variables ahead of our loops + uint numIds; + address collectionAddress; + string[] memory uris; + IERC721Metadata erc721; + // Go through each collection, set values if needed - for (uint256 i; i < numCollections;) { + for (uint i; i < numCollections; ++i) { // Cache values needed - uint256 numIds = idsToCross[i].length; - address collectionAddress = collectionAddresses[i]; + numIds = idsToCross[i].length; + collectionAddress = collectionAddresses[i]; + + erc721 = IERC721Metadata(collectionAddress); // Go through each NFT, set its URI and escrow it - string[] memory uris = new string[](numIds); - for (uint256 j; j < numIds;) { - uris[j] = IERC721Metadata(collectionAddress).tokenURI(idsToCross[i][j]); - IERC721Metadata(collectionAddress).transferFrom(msg.sender, address(this), idsToCross[i][j]); - unchecked { - ++j; - } + uris = new string[](numIds); + for (uint j; j < numIds; ++j) { + uris[j] = erc721.tokenURI(idsToCross[i][j]); + erc721.transferFrom(msg.sender, address(this), idsToCross[i][j]); } // Grab royalty value from first ID - address royaltyLookupAddress = IRoyaltyRegistry(ROYALTY_REGISTRY).getRoyaltyLookupAddress(collectionAddress); uint96 royaltyBps; - try ERC2981(royaltyLookupAddress).royaltyInfo(idsToCross[i][0], BPS_MULTIPLIER) returns ( - address, uint256 _royaltyAmount - ) { + try ERC2981( + ROYALTY_REGISTRY.getRoyaltyLookupAddress(collectionAddress) + ).royaltyInfo(idsToCross[i][0], BPS_MULTIPLIER) returns (address, uint _royaltyAmount) { royaltyBps = uint96(_royaltyAmount); } catch { // It's okay if it reverts (: @@ -85,15 +109,13 @@ contract InfernalRiftAbove is IInfernalPackage, IInfernalRiftAbove { ids: idsToCross[i], uris: uris, royaltyBps: royaltyBps, - name: IERC721Metadata(collectionAddress).name(), - symbol: IERC721Metadata(collectionAddress).symbol() + name: erc721.name(), + symbol: erc721.symbol() }); - unchecked { - ++i; - } } + // Send package off to the portal - IOptimismPortal(PORTAL).depositTransaction{value: msg.value}( + PORTAL.depositTransaction{value: msg.value}( INFERNAL_RIFT_BELOW, 0, gasLimit, @@ -102,33 +124,75 @@ contract InfernalRiftAbove is IInfernalPackage, IInfernalRiftAbove { ); } + /** + * Handle NFTs being transferred back to the L1 from the L2. + * + * @dev The NFTs must be stored in this contract to redistribute back on L1 + * + * @param collectionAddresses Addresses of collections returning from L2 + * @param idsToCross Array of tokenIds, with the first iterator referring to collectionAddress + * @param recipient The recipient of the tokens + */ function returnFromTheThreshold( address[] calldata collectionAddresses, - uint256[][] calldata idsToCross, + uint[][] calldata idsToCross, address recipient ) external { // Validate caller is cross-chain and comes from rift below if (msg.sender != L1_CROSS_DOMAIN_MESSENGER) { revert NotCrossDomainMessenger(); } + if (ICrossDomainMessenger(msg.sender).xDomainMessageSender() != INFERNAL_RIFT_BELOW) { revert CrossChainSenderIsNotRiftBelow(); } // Unlock NFTs to caller - uint256 numCollections = collectionAddresses.length; - for (uint256 i; i < numCollections;) { - address l1CollectionAddress = collectionAddresses[i]; - uint256 numIds = idsToCross[i].length; - for (uint256 j; j < numIds;) { - IERC721Metadata(l1CollectionAddress).transferFrom(address(this), recipient, idsToCross[i][j]); - unchecked { - ++j; - } - } - unchecked { - ++i; + uint numCollections = collectionAddresses.length; + + IERC721Metadata erc721; + uint numIds; + + for (uint i; i < numCollections; ++i) { + erc721 = IERC721Metadata(collectionAddresses[i]); + numIds = idsToCross[i].length; + + for (uint j; j < numIds; ++j) { + erc721.transferFrom(address(this), recipient, idsToCross[i][j]); } } } + + /** + * If the contract address on L1 implements `EIP-2981`, then we can allow the recipient + * of the L1 royalties make the claim against the L2 equivalent. + * + * @param _collectionAddress The address of the L1 collection + * @param _recipient The L2 recipient of the claim + * @param _tokens Addresses of tokens to claim + * @param _gasLimit The limit of gas to send + */ + function claimRoyalties(address _collectionAddress, address _recipient, address[] calldata _tokens, uint32 _gasLimit) external { + // We then need to make sure that the L1 contract supports royalties via EIP-2981 + if (!IERC2981(_collectionAddress).supportsInterface(type(IERC2981).interfaceId)) revert CollectionNotERC2981Compliant(); + + // We can now pull the royalty information from the L1 to confirm that the caller + // is the receiver of the royalties. We can't actually pull in the default royalty + // provider so instead we just use token0. + (address receiver,) = IERC2981(_collectionAddress).royaltyInfo(0, 0); + + // Check that the receiver of royalties is making this call + if (receiver != msg.sender) revert CallerIsNotRoyaltiesReceiver(msg.sender, receiver); + + // Make our call to the L2 that will pull tokens from the contract + ICrossDomainMessenger(L1_CROSS_DOMAIN_MESSENGER).sendMessage( + INFERNAL_RIFT_BELOW, + abi.encodeCall( + IInfernalRiftBelow.claimRoyalties, + (_collectionAddress, _recipient, _tokens) + ), + _gasLimit + ); + } + } diff --git a/src/InfernalRiftBelow.sol b/src/InfernalRiftBelow.sol index 1092be0..5fb611b 100644 --- a/src/InfernalRiftBelow.sol +++ b/src/InfernalRiftBelow.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0 -pragma solidity ^0.8.0; +pragma solidity ^0.8.26; /* solhint-disable private-vars-leading-underscore */ /* solhint-disable var-name-mixedcase */ @@ -10,22 +10,28 @@ import {IERC721} from "@openzeppelin/token/ERC721/IERC721.sol"; import {IInfernalPackage} from "./interfaces/IInfernalPackage.sol"; import {IInfernalRiftAbove} from "./interfaces/IInfernalRiftAbove.sol"; +import {IInfernalRiftBelow} from "./interfaces/IInfernalRiftBelow.sol"; import {ICrossDomainMessenger} from "./interfaces/ICrossDomainMessenger.sol"; + import {ERC721Bridgable} from "./libs/ERC721Bridgable.sol"; -contract InfernalRiftBelow is IInfernalPackage { - address immutable RELAYER_ADDRESS; - address immutable L2_CROSS_DOMAIN_MESSENGER; - address immutable INFERNAL_RIFT_ABOVE; +contract InfernalRiftBelow is IInfernalPackage, IInfernalRiftBelow { + + address immutable public RELAYER_ADDRESS; + ICrossDomainMessenger immutable public L2_CROSS_DOMAIN_MESSENGER; + address immutable public INFERNAL_RIFT_ABOVE; error TemplateAlreadySet(); error NotRelayerCaller(); error CrossChainSenderIsNotRiftAbove(); error L1CollectionDoesNotExist(); - mapping(address => address) public l1AddressForL2Collection; - address ERC721_BRIDGABLE_IMPLEMENTATION; + /// Stores mapping of L1 addresses for their corresponding L2 addresses + mapping(address _l2TokenAddress => address _l1TokenAddress) public l1AddressForL2Collection; + + /// The deployed contract address of the ERC721Bridgable used for implementations + address public ERC721_BRIDGABLE_IMPLEMENTATION; constructor( address _RELAYER_ADDRESS, @@ -33,107 +39,185 @@ contract InfernalRiftBelow is IInfernalPackage { address _INFERNAL_RIFT_ABOVE ) { RELAYER_ADDRESS = _RELAYER_ADDRESS; - L2_CROSS_DOMAIN_MESSENGER = _L2_CROSS_DOMAIN_MESSENGER; + L2_CROSS_DOMAIN_MESSENGER = ICrossDomainMessenger(_L2_CROSS_DOMAIN_MESSENGER); INFERNAL_RIFT_ABOVE = _INFERNAL_RIFT_ABOVE; } + /** + * Provides the L2 address for the L1 collection. This does not require that the collection + * to actually be deployed, but only provides the address that it either does, or will, have. + * + * @param l1CollectionAddress The L1 collection address + * + * @return l2CollectionAddress The corresponding L2 collection address + */ function l2AddressForL1Collection(address l1CollectionAddress) public view returns (address l2CollectionAddress) { - l2CollectionAddress = - Clones.predictDeterministicAddress(ERC721_BRIDGABLE_IMPLEMENTATION, bytes32(bytes20(l1CollectionAddress))); + l2CollectionAddress = Clones.predictDeterministicAddress( + ERC721_BRIDGABLE_IMPLEMENTATION, + bytes32(bytes20(l1CollectionAddress)) + ); } + /** + * Checks if the specified L1 address has code deployed to it on the L2. + * + * @param l1CollectionAddress The L1 collection address + * + * @return isDeployed If the determined L2 address has code deployed to it + */ function isDeployedOnL2(address l1CollectionAddress) public view returns (bool isDeployed) { isDeployed = l2AddressForL1Collection(l1CollectionAddress).code.length > 0; } - function initializeERC721Bridgable(address a) external { + /** + * Allows the {ERC721Bridgable} implementation to be set. + * + * @dev If this value has already been set, then it cannot be updated. + * + * @param _erc721Bridgable Address of the {ERC721Bridgable} implementation + */ + function initializeERC721Bridgable(address _erc721Bridgable) external { if (ERC721_BRIDGABLE_IMPLEMENTATION != address(0)) { revert TemplateAlreadySet(); } - ERC721_BRIDGABLE_IMPLEMENTATION = a; + + ERC721_BRIDGABLE_IMPLEMENTATION = _erc721Bridgable; } + /** + * Handles `crossTheThreshold` calls from {InfernalRiftAbove} to distribute migrated + * tokens across the L2 to the specified recipient. + * + * @param packages Information for NFTs to distribute + * @param recipient The L2 recipient address + */ function thresholdCross(Package[] calldata packages, address recipient) external { - // Ensure call is coming from the cross chain messenger, and original msg.sender is Infernal Rift Above + // Ensure call is coming from the cross chain messenger if (msg.sender != RELAYER_ADDRESS) { revert NotRelayerCaller(); } + + // Ensure original msg.sender is {InfernalRiftAbove} if (ICrossDomainMessenger(msg.sender).xDomainMessageSender() != INFERNAL_RIFT_ABOVE) { revert CrossChainSenderIsNotRiftAbove(); } // Go through and mint (or transfer) NFTs to recipient - uint256 numPackages = packages.length; - for (uint256 i; i < numPackages;) { + uint numPackages = packages.length; + for (uint i; i < numPackages; ++i) { Package memory package = packages[i]; - // If not yet deployed, deploy the L2 collection and set name/symbol/royalty + address l1CollectionAddress = package.collectionAddress; address l2CollectionAddress = l2AddressForL1Collection(l1CollectionAddress); + + ERC721Bridgable l2Collection; + + // If not yet deployed, deploy the L2 collection and set name/symbol/royalty if (!isDeployedOnL2(l1CollectionAddress)) { Clones.cloneDeterministic(ERC721_BRIDGABLE_IMPLEMENTATION, bytes32(bytes20(l1CollectionAddress))); - ERC721Bridgable(l2CollectionAddress).initialize(package.name, package.symbol, package.royaltyBps); + + l2Collection = ERC721Bridgable(l2CollectionAddress); + l2Collection.initialize(package.name, package.symbol, package.royaltyBps); + // Set the reverse mapping - l1AddressForL2Collection[l1CollectionAddress] = l2CollectionAddress; + l1AddressForL2Collection[l2CollectionAddress] = l1CollectionAddress; + } + // Otherwise, our collection already exists and we can reference it directly + else { + l2Collection = ERC721Bridgable(l2CollectionAddress); } - uint256 numIds = package.ids.length; - for (uint256 j; j < numIds;) { - uint256 id = package.ids[j]; + + // Iterate over our tokenIds to transfer them to the recipient + uint numIds = package.ids.length; + uint id; + + for (uint j; j < numIds; ++j) { + id = package.ids[j]; + // If already escrowed in the bridge, then transfer to recipient - if (ERC721Bridgable(l2CollectionAddress).ownerOf(id) == address(this)) { - ERC721Bridgable(l2CollectionAddress).transferFrom(address(this), recipient, id); + if (l2Collection.ownerOf(id) == address(this)) { + l2Collection.transferFrom(address(this), recipient, id); } // Otherwise, set tokenURI and mint to recipient else { - ERC721Bridgable(l2CollectionAddress).setTokenURIAndMintFromRiftAbove(id, package.uris[j], recipient); - } - unchecked { - ++j; + l2Collection.setTokenURIAndMintFromRiftAbove(id, package.uris[j], recipient); } } - unchecked { - ++i; - } } } - // Do the reverse (lock up, notify the L1) + /** + * Handles the bridging of tokens from the L2 back to L1. + * + * @param collectionAddresses The L2 collection addresses to bridge + * @param idsToCross The tokenIds for respective collections to bridge + * @param recipient The L1 recipient of the bridged tokens + * @param gasLimit The limit of gas to send + */ function returnFromThreshold( address[] calldata collectionAddresses, - uint256[][] calldata idsToCross, + uint[][] calldata idsToCross, address recipient, uint32 gasLimit ) external { - uint256 numCollections = collectionAddresses.length; + uint numCollections = collectionAddresses.length; address[] memory l1CollectionAddresses = new address[](numCollections); - for (uint256 i; i < numCollections;) { - address l2CollectionAddress = collectionAddresses[i]; - uint256 numIds = idsToCross[i].length; - for (uint256 j; j < numIds;) { - IERC721(l2CollectionAddress).transferFrom(msg.sender, address(this), idsToCross[i][j]); - unchecked { - ++j; - } + address l1CollectionAddress; + IERC721 l2Collection; + uint numIds; + + // Iterate over our collections + for (uint i; i < numCollections; ++i) { + l2Collection = IERC721(collectionAddresses[i]); + numIds = idsToCross[i].length; + + // Iterate over the specified NFTs to pull them from the user and store + // within this contract for potential future bridging use. + for (uint j; j < numIds; ++j) { + l2Collection.transferFrom(msg.sender, address(this), idsToCross[i][j]); } + // Look up the L1 collection address - address l1CollectionAddress = l1AddressForL2Collection[l2CollectionAddress]; + l1CollectionAddress = l1AddressForL2Collection[address(l2Collection)]; // Revert if L1 collection does not exist - // (e.g. if a non bridged NFT is trying to bridge) - if (l1CollectionAddress == address(0)) { - revert L1CollectionDoesNotExist(); - } - + if (l1CollectionAddress == address(0)) revert L1CollectionDoesNotExist(); l1CollectionAddresses[i] = l1CollectionAddress; - unchecked { - ++i; - } } - ICrossDomainMessenger(L2_CROSS_DOMAIN_MESSENGER).sendMessage( + + // Send our message to {InfernalRiftAbove} + L2_CROSS_DOMAIN_MESSENGER.sendMessage( INFERNAL_RIFT_ABOVE, - abi.encodeCall(IInfernalRiftAbove.returnFromTheThreshold, (l1CollectionAddresses, idsToCross, recipient)), + abi.encodeCall( + IInfernalRiftAbove.returnFromTheThreshold, + (l1CollectionAddresses, idsToCross, recipient) + ), gasLimit ); } - // TODO: handle royalty collections + /** + * Routes a royalty claim call to the L2 ERC721, as this contract will be the owner of + * the royalties. + * + * @dev This assumes that {InfernalRiftAbove} has already validated the initial caller + * as the royalty holder of the token. + * + * @param _collectionAddress The L1 collection address to claim royalties for + * @param _recipient The L2 recipient of the royalties + * @param _tokens Array of token addresses to claim + */ + function claimRoyalties(address _collectionAddress, address _recipient, address[] calldata _tokens) public { + // Ensure that our message is sent from the L1 domain messenger + if (ICrossDomainMessenger(msg.sender).xDomainMessageSender() != INFERNAL_RIFT_ABOVE) { + revert CrossChainSenderIsNotRiftAbove(); + } + + // Get our L2 address from the L1 + if (!isDeployedOnL2(_collectionAddress)) revert L1CollectionDoesNotExist(); + + // Call our ERC721Bridgable contract as the owner to claim royalties to the recipient + ERC721Bridgable(l2AddressForL1Collection(_collectionAddress)).claimRoyalties(_recipient, _tokens); + } + } diff --git a/src/interfaces/IInfernalRiftBelow.sol b/src/interfaces/IInfernalRiftBelow.sol new file mode 100644 index 0000000..3e82894 --- /dev/null +++ b/src/interfaces/IInfernalRiftBelow.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0 + +pragma solidity ^0.8.0; + +interface IInfernalRiftBelow { + + function l2AddressForL1Collection(address _l1CollectionAddress) external view returns (address l2CollectionAddress_); + + function isDeployedOnL2(address _l1CollectionAddress) external view returns (bool isDeployed_); + + function claimRoyalties( + address _collectionAddress, + address _recipient, + address[] calldata _tokens + ) external; + +} diff --git a/src/libs/ERC721Bridgable.sol b/src/libs/ERC721Bridgable.sol index 4eb54f3..64824d2 100644 --- a/src/libs/ERC721Bridgable.sol +++ b/src/libs/ERC721Bridgable.sol @@ -7,218 +7,20 @@ pragma solidity >=0.8.0; import {ERC2981, IERC2981} from "@openzeppelin/token/common/ERC2981.sol"; -/// Forked from Solmate: -/// @notice Modern, minimalist, and gas efficient ERC-721 implementation. -/// @author Solmate (https://github.com/transmissions11/solmate/blob/main/src/tokens/ERC721.sol) -abstract contract ERC721 { - /*////////////////////////////////////////////////////////////// - EVENTS - //////////////////////////////////////////////////////////////*/ +import {ERC20} from '@solmate/tokens/ERC20.sol'; +import {ERC721} from '@solmate/tokens/ERC721.sol'; +import {SafeTransferLib} from '@solmate/utils/SafeTransferLib.sol'; - event Transfer(address indexed from, address indexed to, uint256 indexed id); - - event Approval(address indexed owner, address indexed spender, uint256 indexed id); - - event ApprovalForAll(address indexed owner, address indexed operator, bool approved); - - /*////////////////////////////////////////////////////////////// - METADATA STORAGE/LOGIC - //////////////////////////////////////////////////////////////*/ - - string public name; - - string public symbol; - - function tokenURI(uint256 id) public view virtual returns (string memory); - - /*////////////////////////////////////////////////////////////// - ERC721 BALANCE/OWNER STORAGE - //////////////////////////////////////////////////////////////*/ - - mapping(uint256 => address) internal _ownerOf; - - mapping(address => uint256) internal _balanceOf; - - function ownerOf(uint256 id) public view virtual returns (address owner) { - // If owner is 0, it means it is either burned or unminted - owner = _ownerOf[id]; - } - - function balanceOf(address owner) public view virtual returns (uint256) { - require(owner != address(0), "ZERO_ADDRESS"); - - return _balanceOf[owner]; - } - - /*////////////////////////////////////////////////////////////// - ERC721 APPROVAL STORAGE - //////////////////////////////////////////////////////////////*/ - - mapping(uint256 => address) public getApproved; - - mapping(address => mapping(address => bool)) public isApprovedForAll; - - /*////////////////////////////////////////////////////////////// - CONSTRUCTOR - //////////////////////////////////////////////////////////////*/ - - constructor(string memory _name, string memory _symbol) { - name = _name; - symbol = _symbol; - } - - /*////////////////////////////////////////////////////////////// - ERC721 LOGIC - //////////////////////////////////////////////////////////////*/ - - function approve(address spender, uint256 id) public virtual { - address owner = _ownerOf[id]; - - require(msg.sender == owner || isApprovedForAll[owner][msg.sender], "NOT_AUTHORIZED"); - - getApproved[id] = spender; - - emit Approval(owner, spender, id); - } - - function setApprovalForAll(address operator, bool approved) public virtual { - isApprovedForAll[msg.sender][operator] = approved; - - emit ApprovalForAll(msg.sender, operator, approved); - } - - function transferFrom(address from, address to, uint256 id) public virtual { - require(from == _ownerOf[id], "WRONG_FROM"); - - require(to != address(0), "INVALID_RECIPIENT"); - - require( - msg.sender == from || isApprovedForAll[from][msg.sender] || msg.sender == getApproved[id], "NOT_AUTHORIZED" - ); - - // Underflow of the sender's balance is impossible because we check for - // ownership above and the recipient's balance can't realistically overflow. - unchecked { - _balanceOf[from]--; - - _balanceOf[to]++; - } - - _ownerOf[id] = to; - - delete getApproved[id]; - - emit Transfer(from, to, id); - } - - function safeTransferFrom(address from, address to, uint256 id) public virtual { - transferFrom(from, to, id); - - require( - to.code.length == 0 - || ERC721TokenReceiver(to).onERC721Received(msg.sender, from, id, "") - == ERC721TokenReceiver.onERC721Received.selector, - "UNSAFE_RECIPIENT" - ); - } - - function safeTransferFrom(address from, address to, uint256 id, bytes calldata data) public virtual { - transferFrom(from, to, id); - - require( - to.code.length == 0 - || ERC721TokenReceiver(to).onERC721Received(msg.sender, from, id, data) - == ERC721TokenReceiver.onERC721Received.selector, - "UNSAFE_RECIPIENT" - ); - } - - /*////////////////////////////////////////////////////////////// - ERC165 LOGIC - //////////////////////////////////////////////////////////////*/ - - function supportsInterface(bytes4 interfaceId) public view virtual returns (bool) { - return interfaceId == 0x01ffc9a7 // ERC165 Interface ID for ERC165 - || interfaceId == 0x80ac58cd // ERC165 Interface ID for ERC721 - || interfaceId == 0x5b5e139f; // ERC165 Interface ID for ERC721Metadata - } - - /*////////////////////////////////////////////////////////////// - INTERNAL MINT/BURN LOGIC - //////////////////////////////////////////////////////////////*/ - - function _mint(address to, uint256 id) internal virtual { - require(to != address(0), "INVALID_RECIPIENT"); - - require(_ownerOf[id] == address(0), "ALREADY_MINTED"); - - // Counter overflow is incredibly unrealistic. - unchecked { - _balanceOf[to]++; - } - - _ownerOf[id] = to; - - emit Transfer(address(0), to, id); - } - - function _burn(uint256 id) internal virtual { - address owner = _ownerOf[id]; - - require(owner != address(0), "NOT_MINTED"); - - // Ownership check above ensures no underflow. - unchecked { - _balanceOf[owner]--; - } - - delete _ownerOf[id]; - - delete getApproved[id]; - - emit Transfer(owner, address(0), id); - } - - /*////////////////////////////////////////////////////////////// - INTERNAL SAFE MINT LOGIC - //////////////////////////////////////////////////////////////*/ - - function _safeMint(address to, uint256 id) internal virtual { - _mint(to, id); - - require( - to.code.length == 0 - || ERC721TokenReceiver(to).onERC721Received(msg.sender, address(0), id, "") - == ERC721TokenReceiver.onERC721Received.selector, - "UNSAFE_RECIPIENT" - ); - } - - function _safeMint(address to, uint256 id, bytes memory data) internal virtual { - _mint(to, id); - - require( - to.code.length == 0 - || ERC721TokenReceiver(to).onERC721Received(msg.sender, address(0), id, data) - == ERC721TokenReceiver.onERC721Received.selector, - "UNSAFE_RECIPIENT" - ); - } -} - -/// @notice A generic interface for a contract which properly accepts ERC721 tokens. -/// @author Solmate (https://github.com/transmissions11/solmate/blob/main/src/tokens/ERC721.sol) -abstract contract ERC721TokenReceiver { - function onERC721Received(address, address, uint256, bytes calldata) external virtual returns (bytes4) { - return ERC721TokenReceiver.onERC721Received.selector; - } -} contract ERC721Bridgable is ERC721, ERC2981 { - address immutable INFERNAL_RIFT_BELOW; + /// The {InfernalRiftBelow} contract address that can make protected calls + address immutable public INFERNAL_RIFT_BELOW; - mapping(uint256 => string) uriForToken; - bool initialized; + /// Maps tokenIds to their token URI + mapping(uint _tokenId => string _tokenUri) public uriForToken; + + /// Stores if the contract has been initialized + bool public initialized; error NotRiftBelow(); error AlreadyInitialized(); @@ -227,32 +29,113 @@ contract ERC721Bridgable is ERC721, ERC2981 { INFERNAL_RIFT_BELOW = _INFERNAL_RIFT_BELOW; } - function initialize(string memory _name, string memory _symbol, uint96 royaltyBps) external { + /** + * Sets the ERC721 metadata and royalty information to the contract. + * + * @param _name Name of the ERC721 + * @param _symbol Symbol for the ERC721 + * @param _royaltyBps The denominated royalty amount + */ + function initialize(string memory _name, string memory _symbol, uint96 _royaltyBps) external { if (msg.sender != INFERNAL_RIFT_BELOW) { revert NotRiftBelow(); } + + // If this function has already been called, prevent it from being called again if (initialized) { revert AlreadyInitialized(); } + + // Set our ERC721 metadata name = _name; symbol = _symbol; - _setDefaultRoyalty(address(this), royaltyBps); + + // Set this contract to receive marketplace royalty + _setDefaultRoyalty(address(this), _royaltyBps); + + // Prevent this function from being called again initialized = true; } - function tokenURI(uint256 id) public view override returns (string memory) { + /** + * Returns the Uniform Resource Identifier (URI) for `tokenId` token. + * + * @param id The tokenId of the ERC721 + * + * @return The `tokenURI` for the tokenId + */ + function tokenURI(uint id) public view override returns (string memory) { return uriForToken[id]; } - function setTokenURIAndMintFromRiftAbove(uint256 id, string memory uri, address recipient) external { + /** + * If owner is 0, it means it is either burned or unminted. + * + * @param id The tokenId of the ERC721 + * + * @return owner_ The owner of the tokenId for the collection + * + * @dev We prevent this call from reverting if the owner is zero. + */ + function ownerOf(uint id) public view override returns (address owner_) { + owner_ = _ownerOf[id]; + } + + /** + * Sets the `uri` against the `tokenId` and mints it to the `recipient` on the L2. + * + * @param _id The tokenId of the ERC721 + * @param _uri The URI to be assigned to the tokenId + * @param _recipient The user that will be the recipient of the token + */ + function setTokenURIAndMintFromRiftAbove(uint _id, string memory _uri, address _recipient) external { if (msg.sender != INFERNAL_RIFT_BELOW) { revert NotRiftBelow(); } - uriForToken[id] = uri; - _mint(recipient, id); + + // Set our tokenURI + uriForToken[_id] = _uri; + + // Mint the token to the specified recipient + _mint(_recipient, _id); + } + + /** + * Allows a caller to retrieve all tokens from the contract, assuming that they have + * been paid in as royalties. + * + * If a zero-address token address is passed into the function, this will assume that + * native ETH is requested and will transfer that to the recipient. + * + * @dev This assumes that {InfernalRiftBelow} has already validated the caller. + * + * @param _recipient The L2 recipient of the royalties + * @param _tokens The token addresses to claim + */ + function claimRoyalties(address _recipient, address[] calldata _tokens) external { + if (msg.sender != INFERNAL_RIFT_BELOW) { + revert NotRiftBelow(); + } + + // We can iterate through the tokens that were requested and transfer them all + // to the specified recipient. + uint tokensLength = _tokens.length; + for (uint i; i < tokensLength; ++i) { + // Map our ERC20 + ERC20 token = ERC20(_tokens[i]); + + // If we have a zero-address token specified, then we treat this as native ETH + if (address(token) == address(0)) { + SafeTransferLib.safeTransferETH(_recipient, payable(address(this)).balance); + } else { + SafeTransferLib.safeTransfer(token, _recipient, token.balanceOf(address(this))); + } + } } - // Overrides both ERC721 and ERC2981 + /** + * Overrides both ERC721 and ERC2981. + */ function supportsInterface(bytes4 interfaceId) public pure override(ERC2981, ERC721) returns (bool) { return interfaceId == 0x01ffc9a7 // ERC165 Interface ID for ERC165 || interfaceId == 0x80ac58cd // ERC165 Interface ID for ERC721 diff --git a/test/RiftTest.t.sol b/test/RiftTest.t.sol index c46841c..cb73848 100644 --- a/test/RiftTest.t.sol +++ b/test/RiftTest.t.sol @@ -6,11 +6,13 @@ /* solhint-disable no-unused-vars */ /* solhint-disable func-name-mixedcase */ -pragma solidity ^0.8.0; +pragma solidity ^0.8.26; import "forge-std/Test.sol"; +import {Test20} from './mocks/Test20.sol'; import {Test721} from "./mocks/Test721.sol"; +import {Test721NoRoyalty} from './mocks/Test721NoRoyalty.sol'; import {MockPortalAndCrossDomainMessenger} from "./mocks/MockPortalAndCrossDomainMessenger.sol"; import {MockRoyaltyRegistry} from "./mocks/MockRoyaltyRegistry.sol"; import {ERC721Bridgable} from "../src/libs/ERC721Bridgable.sol"; @@ -28,6 +30,7 @@ contract RiftTest is Test { ERC721Bridgable erc721Template; InfernalRiftAbove riftAbove; InfernalRiftBelow riftBelow; + Test20 USDC; function setUp() public { @@ -40,6 +43,7 @@ contract RiftTest is Test { - Everything now immutable */ + USDC = new Test20('USDC', 'USDC', 18); l1NFT = new Test721(); mockPortalAndMessenger = new MockPortalAndCrossDomainMessenger(); mockRoyaltyRegistry = new MockRoyaltyRegistry(); @@ -76,4 +80,209 @@ contract RiftTest is Test { ); } + function test_basicSendMultipleNfts() public { + vm.startPrank(ALICE); + + // Build up a list of 3 collections, each containing a number of NFTs + address[] memory collections = new address[](3); + collections[0] = address(new Test721()); + collections[1] = address(new Test721()); + collections[2] = address(new Test721()); + + uint[][] memory ids = new uint[][](collections.length); + + // Mint the NFT for each collection + ids[0] = new uint[](5); + ids[1] = new uint[](10); + ids[2] = new uint[](1); + + // Mint our tokens to the test user and approve them for use by the portal + for (uint i; i < ids.length; ++i) { + Test721 nft = Test721(collections[i]); + + // Set our tokenIds + for (uint j; j < ids[i].length; ++j) { + ids[i][j] = j; + } + + nft.mint(ALICE, ids[i]); + nft.setApprovalForAll(address(riftAbove), true); + } + + // Set our XDomain Messenger + mockPortalAndMessenger.setXDomainMessenger(address(riftAbove)); + + // Cross the threshold with multiple collections and tokens + riftAbove.crossTheThreshold(collections, ids, ALICE, 0); + } + + function test_CanBridgeNftBackAndForth() public { + // This logic is tested in `test_basicSendOneNFT` + _bridgeNft(address(this), address(l1NFT), 0); + + // Get our "L2" address + Test721 l2NFT = Test721(riftBelow.l2AddressForL1Collection(address(l1NFT))); + + // Confirm our NFT holdings after the first transfer + assertEq(l1NFT.ownerOf(0), address(riftAbove)); + assertEq(l2NFT.ownerOf(0), address(this)); + + // Set up our return threshold parameters + address[] memory collectionAddresses = new address[](1); + collectionAddresses[0] = address(l2NFT); + + // Set up our tokenIds + uint[][] memory tokenIds = new uint[][](1); + tokenIds[0] = new uint[](1); + tokenIds[0][0] = 0; + + // Approve the tokenIds on L2 + l2NFT.setApprovalForAll(address(riftBelow), true); + + // Set our domain messenger + mockPortalAndMessenger.setXDomainMessenger(address(riftBelow)); + + // Return the NFT + riftBelow.returnFromThreshold(collectionAddresses, tokenIds, ALICE, 0); + + // Confirm that the NFT is back on the L1 + assertEq(l1NFT.ownerOf(0), ALICE); + assertEq(l2NFT.ownerOf(0), address(riftBelow)); + + // Transfer it to over to another user + vm.prank(ALICE); + l1NFT.transferFrom(ALICE, address(this), 0); + + // We will need to overwrite our collection addresses, but the ID will + // stay the same. This time around we will send it to another user. + collectionAddresses[0] = address(l1NFT); + + // Set our domain messenger + mockPortalAndMessenger.setXDomainMessenger(address(riftAbove)); + + riftAbove.crossTheThreshold(collectionAddresses, tokenIds, ALICE, 0); + + // Confirm the final holdings + assertEq(l1NFT.ownerOf(0), address(riftAbove)); + assertEq(l2NFT.ownerOf(0), ALICE); + } + + function test_CanClaimRoyalties() public { + // Set the royalty information for the L1 contract + l1NFT.setDefaultRoyalty(address(this), 1000); + + // Create an ERC721 that implements ERC2981 for royalties + _bridgeNft(address(this), address(l1NFT), 0); + + // Get our "L2" address + Test721 l2NFT = Test721(riftBelow.l2AddressForL1Collection(address(l1NFT))); + + // Add some royalties (10 ETH and 1000 USDC) onto the L2 contract + deal(address(l2NFT), 10 ether); + deal(address(USDC), address(l2NFT), 1000 ether); + + // Set up our tokens array to try and claim native ETH + address[] memory tokens = new address[](2); + tokens[0] = address(0); + tokens[1] = address(USDC); + + // Capture the starting ETH of this caller + uint startEthBalance = payable(address(this)).balance; + + // Make a claim call to an external recipient address + riftAbove.claimRoyalties(address(l1NFT), ALICE, tokens, 0); + + // Confirm that tokens have been sent to ALICE and not the caller + assertEq(payable(address(this)).balance, startEthBalance, 'Invalid caller ETH'); + assertEq(payable(ALICE).balance, 10 ether, 'Invalid ALICE ETH'); + + assertEq(USDC.balanceOf(address(this)), 0, 'Invalid caller USDC'); + assertEq(USDC.balanceOf(ALICE), 1000 ether, 'Invalid ALICE USDC'); + } + + function test_CanClaimRoyaltiesWithMultipleTokenIdRoyaltyRecipients() public { + /** + * TODO: This could throw spanners as we want to have a global claim, but the + * assignment method allows for individual overwrites without being able to + * access the global directly. + * + * How can we effectively determine the royalty caller that can access all + * without just assuming `tokenId = 0`, or giving anyone access? + */ + } + + function test_CannotClaimRoyaltiesOnInvalidContract() public { + Test721NoRoyalty noRoyaltyNft = new Test721NoRoyalty(); + + // Create an ERC721 that does not implement ERC2981 + _bridgeNft(address(this), address(noRoyaltyNft), 0); + + // Set up our tokens array to try and claim native ETH + address[] memory tokens = new address[](1); + tokens[0] = address(0); + + // Try and claim royalties against the contract, even though it doesn't support + // royalties in the expected way. + vm.expectRevert(InfernalRiftAbove.CollectionNotERC2981Compliant.selector); + riftAbove.claimRoyalties(address(noRoyaltyNft), ALICE, tokens, 0); + } + + function test_CannotClaimRoyaltiesAsInvalidCaller() public { + // Bridge our ERC721 onto the L2 + _bridgeNft(address(this), address(l1NFT), 0); + + // Set up our tokens array to try and claim native ETH + address[] memory tokens = new address[](1); + tokens[0] = address(0); + + vm.startPrank(ALICE); + vm.expectRevert( + abi.encodeWithSelector( + InfernalRiftAbove.CallerIsNotRoyaltiesReceiver.selector, + ALICE, address(0) + ) + ); + riftAbove.claimRoyalties(address(l1NFT), ALICE, tokens, 0); + vm.stopPrank(); + } + + function test_CannotClaimRoyaltiesWithoutInfernalRift() public { + // Bridge our ERC721 onto the L2 + _bridgeNft(address(this), address(l1NFT), 0); + + // Get our L2 address + address l2NFT = riftBelow.l2AddressForL1Collection(address(l1NFT)); + + // Set up our tokens array to try and claim native ETH + address[] memory tokens = new address[](1); + tokens[0] = address(0); + + // Try and directly claim royalties + vm.expectRevert(ERC721Bridgable.NotRiftBelow.selector); + ERC721Bridgable(l2NFT).claimRoyalties(address(this), tokens); + } + + function _bridgeNft(address _recipient, address _collection, uint _tokenId) internal { + // Set our tokenId + uint[] memory ids = new uint[](1); + ids[0] = _tokenId; + + // Mint the token to our recipient + Test721(_collection).mint(_recipient, ids); + Test721(_collection).setApprovalForAll(address(riftAbove), true); + + // Register our collection and ID list + address[] memory collections = new address[](1); + collections[0] = _collection; + + uint256[][] memory idList = new uint256[][](1); + idList[0] = ids; + + // Set our domain messenger + mockPortalAndMessenger.setXDomainMessenger(address(riftAbove)); + + // Cross the threshold! + riftAbove.crossTheThreshold(collections, idList, address(this), 0); + } + } \ No newline at end of file diff --git a/test/mocks/MockRoyaltyRegistry.sol b/test/mocks/MockRoyaltyRegistry.sol index d7b7575..17cb555 100644 --- a/test/mocks/MockRoyaltyRegistry.sol +++ b/test/mocks/MockRoyaltyRegistry.sol @@ -11,7 +11,7 @@ pragma solidity ^0.8.0; import {IRoyaltyRegistry} from "../../src/interfaces/IRoyaltyRegistry.sol"; contract MockRoyaltyRegistry is IRoyaltyRegistry { - function getRoyaltyLookupAddress(address tokenAddress) external view returns (address) { + function getRoyaltyLookupAddress(address tokenAddress) external pure returns (address) { return tokenAddress; } } \ No newline at end of file diff --git a/test/mocks/Test20.sol b/test/mocks/Test20.sol new file mode 100644 index 0000000..2913808 --- /dev/null +++ b/test/mocks/Test20.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.0; + +import {ERC20} from '@solmate/tokens/ERC20.sol'; + +contract Test20 is ERC20 { + + constructor(string memory _name, string memory _symbol, uint8 _decimals) ERC20(_name, _symbol, _decimals) {} + +} diff --git a/test/mocks/Test721.sol b/test/mocks/Test721.sol index 63e83c5..770a04a 100644 --- a/test/mocks/Test721.sol +++ b/test/mocks/Test721.sol @@ -14,7 +14,11 @@ contract Test721 is ERC721Royalty { } } - function tokenURI(uint256 id) public view override returns (string memory uri) { + function tokenURI(uint256 id) public pure override returns (string memory uri) { uri = string.concat("foo", Strings.toString(id)); } + + function setDefaultRoyalty(address _receiver, uint96 _feeNumerator) public { + _setDefaultRoyalty(_receiver, _feeNumerator); + } } diff --git a/test/mocks/Test721NoRoyalty.sol b/test/mocks/Test721NoRoyalty.sol new file mode 100644 index 0000000..6ce3b50 --- /dev/null +++ b/test/mocks/Test721NoRoyalty.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.0; + +import {ERC721} from "@openzeppelin/token/ERC721/ERC721.sol"; +import {Strings} from "@openzeppelin/utils/Strings.sol"; + +contract Test721NoRoyalty is ERC721 { + + constructor() ERC721("Test721", "T721") {} + + function mint(address to, uint256[] calldata ids) external { + for (uint i; i < ids.length; ++i) { + _mint(to, ids[i]); + } + } + + function tokenURI(uint256 id) public pure override returns (string memory uri) { + uri = string.concat("foo", Strings.toString(id)); + } +}