Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion contracts/protocol/domain/Errors.sol
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,15 @@ 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 InsufficientVaultBalance(uint256 tokenId, uint256 required, uint256 available);
error UpdateRequestExpired(uint256 tokenId);
error UpdateRequestTooRecent(uint256 tokenId, uint256 waitTime);
error NoTokensInCustody(uint256 offerId);
error InvalidCustodianFeePeriod();
}

interface AuctionErrors {
Expand Down
7 changes: 7 additions & 0 deletions contracts/protocol/domain/Types.sol
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ contract FermionTypes {
uint256 taxAmount;
}

struct CustodianUpdateRequest {
uint256 newCustodianId;
CustodianFee custodianFee;
CustodianVaultParameters custodianVaultParameters;
uint256 requestTimestamp;
}

struct CustodianVaultParameters {
uint256 partialAuctionThreshold;
uint256 partialAuctionDuration;
Expand Down
289 changes: 289 additions & 0 deletions contracts/protocol/facets/Custody.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -258,4 +259,292 @@ 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
* - 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
* @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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In line with #394 please also add here a check to revert if _newCustodianFee.period == 0.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in commit

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
if (offerLookups.custodianUpdateRequest.requestTimestamp + 1 days > block.timestamp) {
revert UpdateRequestTooRecent(_offerId, 1 days);
}

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,
_newCustodianFee,
_newCustodianVaultParameters,
currentCustodianId,
_newCustodianId,
offerLookups
);
}

/**
* @notice Emergency update of custodian
* @dev Allows the current custodian's assistant or seller's assistant to force a custodian update
*
* 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.
*
* Emits CustodianUpdateRequested and CustodianUpdateAccepted events
*
* Reverts if:
* - Custody region is paused
* - 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
* - There are not enough funds in any vault to pay the current custodian
*
* @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(
uint256 _offerId,
uint256 _newCustodianId,
bool _isCustodianAssistant
) external notPaused(FermionTypes.PausableRegion.Custody) nonReentrant {
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;

if (_isCustodianAssistant) {
EntityLib.validateAccountRole(
currentCustodianId,
_msgSender(),
FermionTypes.EntityRole.Custodian,
FermionTypes.AccountRole.Assistant
);
} else {
EntityLib.validateSellerAssistantOrFacilitator(offer.sellerId, offer.facilitatorId);
}

EntityLib.validateEntityRole(
_newCustodianId,
pe.entityData[_newCustodianId].roles,
FermionTypes.EntityRole.Custodian
);

_createCustodianUpdateRequest(
_offerId,
offer.custodianFee,
offerLookups.custodianVaultParameters,
currentCustodianId,
_newCustodianId,
offerLookups
);

_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 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 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
*
* @param _offerId The offer ID for which to accept the custodian update
*/
function acceptCustodianUpdate(
uint256 _offerId
) external notPaused(FermionTypes.PausableRegion.Custody) nonReentrant {
FermionStorage.ProtocolLookups storage pl = FermionStorage.protocolLookups();
FermionStorage.OfferLookups storage offerLookups = pl.offerLookups[_offerId];
FermionTypes.CustodianUpdateRequest storage updateRequest = offerLookups.custodianUpdateRequest;

// Check request hasn't expired
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;
bool hasInCustodyToken;
for (uint256 i; i < itemCount; ++i) {
uint256 tokenId = firstTokenId + i;
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],
updateRequest,
offerLookups
);
}

/**
* @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 _newCustodianId The ID of the new custodian
* @param _offerLookups The offer lookups storage pointer
*/
function _createCustodianUpdateRequest(
uint256 _offerId,
FermionTypes.CustodianFee memory _custodianFee,
FermionTypes.CustodianVaultParameters memory _custodianVaultParameters,
uint256 _currentCustodianId,
uint256 _newCustodianId,
FermionStorage.OfferLookups storage _offerLookups
) internal {
_offerLookups.custodianUpdateRequest = FermionTypes.CustodianUpdateRequest({
newCustodianId: _newCustodianId,
custodianFee: _custodianFee,
custodianVaultParameters: _custodianVaultParameters,
requestTimestamp: block.timestamp
});

emit CustodianUpdateRequested(
_offerId,
_currentCustodianId,
_newCustodianId,
_custodianFee,
_custodianVaultParameters
);
}

/**
* @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) {
continue;
}
// 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;
}

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;

delete _offerLookups.custodianUpdateRequest;

emit CustodianUpdateAccepted(
_offerId,
currentCustodianId,
newCustodianId,
custodianFee,
custodianVaultParameters
);
}
}
7 changes: 7 additions & 0 deletions contracts/protocol/facets/Offer.sol
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,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);
Expand All @@ -619,6 +620,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);
}
Expand Down
16 changes: 16 additions & 0 deletions contracts/protocol/interfaces/events/ICustodyEvents.sol
Original file line number Diff line number Diff line change
@@ -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
*
Expand All @@ -22,4 +24,18 @@ 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 offerId,
uint256 indexed currentCustodianId,
uint256 indexed custodianId,
FermionTypes.CustodianFee custodianFee,
FermionTypes.CustodianVaultParameters custodianVaultParameters
);
event CustodianUpdateAccepted(
uint256 indexed offerId,
uint256 indexed oldCustodianId,
uint256 indexed newCustodianId,
FermionTypes.CustodianFee newCustodianFee,
FermionTypes.CustodianVaultParameters newCustodianVaultParameters
);
}
4 changes: 4 additions & 0 deletions contracts/protocol/libs/Storage.sol
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,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 {
Expand Down
Loading
Loading