diff --git a/src/HookCoveredCallFactory.sol b/src/HookCoveredCallFactory.sol index 87d1c09..f534c94 100644 --- a/src/HookCoveredCallFactory.sol +++ b/src/HookCoveredCallFactory.sol @@ -37,9 +37,11 @@ contract HookCoveredCallFactory is getCallInstrument[assetAddress] == address(0), "makeCallInstrument -- a call instrument already exists" ); - // make sure new instruments created by admins. + // make sure new instruments created by admins or the role + // has been burned require( - _protocol.hasRole(ALLOWLISTER_ROLE, msg.sender), + _protocol.hasRole(ALLOWLISTER_ROLE, msg.sender) || + _protocol.hasRole(ALLOWLISTER_ROLE, address(0)), "makeCallInstrument -- Only admins can make instruments" ); diff --git a/src/HookCoveredCallImplV1.sol b/src/HookCoveredCallImplV1.sol index 2dacf6b..91bb69f 100644 --- a/src/HookCoveredCallImplV1.sol +++ b/src/HookCoveredCallImplV1.sol @@ -128,7 +128,7 @@ contract HookCoveredCallImplV1 is address protocol, address nftContract, address hookVaultFactory - ) public initializer { + ) external initializer { _protocol = IHookProtocol(protocol); _erc721VaultFactory = IHookERC721VaultFactory(hookVaultFactory); weth = _protocol.getWETHAddress(); @@ -179,13 +179,7 @@ contract HookCoveredCallImplV1 is vault.imposeEntitlement(entitlement, signature); return - _mintOptionWithVault( - writer, - address(vault), - assetId, - strikePrice, - expirationTime - ); + _mintOptionWithVault(writer, vault, assetId, strikePrice, expirationTime); } /// @dev See {IHookCoveredCall-mintWithEntitledVault}. @@ -223,13 +217,7 @@ contract HookCoveredCallImplV1 is address writer = vault.getBeneficialOwner(assetId); return - _mintOptionWithVault( - writer, - vaultAddress, - assetId, - strikePrice, - expirationTime - ); + _mintOptionWithVault(writer, vault, assetId, strikePrice, expirationTime); } /// @dev See {IHookCoveredCall-mintWithErc721}. @@ -259,10 +247,10 @@ contract HookCoveredCallImplV1 is ); // FIND OR CREATE HOOK VAULT, SET AN ENTITLEMENT - address vault = _erc721VaultFactory.getVault(tokenAddress, tokenId); - if (vault == address(0)) { - vault = _erc721VaultFactory.makeVault(tokenAddress, tokenId); - } + IHookERC721Vault vault = _erc721VaultFactory.findOrCreateVault( + tokenAddress, + tokenId + ); /// IMPORTANT: the entitlement entitles the user to this contract address. That means that even if this // implementation code were upgraded, the contract at this address (i.e. with the new implementation) would @@ -270,7 +258,7 @@ contract HookCoveredCallImplV1 is Entitlements.Entitlement memory entitlement = Entitlements.Entitlement({ beneficialOwner: tokenOwner, operator: address(this), - vaultAddress: vault, + vaultAddress: address(vault), assetId: assetId, /// assume that the asset within the vault has assetId 0 expiry: expirationTime }); @@ -279,21 +267,21 @@ contract HookCoveredCallImplV1 is // here will be accepted by the vault because we are also simultaneously tendering the asset. IERC721(tokenAddress).safeTransferFrom( tokenOwner, - vault, + address(vault), tokenId, abi.encode(entitlement) ); // make sure that the vault actually has the asset. require( - IHookVault(vault).getHoldsAsset(assetId), + vault.getHoldsAsset(assetId), "mintWithErc712 -- asset must be in vault" ); return _mintOptionWithVault( tokenOwner, - vault, + IHookVault(vault), assetId, strikePrice, expirationTime @@ -304,13 +292,13 @@ contract HookCoveredCallImplV1 is /// @dev the vault is completely unchecked here, so the caller must ensure the vault is created, /// has a valid entitlement, and has the asset inside it /// @param writer the writer of the call option, usually the current owner of the underlying asset - /// @param vaultAddress the address of the IHookVault which contains the underlying asset + /// @param vault the address of the IHookVault which contains the underlying asset /// @param assetId the id of the underlying asset /// @param strikePrice the strike price for this current option, in ETH /// @param expirationTime the time after which the option will be considered expired function _mintOptionWithVault( address writer, - address vaultAddress, + IHookVault vault, uint256 assetId, uint256 strikePrice, uint256 expirationTime @@ -328,7 +316,7 @@ contract HookCoveredCallImplV1 is // save the option metadata optionParams[newOptionId] = CallOption({ writer: writer, - vaultAddress: vaultAddress, + vaultAddress: address(vault), assetId: assetId, strike: strikePrice, expiration: expirationTime, @@ -348,7 +336,7 @@ contract HookCoveredCallImplV1 is emit CallCreated( writer, - vaultAddress, + address(vault), assetId, newOptionId, strikePrice, diff --git a/src/HookERC721MultiVault.sol b/src/HookERC721MultiVault.sol new file mode 100644 index 0000000..ddec98c --- /dev/null +++ b/src/HookERC721MultiVault.sol @@ -0,0 +1,24 @@ +pragma solidity ^0.8.10; + +import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; + +/// @title ERC-721 MultiVault Proxy Contract +/// @author Jake Nyquist -- j@hook.xyz +/// @notice Each instance of this contract is a unique multi-vault which references the +/// shared implementation pointed to by the Beacon +contract HookERC721MultiVault is BeaconProxy { + constructor( + address beacon, + address nftAddress, + address hookProtocolAddress + ) + BeaconProxy( + beacon, + abi.encodeWithSignature( + "initialize(address,address)", + nftAddress, + hookProtocolAddress + ) + ) + {} +} diff --git a/src/HookERC721MultiVaultBeacon.sol b/src/HookERC721MultiVaultBeacon.sol new file mode 100644 index 0000000..e6b71a5 --- /dev/null +++ b/src/HookERC721MultiVaultBeacon.sol @@ -0,0 +1,15 @@ +pragma solidity ^0.8.10; + +import "./HookUpgradeableBeacon.sol"; + +/// @title HookERC721MultiVaultBeacon -- beacon holding pointer to current ERC721MultiVault implementation +/// @author Jake Nyquist -- j@hook.xyz +/// @notice The beacon broadcasts the address which contains the existing implementation of the ERC721MultiVault +/// @dev Permissions for who can upgrade are contained within the protocol contract. +contract HookERC721MultiVaultBeacon is HookUpgradeableBeacon { + constructor( + address implementation, + address hookProtocol, + bytes32 upgraderRole + ) HookUpgradeableBeacon(implementation, hookProtocol, upgraderRole) {} +} diff --git a/src/HookERC721MultiVaultImplV1.sol b/src/HookERC721MultiVaultImplV1.sol new file mode 100644 index 0000000..02a6041 --- /dev/null +++ b/src/HookERC721MultiVaultImplV1.sol @@ -0,0 +1,441 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.10; + +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; + +import "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; + +import "./interfaces/IHookERC721Vault.sol"; +import "./interfaces/IERC721FlashLoanReceiver.sol"; +import "./interfaces/IHookProtocol.sol"; +import "./lib/Entitlements.sol"; +import "./lib/Signatures.sol"; +import "./mixin/EIP712.sol"; + +/// @title HookMulitVault -- implemenation of a Vault for multiple assets within a NFT collection, with entitlements. +/// @author Jake Nyquist - j@hook.xyz +/// @notice HookVault holds a multiple NFT asset in escrow on behalf of multiple beneficial owners. Other contracts +/// are able to register "entitlements" for a fixed period of time on the asset, which give them the ability to +/// change the vault's owner. +/// @dev This contract implements ERC721Reciever +/// This contract views the tokenId for the asset on the ERC721 contract as the corresponding assetId for that asset +/// when deposited into the vault +contract HookERC721MultiVaultImplV1 is + IHookERC721Vault, + EIP712, + Initializable, + ReentrancyGuard +{ + /// ---------------- STORAGE ---------------- /// + + /// @dev these are the NFT contract address and tokenId the vault is covering + IERC721 private _nftContract; + + /// @dev the current entitlement applied to each asset, which includes the beneficialOwner + /// for the asset + /// if the entitled operator field is non-null, it means an unreleased entitlement has been + /// applied; however, that entitlement could still be expired (if block.timestamp > entitlement.expiry) + mapping(uint256 => Entitlements.Entitlement) private entitlements; + + IHookProtocol private _hookProtocol; + + /// Upgradeable Implementations cannot have a contructor, so we call the initialize instead; + constructor() {} + + /// -- constructor + function initialize(address nftContract, address hookAddress) + public + initializer + { + setAddressForEipDomain(hookAddress); + _nftContract = IERC721(nftContract); + _hookProtocol = IHookProtocol(hookAddress); + } + + /// ---------------- PUBLIC FUNCTIONS ---------------- /// + + /// @dev See {IHookERC721Vault-withdrawalAsset}. + /// @dev withdrawals can only be performed to the beneficial owner if there are no entitlements + function withdrawalAsset(uint256 assetId) external { + require( + !hasActiveEntitlement(assetId), + "withdrawalAsset -- the asset canot be withdrawn with an active entitlement" + ); + + _nftContract.safeTransferFrom( + address(this), + entitlements[assetId].beneficialOwner, + assetId + ); + + emit AssetWithdrawn( + assetId, + msg.sender, + entitlements[assetId].beneficialOwner + ); + } + + /// @dev See {IHookERC721Vault-imposeEntitlement}. + /// @dev The entitlement must be signed by the current beneficial owner of the contract. Anyone can submit the + /// entitlement + function imposeEntitlement( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory signature + ) external { + // check that the asset has a current beneficial owner + // before creating a new entitlement + require( + entitlements[entitlement.assetId].beneficialOwner != address(0), + "imposeEntitlement -- beneficial owner must be set to impose an entitlement" + ); + + // the beneficial owner of an asset is able to set any entitlement on their own asset + // as long as it has not already been committed to someone else. + _verifyAndRegisterEntitlement(entitlement, signature); + } + + /// @dev See {IHookERC721Vault-grantEntitlement}. + /// @dev The entitlement must be signed by the current beneficial owner of the contract. Anyone can submit the + /// entitlement + function grantEntitlement(Entitlements.Entitlement memory entitlement) + external + { + require( + entitlements[entitlement.assetId].beneficialOwner == msg.sender, + "grantEntitlement -- only the beneficial owner can grant an entitlement" + ); + + // the beneficial owner of an asset is able to directly set any entitlement on their own asset + // as long as it has not already been committed to someone else. + _registerEntitlement(entitlement); + } + + /// @dev See {IERC721Receiver-onERC721Received}. + /// + /// Always returns `IERC721Receiver.onERC721Received.selector`. + function onERC721Received( + address operator, // this arg is the address of the operator + address from, + uint256 tokenId, + bytes calldata data + ) external virtual override returns (bytes4) { + /// We should make sure that the owner of an asset never changes simply as a result of someone sending + /// a NFT into this contract. + /// + /// (1) When recieving a nft from the ERC-721 contract this vault covers, create a new entitlement entry + /// with the sender as the beneficial owner to track the asset within the vault. + /// + /// (1a) If the transfer additionally specifices data (i.e. an abi-encoded entitlement), the entitlement will + /// be imposed via that transfer, including a new beneficial owner. + /// NOTE: this is an opionated approach, however, the authors believe that anyone with the ability to + /// transfer the asset into this contract could also trivially transfer the asset to another address + /// they control and then deposit, so allowing this method of setting the beneficial owner simply + /// saves gas and has no practical impact on the rights a hypothetical sender has regarding the asset. + /// + /// (2) If another nft is sent to the contract, we should verify that airdrops are allowed to this vault; + /// if they are disabled, we should not return the selector, otherwise we can allow them. + /// + /// IMPORTANT: If an unrelated contract is currently holding the asset on behalf of an owner and then + /// subsequently transfers the asset into the contract, it needs to manually call (setBeneficialOwner) + /// after making this call to ensure that the true owner of the asset is known to the vault. Otherwise, + /// the owner will lose the ability to reclaim their asset. Alternatively, they could pass an entitlement + /// in prepopulated with the correct beneficial owner, which will give that owner the ability to reclaim + /// the asseet. + if (msg.sender == address(_nftContract)) { + // There is no need to check if we currently have this token or an entitlement set. + // Even if the contract were able to get into this state, it should still accept the asset + // which will allow it to enforce the entitlement. + + // If additional data is sent with the transfer, we attempt to parse an entitlement from it. + // this allows the entitlement to be registered ahead of time. + if (data.length > 0) { + // Decode the order, signature from `data`. If `data` does not encode such parameters, this + // will throw. + Entitlements.Entitlement memory entitlement = abi.decode( + data, + (Entitlements.Entitlement) + ); + + // Check to ensure that the passed entitlement is not attempting to be registered on any asset other than + // the asset that is actually being deposited. Without this check, it is possible for a malicious user + // to send an arbitrary asset from this collection to the contract and simultaneously impose an entitlement + // on another asset contained in the contract. This check is critical + require( + entitlement.assetId == tokenId, + "onERC721Recieved -- cannot impose an entitlement on an asset other than the asset deposited in the transfer" + ); + // if someone has the asset, they should be able to set whichever beneficial owner they'd like. + // equally, they could transfer the asset first to themselves and subsequently grant a specific + // entitlement, which is equivalent to this. + _setBeneficialOwner(tokenId, entitlement.beneficialOwner); + _registerEntitlement(entitlement); + } else { + _setBeneficialOwner(tokenId, from); + } + } else { + // If we're recieving an airdrop or other asset uncovered by escrow to this address, we should ensure + // that this is allowed by our current settings. + require( + !_hookProtocol.getCollectionConfig( + address(_nftContract), + keccak256("vault.airdropsProhibited") + ), + "onERC721Received -- non-escrow asset returned when airdrops are disabled" + ); + } + emit AssetReceived(from, operator, msg.sender, tokenId); + return this.onERC721Received.selector; + } + + /// @dev See {IHookERC721Vault-flashLoan}. + function flashLoan( + uint256 assetId, + address receiverAddress, + bytes calldata params + ) external override nonReentrant { + IERC721FlashLoanReceiver receiver = IERC721FlashLoanReceiver( + receiverAddress + ); + require(receiverAddress != address(0), "flashLoan -- zero address"); + require( + _nftContract.ownerOf(assetId) == address(this), + "flashLoan -- asset not in vault" + ); + require( + msg.sender == entitlements[assetId].beneficialOwner, + "flashLoan -- not called by the asset owner" + ); + + require( + !_hookProtocol.getCollectionConfig( + address(_nftContract), + keccak256("vault.flashLoanDisabled") + ), + "flashLoan -- flashLoan feature disabled for this contract" + ); + + // (1) send the flashloan contract the vaulted NFT + _nftContract.safeTransferFrom(address(this), receiverAddress, assetId); + + // (2) call the flashloan contract, giving it a chance to do whatever it wants + // NOTE: The flashloan contract MUST approve this vault contract as an operator + // for the nft, such that we're able to make sure it has arrived. + require( + receiver.executeOperation( + address(_nftContract), + assetId, + msg.sender, + address(this), + params + ), + "flashLoan -- the flash loan contract must return true" + ); + + // (3) return the nft back into the vault + _nftContract.safeTransferFrom(receiverAddress, address(this), assetId); + + // (4) sanity check to ensure the asset was actually returned to the vault. + // this is a concern because its possible that the safeTransferFrom implemented by + // some contract fails silently + require(_nftContract.ownerOf(assetId) == address(this)); + + // (5) emit an event to record the flashloan + emit AssetFlashLoaned( + entitlements[assetId].beneficialOwner, + assetId, + receiverAddress + ); + } + + /// @dev See {IHookVault-entitlementExpiration}. + function entitlementExpiration(uint256 assetId) + external + view + returns (uint256 expiry) + { + if (!hasActiveEntitlement(assetId)) { + return 0; + } else { + entitlements[assetId].expiry; + } + } + + /// @dev See {IHookERC721Vault-getBeneficialOwner}. + function getBeneficialOwner(uint256 assetId) external view returns (address) { + return entitlements[assetId].beneficialOwner; + } + + /// @dev See {IHookERC721Vault-getHoldsAsset}. + function getHoldsAsset(uint256 assetId) + external + view + returns (bool holdsAsset) + { + return _nftContract.ownerOf(assetId) == address(this); + } + + function assetAddress(uint256) external view returns (address) { + return address(_nftContract); + } + + /// @dev returns the underlying token ID for a given asset. In this case + /// the tokenId == the assetId + function assetTokenId(uint256 assetId) external view returns (uint256) { + return assetId; + } + + /// @dev See {IHookERC721Vault-setBeneficialOwner}. + /// setBeneficialOwner can only be called by the entitlementContract if there is an activeEntitlement. + function setBeneficialOwner(uint256 assetId, address newBeneficialOwner) + external + { + if (hasActiveEntitlement(assetId)) { + require( + msg.sender == entitlements[assetId].operator, + "setBeneficialOwner -- only the contract with the active entitlement can update the beneficial owner" + ); + } else { + require( + msg.sender == entitlements[assetId].beneficialOwner, + "setBeneficialOwner -- only the current owner can update the beneficial owner" + ); + } + _setBeneficialOwner(assetId, newBeneficialOwner); + } + + /// @dev See {IHookERC721Vault-clearEntitlement}. + /// @dev This can only be called if an entitlement currently exists, otherwise it would be a no-op + function clearEntitlement(uint256 assetId) public { + require( + hasActiveEntitlement(assetId), + "clearEntitlement -- an active entitlement must exist" + ); + require( + msg.sender == entitlements[assetId].operator, + "clearEntitlement -- only the entitled address can clear the entitlement" + ); + _clearEntitlement(assetId); + } + + /// @dev See {IHookERC721Vault-clearEntitlementAndDistribute}. + /// @dev The entitlement must be exist, and must be called by the {operator}. The operator can specify a + /// intended reciever, which should match the beneficialOwner. The function will throw if + /// the reciever and owner do not match. + /// @param assetId the id of the specific vaulted asset + /// @param reciever the intended reciever of the asset + function clearEntitlementAndDistribute(uint256 assetId, address reciever) + external + nonReentrant + { + require( + entitlements[assetId].beneficialOwner == reciever, + "clearEntitlementAndDistribute -- Only the beneficial owner can recieve the asset" + ); + clearEntitlement(assetId); + IERC721(_nftContract).safeTransferFrom(address(this), reciever, assetId); + emit AssetWithdrawn( + assetId, + msg.sender, + entitlements[assetId].beneficialOwner + ); + } + + /// @dev Get the EIP-712 hash of an Entitlement. + /// @param entitlement The entitlement to hash + /// @return entitlementHash The hash of the entitlement. + function getEntitlementHash(Entitlements.Entitlement memory entitlement) + public + view + returns (bytes32 entitlementHash) + { + return _getEIP712Hash(Entitlements.getEntitlementStructHash(entitlement)); + } + + /// @dev Validates that a specific signature is actually the entitlement + /// EIP-712 signed by the beneficial owner specified in the entitlement. + function validateEntitlementSignature( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory signature + ) public view { + bytes32 entitlementHash = getEntitlementHash(entitlement); + address signer = Signatures.getSignerOfHash(entitlementHash, signature); + require( + signer == entitlement.beneficialOwner, + "validateEntitlementSignature --- not signed by beneficialOwner" + ); + } + + /// ---------------- INTERNAL/PRIVATE FUNCTIONS ---------------- /// + + /// @notice Verify that an entitlement is properly signed and apply it to the asset if able. + /// @dev The entitlement must be signed by the beneficial owner of the asset in order for it to be considered valid + /// @param entitlement the entitlement to impose on the asset + /// @param signature the EIP-712 signed entitlement by the beneficial owner + function _verifyAndRegisterEntitlement( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory signature + ) private { + validateEntitlementSignature(entitlement, signature); + _registerEntitlement(entitlement); + } + + function _registerEntitlement(Entitlements.Entitlement memory entitlement) + private + { + uint256 assetId = entitlement.assetId; + require( + !hasActiveEntitlement(assetId), + "_verifyAndRegisterEntitlement -- existing entitlement must be cleared before registering a new one" + ); + require( + entitlement.beneficialOwner == entitlements[assetId].beneficialOwner, + "_verifyAndRegisterEntitlement -- only the current beneficial owner can make an entitlement" + ); + require( + entitlement.vaultAddress == address(this), + "_verifyAndRegisterEntitlement -- the entitled contract must match the vault contract" + ); + entitlements[assetId] = entitlement; + emit EntitlementImposed( + assetId, + entitlement.operator, + entitlement.expiry, + entitlement.beneficialOwner + ); + } + + function _clearEntitlement(uint256 assetId) private { + entitlements[assetId].expiry = 0; + entitlements[assetId].operator = address(0); + emit EntitlementCleared(assetId, entitlements[assetId].beneficialOwner); + } + + function hasActiveEntitlement(uint256 assetId) public view returns (bool) { + /// Although we do clear the expiry in _clearEntitlement, making the second half of the AND redundant, + /// we choose to include it here because we rely on this field being null to clear an entitlement. + return + block.timestamp < entitlements[assetId].expiry && + entitlements[assetId].operator != address(0); + } + + function getCurrentEntitlementOperator(uint256 assetId) + external + view + returns (bool isActive, address operator) + { + isActive = hasActiveEntitlement(assetId); + operator = entitlements[assetId].operator; + } + + function _setBeneficialOwner(uint256 assetId, address newBeneficialOwner) + private + { + require( + newBeneficialOwner != address(0), + "_setBeneficialOwner -- new owner is the zero address" + ); + entitlements[assetId].beneficialOwner = newBeneficialOwner; + emit BeneficialOwnerSet(assetId, newBeneficialOwner, msg.sender); + } +} diff --git a/src/HookERC721VaultFactory.sol b/src/HookERC721VaultFactory.sol index 4a61ca0..b2eaa9c 100644 --- a/src/HookERC721VaultFactory.sol +++ b/src/HookERC721VaultFactory.sol @@ -2,45 +2,119 @@ pragma solidity ^0.8.10; import "./HookERC721Vault.sol"; +import "./HookERC721MultiVault.sol"; import "./interfaces/IHookERC721VaultFactory.sol"; +import "./interfaces/IHookERC721Vault.sol"; +import "./interfaces/IHookProtocol.sol"; + +import "./mixin/PermissionConstants.sol"; /// @dev The factory itself is non-upgradeable; however, each vault is upgradeable (i.e. all vaults) /// created by this factory can be upgraded at one time via the beacon pattern. -contract HookERC721VaultFactory is IHookERC721VaultFactory { +contract HookERC721VaultFactory is + IHookERC721VaultFactory, + PermissionConstants +{ /// @notice Registry of all of the active vaults within the protocol, allowing users to find vaults by /// project address and tokenId; /// @dev From this view, we do not know if a vault is empty or full - mapping(address => mapping(uint256 => address)) public override getVault; + mapping(address => mapping(uint256 => IHookERC721Vault)) + public + override getVault; + + /// @notice Registry of all of the active multi-vaults within the protocol + mapping(address => IHookERC721Vault) public override getMultiVault; address private _hookProtocol; address private _beacon; + address private _multiBeacon; - constructor(address hookProtocolAddress, address beaconAddress) { + constructor( + address hookProtocolAddress, + address beaconAddress, + address multiBeaconAddress + ) { _hookProtocol = hookProtocolAddress; _beacon = beaconAddress; + _multiBeacon = multiBeaconAddress; } - function makeVault(address nftAddress, uint256 tokenId) + /// @notice create a new vault that can support multiple ERC-721s within the same + /// instance + function makeMultiVault(address nftAddress) external - returns (address vault) + returns (IHookERC721Vault vault) { require( - getVault[nftAddress][tokenId] == address(0), - "makeVault -- a vault cannot already exist" + IHookProtocol(_hookProtocol).hasRole(ALLOWLISTER_ROLE, msg.sender) || + IHookProtocol(_hookProtocol).hasRole(ALLOWLISTER_ROLE, address(0)), + "makeMultiVault -- Only accounts with the ALLOWLISTER role can make new multiVaults" + ); + + require( + getMultiVault[nftAddress] == IHookERC721Vault(address(0)), + "makeMultiVault -- vault cannot already exist" ); + getMultiVault[nftAddress] = IHookERC721Vault( + address( + new HookERC721MultiVault{salt: keccak256(abi.encode(nftAddress))}( + _multiBeacon, + nftAddress, + _hookProtocol + ) + ) + ); + + return getMultiVault[nftAddress]; + } + + /// @notice make a new vault that can contain a single asset only + function makeSoloVault(address nftAddress, uint256 tokenId) + public + returns (IHookERC721Vault vault) + { + require( + getVault[nftAddress][tokenId] == IHookERC721Vault(address(0)), + "makeVault -- a vault cannot already exist" + ); // use the salt here to attempt to pre-compute the address where the vault will live. // we don't leverage this predictability for now. - getVault[nftAddress][tokenId] = address( - new HookERC721Vault{salt: keccak256(abi.encode(nftAddress, tokenId))}( - _beacon, - nftAddress, - tokenId, - _hookProtocol + getVault[nftAddress][tokenId] = IHookERC721Vault( + address( + new HookERC721Vault{salt: keccak256(abi.encode(nftAddress, tokenId))}( + _beacon, + nftAddress, + tokenId, + _hookProtocol + ) ) ); - emit ERC721VaultCreated(nftAddress, tokenId, getVault[nftAddress][tokenId]); + emit ERC721VaultCreated( + nftAddress, + tokenId, + address(getVault[nftAddress][tokenId]) + ); return getVault[nftAddress][tokenId]; } + + /// @notice creates a vault for a specific tokenId. If there + /// is a multi-vault in existence which supports that address + /// the address for that vault is returned as a new one + /// does not need to be made. + function findOrCreateVault(address nftAddress, uint256 tokenId) + external + returns (IHookERC721Vault vault) + { + if (getMultiVault[nftAddress] != IHookERC721Vault(address(0))) { + return getMultiVault[nftAddress]; + } + + if (getVault[nftAddress][tokenId] != IHookERC721Vault(address(0))) { + return getVault[nftAddress][tokenId]; + } + + return makeSoloVault(nftAddress, tokenId); + } } diff --git a/src/HookERC721VaultImplV1.sol b/src/HookERC721VaultImplV1.sol index 0240edf..71c0b0e 100644 --- a/src/HookERC721VaultImplV1.sol +++ b/src/HookERC721VaultImplV1.sol @@ -168,7 +168,7 @@ contract HookERC721VaultImplV1 is "onERC721Received -- non-escrow asset returned when airdrops are disabled" ); } - emit AssetReceived(from, operator, msg.sender, tokenId, ASSET_ID); + emit AssetReceived(from, operator, msg.sender, ASSET_ID); return this.onERC721Received.selector; } @@ -257,22 +257,7 @@ contract HookERC721VaultImplV1 is require(_nftContract.ownerOf(_tokenId) == address(this)); // (5) emit an event to record the flashloan - emit AssetFlashLoaned(beneficialOwner, receiverAddress); - } - - /// @notice Looks up the address of the currently entitled operator - /// @dev returns the null address if there is no active entitlement - /// @return operator the address of the current operator - function entitledOperatorContract(uint256) - external - view - returns (address operator) - { - if (!hasActiveEntitlement()) { - return address(0); - } else { - _currentEntitlement.operator; - } + emit AssetFlashLoaned(beneficialOwner, _tokenId, receiverAddress); } /// @dev See {IHookVault-entitlementExpiration}. diff --git a/src/interfaces/IHookERC721Vault.sol b/src/interfaces/IHookERC721Vault.sol index 4e672e7..219426e 100644 --- a/src/interfaces/IHookERC721Vault.sol +++ b/src/interfaces/IHookERC721Vault.sol @@ -18,7 +18,10 @@ import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; /// contract, and then call the flashLoan method. /// (3) At the end of the flashLoan, we ensure the asset is still owned by the vault. interface IHookERC721Vault is IHookVault, IERC721Receiver { - event AssetFlashLoaned(address owner, address flashLoanImpl); + /// @notice emitted after an asset is flash loaned by its beneficial owner. + /// @dev only one asset can be flash loaned at a time, and that asset is + /// denoted by the tokenId emitted. + event AssetFlashLoaned(address owner, uint256 tokenId, address flashLoanImpl); /// @notice the tokenID of the underlying ERC721 token; function assetTokenId(uint256 assetId) external view returns (uint256); diff --git a/src/interfaces/IHookERC721VaultFactory.sol b/src/interfaces/IHookERC721VaultFactory.sol index 258c183..1abb43b 100644 --- a/src/interfaces/IHookERC721VaultFactory.sol +++ b/src/interfaces/IHookERC721VaultFactory.sol @@ -1,5 +1,7 @@ pragma solidity ^0.8.10; +import "./IHookERC721Vault.sol"; + /// @title HookERC721Factory -- factory for instances of the hook vault /// @author Jake Nyquist -- j@hook.xyz /// @notice The Factory creates a specific vault for ERC721s. @@ -7,15 +9,26 @@ interface IHookERC721VaultFactory { event ERC721VaultCreated( address nftAddress, uint256 tokenId, - address vaultId + address vaultAddress ); + event ERC721MultiVaultCreated(address nftAddress, address vaultAddress); + function getVault(address nftAddress, uint256 tokenId) external view - returns (address vault); + returns (IHookERC721Vault vault); + + function getMultiVault(address nftAddress) + external + view + returns (IHookERC721Vault vault); + + function makeMultiVault(address nftAddress) + external + returns (IHookERC721Vault vault); - function makeVault(address nftAddress, uint256 tokenId) + function findOrCreateVault(address nftAddress, uint256 tokenId) external - returns (address vault); + returns (IHookERC721Vault vault); } diff --git a/src/interfaces/IHookVault.sol b/src/interfaces/IHookVault.sol index aff30e1..edc7045 100644 --- a/src/interfaces/IHookVault.sol +++ b/src/interfaces/IHookVault.sol @@ -1,7 +1,6 @@ /// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.10; -import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; import "../lib/Entitlements.sol"; /// @title Generic Hook Vault -- a vault designed to contain a single asset to be used as escrow. @@ -50,10 +49,10 @@ interface IHookVault { address owner, address sender, address contractAddress, - uint256 tokenId, uint256 assetId ); + /// @notice emitted when an asset is withdrawn from the vault event AssetWithdrawn(uint256 assetId, address to, address beneficialOwner); /// @notice Withdrawal an unencumbered asset from this vault @@ -96,6 +95,7 @@ interface IHookVault { /// @notice checks if the asset is currently stored in the vault function getHoldsAsset(uint256 assetId) external view returns (bool); + /// @notice the contract address of the vaulted asset function assetAddress(uint256 assetId) external view returns (address); /// @notice looks up the current operator of an entitlemnt on an asset diff --git a/src/test/HookCoveredCallTests.sol b/src/test/HookCoveredCallTests.sol index aea0b71..ba818e5 100644 --- a/src/test/HookCoveredCallTests.sol +++ b/src/test/HookCoveredCallTests.sol @@ -54,7 +54,9 @@ contract HookCoveredCallMintTests is HookProtocolTest { function test_MintOptionWithVault() public { vm.startPrank(address(writer)); - try vaultFactory.makeVault(address(token), underlyingTokenId) {} catch {} + try + vaultFactory.findOrCreateVault(address(token), underlyingTokenId) + {} catch {} IHookERC721Vault vault = IHookERC721Vault( vaultFactory.getVault(address(token), underlyingTokenId) @@ -96,7 +98,9 @@ contract HookCoveredCallMintTests is HookProtocolTest { function test_MintOptionWithVaultFailsExpiration() public { vm.startPrank(address(writer)); - try vaultFactory.makeVault(address(token), underlyingTokenId) {} catch {} + try + vaultFactory.findOrCreateVault(address(token), underlyingTokenId) + {} catch {} IHookERC721Vault vault = IHookERC721Vault( vaultFactory.getVault(address(token), underlyingTokenId) @@ -127,7 +131,9 @@ contract HookCoveredCallMintTests is HookProtocolTest { function test_MintOptionWithVaultFailsEmptyVault() public { vm.startPrank(address(writer)); - try vaultFactory.makeVault(address(token), underlyingTokenId) {} catch {} + try + vaultFactory.findOrCreateVault(address(token), underlyingTokenId) + {} catch {} IHookERC721Vault vault = IHookERC721Vault( vaultFactory.getVault(address(token), underlyingTokenId) @@ -153,7 +159,9 @@ contract HookCoveredCallMintTests is HookProtocolTest { function test_MintOptionWithVaultFailsUnsupportedCollection() public { vm.startPrank(address(writer)); - try vaultFactory.makeVault(address(calls), underlyingTokenId) {} catch {} + try + vaultFactory.findOrCreateVault(address(calls), underlyingTokenId) + {} catch {} IHookERC721Vault vault = IHookERC721Vault( vaultFactory.getVault(address(calls), underlyingTokenId) @@ -282,7 +290,9 @@ contract HookCoveredCallMintTests is HookProtocolTest { function testCannotMintOptionInvalidSignature() public { vm.startPrank(address(writer)); - try vaultFactory.makeVault(address(token), underlyingTokenId) {} catch {} + try + vaultFactory.findOrCreateVault(address(token), underlyingTokenId) + {} catch {} IHookERC721Vault vault = IHookERC721Vault( vaultFactory.getVault(address(token), underlyingTokenId) @@ -919,12 +929,12 @@ contract HookCoveredCallSettleTests is HookProtocolTest { uint256 buyerStartBalance = buyer.balance; uint256 writerStartBalance = writer.balance; - address vaultAddress = vaultFactory.getVault( + IHookERC721Vault vault = vaultFactory.getVault( address(token), underlyingTokenId ); vm.expectCall( - vaultAddress, + address(vault), abi.encodeWithSignature("withdrawalAsset(uint256)", 0) ); @@ -1233,12 +1243,12 @@ contract HookCoveredCallReclaimTests is HookProtocolTest { vm.startPrank(writer); - address vaultAddress = vaultFactory.getVault( + IHookERC721Vault vault = vaultFactory.getVault( address(token), underlyingTokenId ); vm.expectCall( - vaultAddress, + address(vault), abi.encodeWithSignature("withdrawalAsset(uint256)", 0) ); calls.reclaimAsset(optionTokenId, true); @@ -1326,12 +1336,12 @@ contract HookCoveredCallReclaimTests is HookProtocolTest { vm.startPrank(writer); - address vaultAddress = vaultFactory.getVault( + IHookERC721Vault vault = vaultFactory.getVault( address(token), underlyingTokenId ); vm.expectCall( - vaultAddress, + address(vault), abi.encodeWithSignature("withdrawalAsset(uint256)", 0) ); calls.reclaimAsset(optionTokenId, true); @@ -1378,12 +1388,12 @@ contract HookCoveredCallReclaimTests is HookProtocolTest { vm.warp(block.timestamp + 1 days); vm.startPrank(writer); - address vaultAddress = vaultFactory.getVault( + IHookERC721Vault vault = vaultFactory.getVault( address(token), underlyingTokenId ); vm.expectCall( - vaultAddress, + address(vault), abi.encodeWithSignature("withdrawalAsset(uint256)", 0) ); calls.reclaimAsset(optionTokenId, true); @@ -1433,12 +1443,12 @@ contract HookCoveredCallReclaimTests is HookProtocolTest { vm.warp(block.timestamp + 1 days); vm.startPrank(writer); - address vaultAddress = vaultFactory.getVault( + IHookERC721Vault vault = vaultFactory.getVault( address(token), underlyingTokenId ); vm.expectCall( - vaultAddress, + address(vault), abi.encodeWithSignature("withdrawalAsset(uint256)", 0) ); calls.reclaimAsset(optionTokenId, true); diff --git a/src/test/HookMultiVaultTests.sol b/src/test/HookMultiVaultTests.sol new file mode 100644 index 0000000..8553f3d --- /dev/null +++ b/src/test/HookMultiVaultTests.sol @@ -0,0 +1,900 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.10; + +import "ds-test/test.sol"; +import "forge-std/Test.sol"; + +import "./utils/base.sol"; +import "../interfaces/IHookERC721VaultFactory.sol"; + +import "../lib/Entitlements.sol"; +import "../lib/Signatures.sol"; +import "../mixin/EIP712.sol"; + +import "./utils/mocks/FlashLoan.sol"; + +contract HookMultiVaultTests is HookProtocolTest { + IHookERC721VaultFactory vault; + uint256 tokenStartIndex = 300; + + function setUp() public { + setUpAddresses(); + setUpFullProtocol(); + vault = IHookERC721VaultFactory(protocol.vaultContract()); + } + + function createVaultandAsset() + internal + returns (address vaultAddress, uint256 tokenId) + { + vm.startPrank(admin); + tokenStartIndex += 1; + tokenId = tokenStartIndex; + token.mint(address(writer), tokenId); + vault.makeMultiVault(address(token)); + address vaultAddress = address( + vault.findOrCreateVault(address(token), tokenId) + ); + vm.stopPrank(); + return (vaultAddress, tokenId); + } + + function makeEntitlementAndSignature( + uint256 ownerPkey, + address operator, + address vaultAddress, + uint256 tokenId, + uint256 _expiry + ) + internal + returns ( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory signature + ) + { + address ownerAdd = vm.addr(writerpkey); + + Entitlements.Entitlement memory entitlement = Entitlements.Entitlement({ + beneficialOwner: ownerAdd, + operator: operator, + vaultAddress: vaultAddress, + assetId: tokenId, + expiry: _expiry + }); + + bytes32 structHash = Entitlements.getEntitlementStructHash(entitlement); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + ownerPkey, + _getEIP712Hash(structHash) + ); + + Signatures.Signature memory sig = Signatures.Signature({ + signatureType: Signatures.SignatureType.EIP712, + v: v, + r: r, + s: s + }); + return (entitlement, sig); + } + + function testImposeEntitlmentOnTransferIn() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + address mockContract = address(69); + uint256 expiration = block.timestamp + 1 days; + + ( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory sig + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration + ); + + vm.prank(writer); + + token.safeTransferFrom( + writer, + vaultAddress, + tokenId, + abi.encode(entitlement, sig) + ); + + IHookERC721Vault vaultImpl = IHookERC721Vault(vaultAddress); + assertTrue( + vaultImpl.getHoldsAsset(tokenId), + "the token should be owned by the vault" + ); + assertTrue( + vaultImpl.getBeneficialOwner(tokenId) == writer, + "writer should be the beneficial owner" + ); + (bool active, address operator) = vaultImpl.getCurrentEntitlementOperator( + tokenId + ); + assertTrue(active, "there should be an active entitlement"); + assertTrue( + operator == mockContract, + "active entitlement is to correct person" + ); + } + + function testBasicFlashLoan() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + address mockContract = address(69); + uint256 expiration = block.timestamp + 1 days; + + ( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory sig + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration + ); + + vm.prank(writer); + + token.safeTransferFrom( + writer, + vaultAddress, + tokenId, + abi.encode(entitlement, sig) + ); + + IHookERC721Vault vaultImpl = IHookERC721Vault(vaultAddress); + IERC721FlashLoanReceiver flashLoan = new FlashLoanSuccess(); + + vm.prank(writer); + vaultImpl.flashLoan(tokenId, address(flashLoan), " "); + assertTrue( + token.ownerOf(tokenId) == vaultAddress, + "good flashloan should work" + ); + } + + function testFlashLoanFailsIfDisabled() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + address mockContract = address(69); + uint256 expiration = block.timestamp + 1 days; + + ( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory sig + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration + ); + + vm.prank(writer); + + token.safeTransferFrom( + writer, + vaultAddress, + tokenId, + abi.encode(entitlement, sig) + ); + + IHookERC721Vault vaultImpl = IHookERC721Vault(vaultAddress); + IERC721FlashLoanReceiver flashLoan = new FlashLoanSuccess(); + vm.prank(admin); + protocol.setCollectionConfig( + address(token), + keccak256("vault.flashLoanDisabled"), + true + ); + vm.prank(writer); + vm.expectRevert( + "flashLoan -- flashLoan feature disabled for this contract" + ); + vaultImpl.flashLoan(tokenId, address(flashLoan), " "); + assertTrue( + token.ownerOf(tokenId) == vaultAddress, + "good flashloan should work" + ); + } + + function testBasicFlashLoanAlternateApprove() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + address mockContract = address(69); + uint256 expiration = block.timestamp + 1 days; + + ( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory sig + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration + ); + + vm.prank(writer); + + token.safeTransferFrom( + writer, + vaultAddress, + tokenId, + abi.encode(entitlement, sig) + ); + + IHookERC721Vault vaultImpl = IHookERC721Vault(vaultAddress); + IERC721FlashLoanReceiver flashLoan = new FlashLoanApproveForAll(); + + vm.prank(writer); + vaultImpl.flashLoan(tokenId, address(flashLoan), " "); + assertTrue( + token.ownerOf(tokenId) == vaultAddress, + "good flashloan should work" + ); + } + + function testBasicFlashCantReturnFalse() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + address mockContract = address(69); + uint256 expiration = block.timestamp + 1 days; + + ( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory sig + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration + ); + + vm.prank(writer); + + token.safeTransferFrom( + writer, + vaultAddress, + tokenId, + abi.encode(entitlement, sig) + ); + + IHookERC721Vault vaultImpl = IHookERC721Vault(vaultAddress); + IERC721FlashLoanReceiver flashLoan = new FlashLoanReturnsFalse(); + + vm.prank(writer); + vm.expectRevert("flashLoan -- the flash loan contract must return true"); + vaultImpl.flashLoan(tokenId, address(flashLoan), " "); + assertTrue( + token.ownerOf(tokenId) == vaultAddress, + "good flashloan should work" + ); + } + + function testBasicFlashMustApprove() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + address mockContract = address(69); + uint256 expiration = block.timestamp + 1 days; + + ( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory sig + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration + ); + + vm.prank(writer); + + token.safeTransferFrom( + writer, + vaultAddress, + tokenId, + abi.encode(entitlement, sig) + ); + + IHookERC721Vault vaultImpl = IHookERC721Vault(vaultAddress); + IERC721FlashLoanReceiver flashLoan = new FlashLoanDoesNotApprove(); + + vm.prank(writer); + vm.expectRevert("ERC721: transfer caller is not owner nor approved"); + vaultImpl.flashLoan(tokenId, address(flashLoan), " "); + assertTrue( + token.ownerOf(tokenId) == vaultAddress, + "good flashloan should work" + ); + } + + function testBasicFlashCantBurn() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + address mockContract = address(69); + uint256 expiration = block.timestamp + 1 days; + + ( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory sig + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration + ); + + vm.prank(writer); + + token.safeTransferFrom( + writer, + vaultAddress, + tokenId, + abi.encode(entitlement, sig) + ); + + IHookERC721Vault vaultImpl = IHookERC721Vault(vaultAddress); + IERC721FlashLoanReceiver flashLoan = new FlashLoanBurnsAsset(); + + vm.prank(writer); + vm.expectRevert("ERC721: operator query for nonexistent token"); + vaultImpl.flashLoan(tokenId, address(flashLoan), " "); + // operation reverted, so we can still mess with the asset + assertTrue( + token.ownerOf(tokenId) == vaultAddress, + "good flashloan should work" + ); + } + + function testFlashCallData() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + address mockContract = address(69); + uint256 expiration = block.timestamp + 1 days; + + ( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory sig + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration + ); + + vm.prank(writer); + + token.safeTransferFrom( + writer, + vaultAddress, + tokenId, + abi.encode(entitlement, sig) + ); + + IHookERC721Vault vaultImpl = IHookERC721Vault(vaultAddress); + IERC721FlashLoanReceiver flashLoan = new FlashLoanVerifyCalldata(); + + vm.prank(writer); + vaultImpl.flashLoan(tokenId, address(flashLoan), "hello world"); + // operation reverted, so we can still mess with the asset + assertTrue( + token.ownerOf(tokenId) == vaultAddress, + "good flashloan should work" + ); + } + + function testFlashWillRevert() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + address mockContract = address(69); + uint256 expiration = block.timestamp + 1 days; + + ( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory sig + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration + ); + + vm.prank(writer); + + token.safeTransferFrom( + writer, + vaultAddress, + tokenId, + abi.encode(entitlement, sig) + ); + + IHookERC721Vault vaultImpl = IHookERC721Vault(vaultAddress); + IERC721FlashLoanReceiver flashLoan = new FlashLoanVerifyCalldata(); + + vm.prank(writer); + vm.expectRevert("should check helloworld"); + vaultImpl.flashLoan(tokenId, address(flashLoan), "hello world wrong!"); + // operation reverted, so we can still mess with the asset + assertTrue( + token.ownerOf(tokenId) == vaultAddress, + "good flashloan should work" + ); + } + + function testImposeEntitlementAfterInitialTransfer() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + address mockContract = address(69); + uint256 expiration = block.timestamp + 1 days; + + ( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory sig + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration + ); + + vm.prank(writer); + + token.safeTransferFrom(writer, vaultAddress, tokenId); + + IHookERC721Vault vaultImpl = IHookERC721Vault(vaultAddress); + + // impose the entitlement onto the vault + vm.prank(mockContract); + vaultImpl.imposeEntitlement(entitlement, sig); + + assertTrue( + vaultImpl.getHoldsAsset(tokenId), + "the token should be owned by the vault" + ); + assertTrue( + vaultImpl.getBeneficialOwner(tokenId) == writer, + "writer should be the beneficial owner" + ); + (bool active, address operator) = vaultImpl.getCurrentEntitlementOperator( + tokenId + ); + assertTrue(active, "there should be an active entitlement"); + assertTrue( + operator == mockContract, + "active entitlement is to correct person" + ); + + // verify that beneficial owner cannot withdrawl + // during an active entitlement. + vm.expectRevert( + "withdrawalAsset -- the asset canot be withdrawn with an active entitlement" + ); + vm.prank(writer); + vaultImpl.withdrawalAsset(tokenId); + } + + function testEntitlementGoesAwayAfterExpiration() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + address mockContract = address(69); + uint256 expiration = block.timestamp + 1 days; + + ( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory sig + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration + ); + + vm.prank(writer); + token.safeTransferFrom( + writer, + vaultAddress, + tokenId, + abi.encode(entitlement, sig) + ); + IHookERC721Vault vaultImpl = IHookERC721Vault(vaultAddress); + + (bool active, address operator) = vaultImpl.getCurrentEntitlementOperator( + tokenId + ); + assertTrue(active, "there should be an active entitlement"); + assertTrue( + operator == mockContract, + "active entitlement is to correct person" + ); + vm.warp(block.timestamp + 2 days); + + (active, operator) = vaultImpl.getCurrentEntitlementOperator(tokenId); + assertTrue(!active, "there should not be an active entitlement"); + + vm.prank(writer); + vaultImpl.withdrawalAsset(tokenId); + assertTrue( + !vaultImpl.getHoldsAsset(tokenId), + "the token should not be owned by the vault" + ); + + assertTrue( + token.ownerOf(tokenId) == writer, + "token should be owned by the writer" + ); + } + + function testEntitlementCanBeClearedByOperator() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + address mockContract = address(69); + uint256 expiration = block.timestamp + 1 days; + + ( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory sig + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration + ); + + vm.prank(writer); + token.safeTransferFrom( + writer, + vaultAddress, + tokenId, + abi.encode(entitlement, sig) + ); + IHookERC721Vault vaultImpl = IHookERC721Vault(vaultAddress); + + vm.prank(mockContract); + vaultImpl.clearEntitlement(tokenId); + + (bool active, address operator) = vaultImpl.getCurrentEntitlementOperator( + tokenId + ); + assertTrue(!active, "there should not be an active entitlement"); + + // check that the owner can actually withdrawl + vm.prank(writer); + vaultImpl.withdrawalAsset(tokenId); + assertTrue( + !vaultImpl.getHoldsAsset(tokenId), + "the token should not be owned by the vault" + ); + + assertTrue( + token.ownerOf(tokenId) == writer, + "token should be owned by the writer" + ); + } + + function testNewEntitlementPossibleAferExpiredEntitlement() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + address mockContract = address(69); + uint256 expiration = block.timestamp + 1 days; + + ( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory sig + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration + ); + + vm.prank(writer); + token.safeTransferFrom( + writer, + vaultAddress, + tokenId, + abi.encode(entitlement, sig) + ); + IHookERC721Vault vaultImpl = IHookERC721Vault(vaultAddress); + + (bool active, address operator) = vaultImpl.getCurrentEntitlementOperator( + tokenId + ); + assertTrue(active, "there should be an active entitlement"); + + vm.warp(block.timestamp + 2 days); + + (active, operator) = vaultImpl.getCurrentEntitlementOperator(tokenId); + assertTrue(!active, "there should not be an active entitlement"); + + // asset is not withdrawn, try to add a new entitlement + uint256 expiration2 = block.timestamp + 10 days; + + ( + Entitlements.Entitlement memory entitlement2, + Signatures.Signature memory sig2 + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration2 + ); + vaultImpl.imposeEntitlement(entitlement2, sig2); + (active, operator) = vaultImpl.getCurrentEntitlementOperator(tokenId); + assertTrue(active, "there should be an active entitlement"); + } + + function testNewEntitlementPossibleAfterClearedEntitlement() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + address mockContract = address(69); + uint256 expiration = block.timestamp + 1 days; + + ( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory sig + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration + ); + + vm.prank(writer); + token.safeTransferFrom( + writer, + vaultAddress, + tokenId, + abi.encode(entitlement, sig) + ); + IHookERC721Vault vaultImpl = IHookERC721Vault(vaultAddress); + + (bool active, address operator) = vaultImpl.getCurrentEntitlementOperator( + tokenId + ); + assertTrue(active, "there should be an active entitlement"); + vm.prank(mockContract); + vaultImpl.clearEntitlement(tokenId); + + (active, operator) = vaultImpl.getCurrentEntitlementOperator(tokenId); + assertTrue(!active, "there should not be an active entitlement"); + + uint256 expiration2 = block.timestamp + 3 days; + + ( + Entitlements.Entitlement memory entitlement2, + Signatures.Signature memory sig2 + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration2 + ); + + vaultImpl.imposeEntitlement(entitlement2, sig2); + (active, operator) = vaultImpl.getCurrentEntitlementOperator(tokenId); + assertTrue(active, "there should be an active entitlement"); + } + + function testOnlyOneEntitlementAllowed() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + address mockContract = address(3333); + uint256 expiration = block.timestamp + 1 days; + + ( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory sig + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration + ); + + // transfer in with first entitlement + vm.prank(writer); + token.safeTransferFrom( + writer, + vaultAddress, + tokenId, + abi.encode(entitlement, sig) + ); + IHookERC721Vault vaultImpl = IHookERC721Vault(vaultAddress); + + address mockContract2 = address(35553445); + (bool active, address operator) = vaultImpl.getCurrentEntitlementOperator( + tokenId + ); + assertTrue(active, "there should be an active entitlement"); + + uint256 expiration2 = block.timestamp + 3 days; + + ( + Entitlements.Entitlement memory entitlement2, + Signatures.Signature memory sig2 + ) = makeEntitlementAndSignature( + writerpkey, + mockContract2, + vaultAddress, + tokenId, + expiration2 + ); + + vm.prank(mockContract2); + vm.expectRevert( + "_verifyAndRegisterEntitlement -- existing entitlement must be cleared before registering a new one" + ); + + vaultImpl.imposeEntitlement(entitlement2, sig2); + } + + function testBeneficialOwnerCannotClearEntitlement() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + address mockContract = address(69420); + uint256 expiration = block.timestamp + 1 days; + + ( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory sig + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration + ); + + // transfer in with first entitlement + vm.prank(writer); + token.safeTransferFrom( + writer, + vaultAddress, + tokenId, + abi.encode(entitlement, sig) + ); + IHookERC721Vault vaultImpl = IHookERC721Vault(vaultAddress); + + (bool active, address operator) = vaultImpl.getCurrentEntitlementOperator( + tokenId + ); + assertTrue(active, "there should be an active entitlement"); + + vm.prank(writer); + vm.expectRevert( + "clearEntitlement -- only the entitled address can clear the entitlement" + ); + vaultImpl.clearEntitlement(tokenId); + + vm.prank(address(55566677788899911)); + vm.expectRevert( + "clearEntitlement -- only the entitled address can clear the entitlement" + ); + vaultImpl.clearEntitlement(tokenId); + } + + function testClearAndDistributeReturnsNFT2() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + address mockContract = address(69); + uint256 expiration = block.timestamp + 1 days; + + ( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory sig + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration + ); + + vm.prank(writer); + token.safeTransferFrom( + writer, + vaultAddress, + tokenId, + abi.encode(entitlement, sig) + ); + + IHookERC721Vault vaultImpl = IHookERC721Vault(vaultAddress); + vaultImpl.getBeneficialOwner(tokenId); + vm.prank(mockContract); + vaultImpl.clearEntitlementAndDistribute(tokenId, writer); + + (bool active, ) = vaultImpl.getCurrentEntitlementOperator(tokenId); + assertTrue(!active, "there should not be an active entitlement"); + + assertTrue( + token.ownerOf(tokenId) == writer, + "Token should be returned to the owner" + ); + } + + function testAirdropsCanBeDisbled() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + vm.prank(admin); + protocol.setCollectionConfig( + address(token), + keccak256("vault.airdropsProhibited"), + true + ); + + TestERC721 token2 = new TestERC721(); + vm.expectRevert( + "onERC721Received -- non-escrow asset returned when airdrops are disabled" + ); + token2.mint(vaultAddress, 0); + } + + function testAirdropsAllowedWhenEnabled() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + vm.prank(admin); + protocol.setCollectionConfig( + address(token), + keccak256("vault.airdropsProhibited"), + false + ); + + TestERC721 token2 = new TestERC721(); + token2.mint(vaultAddress, 0); + assertTrue(token2.ownerOf(0) == vaultAddress, "vault should hold airdrop"); + } + + function testClearAndDistributeDoesNotReturnToWrongPerson() public { + (address vaultAddress, uint256 tokenId) = createVaultandAsset(); + + address mockContract = address(69); + uint256 expiration = block.timestamp + 1 days; + + ( + Entitlements.Entitlement memory entitlement, + Signatures.Signature memory sig + ) = makeEntitlementAndSignature( + writerpkey, + mockContract, + vaultAddress, + tokenId, + expiration + ); + + vm.prank(writer); + token.safeTransferFrom( + writer, + vaultAddress, + tokenId, + abi.encode(entitlement, sig) + ); + IHookERC721Vault vaultImpl = IHookERC721Vault(vaultAddress); + + vm.expectRevert( + "clearEntitlementAndDistribute -- Only the beneficial owner can recieve the asset" + ); + vm.prank(mockContract); + vaultImpl.clearEntitlementAndDistribute(0, address(0x033333344545)); + } +} diff --git a/src/test/HookVaultTests.sol b/src/test/HookVaultTests.sol index 3cace63..4384a9e 100644 --- a/src/test/HookVaultTests.sol +++ b/src/test/HookVaultTests.sol @@ -31,9 +31,12 @@ contract HookVaultTests is HookProtocolTest { tokenStartIndex += 1; tokenId = tokenStartIndex; token.mint(address(writer), tokenId); - address vaultAddress = vault.makeVault(address(token), tokenId); + IHookERC721Vault vaultAddress = vault.findOrCreateVault( + address(token), + tokenId + ); vm.stopPrank(); - return (vaultAddress, tokenId); + return (address(vaultAddress), tokenId); } function makeEntitlementAndSignature( diff --git a/src/test/utils/base.sol b/src/test/utils/base.sol index 02eac44..6f5e228 100644 --- a/src/test/utils/base.sol +++ b/src/test/utils/base.sol @@ -14,6 +14,8 @@ import "../../HookERC721Vault.sol"; import "../../HookERC721VaultBeacon.sol"; import "../../HookERC721VaultFactory.sol"; import "../../HookERC721VaultImplV1.sol"; +import "../../HookERC721MultiVaultImplV1.sol"; +import "../../HookERC721MultiVaultBeacon.sol"; import "../../HookProtocol.sol"; import "../../lib/Entitlements.sol"; @@ -74,14 +76,25 @@ contract HookProtocolTest is Test, EIP712, PermissionConstants { // Deploy new vault factory HookERC721VaultImplV1 vaultImpl = new HookERC721VaultImplV1(); + HookERC721VaultBeacon vaultBeacon = new HookERC721VaultBeacon( address(vaultImpl), address(protocol), PermissionConstants.VAULT_UPGRADER ); + + HookERC721MultiVaultImplV1 multiVaultImpl = new HookERC721MultiVaultImplV1(); + + HookERC721MultiVaultBeacon multiVaultBeacon = new HookERC721MultiVaultBeacon( + address(multiVaultImpl), + address(protocol), + PermissionConstants.VAULT_UPGRADER + ); + vaultFactory = new HookERC721VaultFactory( protocolAddress, - address(vaultBeacon) + address(vaultBeacon), + address(multiVaultBeacon) ); vm.prank(address(admin)); protocol.setVaultFactory(address(vaultFactory)); @@ -160,14 +173,14 @@ contract HookProtocolTest is Test, EIP712, PermissionConstants { function makeSignature( uint256 tokenId, uint256 expiry, - address writer + address _writer ) internal returns (Signatures.Signature memory sig) { - try vaultFactory.makeVault(address(token), tokenId) {} catch {} - address va = vaultFactory.getVault(address(token), tokenId); + try vaultFactory.findOrCreateVault(address(token), tokenId) {} catch {} + address va = address(vaultFactory.getVault(address(token), tokenId)); bytes32 structHash = Entitlements.getEntitlementStructHash( Entitlements.Entitlement({ - beneficialOwner: address(writer), + beneficialOwner: address(_writer), operator: address(calls), vaultAddress: va, assetId: 0,