From d9b1a51f4042756eec82a7e88865c13650093031 Mon Sep 17 00:00:00 2001 From: 0xlucian <0xluciandev@gmail.com> Date: Tue, 7 Jan 2025 14:16:11 +0200 Subject: [PATCH 01/12] feat: initial implementation --- contracts/protocol/domain/Errors.sol | 9 + contracts/protocol/domain/Types.sol | 16 + contracts/protocol/facets/Custody.sol | 375 +++++++++++++++++- .../interfaces/events/ICustodyEvents.sol | 18 + contracts/protocol/libs/Storage.sol | 2 + 5 files changed, 419 insertions(+), 1 deletion(-) diff --git a/contracts/protocol/domain/Errors.sol b/contracts/protocol/domain/Errors.sol index 1279de6f..a848ff01 100644 --- a/contracts/protocol/domain/Errors.sol +++ b/contracts/protocol/domain/Errors.sol @@ -74,6 +74,15 @@ interface CustodyErrors { FermionTypes.CheckoutRequestStatus expectedStatus, FermionTypes.CheckoutRequestStatus actualStatus ); + error InvalidCustodianUpdateStatus( + uint256 tokenId, + FermionTypes.CustodianUpdateStatus expected, + FermionTypes.CustodianUpdateStatus actual + ); + error TokenCheckedOut(uint256 tokenId); + error InsufficientVaultBalance(uint256 tokenId, uint256 required, uint256 available); + error UpdateRequestExpired(uint256 tokenId); + error UpdateRequestTooRecent(uint256 tokenId, uint256 waitTime); } interface AuctionErrors { diff --git a/contracts/protocol/domain/Types.sol b/contracts/protocol/domain/Types.sol index 3eaf0704..04a6c401 100644 --- a/contracts/protocol/domain/Types.sol +++ b/contracts/protocol/domain/Types.sol @@ -111,6 +111,22 @@ contract FermionTypes { uint256 taxAmount; } + enum CustodianUpdateStatus { + None, + Requested, + Accepted, + Rejected + } + + struct CustodianUpdateRequest { + CustodianUpdateStatus status; + uint256 newCustodianId; + CustodianFee newCustodianFee; + uint256 requestTimestamp; + bool keepExistingParameters; + bool isEmergencyUpdate; + } + struct CustodianVaultParameters { uint256 partialAuctionThreshold; uint256 partialAuctionDuration; diff --git a/contracts/protocol/facets/Custody.sol b/contracts/protocol/facets/Custody.sol index 6cbd5694..9c492873 100644 --- a/contracts/protocol/facets/Custody.sol +++ b/contracts/protocol/facets/Custody.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.24; -import { CustodyErrors } from "../domain/Errors.sol"; +import { CustodyErrors, FermionGeneralErrors } from "../domain/Errors.sol"; import { FermionTypes } from "../domain/Types.sol"; import { Access } from "../libs/Access.sol"; import { FermionStorage } from "../libs/Storage.sol"; @@ -12,6 +12,7 @@ import { Context } from "../libs/Context.sol"; import { ICustodyEvents } from "../interfaces/events/ICustodyEvents.sol"; import { IFundsEvents } from "../interfaces/events/IFundsEvents.sol"; import { FermionFNFTLib } from "../libs/FermionFNFTLib.sol"; +import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; /** * @title CustodyFacet @@ -258,4 +259,376 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve return checkoutRequest; } + + /** + * @notice Request a custodian update + * + * The new custodian initiates the update process by calling this function. + * The request is valid for 24 hours. + * A new request can be made only after 24 hours from the previous request. + * + * Emits a CustodianUpdateRequested event + * + * Reverts if: + * - Custody region is paused + * - Caller is not the new custodian's assistant + * - The token is checked out + * - The previous request is too recent + * - For multi-item offers, any item is checked out + * + * @param _tokenId - the token ID + * @param _newCustodianFee - the new custodian fee, ignored if keepExistingParameters is true + * @param _keepExistingParameters - if true, keep the current custodian fee + */ + function requestCustodianUpdate( + uint256 _tokenId, + FermionTypes.CustodianFee calldata _newCustodianFee, + bool _keepExistingParameters + ) external notPaused(FermionTypes.PausableRegion.Custody) nonReentrant { + FermionStorage.ProtocolLookups storage pl = FermionStorage.protocolLookups(); + FermionStorage.TokenLookups storage tokenLookups = pl.tokenLookups[_tokenId]; + + // Check if there was a recent request + if (tokenLookups.custodianUpdateRequest.requestTimestamp + 1 days > block.timestamp) { + revert UpdateRequestTooRecent(_tokenId, 1 days); + } + + (uint256 offerId, FermionTypes.Offer storage offer) = FermionStorage.getOfferFromTokenId(_tokenId); + uint256 currentCustodianId = offer.custodianId; + + // For multi-item offers, check that no item is checked out + FermionStorage.OfferLookups storage offerLookups = pl.offerLookups[offerId]; + uint256 itemCount = offerLookups.custodianVaultItems; + if (itemCount > 1) { + uint256 firstTokenId = _tokenId & ~uint256(0xFFFFFFFFFFFFFFFF); // Clear lower 64 bits + for (uint256 i; i < itemCount; ) { + uint256 tokenId = firstTokenId + i; + if (pl.tokenLookups[tokenId].checkoutRequest.status == FermionTypes.CheckoutRequestStatus.CheckedOut) { + revert TokenCheckedOut(tokenId); + } + unchecked { + ++i; + } + } + } else { + // Single item - check just this token + if (tokenLookups.checkoutRequest.status == FermionTypes.CheckoutRequestStatus.CheckedOut) { + revert TokenCheckedOut(_tokenId); + } + } + + _createCustodianUpdateRequest( + _tokenId, + _newCustodianFee, + _keepExistingParameters, + currentCustodianId, + offer, + tokenLookups, + pl + ); + } + + function _createCustodianUpdateRequest( + uint256 _tokenId, + FermionTypes.CustodianFee calldata _newCustodianFee, + bool _keepExistingParameters, + uint256 _currentCustodianId, + FermionTypes.Offer storage _offer, + FermionStorage.TokenLookups storage _tokenLookups, + FermionStorage.ProtocolLookups storage _pl + ) internal { + // Check the caller is the new custodian's assistant + address msgSender = _msgSender(); + uint256 newCustodianId = EntityLib.getOrCreateBuyerId(msgSender, _pl); + EntityLib.validateAccountRole( + newCustodianId, + msgSender, + FermionTypes.EntityRole.Custodian, + FermionTypes.AccountRole.Assistant + ); + + // Store the update request + _tokenLookups.custodianUpdateRequest = FermionTypes.CustodianUpdateRequest({ + status: FermionTypes.CustodianUpdateStatus.Requested, + newCustodianId: newCustodianId, + newCustodianFee: _keepExistingParameters + ? FermionTypes.CustodianFee({ amount: _offer.custodianFee.amount, period: _offer.custodianFee.period }) + : _newCustodianFee, + requestTimestamp: block.timestamp, + keepExistingParameters: _keepExistingParameters, + isEmergencyUpdate: false + }); + + emit CustodianUpdateRequested(_tokenId, _currentCustodianId, newCustodianId, _newCustodianFee); + } + + /** + * @notice Request an emergency custodian update + * + * The current custodian or seller can initiate an emergency update when the custodian stops operating. + * This bypasses the owner acceptance and keeps the existing fee parameters. + * + * Emits a CustodianUpdateRequested event + * + * Reverts if: + * - Custody region is paused + * - Caller is not the current custodian's assistant or seller's assistant + * - The token is checked out + * - The previous request is too recent + * - For multi-item offers, any item is checked out + * + * @param _tokenId - the token ID + * @param _newCustodianId - the ID of the new custodian + * @param _isCustodianAssistant - if true, validate caller as custodian assistant, otherwise as seller assistant + */ + function requestEmergencyCustodianUpdate( + uint256 _tokenId, + uint256 _newCustodianId, + bool _isCustodianAssistant + ) external notPaused(FermionTypes.PausableRegion.Custody) nonReentrant { + FermionStorage.ProtocolLookups storage pl = FermionStorage.protocolLookups(); + FermionStorage.TokenLookups storage tokenLookups = pl.tokenLookups[_tokenId]; + + // Check if there was a recent request + if (tokenLookups.custodianUpdateRequest.requestTimestamp + 1 days > block.timestamp) { + revert UpdateRequestTooRecent(_tokenId, 1 days); + } + + (uint256 offerId, FermionTypes.Offer storage offer) = FermionStorage.getOfferFromTokenId(_tokenId); + uint256 currentCustodianId = offer.custodianId; + + // For multi-item offers, check that no item is checked out + FermionStorage.OfferLookups storage offerLookups = pl.offerLookups[offerId]; + uint256 itemCount = offerLookups.custodianVaultItems; + if (itemCount > 1) { + uint256 firstTokenId = _tokenId & ~uint256(0xFFFFFFFFFFFFFFFF); // Clear lower 64 bits + for (uint256 i; i < itemCount; ) { + uint256 tokenId = firstTokenId + i; + if (pl.tokenLookups[tokenId].checkoutRequest.status == FermionTypes.CheckoutRequestStatus.CheckedOut) { + revert TokenCheckedOut(tokenId); + } + unchecked { + ++i; + } + } + } else { + // Single item - check just this token + if (tokenLookups.checkoutRequest.status == FermionTypes.CheckoutRequestStatus.CheckedOut) { + revert TokenCheckedOut(_tokenId); + } + } + + // Check the caller is either the current custodian's assistant or seller's assistant + address msgSender = _msgSender(); + if (_isCustodianAssistant) { + EntityLib.validateAccountRole( + currentCustodianId, + msgSender, + FermionTypes.EntityRole.Custodian, + FermionTypes.AccountRole.Assistant + ); + } else { + EntityLib.validateSellerAssistantOrFacilitator(offer.sellerId, offer.facilitatorId); + } + + // Validate that the new custodian exists and has the Custodian role + EntityLib.validateEntityRole( + _newCustodianId, + FermionStorage.protocolEntities().entityData[_newCustodianId].roles, + FermionTypes.EntityRole.Custodian + ); + + // Store the update request - keep existing parameters in emergency update + tokenLookups.custodianUpdateRequest = FermionTypes.CustodianUpdateRequest({ + status: FermionTypes.CustodianUpdateStatus.Requested, + newCustodianId: _newCustodianId, + newCustodianFee: offer.custodianFee, + requestTimestamp: block.timestamp, + keepExistingParameters: true, + isEmergencyUpdate: true + }); + + emit CustodianUpdateRequested(_tokenId, currentCustodianId, _newCustodianId, offer.custodianFee); + } + + /** + * @notice Accept a custodian update request + * + * The FNFT owner accepts the update request. + * The current custodian is paid for the used period. + * The vault parameters are updated with the new custodian's parameters. + * For multi-item offers, all items are updated and the current custodian is paid for each item. + * + * Emits a CustodianUpdateAccepted event + * + * Reverts if: + * - Custody region is paused + * - Caller is not the owner of the token (unless it's an emergency update) + * - The token is checked out + * - The update request status is not Requested + * - The update request has expired + * - There are not enough funds in any vault to pay the current custodian + * + * @param _tokenId - the token ID + */ + function acceptCustodianUpdate( + uint256 _tokenId + ) external notPaused(FermionTypes.PausableRegion.Custody) nonReentrant { + FermionStorage.ProtocolLookups storage pl = FermionStorage.protocolLookups(); + FermionStorage.TokenLookups storage tokenLookups = pl.tokenLookups[_tokenId]; + + // Validate update request status + FermionTypes.CustodianUpdateRequest storage updateRequest = tokenLookups.custodianUpdateRequest; + if (updateRequest.status != FermionTypes.CustodianUpdateStatus.Requested) { + revert InvalidCustodianUpdateStatus( + _tokenId, + FermionTypes.CustodianUpdateStatus.Requested, + updateRequest.status + ); + } + + // Check request hasn't expired + if (updateRequest.requestTimestamp + 1 days < block.timestamp) { + revert UpdateRequestExpired(_tokenId); + } + + (uint256 offerId, FermionTypes.Offer storage offer) = FermionStorage.getOfferFromTokenId(_tokenId); + uint256 oldCustodianId = offer.custodianId; + + // For non-emergency updates, check the caller owns the token + if (!updateRequest.isEmergencyUpdate) { + address msgSender = _msgSender(); + address owner = IERC721(pl.offerLookups[offerId].fermionFNFTAddress).ownerOf(_tokenId); + if (owner != msgSender) { + revert NotTokenBuyer(_tokenId, owner, msgSender); + } + } + + // For multi-item offers, process all items + FermionStorage.OfferLookups storage offerLookups = pl.offerLookups[offerId]; + uint256 itemCount = offerLookups.custodianVaultItems; + if (itemCount > 1) { + uint256 firstTokenId = _tokenId & ~uint256(0xFFFFFFFFFFFFFFFF); // Clear lower 64 bits + for (uint256 i; i < itemCount; ) { + uint256 tokenId = firstTokenId + i; + _processCustodianUpdate(tokenId, oldCustodianId, offer, updateRequest); + unchecked { + ++i; + } + } + } else { + _processCustodianUpdate(_tokenId, oldCustodianId, offer, updateRequest); + } + + offer.custodianId = updateRequest.newCustodianId; + if (!updateRequest.keepExistingParameters) { + offer.custodianFee = updateRequest.newCustodianFee; + } + + uint256 newCustodianId = updateRequest.newCustodianId; + delete tokenLookups.custodianUpdateRequest; + + emit CustodianUpdateAccepted(_tokenId, oldCustodianId, newCustodianId); + } + + /** + * @notice Process custodian update for a single token + * + * Internal helper function to process the custodian update for a single token. + * Calculates and pays the current custodian, updates the vault period. + * + * Reverts if: + * - The token is checked out + * - There are not enough funds in the vault to pay the current custodian + * + * @param _tokenId - the token ID + * @param _oldCustodianId - the ID of the current custodian + * @param _offer - the offer storage pointer + * @param _updateRequest - the update request storage pointer + */ + function _processCustodianUpdate( + uint256 _tokenId, + uint256 _oldCustodianId, + FermionTypes.Offer storage _offer, + FermionTypes.CustodianUpdateRequest storage _updateRequest + ) internal { + FermionStorage.TokenLookups storage tokenLookups = FermionStorage.protocolLookups().tokenLookups[_tokenId]; + + if (tokenLookups.checkoutRequest.status == FermionTypes.CheckoutRequestStatus.CheckedOut) { + revert TokenCheckedOut(_tokenId); + } + + // Calculate and pay the current custodian + FermionTypes.CustodianFee storage vault = tokenLookups.vault; + uint256 lastReleased = vault.period; + uint256 custodianFee = _offer.custodianFee.amount; + uint256 custodianPeriod = _offer.custodianFee.period; + uint256 custodianPayoff = ((block.timestamp - lastReleased) * custodianFee) / custodianPeriod; + + if (custodianPayoff > vault.amount) { + revert InsufficientVaultBalance(_tokenId, custodianPayoff, vault.amount); + } + + // Pay the current custodian + increaseAvailableFunds(_oldCustodianId, _offer.exchangeToken, custodianPayoff); + vault.amount -= custodianPayoff; + + // Reset the vault period + vault.period = block.timestamp; + } + + /** + * @notice Reject a custodian update request + * + * The FNFT owner rejects the update request. + * For emergency updates, this function cannot be called. + * + * Emits a CustodianUpdateRejected event + * + * Reverts if: + * - Custody region is paused + * - Caller is not the owner of the token + * - The update request status is not Requested + * - The update request has expired + * - The update is an emergency update + * + * @param _tokenId - the token ID + */ + function rejectCustodianUpdate( + uint256 _tokenId + ) external notPaused(FermionTypes.PausableRegion.Custody) nonReentrant { + FermionStorage.ProtocolLookups storage pl = FermionStorage.protocolLookups(); + FermionStorage.TokenLookups storage tokenLookups = pl.tokenLookups[_tokenId]; + + // Validate update request status + FermionTypes.CustodianUpdateRequest storage updateRequest = tokenLookups.custodianUpdateRequest; + if (updateRequest.status != FermionTypes.CustodianUpdateStatus.Requested) { + revert InvalidCustodianUpdateStatus( + _tokenId, + FermionTypes.CustodianUpdateStatus.Requested, + updateRequest.status + ); + } + + if (updateRequest.requestTimestamp + 1 days < block.timestamp) { + revert UpdateRequestExpired(_tokenId); + } + + address msgSender = _msgSender(); + + if (updateRequest.isEmergencyUpdate) { + revert FermionGeneralErrors.AccessDenied(msgSender); + } + + (uint256 offerId, FermionTypes.Offer storage offer) = FermionStorage.getOfferFromTokenId(_tokenId); + + address owner = IERC721(pl.offerLookups[offerId].fermionFNFTAddress).ownerOf(_tokenId); + if (owner != msgSender) { + revert NotTokenBuyer(_tokenId, owner, msgSender); + } + + delete tokenLookups.custodianUpdateRequest; + + emit CustodianUpdateRejected(_tokenId, offer.custodianId, updateRequest.newCustodianId); + } } diff --git a/contracts/protocol/interfaces/events/ICustodyEvents.sol b/contracts/protocol/interfaces/events/ICustodyEvents.sol index 93f6f024..b158c352 100644 --- a/contracts/protocol/interfaces/events/ICustodyEvents.sol +++ b/contracts/protocol/interfaces/events/ICustodyEvents.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.24; +import { FermionTypes } from "../../domain/Types.sol"; + /** * @title ICustodyEvents * @@ -22,4 +24,20 @@ interface ICustodyEvents { event BidPlaced(uint256 indexed tokenId, address indexed bidder, uint256 bidderId, uint256 amount); event AuctionFinished(uint256 indexed tokenId, address indexed winner, uint256 soldFractions, uint256 winningBid); event VaultBalanceUpdated(uint256 indexed tokenId, uint256 amount); + event CustodianUpdateRequested( + uint256 indexed tokenId, + uint256 indexed currentCustodianId, + uint256 indexed newCustodianId, + FermionTypes.CustodianFee newCustodianFee + ); + event CustodianUpdateAccepted( + uint256 indexed tokenId, + uint256 indexed oldCustodianId, + uint256 indexed newCustodianId + ); + event CustodianUpdateRejected( + uint256 indexed tokenId, + uint256 indexed currentCustodianId, + uint256 indexed newCustodianId + ); } diff --git a/contracts/protocol/libs/Storage.sol b/contracts/protocol/libs/Storage.sol index af1e8856..a6290c72 100644 --- a/contracts/protocol/libs/Storage.sol +++ b/contracts/protocol/libs/Storage.sol @@ -142,6 +142,8 @@ library FermionStorage { FermionTypes.Phygital[] phygitals; // phygitals recipient uint256 phygitalsRecipient; + // custodian update request + FermionTypes.CustodianUpdateRequest custodianUpdateRequest; } struct SellerLookups { From e1f4f635a0b290b6e39270660153dcaadf27e09b Mon Sep 17 00:00:00 2001 From: 0xlucian <0xluciandev@gmail.com> Date: Thu, 9 Jan 2025 19:25:13 +0200 Subject: [PATCH 02/12] refactor: align storage structs events and errors realted to latest comments --- contracts/protocol/domain/Errors.sol | 4 ++-- contracts/protocol/domain/Types.sol | 3 +-- .../interfaces/events/ICustodyEvents.sol | 17 ++++++++--------- contracts/protocol/libs/Storage.sol | 6 ++++-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/contracts/protocol/domain/Errors.sol b/contracts/protocol/domain/Errors.sol index a848ff01..23aac075 100644 --- a/contracts/protocol/domain/Errors.sol +++ b/contracts/protocol/domain/Errors.sol @@ -70,12 +70,12 @@ interface CustodyErrors { error NotTokenBuyer(uint256 tokenId, address owner, address caller); error InvalidTaxAmount(); error InvalidCheckoutRequestStatus( - uint256 tokenId, + uint256 offerId, FermionTypes.CheckoutRequestStatus expectedStatus, FermionTypes.CheckoutRequestStatus actualStatus ); error InvalidCustodianUpdateStatus( - uint256 tokenId, + uint256 offerId, FermionTypes.CustodianUpdateStatus expected, FermionTypes.CustodianUpdateStatus actual ); diff --git a/contracts/protocol/domain/Types.sol b/contracts/protocol/domain/Types.sol index 04a6c401..5c9bd099 100644 --- a/contracts/protocol/domain/Types.sol +++ b/contracts/protocol/domain/Types.sol @@ -122,9 +122,8 @@ contract FermionTypes { CustodianUpdateStatus status; uint256 newCustodianId; CustodianFee newCustodianFee; + CustodianVaultParameters newCustodianVaultParameters; uint256 requestTimestamp; - bool keepExistingParameters; - bool isEmergencyUpdate; } struct CustodianVaultParameters { diff --git a/contracts/protocol/interfaces/events/ICustodyEvents.sol b/contracts/protocol/interfaces/events/ICustodyEvents.sol index b158c352..97d118ae 100644 --- a/contracts/protocol/interfaces/events/ICustodyEvents.sol +++ b/contracts/protocol/interfaces/events/ICustodyEvents.sol @@ -25,19 +25,18 @@ interface ICustodyEvents { event AuctionFinished(uint256 indexed tokenId, address indexed winner, uint256 soldFractions, uint256 winningBid); event VaultBalanceUpdated(uint256 indexed tokenId, uint256 amount); event CustodianUpdateRequested( - uint256 indexed tokenId, + uint256 indexed offerId, uint256 indexed currentCustodianId, uint256 indexed newCustodianId, - FermionTypes.CustodianFee newCustodianFee + FermionTypes.CustodianFee newCustodianFee, + FermionTypes.CustodianVaultParameters newCustodianVaultParameters ); event CustodianUpdateAccepted( - uint256 indexed tokenId, + uint256 indexed offerId, uint256 indexed oldCustodianId, - uint256 indexed newCustodianId - ); - event CustodianUpdateRejected( - uint256 indexed tokenId, - uint256 indexed currentCustodianId, - uint256 indexed newCustodianId + uint256 indexed newCustodianId, + FermionTypes.CustodianFee newCustodianFee, + FermionTypes.CustodianVaultParameters newCustodianVaultParameters ); + event CustodianUpdateRejected(uint256 indexed offerId, uint256 indexed newCustodianId); } diff --git a/contracts/protocol/libs/Storage.sol b/contracts/protocol/libs/Storage.sol index a6290c72..ef7959d5 100644 --- a/contracts/protocol/libs/Storage.sol +++ b/contracts/protocol/libs/Storage.sol @@ -113,6 +113,10 @@ library FermionStorage { FermionTypes.CustodianVaultParameters custodianVaultParameters; // number of items in custodian vault uint256 custodianVaultItems; + uint256 itemQuantity; + uint256 firstTokenId; + // custodian update request + FermionTypes.CustodianUpdateRequest custodianUpdateRequest; } struct TokenLookups { @@ -142,8 +146,6 @@ library FermionStorage { FermionTypes.Phygital[] phygitals; // phygitals recipient uint256 phygitalsRecipient; - // custodian update request - FermionTypes.CustodianUpdateRequest custodianUpdateRequest; } struct SellerLookups { From ae89f767d0ff35c3814c44461658b39de9cef410 Mon Sep 17 00:00:00 2001 From: 0xlucian <0xluciandev@gmail.com> Date: Fri, 10 Jan 2025 09:19:23 +0200 Subject: [PATCH 03/12] refactor: finalized implementation refactoring --- contracts/protocol/domain/Types.sol | 4 +- contracts/protocol/facets/Custody.sol | 428 +++++++++--------- contracts/protocol/facets/Offer.sol | 7 + .../interfaces/events/ICustodyEvents.sol | 6 +- 4 files changed, 219 insertions(+), 226 deletions(-) diff --git a/contracts/protocol/domain/Types.sol b/contracts/protocol/domain/Types.sol index 5c9bd099..ec3ea9e1 100644 --- a/contracts/protocol/domain/Types.sol +++ b/contracts/protocol/domain/Types.sol @@ -121,8 +121,8 @@ contract FermionTypes { struct CustodianUpdateRequest { CustodianUpdateStatus status; uint256 newCustodianId; - CustodianFee newCustodianFee; - CustodianVaultParameters newCustodianVaultParameters; + CustodianFee custodianFee; + CustodianVaultParameters custodianVaultParameters; uint256 requestTimestamp; } diff --git a/contracts/protocol/facets/Custody.sol b/contracts/protocol/facets/Custody.sol index 9c492873..e8156974 100644 --- a/contracts/protocol/facets/Custody.sol +++ b/contracts/protocol/facets/Custody.sol @@ -272,154 +272,77 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve * Reverts if: * - Custody region is paused * - Caller is not the new custodian's assistant - * - The token is checked out + * - Any token in the corresponding offer is checked out * - The previous request is too recent - * - For multi-item offers, any item is checked out * - * @param _tokenId - the token ID - * @param _newCustodianFee - the new custodian fee, ignored if keepExistingParameters is true - * @param _keepExistingParameters - if true, keep the current custodian fee + * @param _offerId The offer ID for which to request the custodian update + * @param _newCustodianFee The new custodian fee parameters including amount and period + * @param _newCustodianVaultParameters The new custodian vault parameters including minimum and maximum amounts */ function requestCustodianUpdate( - uint256 _tokenId, + uint256 _offerId, FermionTypes.CustodianFee calldata _newCustodianFee, - bool _keepExistingParameters + FermionTypes.CustodianVaultParameters calldata _newCustodianVaultParameters ) external notPaused(FermionTypes.PausableRegion.Custody) nonReentrant { - FermionStorage.ProtocolLookups storage pl = FermionStorage.protocolLookups(); - FermionStorage.TokenLookups storage tokenLookups = pl.tokenLookups[_tokenId]; + FermionStorage.OfferLookups storage offerLookups = FermionStorage.protocolLookups().offerLookups[_offerId]; // Check if there was a recent request - if (tokenLookups.custodianUpdateRequest.requestTimestamp + 1 days > block.timestamp) { - revert UpdateRequestTooRecent(_tokenId, 1 days); + if (offerLookups.custodianUpdateRequest.requestTimestamp + 1 days > block.timestamp) { + revert UpdateRequestTooRecent(_offerId, 1 days); } - (uint256 offerId, FermionTypes.Offer storage offer) = FermionStorage.getOfferFromTokenId(_tokenId); + FermionTypes.Offer storage offer = FermionStorage.protocolEntities().offer[_offerId]; uint256 currentCustodianId = offer.custodianId; - // For multi-item offers, check that no item is checked out - FermionStorage.OfferLookups storage offerLookups = pl.offerLookups[offerId]; - uint256 itemCount = offerLookups.custodianVaultItems; - if (itemCount > 1) { - uint256 firstTokenId = _tokenId & ~uint256(0xFFFFFFFFFFFFFFFF); // Clear lower 64 bits - for (uint256 i; i < itemCount; ) { - uint256 tokenId = firstTokenId + i; - if (pl.tokenLookups[tokenId].checkoutRequest.status == FermionTypes.CheckoutRequestStatus.CheckedOut) { - revert TokenCheckedOut(tokenId); - } - unchecked { - ++i; - } - } - } else { - // Single item - check just this token - if (tokenLookups.checkoutRequest.status == FermionTypes.CheckoutRequestStatus.CheckedOut) { - revert TokenCheckedOut(_tokenId); - } - } - _createCustodianUpdateRequest( - _tokenId, + _offerId, _newCustodianFee, - _keepExistingParameters, + _newCustodianVaultParameters, currentCustodianId, offer, - tokenLookups, - pl - ); - } - - function _createCustodianUpdateRequest( - uint256 _tokenId, - FermionTypes.CustodianFee calldata _newCustodianFee, - bool _keepExistingParameters, - uint256 _currentCustodianId, - FermionTypes.Offer storage _offer, - FermionStorage.TokenLookups storage _tokenLookups, - FermionStorage.ProtocolLookups storage _pl - ) internal { - // Check the caller is the new custodian's assistant - address msgSender = _msgSender(); - uint256 newCustodianId = EntityLib.getOrCreateBuyerId(msgSender, _pl); - EntityLib.validateAccountRole( - newCustodianId, - msgSender, - FermionTypes.EntityRole.Custodian, - FermionTypes.AccountRole.Assistant + offerLookups, + FermionStorage.protocolLookups() ); - - // Store the update request - _tokenLookups.custodianUpdateRequest = FermionTypes.CustodianUpdateRequest({ - status: FermionTypes.CustodianUpdateStatus.Requested, - newCustodianId: newCustodianId, - newCustodianFee: _keepExistingParameters - ? FermionTypes.CustodianFee({ amount: _offer.custodianFee.amount, period: _offer.custodianFee.period }) - : _newCustodianFee, - requestTimestamp: block.timestamp, - keepExistingParameters: _keepExistingParameters, - isEmergencyUpdate: false - }); - - emit CustodianUpdateRequested(_tokenId, _currentCustodianId, newCustodianId, _newCustodianFee); } /** - * @notice Request an emergency custodian update + * @notice Executes an emergency custodian update + * @dev Allows immediate custodian change in emergency situations * * The current custodian or seller can initiate an emergency update when the custodian stops operating. - * This bypasses the owner acceptance and keeps the existing fee parameters. + * This bypasses the owner acceptance and updates the custodian parameters within the same transaction. + * The existing custodian parameters are kept unchanged in emergency updates. * - * Emits a CustodianUpdateRequested event + * Emits a CustodianUpdateRequested event followed by a CustodianUpdateAccepted event * * Reverts if: * - Custody region is paused * - Caller is not the current custodian's assistant or seller's assistant - * - The token is checked out + * - Any token within the offer is checked out * - The previous request is too recent - * - For multi-item offers, any item is checked out + * - The new custodian ID is invalid or does not have the Custodian role * - * @param _tokenId - the token ID - * @param _newCustodianId - the ID of the new custodian - * @param _isCustodianAssistant - if true, validate caller as custodian assistant, otherwise as seller assistant + * @param _offerId The offer ID for which to execute the emergency update + * @param _newCustodianId The ID of the new custodian to take over + * @param _isCustodianAssistant If true, validates caller as custodian assistant, otherwise as seller assistant */ - function requestEmergencyCustodianUpdate( - uint256 _tokenId, + function emergencyCustodianUpdate( + uint256 _offerId, uint256 _newCustodianId, bool _isCustodianAssistant ) external notPaused(FermionTypes.PausableRegion.Custody) nonReentrant { - FermionStorage.ProtocolLookups storage pl = FermionStorage.protocolLookups(); - FermionStorage.TokenLookups storage tokenLookups = pl.tokenLookups[_tokenId]; + FermionTypes.Offer storage offer = FermionStorage.protocolEntities().offer[_offerId]; + FermionStorage.OfferLookups storage offerLookups = FermionStorage.protocolLookups().offerLookups[_offerId]; // Check if there was a recent request - if (tokenLookups.custodianUpdateRequest.requestTimestamp + 1 days > block.timestamp) { - revert UpdateRequestTooRecent(_tokenId, 1 days); + if (offerLookups.custodianUpdateRequest.requestTimestamp + 1 days > block.timestamp) { + revert UpdateRequestTooRecent(_offerId, 1 days); } - (uint256 offerId, FermionTypes.Offer storage offer) = FermionStorage.getOfferFromTokenId(_tokenId); uint256 currentCustodianId = offer.custodianId; - // For multi-item offers, check that no item is checked out - FermionStorage.OfferLookups storage offerLookups = pl.offerLookups[offerId]; - uint256 itemCount = offerLookups.custodianVaultItems; - if (itemCount > 1) { - uint256 firstTokenId = _tokenId & ~uint256(0xFFFFFFFFFFFFFFFF); // Clear lower 64 bits - for (uint256 i; i < itemCount; ) { - uint256 tokenId = firstTokenId + i; - if (pl.tokenLookups[tokenId].checkoutRequest.status == FermionTypes.CheckoutRequestStatus.CheckedOut) { - revert TokenCheckedOut(tokenId); - } - unchecked { - ++i; - } - } - } else { - // Single item - check just this token - if (tokenLookups.checkoutRequest.status == FermionTypes.CheckoutRequestStatus.CheckedOut) { - revert TokenCheckedOut(_tokenId); - } - } - - // Check the caller is either the current custodian's assistant or seller's assistant address msgSender = _msgSender(); + if (_isCustodianAssistant) { EntityLib.validateAccountRole( currentCustodianId, @@ -431,7 +354,6 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve EntityLib.validateSellerAssistantOrFacilitator(offer.sellerId, offer.facilitatorId); } - // Validate that the new custodian exists and has the Custodian role EntityLib.validateEntityRole( _newCustodianId, FermionStorage.protocolEntities().entityData[_newCustodianId].roles, @@ -439,49 +361,55 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve ); // Store the update request - keep existing parameters in emergency update - tokenLookups.custodianUpdateRequest = FermionTypes.CustodianUpdateRequest({ + offerLookups.custodianUpdateRequest = FermionTypes.CustodianUpdateRequest({ status: FermionTypes.CustodianUpdateStatus.Requested, newCustodianId: _newCustodianId, - newCustodianFee: offer.custodianFee, - requestTimestamp: block.timestamp, - keepExistingParameters: true, - isEmergencyUpdate: true + custodianFee: offer.custodianFee, + custodianVaultParameters: offerLookups.custodianVaultParameters, + requestTimestamp: block.timestamp }); - emit CustodianUpdateRequested(_tokenId, currentCustodianId, _newCustodianId, offer.custodianFee); + emit CustodianUpdateRequested( + _offerId, + currentCustodianId, + _newCustodianId, + offer.custodianFee, + offerLookups.custodianVaultParameters + ); + _processCustodianUpdate(_offerId, offer, offerLookups.custodianUpdateRequest, offerLookups); } /** * @notice Accept a custodian update request + * @dev Processes the acceptance of a custodian update request by the token owner * - * The FNFT owner accepts the update request. * The current custodian is paid for the used period. * The vault parameters are updated with the new custodian's parameters. - * For multi-item offers, all items are updated and the current custodian is paid for each item. + * All items in the offer are updated and the current custodian is paid for each item. * * Emits a CustodianUpdateAccepted event * * Reverts if: * - Custody region is paused - * - Caller is not the owner of the token (unless it's an emergency update) - * - The token is checked out + * - Caller is not the owner of all tokens in the offer + * - Any token in the offer is checked out * - The update request status is not Requested * - The update request has expired * - There are not enough funds in any vault to pay the current custodian * - * @param _tokenId - the token ID + * @param _offerId The offer ID for which to accept the custodian update */ function acceptCustodianUpdate( - uint256 _tokenId + uint256 _offerId ) external notPaused(FermionTypes.PausableRegion.Custody) nonReentrant { FermionStorage.ProtocolLookups storage pl = FermionStorage.protocolLookups(); - FermionStorage.TokenLookups storage tokenLookups = pl.tokenLookups[_tokenId]; + FermionStorage.OfferLookups storage offerLookups = pl.offerLookups[_offerId]; // Validate update request status - FermionTypes.CustodianUpdateRequest storage updateRequest = tokenLookups.custodianUpdateRequest; + FermionTypes.CustodianUpdateRequest storage updateRequest = offerLookups.custodianUpdateRequest; if (updateRequest.status != FermionTypes.CustodianUpdateStatus.Requested) { revert InvalidCustodianUpdateStatus( - _tokenId, + _offerId, FermionTypes.CustodianUpdateStatus.Requested, updateRequest.status ); @@ -489,146 +417,204 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve // Check request hasn't expired if (updateRequest.requestTimestamp + 1 days < block.timestamp) { - revert UpdateRequestExpired(_tokenId); + revert UpdateRequestExpired(_offerId); } - (uint256 offerId, FermionTypes.Offer storage offer) = FermionStorage.getOfferFromTokenId(_tokenId); - uint256 oldCustodianId = offer.custodianId; + address fermionFNFTAddress = offerLookups.fermionFNFTAddress; + address msgSender = _msgSender(); + uint256 itemCount = offerLookups.itemQuantity; + uint256 firstTokenId = offerLookups.firstTokenId; - // For non-emergency updates, check the caller owns the token - if (!updateRequest.isEmergencyUpdate) { - address msgSender = _msgSender(); - address owner = IERC721(pl.offerLookups[offerId].fermionFNFTAddress).ownerOf(_tokenId); - if (owner != msgSender) { - revert NotTokenBuyer(_tokenId, owner, msgSender); - } - } + for (uint256 i; i < itemCount; ++i) { + uint256 tokenId = firstTokenId + i; + address tokenOwner = IERC721(fermionFNFTAddress).ownerOf(tokenId); - // For multi-item offers, process all items - FermionStorage.OfferLookups storage offerLookups = pl.offerLookups[offerId]; - uint256 itemCount = offerLookups.custodianVaultItems; - if (itemCount > 1) { - uint256 firstTokenId = _tokenId & ~uint256(0xFFFFFFFFFFFFFFFF); // Clear lower 64 bits - for (uint256 i; i < itemCount; ) { - uint256 tokenId = firstTokenId + i; - _processCustodianUpdate(tokenId, oldCustodianId, offer, updateRequest); - unchecked { - ++i; - } + if (tokenOwner != msgSender) { + revert NotTokenBuyer(_offerId, tokenOwner, msgSender); } - } else { - _processCustodianUpdate(_tokenId, oldCustodianId, offer, updateRequest); } - - offer.custodianId = updateRequest.newCustodianId; - if (!updateRequest.keepExistingParameters) { - offer.custodianFee = updateRequest.newCustodianFee; - } - - uint256 newCustodianId = updateRequest.newCustodianId; - delete tokenLookups.custodianUpdateRequest; - - emit CustodianUpdateAccepted(_tokenId, oldCustodianId, newCustodianId); - } - - /** - * @notice Process custodian update for a single token - * - * Internal helper function to process the custodian update for a single token. - * Calculates and pays the current custodian, updates the vault period. - * - * Reverts if: - * - The token is checked out - * - There are not enough funds in the vault to pay the current custodian - * - * @param _tokenId - the token ID - * @param _oldCustodianId - the ID of the current custodian - * @param _offer - the offer storage pointer - * @param _updateRequest - the update request storage pointer - */ - function _processCustodianUpdate( - uint256 _tokenId, - uint256 _oldCustodianId, - FermionTypes.Offer storage _offer, - FermionTypes.CustodianUpdateRequest storage _updateRequest - ) internal { - FermionStorage.TokenLookups storage tokenLookups = FermionStorage.protocolLookups().tokenLookups[_tokenId]; - - if (tokenLookups.checkoutRequest.status == FermionTypes.CheckoutRequestStatus.CheckedOut) { - revert TokenCheckedOut(_tokenId); - } - - // Calculate and pay the current custodian - FermionTypes.CustodianFee storage vault = tokenLookups.vault; - uint256 lastReleased = vault.period; - uint256 custodianFee = _offer.custodianFee.amount; - uint256 custodianPeriod = _offer.custodianFee.period; - uint256 custodianPayoff = ((block.timestamp - lastReleased) * custodianFee) / custodianPeriod; - - if (custodianPayoff > vault.amount) { - revert InsufficientVaultBalance(_tokenId, custodianPayoff, vault.amount); - } - - // Pay the current custodian - increaseAvailableFunds(_oldCustodianId, _offer.exchangeToken, custodianPayoff); - vault.amount -= custodianPayoff; - - // Reset the vault period - vault.period = block.timestamp; + _processCustodianUpdate( + _offerId, + FermionStorage.protocolEntities().offer[_offerId], + updateRequest, + offerLookups + ); } /** * @notice Reject a custodian update request + * @dev Allows the token owner to reject a custodian update request * * The FNFT owner rejects the update request. - * For emergency updates, this function cannot be called. + * Emergency updates cannot be rejected. * * Emits a CustodianUpdateRejected event * * Reverts if: * - Custody region is paused - * - Caller is not the owner of the token + * - Caller is not the owner of every token within the offer * - The update request status is not Requested * - The update request has expired * - The update is an emergency update * - * @param _tokenId - the token ID + * @param _offerId The offer ID for which to reject the custodian update */ function rejectCustodianUpdate( - uint256 _tokenId + uint256 _offerId ) external notPaused(FermionTypes.PausableRegion.Custody) nonReentrant { FermionStorage.ProtocolLookups storage pl = FermionStorage.protocolLookups(); - FermionStorage.TokenLookups storage tokenLookups = pl.tokenLookups[_tokenId]; + FermionStorage.OfferLookups storage offerLookups = pl.offerLookups[_offerId]; // Validate update request status - FermionTypes.CustodianUpdateRequest storage updateRequest = tokenLookups.custodianUpdateRequest; + FermionTypes.CustodianUpdateRequest storage updateRequest = offerLookups.custodianUpdateRequest; if (updateRequest.status != FermionTypes.CustodianUpdateStatus.Requested) { revert InvalidCustodianUpdateStatus( - _tokenId, + _offerId, FermionTypes.CustodianUpdateStatus.Requested, updateRequest.status ); } if (updateRequest.requestTimestamp + 1 days < block.timestamp) { - revert UpdateRequestExpired(_tokenId); + revert UpdateRequestExpired(_offerId); } + address fermionFNFTAddress = offerLookups.fermionFNFTAddress; address msgSender = _msgSender(); - - if (updateRequest.isEmergencyUpdate) { - revert FermionGeneralErrors.AccessDenied(msgSender); + uint256 itemCount = offerLookups.itemQuantity; + uint256 firstTokenId = offerLookups.firstTokenId; + for (uint256 i; i < itemCount; ++i) { + uint256 tokenId = firstTokenId + i; + address tokenOwner = IERC721(fermionFNFTAddress).ownerOf(tokenId); + + if (tokenOwner != msgSender) { + revert NotTokenBuyer(tokenId, tokenOwner, msgSender); + } } + delete offerLookups.custodianUpdateRequest; - (uint256 offerId, FermionTypes.Offer storage offer) = FermionStorage.getOfferFromTokenId(_tokenId); + emit CustodianUpdateRejected(_offerId, updateRequest.newCustodianId); + } + + /** + * @notice Creates a custodian update request + * @dev Internal helper function to create and store a custodian update request + * + * @param _offerId The offer ID for which to create the update request + * @param _custodianFee The new custodian fee parameters + * @param _custodianVaultParameters The new custodian vault parameters + * @param _currentCustodianId The ID of the current custodian + * @param _offer The offer storage pointer + * @param _offerLookups The offer lookups storage pointer + * @param _pl The protocol lookups storage pointer + */ + function _createCustodianUpdateRequest( + uint256 _offerId, + FermionTypes.CustodianFee calldata _custodianFee, + FermionTypes.CustodianVaultParameters calldata _custodianVaultParameters, + uint256 _currentCustodianId, + FermionTypes.Offer storage _offer, + FermionStorage.OfferLookups storage _offerLookups, + FermionStorage.ProtocolLookups storage _pl + ) internal { + address msgSender = _msgSender(); + // TODO: replace getOrCreateBuyerId with the more generic getOrCreateEntityId + // once Fermion Royalties (https://github.com/fermionprotocol/contracts/pull/317)PR is merged + uint256 newCustodianId = EntityLib.getOrCreateBuyerId(msgSender, _pl); + + EntityLib.validateAccountRole( + newCustodianId, + msgSender, + FermionTypes.EntityRole.Custodian, + FermionTypes.AccountRole.Assistant + ); + + _offerLookups.custodianUpdateRequest = FermionTypes.CustodianUpdateRequest({ + status: FermionTypes.CustodianUpdateStatus.Requested, + newCustodianId: newCustodianId, + custodianFee: _custodianFee, + custodianVaultParameters: _custodianVaultParameters, + requestTimestamp: block.timestamp + }); + + emit CustodianUpdateRequested( + _offerId, + _currentCustodianId, + newCustodianId, + _custodianFee, + _custodianVaultParameters + ); + } - address owner = IERC721(pl.offerLookups[offerId].fermionFNFTAddress).ownerOf(_tokenId); - if (owner != msgSender) { - revert NotTokenBuyer(_tokenId, owner, msgSender); + /** + * @notice Process custodian update + * @dev Internal helper function to process the custodian update for the offer. + * Calculates and pays the current custodian, updates the vault period for each token. + * Also updates the vault parameters for the new custodian in the offer. + * + * Reverts if: + * - Any token within the offer is checked out + * - There are not enough funds in the vault to pay the current custodian + * + * @param _offerId The offer ID for which to process the update + * @param _offer The offer storage pointer + * @param _updateRequest The update request storage pointer + * @param _offerLookups The offer lookups storage pointer + */ + function _processCustodianUpdate( + uint256 _offerId, + FermionTypes.Offer storage _offer, + FermionTypes.CustodianUpdateRequest storage _updateRequest, + FermionStorage.OfferLookups storage _offerLookups + ) internal { + FermionStorage.ProtocolLookups storage pl = FermionStorage.protocolLookups(); + + uint256 currentCustodianId = _offer.custodianId; + uint256 currentCustodianFee = _offer.custodianFee.amount; + uint256 currentCustodianPeriod = _offer.custodianFee.period; + + uint256 itemCount = _offerLookups.itemQuantity; + uint256 firstTokenId = _offerLookups.firstTokenId; + + // payout the current custodian + for (uint256 i; i < itemCount; ++i) { + uint256 tokenId = firstTokenId + i; + FermionStorage.TokenLookups storage tokenLookups = pl.tokenLookups[tokenId]; + + if (tokenLookups.checkoutRequest.status == FermionTypes.CheckoutRequestStatus.CheckedOut) { + revert TokenCheckedOut(tokenId); + } + // Calculate and pay the current custodian + FermionTypes.CustodianFee storage vault = tokenLookups.vault; + uint256 custodianPayoff = ((block.timestamp - vault.period) * currentCustodianFee) / currentCustodianPeriod; + + if (custodianPayoff > vault.amount) { + revert InsufficientVaultBalance(tokenId, custodianPayoff, vault.amount); + } + + increaseAvailableFunds(currentCustodianId, _offer.exchangeToken, custodianPayoff); + vault.amount -= custodianPayoff; + + // Reset the vault period + vault.period = block.timestamp; } - delete tokenLookups.custodianUpdateRequest; + FermionTypes.CustodianVaultParameters memory custodianVaultParameters = _updateRequest.custodianVaultParameters; + FermionTypes.CustodianFee memory custodianFee = _updateRequest.custodianFee; + uint256 newCustodianId = _updateRequest.newCustodianId; + + _offer.custodianId = newCustodianId; + _offer.custodianFee = custodianFee; + _offerLookups.custodianVaultParameters = custodianVaultParameters; - emit CustodianUpdateRejected(_tokenId, offer.custodianId, updateRequest.newCustodianId); + delete _offerLookups.custodianUpdateRequest; + + emit CustodianUpdateAccepted( + _offerId, + currentCustodianId, + newCustodianId, + custodianFee, + custodianVaultParameters + ); } } diff --git a/contracts/protocol/facets/Offer.sol b/contracts/protocol/facets/Offer.sol index 51f75bf4..9f3b3e04 100644 --- a/contracts/protocol/facets/Offer.sol +++ b/contracts/protocol/facets/Offer.sol @@ -536,6 +536,7 @@ contract OfferFacet is Context, OfferErrors, Access, FundsLib, IOfferEvents { revert InvalidQuantity(_quantity); } FermionTypes.Offer storage offer = FermionStorage.protocolEntities().offer[_offerId]; + FermionStorage.OfferLookups storage offerLookup = FermionStorage.protocolLookups().offerLookups[_offerId]; // Check the caller is the the seller's assistant or facilitator EntityLib.validateSellerAssistantOrFacilitator(offer.sellerId, offer.facilitatorId); @@ -550,6 +551,12 @@ contract OfferFacet is Context, OfferErrors, Access, FundsLib, IOfferEvents { bosonVoucher = IBosonVoucher(FermionStorage.protocolStatus().bosonNftCollection); bosonVoucher.preMint(_offerId, _quantity); + if (offerLookup.firstTokenId == 0) { + offerLookup.firstTokenId = startingNFTId; + } + + offerLookup.itemQuantity += _quantity; + // emit event emit NFTsMinted(_offerId, startingNFTId, _quantity); } diff --git a/contracts/protocol/interfaces/events/ICustodyEvents.sol b/contracts/protocol/interfaces/events/ICustodyEvents.sol index 97d118ae..56a6d37e 100644 --- a/contracts/protocol/interfaces/events/ICustodyEvents.sol +++ b/contracts/protocol/interfaces/events/ICustodyEvents.sol @@ -27,9 +27,9 @@ interface ICustodyEvents { event CustodianUpdateRequested( uint256 indexed offerId, uint256 indexed currentCustodianId, - uint256 indexed newCustodianId, - FermionTypes.CustodianFee newCustodianFee, - FermionTypes.CustodianVaultParameters newCustodianVaultParameters + uint256 indexed custodianId, + FermionTypes.CustodianFee custodianFee, + FermionTypes.CustodianVaultParameters custodianVaultParameters ); event CustodianUpdateAccepted( uint256 indexed offerId, From 2a714b66963f65942ddfa5f94e25d17a2ef931dd Mon Sep 17 00:00:00 2001 From: 0xlucian <0xluciandev@gmail.com> Date: Mon, 13 Jan 2025 18:12:45 +0200 Subject: [PATCH 04/12] refactor: adjust small implementation details and address latest comments --- contracts/protocol/facets/Custody.sol | 121 +++++++------------------- 1 file changed, 32 insertions(+), 89 deletions(-) diff --git a/contracts/protocol/facets/Custody.sol b/contracts/protocol/facets/Custody.sol index e8156974..603eeca8 100644 --- a/contracts/protocol/facets/Custody.sol +++ b/contracts/protocol/facets/Custody.sol @@ -276,11 +276,13 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve * - The previous request is too recent * * @param _offerId The offer ID for which to request the custodian update + * @param _newCustodianId The ID of the new custodian to take over * @param _newCustodianFee The new custodian fee parameters including amount and period * @param _newCustodianVaultParameters The new custodian vault parameters including minimum and maximum amounts */ function requestCustodianUpdate( uint256 _offerId, + uint256 _newCustodianId, FermionTypes.CustodianFee calldata _newCustodianFee, FermionTypes.CustodianVaultParameters calldata _newCustodianVaultParameters ) external notPaused(FermionTypes.PausableRegion.Custody) nonReentrant { @@ -299,6 +301,7 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve _newCustodianFee, _newCustodianVaultParameters, currentCustodianId, + _newCustodianId, offer, offerLookups, FermionStorage.protocolLookups() @@ -306,24 +309,28 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve } /** - * @notice Executes an emergency custodian update - * @dev Allows immediate custodian change in emergency situations + * @notice Emergency update of custodian + * @dev Allows the current custodian's assistant or seller's assistant to force a custodian update * - * The current custodian or seller can initiate an emergency update when the custodian stops operating. - * This bypasses the owner acceptance and updates the custodian parameters within the same transaction. - * The existing custodian parameters are kept unchanged in emergency updates. + * This is an emergency function that bypasses: + * - The time restriction between updates + * - The owner acceptance requirement + * + * The current custodian is paid for the used period. + * The vault parameters remain unchanged in emergency updates. * - * Emits a CustodianUpdateRequested event followed by a CustodianUpdateAccepted event + * Emits CustodianUpdateRequested and CustodianUpdateAccepted events * * Reverts if: * - Custody region is paused - * - Caller is not the current custodian's assistant or seller's assistant - * - Any token within the offer is checked out - * - The previous request is too recent - * - The new custodian ID is invalid or does not have the Custodian role + * - Caller is not the current custodian's assistant (if _isCustodianAssistant is true) + * - Caller is not the seller's assistant (if _isCustodianAssistant is false) + * - New custodian ID is invalid + * - Any token in the offer is checked out + * - There are not enough funds in any vault to pay the current custodian * - * @param _offerId The offer ID for which to execute the emergency update - * @param _newCustodianId The ID of the new custodian to take over + * @param _offerId The offer ID for which to update the custodian + * @param _newCustodianId The ID of the new custodian * @param _isCustodianAssistant If true, validates caller as custodian assistant, otherwise as seller assistant */ function emergencyCustodianUpdate( @@ -334,11 +341,6 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve FermionTypes.Offer storage offer = FermionStorage.protocolEntities().offer[_offerId]; FermionStorage.OfferLookups storage offerLookups = FermionStorage.protocolLookups().offerLookups[_offerId]; - // Check if there was a recent request - if (offerLookups.custodianUpdateRequest.requestTimestamp + 1 days > block.timestamp) { - revert UpdateRequestTooRecent(_offerId, 1 days); - } - uint256 currentCustodianId = offer.custodianId; address msgSender = _msgSender(); @@ -386,13 +388,13 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve * The current custodian is paid for the used period. * The vault parameters are updated with the new custodian's parameters. * All items in the offer are updated and the current custodian is paid for each item. + * Tokens that are checked out are skipped in ownership validation. * * Emits a CustodianUpdateAccepted event * * Reverts if: * - Custody region is paused - * - Caller is not the owner of all tokens in the offer - * - Any token in the offer is checked out + * - Caller is not the owner of all in-custody tokens in the offer * - The update request status is not Requested * - The update request has expired * - There are not enough funds in any vault to pay the current custodian @@ -427,10 +429,13 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve for (uint256 i; i < itemCount; ++i) { uint256 tokenId = firstTokenId + i; - address tokenOwner = IERC721(fermionFNFTAddress).ownerOf(tokenId); + FermionStorage.TokenLookups storage tokenLookups = pl.tokenLookups[tokenId]; - if (tokenOwner != msgSender) { - revert NotTokenBuyer(_offerId, tokenOwner, msgSender); + if (tokenLookups.checkoutRequest.status != FermionTypes.CheckoutRequestStatus.CheckedOut) { + address tokenOwner = IERC721(fermionFNFTAddress).ownerOf(tokenId); + if (tokenOwner != msgSender) { + revert NotTokenBuyer(_offerId, tokenOwner, msgSender); + } } } _processCustodianUpdate( @@ -441,61 +446,6 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve ); } - /** - * @notice Reject a custodian update request - * @dev Allows the token owner to reject a custodian update request - * - * The FNFT owner rejects the update request. - * Emergency updates cannot be rejected. - * - * Emits a CustodianUpdateRejected event - * - * Reverts if: - * - Custody region is paused - * - Caller is not the owner of every token within the offer - * - The update request status is not Requested - * - The update request has expired - * - The update is an emergency update - * - * @param _offerId The offer ID for which to reject the custodian update - */ - function rejectCustodianUpdate( - uint256 _offerId - ) external notPaused(FermionTypes.PausableRegion.Custody) nonReentrant { - FermionStorage.ProtocolLookups storage pl = FermionStorage.protocolLookups(); - FermionStorage.OfferLookups storage offerLookups = pl.offerLookups[_offerId]; - - // Validate update request status - FermionTypes.CustodianUpdateRequest storage updateRequest = offerLookups.custodianUpdateRequest; - if (updateRequest.status != FermionTypes.CustodianUpdateStatus.Requested) { - revert InvalidCustodianUpdateStatus( - _offerId, - FermionTypes.CustodianUpdateStatus.Requested, - updateRequest.status - ); - } - - if (updateRequest.requestTimestamp + 1 days < block.timestamp) { - revert UpdateRequestExpired(_offerId); - } - - address fermionFNFTAddress = offerLookups.fermionFNFTAddress; - address msgSender = _msgSender(); - uint256 itemCount = offerLookups.itemQuantity; - uint256 firstTokenId = offerLookups.firstTokenId; - for (uint256 i; i < itemCount; ++i) { - uint256 tokenId = firstTokenId + i; - address tokenOwner = IERC721(fermionFNFTAddress).ownerOf(tokenId); - - if (tokenOwner != msgSender) { - revert NotTokenBuyer(tokenId, tokenOwner, msgSender); - } - } - delete offerLookups.custodianUpdateRequest; - - emit CustodianUpdateRejected(_offerId, updateRequest.newCustodianId); - } - /** * @notice Creates a custodian update request * @dev Internal helper function to create and store a custodian update request @@ -513,25 +463,21 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve FermionTypes.CustodianFee calldata _custodianFee, FermionTypes.CustodianVaultParameters calldata _custodianVaultParameters, uint256 _currentCustodianId, + uint256 _newCustodianId, FermionTypes.Offer storage _offer, FermionStorage.OfferLookups storage _offerLookups, FermionStorage.ProtocolLookups storage _pl ) internal { - address msgSender = _msgSender(); - // TODO: replace getOrCreateBuyerId with the more generic getOrCreateEntityId - // once Fermion Royalties (https://github.com/fermionprotocol/contracts/pull/317)PR is merged - uint256 newCustodianId = EntityLib.getOrCreateBuyerId(msgSender, _pl); - EntityLib.validateAccountRole( - newCustodianId, - msgSender, + _newCustodianId, + _msgSender(), FermionTypes.EntityRole.Custodian, FermionTypes.AccountRole.Assistant ); _offerLookups.custodianUpdateRequest = FermionTypes.CustodianUpdateRequest({ status: FermionTypes.CustodianUpdateStatus.Requested, - newCustodianId: newCustodianId, + newCustodianId: _newCustodianId, custodianFee: _custodianFee, custodianVaultParameters: _custodianVaultParameters, requestTimestamp: block.timestamp @@ -540,7 +486,7 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve emit CustodianUpdateRequested( _offerId, _currentCustodianId, - newCustodianId, + _newCustodianId, _custodianFee, _custodianVaultParameters ); @@ -581,9 +527,6 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve uint256 tokenId = firstTokenId + i; FermionStorage.TokenLookups storage tokenLookups = pl.tokenLookups[tokenId]; - if (tokenLookups.checkoutRequest.status == FermionTypes.CheckoutRequestStatus.CheckedOut) { - revert TokenCheckedOut(tokenId); - } // Calculate and pay the current custodian FermionTypes.CustodianFee storage vault = tokenLookups.vault; uint256 custodianPayoff = ((block.timestamp - vault.period) * currentCustodianFee) / currentCustodianPeriod; From e3bf031e0abd571389ed54d45a3126e8012f988f Mon Sep 17 00:00:00 2001 From: 0xlucian <0xluciandev@gmail.com> Date: Mon, 13 Jan 2025 18:51:12 +0200 Subject: [PATCH 05/12] refactor: further refactoring --- contracts/protocol/domain/Errors.sol | 5 ----- contracts/protocol/domain/Types.sol | 8 -------- contracts/protocol/facets/Custody.sol | 11 ----------- 3 files changed, 24 deletions(-) diff --git a/contracts/protocol/domain/Errors.sol b/contracts/protocol/domain/Errors.sol index 23aac075..c417cfa5 100644 --- a/contracts/protocol/domain/Errors.sol +++ b/contracts/protocol/domain/Errors.sol @@ -74,11 +74,6 @@ interface CustodyErrors { FermionTypes.CheckoutRequestStatus expectedStatus, FermionTypes.CheckoutRequestStatus actualStatus ); - error InvalidCustodianUpdateStatus( - uint256 offerId, - FermionTypes.CustodianUpdateStatus expected, - FermionTypes.CustodianUpdateStatus actual - ); error TokenCheckedOut(uint256 tokenId); error InsufficientVaultBalance(uint256 tokenId, uint256 required, uint256 available); error UpdateRequestExpired(uint256 tokenId); diff --git a/contracts/protocol/domain/Types.sol b/contracts/protocol/domain/Types.sol index ec3ea9e1..aa734a37 100644 --- a/contracts/protocol/domain/Types.sol +++ b/contracts/protocol/domain/Types.sol @@ -111,15 +111,7 @@ contract FermionTypes { uint256 taxAmount; } - enum CustodianUpdateStatus { - None, - Requested, - Accepted, - Rejected - } - struct CustodianUpdateRequest { - CustodianUpdateStatus status; uint256 newCustodianId; CustodianFee custodianFee; CustodianVaultParameters custodianVaultParameters; diff --git a/contracts/protocol/facets/Custody.sol b/contracts/protocol/facets/Custody.sol index 603eeca8..dbd2e350 100644 --- a/contracts/protocol/facets/Custody.sol +++ b/contracts/protocol/facets/Custody.sol @@ -364,7 +364,6 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve // Store the update request - keep existing parameters in emergency update offerLookups.custodianUpdateRequest = FermionTypes.CustodianUpdateRequest({ - status: FermionTypes.CustodianUpdateStatus.Requested, newCustodianId: _newCustodianId, custodianFee: offer.custodianFee, custodianVaultParameters: offerLookups.custodianVaultParameters, @@ -406,16 +405,7 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve ) external notPaused(FermionTypes.PausableRegion.Custody) nonReentrant { FermionStorage.ProtocolLookups storage pl = FermionStorage.protocolLookups(); FermionStorage.OfferLookups storage offerLookups = pl.offerLookups[_offerId]; - - // Validate update request status FermionTypes.CustodianUpdateRequest storage updateRequest = offerLookups.custodianUpdateRequest; - if (updateRequest.status != FermionTypes.CustodianUpdateStatus.Requested) { - revert InvalidCustodianUpdateStatus( - _offerId, - FermionTypes.CustodianUpdateStatus.Requested, - updateRequest.status - ); - } // Check request hasn't expired if (updateRequest.requestTimestamp + 1 days < block.timestamp) { @@ -476,7 +466,6 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve ); _offerLookups.custodianUpdateRequest = FermionTypes.CustodianUpdateRequest({ - status: FermionTypes.CustodianUpdateStatus.Requested, newCustodianId: _newCustodianId, custodianFee: _custodianFee, custodianVaultParameters: _custodianVaultParameters, From c12b783937a3df477de92c84b1d3872bb0fa941b Mon Sep 17 00:00:00 2001 From: 0xlucian <0xluciandev@gmail.com> Date: Tue, 14 Jan 2025 10:54:41 +0200 Subject: [PATCH 06/12] feat: add tests for custodian update feature --- test/protocol/custodyFacet.ts | 284 ++++++++++++++++++++++++++++++++-- 1 file changed, 273 insertions(+), 11 deletions(-) diff --git a/test/protocol/custodyFacet.ts b/test/protocol/custodyFacet.ts index 58c70b4a..5de0a0ec 100644 --- a/test/protocol/custodyFacet.ts +++ b/test/protocol/custodyFacet.ts @@ -5,6 +5,7 @@ import { deployMockTokens, deriveTokenId, verifySellerAssistantRoleClosure, + setNextBlockTimestamp, } from "../utils/common"; import { expect } from "chai"; import { ethers } from "hardhat"; @@ -21,6 +22,12 @@ import { import { getBosonProtocolFees } from "../utils/boson-protocol"; import { createBuyerAdvancedOrderClosure } from "../utils/seaport"; import fermionConfig from "../../fermion.config"; +import { + PARTIAL_AUCTION_DURATION_DIVISOR, + LIQUIDATION_THRESHOLD_MULTIPLIER, + PARTIAL_THRESHOLD_MULTIPLIER, + DEFAULT_FRACTION_AMOUNT, +} from "../utils/constants"; const { parseEther } = ethers; @@ -30,7 +37,8 @@ describe("Custody", function () { verificationFacet: Contract, custodyFacet: Contract, fundsFacet: Contract, - pauseFacet: Contract; + pauseFacet: Contract, + custodyVaultFacet: Contract; let mockToken: Contract; let fermionErrors: Contract; let fermionProtocolAddress: string; @@ -46,13 +54,14 @@ describe("Custody", function () { const custodianId = "3"; const facilitatorId = "4"; const facilitator2Id = "5"; + const offerId = "1"; const verifierFee = parseEther("0.1"); const sellerDeposit = parseEther("0.05"); const custodianFee = { amount: parseEther("0.05"), period: 30n * 24n * 60n * 60n, // 30 days }; - const exchange = { tokenId: "", custodianId: "" }; + const exchange = { tokenId: "", custodianId: "", price: parseEther("1.0") }; const exchangeSelfSale = { tokenId: "", custodianId: "" }; const exchangeSelfCustody = { tokenId: "", custodianId: "" }; let verifySellerAssistantRole: ReturnType; @@ -97,7 +106,6 @@ describe("Custody", function () { }; // Make three offers one for normal sale, one of self sale and one for self custody - const offerId = "1"; // buyer != seller, custodian != seller const offerIdSelfSale = "2"; // buyer = seller, custodian != seller const offerIdSelfCustody = "3"; // buyer != seller, custodian = seller await offerFacet.connect(facilitator).createOffer({ ...fermionOffer, facilitatorId }); @@ -118,7 +126,11 @@ describe("Custody", function () { await mockToken.approve(fermionProtocolAddress, 2n * sellerDeposit); const createBuyerAdvancedOrder = createBuyerAdvancedOrderClosure(wallets, seaportAddress, mockToken, offerFacet); - const { buyerAdvancedOrder, tokenId } = await createBuyerAdvancedOrder(buyer, offerId, exchangeId); + const { buyerAdvancedOrder, tokenId, encumberedAmount } = await createBuyerAdvancedOrder( + buyer, + offerId, + exchangeId, + ); await offerFacet.unwrapNFT(tokenId, buyerAdvancedOrder); const { buyerAdvancedOrder: buyerAdvancedOrderSelfCustody, tokenId: tokenIdSelfCustody } = @@ -141,6 +153,7 @@ describe("Custody", function () { exchange.tokenId = tokenId; exchange.custodianId = custodianId; + exchange.price = encumberedAmount; // Self sale exchangeSelfSale.tokenId = tokenIdSelf; @@ -174,6 +187,7 @@ describe("Custody", function () { CustodyFacet: custodyFacet, FundsFacet: fundsFacet, PauseFacet: pauseFacet, + CustodyVaultFacet: custodyVaultFacet, }, fermionErrors, wallets, @@ -262,12 +276,9 @@ describe("Custody", function () { .to.be.revertedWithCustomError(fermionErrors, "AccountHasNoRole") .withArgs(custodianId, wallet.address, EntityRole.Custodian, AccountRole.Assistant); - // seller - await expect(custodyFacet.checkIn(exchange.tokenId)) - .to.be.revertedWithCustomError(fermionErrors, "AccountHasNoRole") - .withArgs(custodianId, defaultSigner.address, EntityRole.Custodian, AccountRole.Assistant); + const wallet2 = wallets[8]; - // an entity-wide Treasury or Manager wallet (not Assistant) + // an account with wrong role await entityFacet .connect(custodian) .addEntityAccounts(custodianId, [wallet], [[]], [[[AccountRole.Treasury, AccountRole.Manager]]]); @@ -275,8 +286,7 @@ describe("Custody", function () { .to.be.revertedWithCustomError(fermionErrors, "AccountHasNoRole") .withArgs(custodianId, wallet.address, EntityRole.Custodian, AccountRole.Assistant); - // a Custodian specific Treasury or Manager wallet - const wallet2 = wallets[10]; + // an account with wrong role await entityFacet .connect(custodian) .addEntityAccounts( @@ -1302,4 +1312,256 @@ describe("Custody", function () { }); }); }); + + context("custodianUpdate", function () { + const newCustodianFee = { + amount: parseEther("0.07"), + period: 60n * 24n * 60n * 60n, // 60 days + }; + const newCustodianVaultParameters = { + partialAuctionThreshold: parseEther("0.2"), + partialAuctionDuration: 7n * 24n * 60n * 60n, // 7 days + liquidationThreshold: parseEther("0.1"), + newFractionsPerAuction: 100n, + }; + let newCustodian: HardhatEthersSigner; + let newCustodianId: string; + + beforeEach(async function () { + newCustodian = wallets[7]; + const metadataURI = "https://example.com/new-custodian-metadata.json"; + await entityFacet.connect(newCustodian).createEntity([EntityRole.Custodian], metadataURI); + newCustodianId = "6"; // Since we already have 5 entities + + // Setup a token with current custodian + await custodyFacet.connect(custodian).checkIn(exchange.tokenId); + + // Fund the vault with sufficient balance + const vaultAmount = parseEther("1.0"); // Large enough to cover fees + await mockToken.approve(fermionProtocolAddress, vaultAmount); + await custodyVaultFacet.topUpCustodianVault(exchange.tokenId, vaultAmount); + }); + + context("requestCustodianUpdate", function () { + it("New custodian can request update", async function () { + const tx = await custodyFacet + .connect(newCustodian) + .requestCustodianUpdate(offerId, newCustodianId, newCustodianFee, newCustodianVaultParameters); + + await expect(tx) + .to.emit(custodyFacet, "CustodianUpdateRequested") + .withArgs( + offerId, + custodianId, + newCustodianId, + Object.values(newCustodianFee), + Object.values(newCustodianVaultParameters), + ); + }); + + context("Revert reasons", function () { + it("Custody region is paused", async function () { + await pauseFacet.pause([PausableRegion.Custody]); + + await expect( + custodyFacet + .connect(newCustodian) + .requestCustodianUpdate(offerId, newCustodianId, newCustodianFee, newCustodianVaultParameters), + ) + .to.be.revertedWithCustomError(fermionErrors, "RegionPaused") + .withArgs(PausableRegion.Custody); + }); + + it("Caller is not the new custodian's assistant", async function () { + // Use a wallet that doesn't have any entity ID yet + const nonCustodianWallet = wallets[9]; + + await expect( + custodyFacet + .connect(nonCustodianWallet) + .requestCustodianUpdate(offerId, newCustodianId, newCustodianFee, newCustodianVaultParameters), + ) + .to.be.revertedWithCustomError(fermionErrors, "AccountHasNoRole") + .withArgs(newCustodianId, nonCustodianWallet.address, EntityRole.Custodian, AccountRole.Assistant); + }); + + it("Cannot request update too soon after previous request", async function () { + await custodyFacet + .connect(newCustodian) + .requestCustodianUpdate(offerId, newCustodianId, newCustodianFee, newCustodianVaultParameters); + + await expect( + custodyFacet + .connect(newCustodian) + .requestCustodianUpdate(offerId, newCustodianId, newCustodianFee, newCustodianVaultParameters), + ) + .to.be.revertedWithCustomError(fermionErrors, "UpdateRequestTooRecent") + .withArgs(offerId, 24 * 60 * 60); // 1 day + }); + }); + }); + + context("acceptCustodianUpdate", function () { + beforeEach(async function () { + await custodyFacet + .connect(newCustodian) + .requestCustodianUpdate(offerId, newCustodianId, newCustodianFee, newCustodianVaultParameters); + }); + + it("Token owner can accept update", async function () { + const tx = await custodyFacet.connect(buyer).acceptCustodianUpdate(offerId); + + await expect(tx) + .to.emit(custodyFacet, "CustodianUpdateAccepted") + .withArgs( + offerId, + custodianId, + newCustodianId, + Object.values(newCustodianFee), + Object.values(newCustodianVaultParameters), + ); + }); + + context("Revert reasons", function () { + it("Custody region is paused", async function () { + await pauseFacet.pause([PausableRegion.Custody]); + + await expect(custodyFacet.connect(buyer).acceptCustodianUpdate(offerId)) + .to.be.revertedWithCustomError(fermionErrors, "RegionPaused") + .withArgs(PausableRegion.Custody); + }); + + it("Caller is not the token owner of all in-custody tokens", async function () { + const randomWallet = wallets[8]; + await expect(custodyFacet.connect(randomWallet).acceptCustodianUpdate(offerId)) + .to.be.revertedWithCustomError(fermionErrors, "NotTokenBuyer") + .withArgs(offerId, buyer.address, randomWallet.address); + }); + + it("Update request has expired", async function () { + const blockNumber = await ethers.provider.getBlockNumber(); + const block = await ethers.provider.getBlock(blockNumber); + const newTime = Number(BigInt(block.timestamp) + BigInt(25 * 60 * 60)); // 25 hours + await setNextBlockTimestamp(newTime); + + await expect(custodyFacet.connect(buyer).acceptCustodianUpdate(offerId)) + .to.be.revertedWithCustomError(fermionErrors, "UpdateRequestExpired") + .withArgs(offerId); + }); + + it("Reverts when token owner is different from offer owner", async function () { + // Transfer the NFT to a different address + const differentOwner = wallets[9]; + await wrapper.connect(buyer).safeTransferFrom(buyer.address, differentOwner.address, exchange.tokenId); + + // Try to accept the update with the original buyer (who is no longer the token owner) + await expect(custodyFacet.connect(buyer).acceptCustodianUpdate(offerId)) + .to.be.revertedWithCustomError(fermionErrors, "NotTokenBuyer") + .withArgs(offerId, differentOwner.address, buyer.address); + }); + }); + }); + + context("emergencyCustodianUpdate", function () { + let currentVaultParameters: any; + + beforeEach(async function () { + currentVaultParameters = { + partialAuctionThreshold: parseEther("0.6"), // 600000000000000000 + partialAuctionDuration: custodianFee.period / PARTIAL_AUCTION_DURATION_DIVISOR, + liquidationThreshold: custodianFee.amount * LIQUIDATION_THRESHOLD_MULTIPLIER, + newFractionsPerAuction: + (custodianFee.amount * PARTIAL_THRESHOLD_MULTIPLIER * DEFAULT_FRACTION_AMOUNT) / exchange.price, + }; + + await custodyFacet + .connect(custodian) + .requestCustodianUpdate(offerId, custodianId, custodianFee, currentVaultParameters); + await custodyFacet.connect(buyer).acceptCustodianUpdate(offerId); + }); + + it("Current custodian can execute emergency update", async function () { + const tx = await custodyFacet.connect(custodian).emergencyCustodianUpdate(offerId, newCustodianId, true); + + await expect(tx) + .to.emit(custodyFacet, "CustodianUpdateRequested") + .withArgs( + offerId, + custodianId, + newCustodianId, + Object.values(custodianFee), + Object.values(currentVaultParameters), + ); + + await expect(tx) + .to.emit(custodyFacet, "CustodianUpdateAccepted") + .withArgs( + offerId, + custodianId, + newCustodianId, + Object.values(custodianFee), + Object.values(currentVaultParameters), + ); + }); + + it("Seller can execute emergency update", async function () { + const tx = await custodyFacet.connect(defaultSigner).emergencyCustodianUpdate(offerId, newCustodianId, false); + + await expect(tx) + .to.emit(custodyFacet, "CustodianUpdateRequested") + .withArgs( + offerId, + custodianId, + newCustodianId, + Object.values(custodianFee), + Object.values(currentVaultParameters), + ); + + await expect(tx) + .to.emit(custodyFacet, "CustodianUpdateAccepted") + .withArgs( + offerId, + custodianId, + newCustodianId, + Object.values(custodianFee), + Object.values(currentVaultParameters), + ); + }); + + context("Revert reasons", function () { + it("Custody region is paused", async function () { + await pauseFacet.pause([PausableRegion.Custody]); + + await expect(custodyFacet.connect(custodian).emergencyCustodianUpdate(offerId, newCustodianId, true)) + .to.be.revertedWithCustomError(fermionErrors, "RegionPaused") + .withArgs(PausableRegion.Custody); + }); + + it("Caller is not custodian or seller assistant", async function () { + const randomWallet = wallets[8]; + await expect(custodyFacet.connect(randomWallet).emergencyCustodianUpdate(offerId, newCustodianId, true)) + .to.be.revertedWithCustomError(fermionErrors, "AccountHasNoRole") + .withArgs(custodianId, randomWallet.address, EntityRole.Custodian, AccountRole.Assistant); + }); + + it("New custodian ID is invalid", async function () { + await expect(custodyFacet.connect(custodian).emergencyCustodianUpdate(offerId, "999", true)) + .to.be.revertedWithCustomError(fermionErrors, "EntityHasNoRole") + .withArgs("999", EntityRole.Custodian); + }); + + it("Reverts when vault balance is insufficient to pay custodian", async function () { + const threeYears = 1095n * 24n * 60n * 60n; + const blockNumber = await ethers.provider.getBlockNumber(); + const block = await ethers.provider.getBlock(blockNumber); + const newTime = Number(BigInt(block.timestamp) + threeYears); + await setNextBlockTimestamp(newTime); + + await expect( + custodyFacet.connect(custodian).emergencyCustodianUpdate(offerId, newCustodianId, true), + ).to.be.revertedWithCustomError(fermionErrors, "InsufficientVaultBalance"); + }); + }); + }); + }); }); From 0532c7324e04f3d7a361260cc0887d08b9bd78e3 Mon Sep 17 00:00:00 2001 From: 0xlucian <0xluciandev@gmail.com> Date: Tue, 14 Jan 2025 14:30:54 +0200 Subject: [PATCH 07/12] fix: lint --- test/protocol/custodyFacet.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/test/protocol/custodyFacet.ts b/test/protocol/custodyFacet.ts index ac91d766..7d453b87 100644 --- a/test/protocol/custodyFacet.ts +++ b/test/protocol/custodyFacet.ts @@ -125,17 +125,8 @@ describe("Custody", function () { await mockToken.approve(fermionProtocolAddress, 2n * sellerDeposit); const createBuyerAdvancedOrder = createBuyerAdvancedOrderClosure(wallets, seaportAddress, mockToken, offerFacet); -<<<<<<< HEAD - const { buyerAdvancedOrder, tokenId, encumberedAmount } = await createBuyerAdvancedOrder( - buyer, - offerId, - exchangeId, - ); - await offerFacet.unwrapNFT(tokenId, buyerAdvancedOrder); -======= const { buyerAdvancedOrder, tokenId } = await createBuyerAdvancedOrder(buyer, offerId, exchangeId); await offerFacet.unwrapNFT(tokenId, WrapType.OS_AUCTION, buyerAdvancedOrder); ->>>>>>> develop-1.1.0 const { buyerAdvancedOrder: buyerAdvancedOrderSelfCustody, tokenId: tokenIdSelfCustody } = await createBuyerAdvancedOrder(buyer, offerIdSelfCustody, exchangeIdSelfCustody); From 4219b7812f0e60b4fdc5ac990a0d6dac648001f6 Mon Sep 17 00:00:00 2001 From: 0xlucian <0xluciandev@gmail.com> Date: Tue, 14 Jan 2025 14:35:02 +0200 Subject: [PATCH 08/12] fix: tests --- test/protocol/custodyFacet.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/protocol/custodyFacet.ts b/test/protocol/custodyFacet.ts index 7d453b87..62c924f2 100644 --- a/test/protocol/custodyFacet.ts +++ b/test/protocol/custodyFacet.ts @@ -148,7 +148,7 @@ describe("Custody", function () { exchange.tokenId = tokenId; exchange.custodianId = custodianId; - exchange.price = encumberedAmount; + exchange.price = minimalPriceSelfSale; // Self sale exchangeSelfSale.tokenId = tokenIdSelf; From d0544162aaa2ad41f64fd7bd9b80bb022112f31f Mon Sep 17 00:00:00 2001 From: 0xlucian <0xluciandev@gmail.com> Date: Mon, 20 Jan 2025 15:30:36 +0200 Subject: [PATCH 09/12] refactor: address most of the PR comments --- contracts/protocol/domain/Errors.sol | 2 +- contracts/protocol/facets/Custody.sol | 79 +++++++++---------- .../interfaces/events/ICustodyEvents.sol | 1 - 3 files changed, 37 insertions(+), 45 deletions(-) diff --git a/contracts/protocol/domain/Errors.sol b/contracts/protocol/domain/Errors.sol index 42e399b3..ebb6da0d 100644 --- a/contracts/protocol/domain/Errors.sol +++ b/contracts/protocol/domain/Errors.sol @@ -75,10 +75,10 @@ interface CustodyErrors { FermionTypes.CheckoutRequestStatus expectedStatus, FermionTypes.CheckoutRequestStatus actualStatus ); - error TokenCheckedOut(uint256 tokenId); error InsufficientVaultBalance(uint256 tokenId, uint256 required, uint256 available); error UpdateRequestExpired(uint256 tokenId); error UpdateRequestTooRecent(uint256 tokenId, uint256 waitTime); + error NoTokensInCustody(uint256 offerId); } interface AuctionErrors { diff --git a/contracts/protocol/facets/Custody.sol b/contracts/protocol/facets/Custody.sol index dbd2e350..e99181ce 100644 --- a/contracts/protocol/facets/Custody.sol +++ b/contracts/protocol/facets/Custody.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.24; -import { CustodyErrors, FermionGeneralErrors } from "../domain/Errors.sol"; +import { CustodyErrors } from "../domain/Errors.sol"; import { FermionTypes } from "../domain/Types.sol"; import { Access } from "../libs/Access.sol"; import { FermionStorage } from "../libs/Storage.sol"; @@ -293,8 +293,15 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve revert UpdateRequestTooRecent(_offerId, 1 days); } - FermionTypes.Offer storage offer = FermionStorage.protocolEntities().offer[_offerId]; - uint256 currentCustodianId = offer.custodianId; + uint256 currentCustodianId = FermionStorage.protocolEntities().offer[_offerId].custodianId; + + // Validate caller is the new custodian's assistant + EntityLib.validateAccountRole( + _newCustodianId, + _msgSender(), + FermionTypes.EntityRole.Custodian, + FermionTypes.AccountRole.Assistant + ); _createCustodianUpdateRequest( _offerId, @@ -302,9 +309,7 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve _newCustodianVaultParameters, currentCustodianId, _newCustodianId, - offer, - offerLookups, - FermionStorage.protocolLookups() + offerLookups ); } @@ -315,6 +320,7 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve * This is an emergency function that bypasses: * - The time restriction between updates * - The owner acceptance requirement + * - The token status check (all checked-in tokens should be owned by the same owner) * * The current custodian is paid for the used period. * The vault parameters remain unchanged in emergency updates. @@ -326,7 +332,6 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve * - Caller is not the current custodian's assistant (if _isCustodianAssistant is true) * - Caller is not the seller's assistant (if _isCustodianAssistant is false) * - New custodian ID is invalid - * - Any token in the offer is checked out * - There are not enough funds in any vault to pay the current custodian * * @param _offerId The offer ID for which to update the custodian @@ -338,17 +343,17 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve uint256 _newCustodianId, bool _isCustodianAssistant ) external notPaused(FermionTypes.PausableRegion.Custody) nonReentrant { - FermionTypes.Offer storage offer = FermionStorage.protocolEntities().offer[_offerId]; - FermionStorage.OfferLookups storage offerLookups = FermionStorage.protocolLookups().offerLookups[_offerId]; + FermionStorage.ProtocolEntities storage pe = FermionStorage.protocolEntities(); + FermionTypes.Offer storage offer = pe.offer[_offerId]; + FermionStorage.ProtocolLookups storage pl = FermionStorage.protocolLookups(); + FermionStorage.OfferLookups storage offerLookups = pl.offerLookups[_offerId]; uint256 currentCustodianId = offer.custodianId; - address msgSender = _msgSender(); - if (_isCustodianAssistant) { EntityLib.validateAccountRole( currentCustodianId, - msgSender, + _msgSender(), FermionTypes.EntityRole.Custodian, FermionTypes.AccountRole.Assistant ); @@ -358,25 +363,19 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve EntityLib.validateEntityRole( _newCustodianId, - FermionStorage.protocolEntities().entityData[_newCustodianId].roles, + pe.entityData[_newCustodianId].roles, FermionTypes.EntityRole.Custodian ); - // Store the update request - keep existing parameters in emergency update - offerLookups.custodianUpdateRequest = FermionTypes.CustodianUpdateRequest({ - newCustodianId: _newCustodianId, - custodianFee: offer.custodianFee, - custodianVaultParameters: offerLookups.custodianVaultParameters, - requestTimestamp: block.timestamp - }); - - emit CustodianUpdateRequested( + _createCustodianUpdateRequest( _offerId, + offer.custodianFee, + offerLookups.custodianVaultParameters, currentCustodianId, _newCustodianId, - offer.custodianFee, - offerLookups.custodianVaultParameters + offerLookups ); + _processCustodianUpdate(_offerId, offer, offerLookups.custodianUpdateRequest, offerLookups); } @@ -416,18 +415,20 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve address msgSender = _msgSender(); uint256 itemCount = offerLookups.itemQuantity; uint256 firstTokenId = offerLookups.firstTokenId; - + bool hasInCustodyToken; for (uint256 i; i < itemCount; ++i) { uint256 tokenId = firstTokenId + i; - FermionStorage.TokenLookups storage tokenLookups = pl.tokenLookups[tokenId]; - - if (tokenLookups.checkoutRequest.status != FermionTypes.CheckoutRequestStatus.CheckedOut) { + if (pl.tokenLookups[tokenId].checkoutRequest.status != FermionTypes.CheckoutRequestStatus.CheckedOut) { address tokenOwner = IERC721(fermionFNFTAddress).ownerOf(tokenId); if (tokenOwner != msgSender) { revert NotTokenBuyer(_offerId, tokenOwner, msgSender); } + hasInCustodyToken = true; } } + if (!hasInCustodyToken) { + revert NoTokensInCustody(_offerId); + } _processCustodianUpdate( _offerId, FermionStorage.protocolEntities().offer[_offerId], @@ -444,27 +445,17 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve * @param _custodianFee The new custodian fee parameters * @param _custodianVaultParameters The new custodian vault parameters * @param _currentCustodianId The ID of the current custodian - * @param _offer The offer storage pointer + * @param _newCustodianId The ID of the new custodian * @param _offerLookups The offer lookups storage pointer - * @param _pl The protocol lookups storage pointer */ function _createCustodianUpdateRequest( uint256 _offerId, - FermionTypes.CustodianFee calldata _custodianFee, - FermionTypes.CustodianVaultParameters calldata _custodianVaultParameters, + FermionTypes.CustodianFee memory _custodianFee, + FermionTypes.CustodianVaultParameters memory _custodianVaultParameters, uint256 _currentCustodianId, uint256 _newCustodianId, - FermionTypes.Offer storage _offer, - FermionStorage.OfferLookups storage _offerLookups, - FermionStorage.ProtocolLookups storage _pl + FermionStorage.OfferLookups storage _offerLookups ) internal { - EntityLib.validateAccountRole( - _newCustodianId, - _msgSender(), - FermionTypes.EntityRole.Custodian, - FermionTypes.AccountRole.Assistant - ); - _offerLookups.custodianUpdateRequest = FermionTypes.CustodianUpdateRequest({ newCustodianId: _newCustodianId, custodianFee: _custodianFee, @@ -515,7 +506,9 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve for (uint256 i; i < itemCount; ++i) { uint256 tokenId = firstTokenId + i; FermionStorage.TokenLookups storage tokenLookups = pl.tokenLookups[tokenId]; - + if (tokenLookups.checkoutRequest.status == FermionTypes.CheckoutRequestStatus.CheckedOut) { + continue; + } // Calculate and pay the current custodian FermionTypes.CustodianFee storage vault = tokenLookups.vault; uint256 custodianPayoff = ((block.timestamp - vault.period) * currentCustodianFee) / currentCustodianPeriod; diff --git a/contracts/protocol/interfaces/events/ICustodyEvents.sol b/contracts/protocol/interfaces/events/ICustodyEvents.sol index 56a6d37e..b9b51f0f 100644 --- a/contracts/protocol/interfaces/events/ICustodyEvents.sol +++ b/contracts/protocol/interfaces/events/ICustodyEvents.sol @@ -38,5 +38,4 @@ interface ICustodyEvents { FermionTypes.CustodianFee newCustodianFee, FermionTypes.CustodianVaultParameters newCustodianVaultParameters ); - event CustodianUpdateRejected(uint256 indexed offerId, uint256 indexed newCustodianId); } From eb73825c7c4a28144e3ac87a4a75407556de9713 Mon Sep 17 00:00:00 2001 From: 0xlucian <0xluciandev@gmail.com> Date: Wed, 24 Jan 2024 17:23:41 +0200 Subject: [PATCH 10/12] refactor: incraese test coverage and refactor test setup --- test/protocol/custodyFacet.ts | 164 ++++++++++++++++++++++++++-------- 1 file changed, 126 insertions(+), 38 deletions(-) diff --git a/test/protocol/custodyFacet.ts b/test/protocol/custodyFacet.ts index 62c924f2..5eaf5045 100644 --- a/test/protocol/custodyFacet.ts +++ b/test/protocol/custodyFacet.ts @@ -47,13 +47,14 @@ describe("Custody", function () { let facilitator: HardhatEthersSigner, facilitator2: HardhatEthersSigner; let buyer: HardhatEthersSigner; let seaportAddress: string; - let wrapper: Contract, wrapperSelfSale: Contract, wrapperSelfCustody: Contract; + let wrapper: Contract, wrapperSelfSale: Contract, wrapperSelfCustody: Contract, wrapperCustodianSwitch: Contract; const sellerId = "1"; const verifierId = "2"; const custodianId = "3"; const facilitatorId = "4"; const facilitator2Id = "5"; const offerId = "1"; + const offerIdCustodianSwitch = "4"; // buyer != seller, custodian != seller const verifierFee = parseEther("0.1"); const sellerDeposit = parseEther("0.05"); const custodianFee = { @@ -63,6 +64,7 @@ describe("Custody", function () { const exchange = { tokenId: "", custodianId: "", price: parseEther("1.0") }; const exchangeSelfSale = { tokenId: "", custodianId: "" }; const exchangeSelfCustody = { tokenId: "", custodianId: "" }; + const exchangeCustodianSwitch = { tokenId1: "", tokenId2: "", tokenId3: "", custodianId: "" }; let verifySellerAssistantRole: ReturnType; let minimalPriceSelfSale: bigint; async function setupCustodyTest() { @@ -104,26 +106,30 @@ describe("Custody", function () { metadataHash: ZeroHash, }; - // Make three offers one for normal sale, one of self sale and one for self custody + // Make four offers one for normal sale, one of self sale, one for self custody and one for custodian switch const offerIdSelfSale = "2"; // buyer = seller, custodian != seller const offerIdSelfCustody = "3"; // buyer != seller, custodian = seller await offerFacet.connect(facilitator).createOffer({ ...fermionOffer, facilitatorId }); await offerFacet.createOffer({ ...fermionOffer, sellerDeposit: "0" }); await offerFacet.createOffer({ ...fermionOffer, verifierId: "1", custodianId: "1", verifierFee: "0" }); + await offerFacet.connect(facilitator).createOffer({ ...fermionOffer, facilitatorId }); // Mint and wrap some NFTs const quantity = "1"; await offerFacet.mintAndWrapNFTs(offerIdSelfSale, quantity); // offerId = 2; exchangeId = 1 await offerFacet.mintAndWrapNFTs(offerId, quantity); // offerId = 1; exchangeId = 2 await offerFacet.mintAndWrapNFTs(offerIdSelfCustody, "2"); // offerId = 3; exchangeId = 3 + await offerFacet.mintAndWrapNFTs(offerIdCustodianSwitch, "3"); // offerId = 4; exchangeId = 5 + const exchangeIdSelf = "1"; const exchangeId = "2"; const exchangeIdSelfCustody = "3"; + const exchangeIdCustodianSwitch = "5"; // Unwrap some NFTs - normal sale and sale with self-custody buyer = wallets[6]; - await mockToken.approve(fermionProtocolAddress, 2n * sellerDeposit); + await mockToken.approve(fermionProtocolAddress, 5n * sellerDeposit); const createBuyerAdvancedOrder = createBuyerAdvancedOrderClosure(wallets, seaportAddress, mockToken, offerFacet); const { buyerAdvancedOrder, tokenId } = await createBuyerAdvancedOrder(buyer, offerId, exchangeId); await offerFacet.unwrapNFT(tokenId, WrapType.OS_AUCTION, buyerAdvancedOrder); @@ -132,6 +138,20 @@ describe("Custody", function () { await createBuyerAdvancedOrder(buyer, offerIdSelfCustody, exchangeIdSelfCustody); await offerFacet.unwrapNFT(tokenIdSelfCustody, WrapType.OS_AUCTION, buyerAdvancedOrderSelfCustody); + const { buyerAdvancedOrder: buyerAdvancedOrderCustodianSwitch, tokenId: tokenIdCustodianSwitch } = + await createBuyerAdvancedOrder(buyer, offerIdCustodianSwitch, exchangeIdCustodianSwitch); + await offerFacet.unwrapNFT(tokenIdCustodianSwitch, WrapType.OS_AUCTION, buyerAdvancedOrderCustodianSwitch); + + const exchangeIdCustodianSwitch2 = "6"; // token 2 + const { buyerAdvancedOrder: buyerAdvancedOrderCustodianSwitch2, tokenId: tokenIdCustodianSwitch2 } = + await createBuyerAdvancedOrder(buyer, offerIdCustodianSwitch, exchangeIdCustodianSwitch2); + await offerFacet.unwrapNFT(tokenIdCustodianSwitch2, WrapType.OS_AUCTION, buyerAdvancedOrderCustodianSwitch2); + + const exchangeIdCustodianSwitch3 = "7"; // token 3 + const { buyerAdvancedOrder: buyerAdvancedOrderCustodianSwitch3, tokenId: tokenIdCustodianSwitch3 } = + await createBuyerAdvancedOrder(buyer, offerIdCustodianSwitch, exchangeIdCustodianSwitch3); + await offerFacet.unwrapNFT(tokenIdCustodianSwitch3, WrapType.OS_AUCTION, buyerAdvancedOrderCustodianSwitch3); + // unwrap to self const tokenIdSelf = deriveTokenId(offerIdSelfSale, exchangeIdSelf).toString(); const { protocolFeePercentage: bosonProtocolFeePercentage } = getBosonProtocolFees(); @@ -158,10 +178,20 @@ describe("Custody", function () { exchangeSelfCustody.tokenId = tokenIdSelfCustody; exchangeSelfCustody.custodianId = sellerId; + // Custodian switch + exchangeCustodianSwitch.tokenId1 = tokenIdCustodianSwitch; + exchangeCustodianSwitch.tokenId2 = tokenIdCustodianSwitch2; + exchangeCustodianSwitch.tokenId3 = tokenIdCustodianSwitch3; + exchangeCustodianSwitch.custodianId = custodianId; + // Submit verdicts await verificationFacet.connect(verifier).submitVerdict(tokenId, VerificationStatus.Verified); await verificationFacet.connect(verifier).submitVerdict(tokenIdSelf, VerificationStatus.Verified); await verificationFacet.submitVerdict(tokenIdSelfCustody, VerificationStatus.Verified); + await verificationFacet.connect(verifier).submitVerdict(tokenIdCustodianSwitch, VerificationStatus.Verified); + await verificationFacet.connect(verifier).submitVerdict(tokenIdCustodianSwitch2, VerificationStatus.Verified); + await verificationFacet.connect(verifier).submitVerdict(tokenIdCustodianSwitch3, VerificationStatus.Verified); + const wrapperAddress = await offerFacet.predictFermionFNFTAddress(offerId); wrapper = await ethers.getContractAt("FermionFNFT", wrapperAddress); @@ -170,6 +200,9 @@ describe("Custody", function () { const wrapperAddressSelfCustody = await offerFacet.predictFermionFNFTAddress(offerIdSelfCustody); wrapperSelfCustody = await ethers.getContractAt("FermionFNFT", wrapperAddressSelfCustody); + + const wrapperAddressCustodianSwitch = await offerFacet.predictFermionFNFTAddress(offerIdCustodianSwitch); + wrapperCustodianSwitch = await ethers.getContractAt("FermionFNFT", wrapperAddressCustodianSwitch); } before(async function () { @@ -1329,24 +1362,27 @@ describe("Custody", function () { newCustodianId = "6"; // Since we already have 5 entities // Setup a token with current custodian - await custodyFacet.connect(custodian).checkIn(exchange.tokenId); - + await custodyFacet.connect(custodian).checkIn(exchangeCustodianSwitch.tokenId1); + await custodyFacet.connect(custodian).checkIn(exchangeCustodianSwitch.tokenId2); + await custodyFacet.connect(custodian).checkIn(exchangeCustodianSwitch.tokenId3); // Fund the vault with sufficient balance const vaultAmount = parseEther("1.0"); // Large enough to cover fees - await mockToken.approve(fermionProtocolAddress, vaultAmount); - await custodyVaultFacet.topUpCustodianVault(exchange.tokenId, vaultAmount); + await mockToken.approve(fermionProtocolAddress, vaultAmount * 3n); + await custodyVaultFacet.topUpCustodianVault(exchangeCustodianSwitch.tokenId1, vaultAmount); + await custodyVaultFacet.topUpCustodianVault(exchangeCustodianSwitch.tokenId2, vaultAmount); + await custodyVaultFacet.topUpCustodianVault(exchangeCustodianSwitch.tokenId3, vaultAmount); }); context("requestCustodianUpdate", function () { it("New custodian can request update", async function () { const tx = await custodyFacet .connect(newCustodian) - .requestCustodianUpdate(offerId, newCustodianId, newCustodianFee, newCustodianVaultParameters); + .requestCustodianUpdate(offerIdCustodianSwitch, newCustodianId, newCustodianFee, newCustodianVaultParameters); await expect(tx) .to.emit(custodyFacet, "CustodianUpdateRequested") .withArgs( - offerId, + offerIdCustodianSwitch, custodianId, newCustodianId, Object.values(newCustodianFee), @@ -1361,7 +1397,12 @@ describe("Custody", function () { await expect( custodyFacet .connect(newCustodian) - .requestCustodianUpdate(offerId, newCustodianId, newCustodianFee, newCustodianVaultParameters), + .requestCustodianUpdate( + offerIdCustodianSwitch, + newCustodianId, + newCustodianFee, + newCustodianVaultParameters, + ), ) .to.be.revertedWithCustomError(fermionErrors, "RegionPaused") .withArgs(PausableRegion.Custody); @@ -1374,7 +1415,12 @@ describe("Custody", function () { await expect( custodyFacet .connect(nonCustodianWallet) - .requestCustodianUpdate(offerId, newCustodianId, newCustodianFee, newCustodianVaultParameters), + .requestCustodianUpdate( + offerIdCustodianSwitch, + newCustodianId, + newCustodianFee, + newCustodianVaultParameters, + ), ) .to.be.revertedWithCustomError(fermionErrors, "AccountHasNoRole") .withArgs(newCustodianId, nonCustodianWallet.address, EntityRole.Custodian, AccountRole.Assistant); @@ -1383,15 +1429,25 @@ describe("Custody", function () { it("Cannot request update too soon after previous request", async function () { await custodyFacet .connect(newCustodian) - .requestCustodianUpdate(offerId, newCustodianId, newCustodianFee, newCustodianVaultParameters); + .requestCustodianUpdate( + offerIdCustodianSwitch, + newCustodianId, + newCustodianFee, + newCustodianVaultParameters, + ); await expect( custodyFacet .connect(newCustodian) - .requestCustodianUpdate(offerId, newCustodianId, newCustodianFee, newCustodianVaultParameters), + .requestCustodianUpdate( + offerIdCustodianSwitch, + newCustodianId, + newCustodianFee, + newCustodianVaultParameters, + ), ) .to.be.revertedWithCustomError(fermionErrors, "UpdateRequestTooRecent") - .withArgs(offerId, 24 * 60 * 60); // 1 day + .withArgs(offerIdCustodianSwitch, 24 * 60 * 60); // 1 day }); }); }); @@ -1400,16 +1456,21 @@ describe("Custody", function () { beforeEach(async function () { await custodyFacet .connect(newCustodian) - .requestCustodianUpdate(offerId, newCustodianId, newCustodianFee, newCustodianVaultParameters); + .requestCustodianUpdate(offerIdCustodianSwitch, newCustodianId, newCustodianFee, newCustodianVaultParameters); }); it("Token owner can accept update", async function () { - const tx = await custodyFacet.connect(buyer).acceptCustodianUpdate(offerId); + //chekout token1 from exchangeCustodianSwitch offer + await wrapperCustodianSwitch.connect(buyer).approve(fermionProtocolAddress, exchangeCustodianSwitch.tokenId1); + await custodyFacet.connect(buyer).requestCheckOut(exchangeCustodianSwitch.tokenId1); + await custodyFacet.clearCheckoutRequest(exchangeCustodianSwitch.tokenId1); + await custodyFacet.connect(custodian).checkOut(exchangeCustodianSwitch.tokenId1); + const tx = await custodyFacet.connect(buyer).acceptCustodianUpdate(offerIdCustodianSwitch); await expect(tx) .to.emit(custodyFacet, "CustodianUpdateAccepted") .withArgs( - offerId, + offerIdCustodianSwitch, custodianId, newCustodianId, Object.values(newCustodianFee), @@ -1421,16 +1482,34 @@ describe("Custody", function () { it("Custody region is paused", async function () { await pauseFacet.pause([PausableRegion.Custody]); - await expect(custodyFacet.connect(buyer).acceptCustodianUpdate(offerId)) + await expect(custodyFacet.connect(buyer).acceptCustodianUpdate(offerIdCustodianSwitch)) .to.be.revertedWithCustomError(fermionErrors, "RegionPaused") .withArgs(PausableRegion.Custody); }); it("Caller is not the token owner of all in-custody tokens", async function () { const randomWallet = wallets[8]; - await expect(custodyFacet.connect(randomWallet).acceptCustodianUpdate(offerId)) + await expect(custodyFacet.connect(randomWallet).acceptCustodianUpdate(offerIdCustodianSwitch)) .to.be.revertedWithCustomError(fermionErrors, "NotTokenBuyer") - .withArgs(offerId, buyer.address, randomWallet.address); + .withArgs(offerIdCustodianSwitch, buyer.address, randomWallet.address); + }); + + it("No tokens in custody", async function () { + // Check out all tokens successfully + for (const token of [ + exchangeCustodianSwitch.tokenId1, + exchangeCustodianSwitch.tokenId2, + exchangeCustodianSwitch.tokenId3, + ]) { + await wrapperCustodianSwitch.connect(buyer).approve(fermionProtocolAddress, token); + await custodyFacet.connect(buyer).requestCheckOut(token); + await custodyFacet.clearCheckoutRequest(token); + await custodyFacet.connect(custodian).checkOut(token); + } + + await expect(custodyFacet.acceptCustodianUpdate(offerIdCustodianSwitch)) + .to.be.revertedWithCustomError(custodyFacet, "NoTokensInCustody") + .withArgs(offerIdCustodianSwitch); }); it("Update request has expired", async function () { @@ -1439,20 +1518,21 @@ describe("Custody", function () { const newTime = Number(BigInt(block.timestamp) + BigInt(25 * 60 * 60)); // 25 hours await setNextBlockTimestamp(newTime); - await expect(custodyFacet.connect(buyer).acceptCustodianUpdate(offerId)) + await expect(custodyFacet.connect(buyer).acceptCustodianUpdate(offerIdCustodianSwitch)) .to.be.revertedWithCustomError(fermionErrors, "UpdateRequestExpired") - .withArgs(offerId); + .withArgs(offerIdCustodianSwitch); }); it("Reverts when token owner is different from offer owner", async function () { // Transfer the NFT to a different address const differentOwner = wallets[9]; - await wrapper.connect(buyer).safeTransferFrom(buyer.address, differentOwner.address, exchange.tokenId); - + await wrapperCustodianSwitch + .connect(buyer) + .safeTransferFrom(buyer.address, differentOwner.address, exchangeCustodianSwitch.tokenId1); // Try to accept the update with the original buyer (who is no longer the token owner) - await expect(custodyFacet.connect(buyer).acceptCustodianUpdate(offerId)) + await expect(custodyFacet.connect(buyer).acceptCustodianUpdate(offerIdCustodianSwitch)) .to.be.revertedWithCustomError(fermionErrors, "NotTokenBuyer") - .withArgs(offerId, differentOwner.address, buyer.address); + .withArgs(offerIdCustodianSwitch, differentOwner.address, buyer.address); }); }); }); @@ -1471,17 +1551,19 @@ describe("Custody", function () { await custodyFacet .connect(custodian) - .requestCustodianUpdate(offerId, custodianId, custodianFee, currentVaultParameters); - await custodyFacet.connect(buyer).acceptCustodianUpdate(offerId); + .requestCustodianUpdate(offerIdCustodianSwitch, custodianId, custodianFee, currentVaultParameters); + await custodyFacet.connect(buyer).acceptCustodianUpdate(offerIdCustodianSwitch); }); it("Current custodian can execute emergency update", async function () { - const tx = await custodyFacet.connect(custodian).emergencyCustodianUpdate(offerId, newCustodianId, true); + const tx = await custodyFacet + .connect(custodian) + .emergencyCustodianUpdate(offerIdCustodianSwitch, newCustodianId, true); await expect(tx) .to.emit(custodyFacet, "CustodianUpdateRequested") .withArgs( - offerId, + offerIdCustodianSwitch, custodianId, newCustodianId, Object.values(custodianFee), @@ -1491,7 +1573,7 @@ describe("Custody", function () { await expect(tx) .to.emit(custodyFacet, "CustodianUpdateAccepted") .withArgs( - offerId, + offerIdCustodianSwitch, custodianId, newCustodianId, Object.values(custodianFee), @@ -1500,12 +1582,14 @@ describe("Custody", function () { }); it("Seller can execute emergency update", async function () { - const tx = await custodyFacet.connect(defaultSigner).emergencyCustodianUpdate(offerId, newCustodianId, false); + const tx = await custodyFacet + .connect(defaultSigner) + .emergencyCustodianUpdate(offerIdCustodianSwitch, newCustodianId, false); await expect(tx) .to.emit(custodyFacet, "CustodianUpdateRequested") .withArgs( - offerId, + offerIdCustodianSwitch, custodianId, newCustodianId, Object.values(custodianFee), @@ -1515,7 +1599,7 @@ describe("Custody", function () { await expect(tx) .to.emit(custodyFacet, "CustodianUpdateAccepted") .withArgs( - offerId, + offerIdCustodianSwitch, custodianId, newCustodianId, Object.values(custodianFee), @@ -1527,20 +1611,24 @@ describe("Custody", function () { it("Custody region is paused", async function () { await pauseFacet.pause([PausableRegion.Custody]); - await expect(custodyFacet.connect(custodian).emergencyCustodianUpdate(offerId, newCustodianId, true)) + await expect( + custodyFacet.connect(custodian).emergencyCustodianUpdate(offerIdCustodianSwitch, newCustodianId, true), + ) .to.be.revertedWithCustomError(fermionErrors, "RegionPaused") .withArgs(PausableRegion.Custody); }); it("Caller is not custodian or seller assistant", async function () { const randomWallet = wallets[8]; - await expect(custodyFacet.connect(randomWallet).emergencyCustodianUpdate(offerId, newCustodianId, true)) + await expect( + custodyFacet.connect(randomWallet).emergencyCustodianUpdate(offerIdCustodianSwitch, newCustodianId, true), + ) .to.be.revertedWithCustomError(fermionErrors, "AccountHasNoRole") .withArgs(custodianId, randomWallet.address, EntityRole.Custodian, AccountRole.Assistant); }); it("New custodian ID is invalid", async function () { - await expect(custodyFacet.connect(custodian).emergencyCustodianUpdate(offerId, "999", true)) + await expect(custodyFacet.connect(custodian).emergencyCustodianUpdate(offerIdCustodianSwitch, "999", true)) .to.be.revertedWithCustomError(fermionErrors, "EntityHasNoRole") .withArgs("999", EntityRole.Custodian); }); @@ -1553,7 +1641,7 @@ describe("Custody", function () { await setNextBlockTimestamp(newTime); await expect( - custodyFacet.connect(custodian).emergencyCustodianUpdate(offerId, newCustodianId, true), + custodyFacet.connect(custodian).emergencyCustodianUpdate(offerIdCustodianSwitch, newCustodianId, true), ).to.be.revertedWithCustomError(fermionErrors, "InsufficientVaultBalance"); }); }); From ea48011f9a814aa13abd27b7c49ba92c93201339 Mon Sep 17 00:00:00 2001 From: 0xlucian <0xluciandev@gmail.com> Date: Wed, 5 Mar 2025 10:25:10 +0200 Subject: [PATCH 11/12] feat: add CustodianFee period validation in requestCustodianUpdate --- contracts/protocol/domain/Errors.sol | 1 + contracts/protocol/facets/Custody.sol | 5 +++++ test/protocol/custodyFacet.ts | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/contracts/protocol/domain/Errors.sol b/contracts/protocol/domain/Errors.sol index ebb6da0d..620ff581 100644 --- a/contracts/protocol/domain/Errors.sol +++ b/contracts/protocol/domain/Errors.sol @@ -79,6 +79,7 @@ interface CustodyErrors { error UpdateRequestExpired(uint256 tokenId); error UpdateRequestTooRecent(uint256 tokenId, uint256 waitTime); error NoTokensInCustody(uint256 offerId); + error InvalidCustodianFeePeriod(); } interface AuctionErrors { diff --git a/contracts/protocol/facets/Custody.sol b/contracts/protocol/facets/Custody.sol index e99181ce..0f2595c9 100644 --- a/contracts/protocol/facets/Custody.sol +++ b/contracts/protocol/facets/Custody.sol @@ -274,6 +274,7 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve * - Caller is not the new custodian's assistant * - Any token in the corresponding offer is checked out * - The previous request is too recent + * - The custodian fee period is 0 * * @param _offerId The offer ID for which to request the custodian update * @param _newCustodianId The ID of the new custodian to take over @@ -286,6 +287,10 @@ contract CustodyFacet is Context, CustodyErrors, Access, CustodyLib, ICustodyEve FermionTypes.CustodianFee calldata _newCustodianFee, FermionTypes.CustodianVaultParameters calldata _newCustodianVaultParameters ) external notPaused(FermionTypes.PausableRegion.Custody) nonReentrant { + if (_newCustodianFee.period == 0) { + revert InvalidCustodianFeePeriod(); + } + FermionStorage.OfferLookups storage offerLookups = FermionStorage.protocolLookups().offerLookups[_offerId]; // Check if there was a recent request diff --git a/test/protocol/custodyFacet.ts b/test/protocol/custodyFacet.ts index 5eaf5045..e9299595 100644 --- a/test/protocol/custodyFacet.ts +++ b/test/protocol/custodyFacet.ts @@ -1408,6 +1408,24 @@ describe("Custody", function () { .withArgs(PausableRegion.Custody); }); + it.only("Custodian fee period is 0", async function () { + const newCustodianFee = { + amount: parseEther("0.07"), + period: 0n, + }; + + await expect( + custodyFacet + .connect(newCustodian) + .requestCustodianUpdate( + offerIdCustodianSwitch, + newCustodianId, + newCustodianFee, + newCustodianVaultParameters, + ), + ).to.be.revertedWithCustomError(fermionErrors, "InvalidCustodianFeePeriod"); + }); + it("Caller is not the new custodian's assistant", async function () { // Use a wallet that doesn't have any entity ID yet const nonCustodianWallet = wallets[9]; From c2985c6cdcfdcea1ad73ab88fccee31ec0285527 Mon Sep 17 00:00:00 2001 From: 0xlucian <96285542+0xlucian@users.noreply.github.com> Date: Thu, 6 Mar 2025 15:05:22 +0200 Subject: [PATCH 12/12] Update test/protocol/custodyFacet.ts Co-authored-by: Klemen <64400885+zajck@users.noreply.github.com> --- test/protocol/custodyFacet.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/protocol/custodyFacet.ts b/test/protocol/custodyFacet.ts index e9299595..0e1999e6 100644 --- a/test/protocol/custodyFacet.ts +++ b/test/protocol/custodyFacet.ts @@ -1408,7 +1408,7 @@ describe("Custody", function () { .withArgs(PausableRegion.Custody); }); - it.only("Custodian fee period is 0", async function () { + it("Custodian fee period is 0", async function () { const newCustodianFee = { amount: parseEther("0.07"), period: 0n,