# Zora NFT Bridge ###### tags: `Aztec Connect` `NFTs` Zora created a 100% on-chain Nft marketplace with simple contract interfaces that make it relatively easy to integrate with Aztec Connect bridges. Here are some things we could build: ## Asks - Sell Nfts with the Zora Asks Module. [docs](https://docs.zora.co/docs/smart-contracts/modules/Asks/zora-v3-asks-intro) - the simplest case is to post an Nft for sale at a specific price - buy NFTs with the fillAsk function - just specify tokenId, contract address and price - https://docs.zora.co/docs/smart-contracts/modules/Asks/Core/zora-v3-asks-coreETH#fillask ## Auctions ### `createAuction` - creating an auction requires a decent amount of info, but it might be possible to pack all of this into 64 bits. - `tokenContract` and `tokenId` will be stored in the bridge contract already and accessed via the virtual asset id. - `duration` could be passed in as hours or days and multiplied out to the appropriate input - `reservePrice` could be passed in fractions of ETH and multiplied - `sellerFundsRecipient` could be passed via the `AddressRegistry` bridge, or defaults to the Zore<>Aztec bridge - `startTime` could be encoded in a compact way ```solidity! function createAuction( address _tokenContract, uint256 _tokenId, uint256 _duration, uint256 _reservePrice, address _sellerFundsRecipient, uint256 _startTime ) ``` ### `createBid` Something to consider. when this function is called, the NFT bid info is stored in `mapping(uint256 => NFTAsset) nftAssets` when it is placed. The contract can't know if a bid wins the NFT. For every successful bid on a given NFT, `nftAssets` is updated. If a bid wins, the associated NFT is transferred to the bridge and the holder of the corresponding virtual asset can withdraw it or transfer it to another bridge (to sell, create an auction, etc). If the bridge is written naively, the `nftAsset` mapping that is tracking NFT ownershp would be overwritten when a bid is placed (they need unique storage). These storage collisions can happen via other mechanisms for getting NFTs into the bridge contract (deposits and `createOffer`). - Bid on Zora auctions, [relevant Zora docs](https://docs.zora.co/docs/smart-contracts/modules/ReserveAuctions/Core/zora-v3-auctions-coreETH#createbid) - an Aztec user will get a virtual asset when they place a bid. - The Zora auctions contract requires bids to be >10% higher than the previous bid to be accepted. this bridge can assume that if a `createBid` call to the Zora auction contract is successful, then it is a valid, higher bid--so this bridge contract doesn't need to check or store bid amounts ### `cancelAuction` An auction can only be canceled before it has started. ### `settleAuction` Auctions can be settled anonymously from the bridge contract or from an Ethereum L1 account. ## Offers ### `fillOffer` - filling offers from aztec is trickier because it takes a `uint256 offerId`. - we could do the trick where there is a `mapping(uint64=>uint256)` and have people register offer ids. Create an `UintRegistery` similar to the `AddressRegistry`. Users would pass the `_offerId`, `_tokenId` and `_tokenContract` to `fillOffer` via auxData. - The ETH received comes back to the bridge contract, then can be returned to the takers Aztec account. ```solidity function fillOffer( address _tokenContract, uint256 _tokenId, uint256 _offerId, address _currency, uint256 _amount, address _finder ) ``` ### Other offers holding off on `createOffer`. see notes at the bottom for details on how this could be done. Preferrably in a different contract ## NounsBuilder Consider another bridge that allows Aztec account to anonymously bid on auctions created with the nouns builder framework. This can be make general to any auction that usees the Zora nouns builder tool, since they all use the same interface. Aztec users would just need to specify the address of the auction contract and the tokenId. [relevant docs here](https://docs.zora.co/docs/smart-contracts/nouns-builder/auctions) ## ZoraBridge.sol Structs ```solidity! struct NFTAsset { address collection; uint256 tokenId; bool isInEscrow; } ``` Storage ```solidity! // virtualAssetId/interactionNonce => NFTAsset // this is updated when a deposit is initiated, an offer is created or a bid is placed mapping(uint256 => NFTAsset) public nftAssets; // NFT => interactionNonce/virtualAssetId // helpful for looking up saved interactionNonce/NFTAsset info from offers/bids that may not be associated with a deposited NFT mapping(address => mapping(uint256 => uint256)) public nftToInteractionNonce ``` ### deposit must check that this bridge contract or the Zora auction contract does not already own the NFT, otherwise someone can use this step to claim ownership of an arbitrary NFT - ETH in - VIRTUAL out - `auxData` - collection - tokenId ```solidity! if (_totalInputValue != 1) { revert ErrorLib.InvalidInputAmount(); } uint256 collectionKey = _auxData & MASK_40; uint256 tokenId = _auxData >> 40; address collection = REGISTRY.addresses(collectionKey); if (collection == address(0x0)) { revert ErrorLib.InvalidAuxData(); } // make sure this contract, or the Zora contracts are not owners of the NFT. if any of these contracts are the owner, allowing a deposit could overwrite the true owner if( IERC721(collection).ownerOf(tokenId) == address(this) || IERC721(collection).ownerOf(tokenId) == address(ZORA_AUCTION_CONTRACT) || IERC721(collection).ownerOf(tokenId) == address(ZORA_ASKS_CONTRACT)){ revert InvalidNft(); // would overwrite current owner } // remove previous stored bids for this NFT, so they wont have ownership over the deposited NFT uint256 storedBid = nftToInteractionNonce[collection][tokenId]; if(storedBid > 0){ delete nftAssets[storedBid]; } nftAssets[_interactionNonce] = NFTAsset({collection: collection, tokenId: tokenId}); return (1, 0, false); ``` ### withdraw - VIRTUAL in - ETH out (not used == 0) - auxData - withdraw address ```solidity! NFTAsset memory token = nftAssets[_inputAssetA.id]; if (token.collection == address(0x0) || token.isInEscrow == false) { revert ErrorLib.InvalidInputA(); } address to = REGISTRY.addresses(_auxData); if (to == address(0x0)) { revert ErrorLib.InvalidAuxData(); } delete nftAssets[_inputAssetA.id]; emit NFTWithdraw(_inputAssetA.id, token.collection, token.tokenId); IERC721(token.collection).transferFrom(address(this), to, token.tokenId); return (0, 0, false); ``` ### transfer - VIRTUAL in - VIRTUAL out - auxData - recipient address ```solidity! NFTAsset memory token = nftAssets[_inputAssetA.id]; if (token.collection == address(0x0) || token.isInEscrow == false) { revert ErrorLib.InvalidInputA(); } address to = REGISTRY.addresses(_auxData); if (to == address(0x0)) { revert ErrorLib.InvalidAuxData(); } delete nftAssets[_inputAssetA.id]; emit NFTWithdraw(_inputAssetA.id, token.collection, token.tokenId); IERC721(token.collection).approve(to, token.tokenId); NFTVault(to).matchAndPull(_interactionNonce, token.collection, token.tokenId); return (1, 0, false); ``` ### createAsk ```solidity! function createAsk( address _tokenContract, uint256 _tokenId, uint256 _askPrice, address _askCurrency, address _sellerFundsRecipient, uint16 _findersFeeBps ) ``` if an ask is filled, how does the bridge contract know who the proceeds belong to? If it receives some ETH, how does it know which NFT sale it came from? This is still being explored, but this may be possible by creating a `new NftReceiver` contract (specified below). This contract is deployed and the address is passed to `createAsk` as the `_sellerFundsRecipient`, so that when the Ask is filled, the NftReceiver contract can relay the funds back to the bridge contract and back to the Aztec user. Not sure, so suggesting to specify a `_sellerFundsRecipient`. not ideal, maybe the auctions are the recommended path to sale - VIRTUAL in - VIRTUAL out - auxData - askPrice - `_sellerFundsRecipient` from address registry - findersfee? Pseudocode ```solidity NFTAsset memory token = nftAssets[inputAssetA.id]; //check that Zora can handle the nft, if not approve if(!IERC721(token.collection).isApprovedForAll(address(this), erc721TransferHelperAddress)){ IERC721(token.collection).setApprovedForAll(erc721TransferHelperAddress, true); }; //check that the the zora manager can handle the asks module if(!moduleManagerContract.isModuleApproved(address(this), mainnetZoraAddresses.AsksV1_1)){ moduleManagerContract.setApprovalForModule(mainnetZoraAddresses.AsksV1_1, true); } uint256 askPrice = parseAuxDataForAsk(auxData); address recipient = parseAuxDataForRecipient(auxData); ZoraAsks.createAsk(token.collection, token.tokenId, askPrice, address(0), 0); _updateVirtualAssetId(inputAssetA.id, _interactionNonce); return(1,0,false); ``` - check that the NFT is approved to be managed by Zora with`function isApprovedForAll(address _owner, address _operator)` - if not, set operator `function setApprovalForAll(address _operator, bool _approved)` - mainnet Zora TransferHelper `0x909e9efE4D87d1a6018C2065aE642b6D0447bc91` - check if the Asks module is approved for this token `function isModuleApproved(address _user, address _module) // Read Only Function` - if not, approve Asks `function setApprovalForModule(address _module, bool _approved)` --- ```solidity! // 1. some convert function listing the nft for sale deploys a new receiver bridge. // 2. Tell zora to send funds to the newly created fund reciever bridge. // 3. Fund receiver bridge can finalise an async flow that is finalised on sale. new NFTReceiver(collection, tokenId); contract NftReceiver { INTERACTION_NONCE = constant 100; address nftCollection; uint256 tokenId; constructor(address _collection, uint256 _tokenId){ nftCollection = _collection; tokenId = _tokenId; } function receive() external { NFT_BRIDGE(BRIDGE_ADDRESS).receiveETH(msg.value, NFT_Id); bool sent = address(BRIDGE_ADDRESS).transferFrom(msg.value); Aztec.processAsyncDeFiInteraction(interactionNonce); self_destuct(); } } ``` ### cancelAsk ```solidity! function cancelAsk(address _tokenContract, uint256 _tokenId) ``` - VIRTUAL in - VIRTUAL out - auxData - not used - get `_tokenkContract` and `_tokenId` from virtual asset id - call `cancelAsk` on Zora - call `_updateVirtualAssetId` ### fillAsk ```soldity! function fillAsk( address _tokenContract, uint256 _tokenId, address _fillCurrency, uint256 _fillAmount, address _finder ) ``` default to ETH currency - ETH in - VIRTUAL out - auxData - `tokenContract` (lookup in address registry) - `tokenId` ### createAuction ```solidity! function createAuction( address _tokenContract, uint256 _tokenId, uint256 _duration, uint256 _reservePrice, address _sellerFundsRecipient, uint256 _startTime ) ``` should sellerFundsRecipient be this bridge contract? can make customizable if room in auxData Check that the Zora contract is an approved operator for the NFT ([source](https://docs.zora.co/docs/guides/v3-approvals#erc-721-tokens)) and that the auction module has been approved ([source](https://docs.zora.co/docs/guides/v3-approvals#approving-modules-in-the-module-manager)). - VIRTUAL in - VIRTUAL out - auxData - duration (if specified in hours, 15 bits gives us a max of 1,365 days) - reservePrice - startTime - sellerFundsRecipient - parse auxData for inputs - call `createAuction` - call `_updateVirtualAssetId` ### createBid If a bid is successful, it can be assumed that the Zora auction exists and that it is >10% higher than a previous bid. Cannot bid on NFTs that are being auctioned from this contract. - ETH in - VIRTUAL out - auxData - function selector - `tokenContract` id from address registry (32 bits) - `tokenId` (32 bits) - store/update interaction nonce and NFT data in `nftToInteractionNonce` and `nftAssets` - call `createBid` on Zora ```solidity! function createBid(address _tokenContract, uint256 _tokenId) ``` ### cancelAuction Someone can only cancel an auction if it has not started yet. could use second output as a function selector flag - VIRTUAL in - VIRTUAL out - auxData - function selector flag - get NFTAsset info with virtual asset id - call `cancelAuction` on Zora - call `_updateVirtualAssetId` - return 1 VIRTUAL ```solidity! function cancelAuction(address _tokenContract, uint256 _tokenId) ``` ### settleAuction ```solidity! function settleAuction(address _tokenContract, uint256 _tokenId) ``` this function is public and can be called by anyone could use second output as a function selector flag - VIRTUAL in - VIRTUAL out - auxData - function selector flag - optional: pass `collection` + `tokenId` in auxData to allow anyone to settle an auction instead of passing via the virtual asset id - get NFTAsset info with virtual asset id - call `settleAuction` - call `_updateVirtualAssetId` ### fillOffer - VIRTUAL in - ETH out - auxData - `offerId` in UintRegistry - amount (must match offer exactly) - get `_tokenContract` and `_tokenId` from virtual asset id - get returned offer amount for given `offerId` - to calculate "[remaining profit](https://github.com/ourzora/v3/blob/1d4c0c951ccd5c1d446283ce6fef3757ad97b804/contracts/modules/Offers/V1/OffersV1.sol#L411-L425)" - call `address(this).balance` before - return the amount of ETH minus the protocol and royalty fees to the aztec account ```solidity uint256 offerId = UintRegistry(parseForOfferId(auxData)); NFTAsset nft = nftAssets[inputAssetA.id]; uint256 startBalance = address(this).balance; ZoraOffersV1.fillOffer(nft.collection, nft.tokenId, offerId, address(0), parseForAmount(auxData), address(0)); uint256 newBalance = address(this).balance; return (newBalance - startBalance, 0, false); ``` ZoraOffersV1 ```solidity! function fillOffer( address _tokenContract, uint256 _tokenId, uint256 _offerId, address _currency, uint256 _amount, address _finder ) ``` ### matchAndPull ```solidity! function matchAndPull(uint256 _virtualAssetId, address _collection, uint256 _tokenId) external { NFTAsset memory nft = nftAssets[_virtualAssetId]; if (nft.collection != _collection || nft.tokenId != _tokenId) { revert InvalidInput(); } nftAssets[_virtualAssetId].isInEscrow = true; IERC721(_collection).transferFrom(msg.sender, address(this), _tokenId); emit NFTDeposit(_virtualAssetId, _collection, _tokenId); } ``` ### _updateVirtualAssetId This function should be called from the convert function whenever the input and output assets are both VIRTUAL and the owner of the NFT should remain the same. eg. `createAsk`, `cancelAsk`, `createAuction`, `cancelAuction`. An interaction with the bridge contract "consumes" the input virtual asset and returns a new virtual asset. So, a `convert` call that takes VIRTUAL in and returns VIRTUAL while the owner remains the same must update the virtualAssetId/interactionNonce associated with the NFT. ```solidity! function _updateVirtualAssetId(uint256 _inputAssetId, uint256 _interactionNonce) internal { nftAssets[_interactionNonce] = nftAssets[_inputAssetId]; delete nftAssets[_inputAssetId]; } ``` ## Old notes ### `createOffer` - anonymously make offers on Nfts, [docs](https://docs.zora.co/docs/smart-contracts/modules/Offers/zora-v3-offers-latest) - creating offers is straightforward - This bridge contract can only keep track of 1 offer at a time, since it can't know when an offer is filled (or a bid in an auction is the winning bid). When a new offer is created, the previous offer should be canceled. - Because the bridge contract can't know when an offer is accepted or an auction is won, it can only keep track of one offer+auction combo. Once an auction is started, no one should be able to make offers on the same NFT from this contract. This can be checked by looking up the owner of the NFT--Zora [transfers ownership](https://docs.zora.co/docs/smart-contracts/modules/ReserveAuctions/zora-v3-reserve-auctions-intro#key-considerations-) to the auction module after the first successful bid, so if the `ownerOf` the token is the Zora auctions contract, offers should not be accepted. - The latest offers for each NFT will be tracked in storage (collection => tokenId => virtualAssetId). `mapping(address => mapping(uint256 => uint256)) public nftToInteractionNonce` - There are two strategies for deciding which offer should canceled and which should be saved. - The first would be to save the offer for the largest amount. This intuitively makes sense, but if the value of the NFT goes down then no new offers can be made from this bridge contract. - The second strategy is to always save the newest offer. - virtual asset ids associated with offers will be saved in a mapping for each NFT (collection => tokenId => virtualAssetId). `mapping(address => mapping(uint256 => uint256)) public nftToInteractionNonce`. this mapping will be updated as new offers come in. - the previous offer must be canceled when a new offer is created. there will be a mapping each NFT to the returned `offerId`, (collection => tokenId => offerId) `mapping(address => mapping(uint256 => uint256)) public previousOfferId`. Use this mapping to get the `offerId` to call [cancelOffer](https://docs.zora.co/docs/smart-contracts/modules/Offers/zora-v3-offers-latest#canceloffer) on the Zora contract before creating the new offer - This call in the `convert` function must check that this Zora bridge contract is not the owner of the NFT for an offer is being created, unless the `createOffer` function is in its own bridge contract. Allowing an offer to be created for an NFT that this bridge already owns would overwrite the current entry in the virtualAssetId=>NFTAsset mapping and thus overwrite the owner. - Given how the bridge contract tracks offers and NFT ownership, an - get `tokenContract` from address registry (in auxData) - `currency` is taken from input asset - `amount` from input amount - `tokenId` in auxData ```solidity! function createOffer( address _tokenContract, uint256 _tokenId, address _currency, //ERC-20 token address or address(0) for ETH uint256 _amount, uint16 _findersFeeBps ) returns (uint256) ``` ### `cancelOffer` Users should be able to cancel an offer. Since the contract can only store 1 offer at a time, `cancelOffer` should be called every time `createOffer` is called. ### _nftOwnerConflict **This function is not needed if createOffer and createBid are in separate bridge contracts.** check `nftToInteractionNonce` for the desired deposit/createBid/createOffer. if there is an entry, check `nftAssets` to see if that `virtualAssetId` is still associated with an NFT. if it is, check the owner in the NFT collection contract. if the owner is this bridge contract, or the Zora Auction contract, revert. otherwise allow the deposit. ```solidity! function _nftOwnerConflict(address _collection, uint256 _tokenId) internal returns(bool) { uint256 virtualAssetId = nftToInteractionNonce[_collection][_tokenId]; if(virtualAssetId == 0) return false; NFTAsset token = nftAssets[virtualAssetId]; if(token.collection == address(0)) return false; address owner = IERC721(_collection).ownerOf(_tokenId); if(owner == address(this) || owner == address(ZORA_AUCTION)){ revert; } return false; } ```