diff --git a/.gitmodules b/.gitmodules index 9b09120..1b60f72 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "lib/solidity-stringutils"] path = lib/solidity-stringutils url = https://github.com/Arachnid/solidity-stringutils +[submodule "lib/chainlink-brownie-contracts"] + path = lib/chainlink-brownie-contracts + url = https://github.com/smartcontractkit/chainlink-brownie-contracts diff --git a/README.md b/README.md index 079c172..cf62034 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![Mentioned in Awesome Foundry](https://awesome.re/mentioned-badge-flat.svg)](https://github.com/crisgarner/awesome-foundry) -# Foundry + Hardhat Diamonds +# Foundry + Hardhat Diamonds sure This is a mimimal template for [Diamonds](https://github.com/ethereum/EIPs/issues/2535) which allows facet selectors to be generated on the go in solidity tests! diff --git a/contracts/facets/BorrowerFacet.sol b/contracts/facets/BorrowerFacet.sol new file mode 100644 index 0000000..705b491 --- /dev/null +++ b/contracts/facets/BorrowerFacet.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IBorrowerFacet} from "../interfaces/IBorrowerFacet.sol"; +import {IERC721} from "../interfaces/IERC721.sol"; +import {LibAppStorage} from "../libraries/LibAppStorage.sol"; +import {LibDiamond} from "../libraries/LibDiamond.sol"; + +contract BorrowerFacet is IBorrowerFacet { + error TokenNotOwned(uint256 tokenId); + error TokenAlreadyBorrowed(uint256 tokenId); + error TokenNotBorrowed(uint256 tokenId); + error BorrowNotExpired(uint256 tokenId); + error DurationTooLong(uint256 duration); + error InsufficientFee(uint256 sent, uint256 required); + error Unauthorized(); + + event TokenBorrowed(address indexed borrower, uint256 indexed tokenId, uint256 duration, uint256 fee, uint256 deadline); + event TokenReturned(address indexed borrower, uint256 indexed tokenId); + event BorrowFeeUpdated(uint256 oldFee, uint256 newFee); + event MaxBorrowDurationUpdated(uint256 oldDuration, uint256 newDuration); + + function borrowToken(uint256 tokenId, uint256 duration) external payable { + LibAppStorage.NFTStorage storage nft = LibAppStorage.nftStorage(); + LibAppStorage.BorrowStorage storage brw = LibAppStorage.borrowStorage(); + + if (nft.owners[tokenId] == address(0)) revert TokenNotOwned(tokenId); + if (nft.owners[tokenId] == msg.sender) revert TokenNotOwned(tokenId); + if (brw.borrows[tokenId].active) revert TokenAlreadyBorrowed(tokenId); + if (duration > brw.maxBorrowDuration) revert DurationTooLong(duration); + + uint256 fee = brw.borrowFee; + if (msg.value < fee) revert InsufficientFee(msg.value, fee); + + address originalOwner = nft.owners[tokenId]; + brw.originalOwners[tokenId] = originalOwner; + + nft.balances[originalOwner] -= 1; + nft.balances[msg.sender] += 1; + nft.owners[tokenId] = msg.sender; + + brw.borrows[tokenId] = LibAppStorage.BorrowInfoStruct({ + borrower: msg.sender, + deadline: block.timestamp + duration, + fee: fee, + active: true + }); + + emit TokenBorrowed(msg.sender, tokenId, duration, fee, block.timestamp + duration); + } + + function returnToken(uint256 tokenId) external { + LibAppStorage.NFTStorage storage nft = LibAppStorage.nftStorage(); + LibAppStorage.BorrowStorage storage brw = LibAppStorage.borrowStorage(); + LibAppStorage.BorrowInfoStruct storage borrow = brw.borrows[tokenId]; + + if (!borrow.active) revert TokenNotBorrowed(tokenId); + if (borrow.borrower != msg.sender) revert Unauthorized(); + if (block.timestamp < borrow.deadline) revert BorrowNotExpired(tokenId); + + address originalOwner = brw.originalOwners[tokenId]; + + nft.balances[msg.sender] -= 1; + nft.balances[originalOwner] += 1; + nft.owners[tokenId] = originalOwner; + + delete brw.borrows[tokenId]; + delete brw.originalOwners[tokenId]; + + emit TokenReturned(msg.sender, tokenId); + } + + function getBorrowInfo(uint256 tokenId) external view returns ( + address borrower, + uint256 deadline, + uint256 fee, + bool active + ) { + LibAppStorage.BorrowInfoStruct storage b = LibAppStorage.borrowStorage().borrows[tokenId]; + return (b.borrower, b.deadline, b.fee, b.active); + } + + function isBorrowed(uint256 tokenId) external view returns (bool) { + return LibAppStorage.borrowStorage().borrows[tokenId].active; + } + + function borrowFee() external view returns (uint256) { + return LibAppStorage.borrowStorage().borrowFee; + } + + function maxBorrowDuration() external view returns (uint256) { + return LibAppStorage.borrowStorage().maxBorrowDuration; + } + + function setBorrowFee(uint256 _fee) external { + LibDiamond.enforceIsContractOwner(); + uint256 oldFee = LibAppStorage.borrowStorage().borrowFee; + LibAppStorage.borrowStorage().borrowFee = _fee; + emit BorrowFeeUpdated(oldFee, _fee); + } + + function setMaxBorrowDuration(uint256 _duration) external { + LibDiamond.enforceIsContractOwner(); + uint256 oldDuration = LibAppStorage.borrowStorage().maxBorrowDuration; + LibAppStorage.borrowStorage().maxBorrowDuration = _duration; + emit MaxBorrowDurationUpdated(oldDuration, _duration); + } +} diff --git a/contracts/facets/DiamondCutFacet.sol b/contracts/facets/DiamondCutFacet.sol index bc0a69a..0b98f15 100644 --- a/contracts/facets/DiamondCutFacet.sol +++ b/contracts/facets/DiamondCutFacet.sol @@ -1,21 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -/******************************************************************************\ -* Author: Nick Mudge (https://twitter.com/mudgen) -* EIP-2535 Diamonds: https://eips.ethereum.org/EIPS/eip-2535 -/******************************************************************************/ - -import { IDiamondCut } from "../interfaces/IDiamondCut.sol"; -import { LibDiamond } from "../libraries/LibDiamond.sol"; +import {IDiamondCut} from "../interfaces/IDiamondCut.sol"; +import {LibDiamond} from "../libraries/LibDiamond.sol"; contract DiamondCutFacet is IDiamondCut { - /// @notice Add/replace/remove any number of functions and optionally execute - /// a function with delegatecall - /// @param _diamondCut Contains the facet addresses and function selectors - /// @param _init The address of the contract or facet to execute _calldata - /// @param _calldata A function call, including function selector and arguments - /// _calldata is executed with delegatecall on _init function diamondCut( FacetCut[] calldata _diamondCut, address _init, diff --git a/contracts/facets/ERC20Facet.sol b/contracts/facets/ERC20Facet.sol new file mode 100644 index 0000000..4c525d0 --- /dev/null +++ b/contracts/facets/ERC20Facet.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IERC20, IERC20Metadata} from "../interfaces/IERC20.sol"; +import {LibAppStorage} from "../libraries/LibAppStorage.sol"; +import {LibDiamond} from "../libraries/LibDiamond.sol"; + +contract ERC20Facet is IERC20, IERC20Metadata { + error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed); + error ERC20InvalidSender(address sender); + error ERC20InvalidReceiver(address receiver); + error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed); + error ERC20InvalidSpender(address spender); + error ERC20ExceedsMaxSupply(uint256 amount, uint256 maxSupply); + + function totalSupplyERC20() external view returns (uint256) { + return LibAppStorage.erc20Storage().erc20TotalSupply; + } + + function balanceOfERC20(address account) external view returns (uint256) { + return LibAppStorage.erc20Storage().erc20Balances[account]; + } + + function transfer(address to, uint256 amount) external returns (bool) { + _transfer(msg.sender, to, amount); + return true; + } + + function allowance(address owner, address spender) external view returns (uint256) { + return LibAppStorage.erc20Storage().erc20Allowances[owner][spender]; + } + + function approveERC20(address spender, uint256 amount) external returns (bool) { + _approve(msg.sender, spender, amount); + return true; + } + + function transferFromERC20(address from, address to, uint256 amount) external returns (bool) { + _spendAllowance(from, msg.sender, amount); + _transfer(from, to, amount); + return true; + } + + function nameERC20() external view returns (string memory) { + return LibAppStorage.erc20Storage().erc20Name; + } + + function symbolERC20() external view returns (string memory) { + return LibAppStorage.erc20Storage().erc20Symbol; + } + + function decimalsERC20() external view returns (uint8) { + return LibAppStorage.erc20Storage().erc20Decimals; + } + + function mintERC20(address to, uint256 amount) external { + LibDiamond.enforceIsContractOwner(); + if (to == address(0)) revert ERC20InvalidReceiver(address(0)); + + LibAppStorage.ERC20Storage storage s = LibAppStorage.erc20Storage(); + if (s.erc20TotalSupply + amount > s.erc20MaxSupply) + revert ERC20ExceedsMaxSupply(amount, s.erc20MaxSupply); + + s.erc20Balances[to] += amount; + s.erc20TotalSupply += amount; + emit Transfer(address(0), to, amount); + } + + function burnERC20(address from, uint256 amount) external { + LibDiamond.enforceIsContractOwner(); + _burn(from, amount); + } + + function burnERC20() external { + _burn(msg.sender, LibAppStorage.erc20Storage().erc20Balances[msg.sender]); + } + + function _transfer(address from, address to, uint256 amount) internal { + if (from == address(0)) revert ERC20InvalidSender(address(0)); + if (to == address(0)) revert ERC20InvalidReceiver(address(0)); + + LibAppStorage.ERC20Storage storage s = LibAppStorage.erc20Storage(); + uint256 fromBalance = s.erc20Balances[from]; + if (fromBalance < amount) + revert ERC20InsufficientBalance(from, fromBalance, amount); + + s.erc20Balances[from] = fromBalance - amount; + s.erc20Balances[to] += amount; + emit Transfer(from, to, amount); + } + + function _approve(address owner, address spender, uint256 amount) internal { + if (owner == address(0)) revert ERC20InvalidSender(address(0)); + if (spender == address(0)) revert ERC20InvalidSpender(address(0)); + + LibAppStorage.erc20Storage().erc20Allowances[owner][spender] = amount; + emit Approval(owner, spender, amount); + } + + function _spendAllowance(address owner, address spender, uint256 amount) internal { + uint256 currentAllowance = LibAppStorage.erc20Storage().erc20Allowances[owner][spender]; + if (currentAllowance != type(uint256).max) { + if (currentAllowance < amount) + revert ERC20InsufficientAllowance(spender, currentAllowance, amount); + LibAppStorage.erc20Storage().erc20Allowances[owner][spender] = currentAllowance - amount; + } + } + + function _burn(address from, uint256 amount) internal { + if (from == address(0)) revert ERC20InvalidSender(address(0)); + + LibAppStorage.ERC20Storage storage s = LibAppStorage.erc20Storage(); + uint256 fromBalance = s.erc20Balances[from]; + if (fromBalance < amount) + revert ERC20InsufficientBalance(from, fromBalance, amount); + + s.erc20Balances[from] = fromBalance - amount; + s.erc20TotalSupply -= amount; + emit Transfer(from, address(0), amount); + } +} diff --git a/contracts/facets/ERC721Facet.sol b/contracts/facets/ERC721Facet.sol new file mode 100644 index 0000000..20fef2b --- /dev/null +++ b/contracts/facets/ERC721Facet.sol @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IERC721, IERC721Metadata, IERC721Receiver} from "../interfaces/IERC721.sol"; +import {LibAppStorage} from "../libraries/LibAppStorage.sol"; + +contract ERC721Facet is IERC721, IERC721Metadata { + error ERC721InvalidOwner(address owner); + error ERC721NonexistentToken(uint256 tokenId); + error ERC721IncorrectOwner(address sender, uint256 tokenId, address owner); + error ERC721InvalidOperator(address operator); + + function balanceOf(address owner) external view returns (uint256) { + if (owner == address(0)) revert ERC721InvalidOwner(address(0)); + return LibAppStorage.nftStorage().balances[owner]; + } + + function ownerOf(uint256 tokenId) public view returns (address) { + address owner = LibAppStorage.nftStorage().owners[tokenId]; + if (owner == address(0)) revert ERC721NonexistentToken(tokenId); + return owner; + } + + function getApproved(uint256 tokenId) external view returns (address) { + _requireTokenExists(tokenId); + return LibAppStorage.nftStorage().tokenApprovals[tokenId]; + } + + function isApprovedForAll(address owner, address operator) external view returns (bool) { + return LibAppStorage.nftStorage().operatorApprovals[owner][operator]; + } + + function approve(address to, uint256 tokenId) external { + address owner = ownerOf(tokenId); + if (to == owner) revert ERC721InvalidOperator(to); + if ( + msg.sender != owner && + !LibAppStorage.nftStorage().operatorApprovals[owner][msg.sender] + ) { + revert ERC721InvalidOperator(msg.sender); + } + LibAppStorage.nftStorage().tokenApprovals[tokenId] = to; + emit Approval(owner, to, tokenId); + } + + function setApprovalForAll(address operator, bool approved) external { + if (operator == msg.sender) revert ERC721InvalidOperator(operator); + LibAppStorage.nftStorage().operatorApprovals[msg.sender][operator] = approved; + emit ApprovalForAll(msg.sender, operator, approved); + } + + function transferFrom(address from, address to, uint256 tokenId) public { + if (to == address(0)) revert ERC721InvalidOwner(address(0)); + address owner = ownerOf(tokenId); + if (from != owner) revert ERC721IncorrectOwner(msg.sender, tokenId, owner); + + LibAppStorage.NFTStorage storage s = LibAppStorage.nftStorage(); + + require( + msg.sender == owner || + s.tokenApprovals[tokenId] == msg.sender || + s.operatorApprovals[owner][msg.sender], + "ERC721: caller not token owner or approved" + ); + + delete s.tokenApprovals[tokenId]; + s.balances[from] -= 1; + s.balances[to] += 1; + s.owners[tokenId] = to; + + emit Transfer(from, to, tokenId); + } + + function safeTransferFrom(address from, address to, uint256 tokenId) public { + safeTransferFrom(from, to, tokenId, ""); + } + + function safeTransferFrom( + address from, + address to, + uint256 tokenId, + bytes memory data + ) public { + transferFrom(from, to, tokenId); + _checkOnERC721Received(from, to, tokenId, data); + } + + function name() external view returns (string memory) { + return LibAppStorage.nftStorage().name; + } + + function symbol() external view returns (string memory) { + return LibAppStorage.nftStorage().symbol; + } + + function tokenURI(uint256 tokenId) external view returns (string memory) { + _requireTokenExists(tokenId); + LibAppStorage.NFTStorage storage s = LibAppStorage.nftStorage(); + string memory base = s.baseTokenURI; + if (bytes(base).length == 0) return ""; + + string memory _tokenId = _toString(tokenId); + return string(abi.encodePacked(base, _tokenId)); + } + + function _requireTokenExists(uint256 tokenId) internal view { + if (LibAppStorage.nftStorage().owners[tokenId] == address(0)) + revert ERC721NonexistentToken(tokenId); + } + + function _checkOnERC721Received( + address from, + address to, + uint256 tokenId, + bytes memory data + ) internal { + if (to.code.length > 0) { + try + IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, data) + returns (bytes4 retval) { + if (retval != IERC721Receiver.onERC721Received.selector) { + revert("ERC721: transfer to non ERC721Receiver implementer"); + } + } catch (bytes memory reason) { + if (reason.length == 0) { + revert("ERC721: transfer to non ERC721Receiver implementer"); + } else { + assembly { + revert(add(32, reason), mload(reason)) + } + } + } + } + } + + function _toString(uint256 value) internal pure returns (string memory) { + if (value == 0) return "0"; + uint256 temp = value; + uint256 digits; + while (temp != 0) { + digits++; + temp /= 10; + } + bytes memory buffer = new bytes(digits); + while (value != 0) { + digits -= 1; + buffer[digits] = bytes1(uint8(48 + uint256(value % 10))); + value /= 10; + } + return string(buffer); + } +} diff --git a/contracts/facets/MarketplaceFacet.sol b/contracts/facets/MarketplaceFacet.sol new file mode 100644 index 0000000..d90213e --- /dev/null +++ b/contracts/facets/MarketplaceFacet.sol @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IMarketplaceFacet} from "../interfaces/IMarketplaceFacet.sol"; +import {IERC721} from "../interfaces/IERC721.sol"; +import {IERC20} from "../interfaces/IERC20.sol"; +import {LibAppStorage} from "../libraries/LibAppStorage.sol"; +import {LibDiamond} from "../libraries/LibDiamond.sol"; + +contract MarketplaceFacet is IMarketplaceFacet { + error TokenNotOwned(uint256 tokenId); + error TokenAlreadyListed(uint256 tokenId); + error TokenNotListed(uint256 tokenId); + error ListingNotActive(uint256 tokenId); + error InsufficientPayment(uint256 sent, uint256 required); + error Unauthorized(); + error InvalidPrice(); + error InvalidFee(); + + event NFTListed(uint256 indexed tokenId, address indexed seller, uint256 price); + event NFTSold(uint256 indexed tokenId, address indexed seller, address indexed buyer, uint256 price); + event NFTDelisted(uint256 indexed tokenId, address indexed seller); + event MarketplaceFeeUpdated(uint256 oldFee, uint256 newFee); + + function listNFT(uint256 tokenId, uint256 price) external { + LibAppStorage.NFTStorage storage nft = LibAppStorage.nftStorage(); + LibAppStorage.MarketplaceStorage storage mkt = LibAppStorage.marketplaceStorage(); + + if (mkt.listings[tokenId].active) revert TokenAlreadyListed(tokenId); + if (nft.owners[tokenId] != msg.sender) revert TokenNotOwned(tokenId); + if (price == 0) revert InvalidPrice(); + + nft.balances[msg.sender] -= 1; + nft.owners[tokenId] = address(this); + + mkt.listings[tokenId] = LibAppStorage.MarketplaceListing({ + tokenId: tokenId, + seller: msg.sender, + price: price, + active: true + }); + mkt.listingTokenIds.push(tokenId); + + emit NFTListed(tokenId, msg.sender, price); + } + + function buyNFT(uint256 tokenId) external payable { + LibAppStorage.NFTStorage storage nft = LibAppStorage.nftStorage(); + LibAppStorage.MarketplaceStorage storage mkt = LibAppStorage.marketplaceStorage(); + LibAppStorage.ERC20Storage storage erc20 = LibAppStorage.erc20Storage(); + LibAppStorage.MarketplaceListing storage listing = mkt.listings[tokenId]; + + if (!listing.active) revert ListingNotActive(tokenId); + if (listing.tokenId != tokenId) revert TokenNotListed(tokenId); + + uint256 price = listing.price; + if (msg.value < price) revert InsufficientPayment(msg.value, price); + + address seller = listing.seller; + uint256 feeAmount = (price * mkt.marketplaceFee) / 10000; + uint256 sellerAmount = price - feeAmount; + + if (erc20.erc20Balances[msg.sender] < price) + revert InsufficientPayment(msg.value, price); + + erc20.erc20Balances[msg.sender] -= price; + erc20.erc20Balances[seller] += sellerAmount; + + address diamondOwner = LibDiamond.contractOwner(); + erc20.erc20Balances[diamondOwner] += feeAmount; + + nft.balances[msg.sender] += 1; + nft.owners[tokenId] = msg.sender; + + listing.active = false; + _removeListingTokenId(tokenId); + + emit NFTSold(tokenId, seller, msg.sender, price); + } + + function delistNFT(uint256 tokenId) external { + LibAppStorage.NFTStorage storage nft = LibAppStorage.nftStorage(); + LibAppStorage.MarketplaceStorage storage mkt = LibAppStorage.marketplaceStorage(); + LibAppStorage.MarketplaceListing storage listing = mkt.listings[tokenId]; + + if (!listing.active) revert ListingNotActive(tokenId); + if (listing.seller != msg.sender) revert Unauthorized(); + + nft.balances[msg.sender] += 1; + nft.owners[tokenId] = msg.sender; + + listing.active = false; + _removeListingTokenId(tokenId); + + emit NFTDelisted(tokenId, msg.sender); + } + + function getListings() external view returns (IMarketplaceFacet.Listing[] memory) { + LibAppStorage.MarketplaceStorage storage mkt = LibAppStorage.marketplaceStorage(); + uint256 count = mkt.listingTokenIds.length; + IMarketplaceFacet.Listing[] memory activeListings = new IMarketplaceFacet.Listing[](count); + uint256 activeCount; + + for (uint256 i = 0; i < count; i++) { + uint256 tokenId = mkt.listingTokenIds[i]; + if (mkt.listings[tokenId].active) { + LibAppStorage.MarketplaceListing storage l = mkt.listings[tokenId]; + activeListings[activeCount] = IMarketplaceFacet.Listing({ + tokenId: l.tokenId, + seller: l.seller, + price: l.price, + active: l.active + }); + activeCount++; + } + } + + assembly { + mstore(activeListings, activeCount) + } + + return activeListings; + } + + function getListing(uint256 tokenId) external view returns (IMarketplaceFacet.Listing memory) { + LibAppStorage.MarketplaceListing storage l = LibAppStorage.marketplaceStorage().listings[tokenId]; + return IMarketplaceFacet.Listing({ + tokenId: l.tokenId, + seller: l.seller, + price: l.price, + active: l.active + }); + } + + function isListed(uint256 tokenId) external view returns (bool) { + return LibAppStorage.marketplaceStorage().listings[tokenId].active; + } + + function marketplaceFee() external view returns (uint256) { + return LibAppStorage.marketplaceStorage().marketplaceFee; + } + + function setMarketplaceFee(uint256 _fee) external { + LibDiamond.enforceIsContractOwner(); + if (_fee > 1000) revert InvalidFee(); + uint256 oldFee = LibAppStorage.marketplaceStorage().marketplaceFee; + LibAppStorage.marketplaceStorage().marketplaceFee = _fee; + emit MarketplaceFeeUpdated(oldFee, _fee); + } + + function _removeListingTokenId(uint256 tokenId) internal { + LibAppStorage.MarketplaceStorage storage mkt = LibAppStorage.marketplaceStorage(); + for (uint256 i = 0; i < mkt.listingTokenIds.length; i++) { + if (mkt.listingTokenIds[i] == tokenId) { + mkt.listingTokenIds[i] = mkt.listingTokenIds[mkt.listingTokenIds.length - 1]; + mkt.listingTokenIds.pop(); + break; + } + } + } +} diff --git a/contracts/facets/MintFacet.sol b/contracts/facets/MintFacet.sol new file mode 100644 index 0000000..bde938a --- /dev/null +++ b/contracts/facets/MintFacet.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {LibAppStorage} from "../libraries/LibAppStorage.sol"; + +contract MintFacet { + error MaxSupplyReached(); + error MintNotActive(); + error InsufficientPayment(); + error InvalidAddress(); + + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + event Minted(address indexed to, uint256 indexed tokenId); + + modifier nonReentrant() { + LibAppStorage.NFTStorage storage s = LibAppStorage.nftStorage(); + require( + s.reentrancyStatus != LibAppStorage._ENTERED, + "ReentrancyGuard: reentrant call" + ); + s.reentrancyStatus = LibAppStorage._ENTERED; + _; + s.reentrancyStatus = LibAppStorage._NOT_ENTERED; + } + + function mint() external payable nonReentrant { + LibAppStorage.NFTStorage storage s = LibAppStorage.nftStorage(); + + if (!s.mintActive) revert MintNotActive(); + if (msg.value < s.mintPrice) revert InsufficientPayment(); + if (s.totalSupply >= s.maxSupply) revert MaxSupplyReached(); + + uint256 tokenId = s.totalSupply + 1; + + s.totalSupply = tokenId; + s.balances[msg.sender] += 1; + s.owners[tokenId] = msg.sender; + + emit Minted(msg.sender, tokenId); + emit Transfer(address(0), msg.sender, tokenId); + } + + function mintTo(address to) external payable nonReentrant { + if (to == address(0)) revert InvalidAddress(); + + LibAppStorage.NFTStorage storage s = LibAppStorage.nftStorage(); + + if (!s.mintActive) revert MintNotActive(); + if (msg.value < s.mintPrice) revert InsufficientPayment(); + if (s.totalSupply >= s.maxSupply) revert MaxSupplyReached(); + + uint256 tokenId = s.totalSupply + 1; + + s.totalSupply = tokenId; + s.balances[to] += 1; + s.owners[tokenId] = to; + + emit Minted(to, tokenId); + emit Transfer(address(0), to, tokenId); + } + + function totalSupply() external view returns (uint256) { + return LibAppStorage.nftStorage().totalSupply; + } + + function mintPrice() external view returns (uint256) { + return LibAppStorage.nftStorage().mintPrice; + } + + function mintActive() external view returns (bool) { + return LibAppStorage.nftStorage().mintActive; + } + + function maxSupply() external view returns (uint256) { + return LibAppStorage.nftStorage().maxSupply; + } +} diff --git a/contracts/facets/MultisigFacet.sol b/contracts/facets/MultisigFacet.sol new file mode 100644 index 0000000..228f2f4 --- /dev/null +++ b/contracts/facets/MultisigFacet.sol @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IMultisigFacet} from "../interfaces/IMultisigFacet.sol"; +import {IDiamondCut} from "../interfaces/IDiamondCut.sol"; +import {LibDiamond} from "../libraries/LibDiamond.sol"; +import {LibAppStorage} from "../libraries/LibAppStorage.sol"; + +contract MultisigFacet is IMultisigFacet { + error NotMultisigOwner(); + error ProposalNotFound(); + error ProposalAlreadyExecuted(); + error ProposalCancelledError(); + error ProposalDeadlineExpired(); + error ProposalDeadlineNotExpired(); + error AlreadyVoted(); + error ThresholdNotMet(); + error InvalidThreshold(); + error CannotRemoveLastOwner(); + error NotProposer(); + error InvalidAddress(); + + event ProposalCreated(uint256 indexed proposalId, address indexed proposer, string description); + event VoteCast(uint256 indexed proposalId, address indexed voter, bool support); + event ProposalExecuted(uint256 indexed proposalId); + event ProposalCancelled(uint256 indexed proposalId); + event OwnerAdded(address indexed owner); + event OwnerRemoved(address indexed owner); + event ThresholdUpdated(uint256 oldThreshold, uint256 newThreshold); + + modifier onlyMultisigOwner() { + if (!LibAppStorage.multisigStorage().isMultisigOwner[msg.sender]) + revert NotMultisigOwner(); + _; + } + + function initializeMultisig(address[] calldata _owners, uint256 _threshold) external { + LibDiamond.enforceIsContractOwner(); + LibAppStorage.MultisigStorage storage s = LibAppStorage.multisigStorage(); + + if (_owners.length == 0) revert InvalidAddress(); + if (_threshold == 0 || _threshold > _owners.length) revert InvalidThreshold(); + + for (uint256 i = 0; i < _owners.length; i++) { + if (_owners[i] == address(0)) revert InvalidAddress(); + if (!s.isMultisigOwner[_owners[i]]) { + s.multisigOwners.push(_owners[i]); + s.isMultisigOwner[_owners[i]] = true; + } + } + s.multisigThreshold = _threshold; + } + + function createProposal( + IDiamondCut.FacetCut[] calldata diamondCut, + address init, + bytes calldata initCalldata, + string calldata description + ) external onlyMultisigOwner returns (uint256) { + LibAppStorage.MultisigStorage storage s = LibAppStorage.multisigStorage(); + + s.multisigProposalCount++; + uint256 proposalId = s.multisigProposalCount; + + _storeProposalCut(proposalId, diamondCut, init, initCalldata); + + s.proposals[proposalId] = LibAppStorage.MultisigProposal({ + proposer: msg.sender, + votesFor: 0, + votesAgainst: 0, + deadline: block.timestamp + 7 days, + executed: false, + cancelled: false + }); + + emit ProposalCreated(proposalId, msg.sender, description); + return proposalId; + } + + function vote(uint256 proposalId, bool support) external onlyMultisigOwner { + LibAppStorage.MultisigStorage storage s = LibAppStorage.multisigStorage(); + + if (s.proposalVotes[proposalId][msg.sender]) revert AlreadyVoted(); + if (s.proposals[proposalId].executed) revert ProposalAlreadyExecuted(); + if (s.proposals[proposalId].cancelled) revert ProposalCancelledError(); + if (block.timestamp >= s.proposals[proposalId].deadline) revert ProposalDeadlineExpired(); + + s.proposalVotes[proposalId][msg.sender] = true; + + if (support) { + s.proposals[proposalId].votesFor++; + } else { + s.proposals[proposalId].votesAgainst++; + } + + emit VoteCast(proposalId, msg.sender, support); + } + + function executeProposal(uint256 proposalId) external { + LibAppStorage.MultisigStorage storage s = LibAppStorage.multisigStorage(); + LibAppStorage.MultisigProposal storage proposal = s.proposals[proposalId]; + + if (proposal.proposer == address(0)) revert ProposalNotFound(); + if (proposal.executed) revert ProposalAlreadyExecuted(); + if (proposal.cancelled) revert ProposalCancelledError(); + if (block.timestamp < proposal.deadline) revert ProposalDeadlineNotExpired(); + if (proposal.votesFor < s.multisigThreshold) revert ThresholdNotMet(); + + proposal.executed = true; + + _executeProposalCut(proposalId); + + emit ProposalExecuted(proposalId); + } + + function cancelProposal(uint256 proposalId) external { + LibAppStorage.MultisigStorage storage s = LibAppStorage.multisigStorage(); + LibAppStorage.MultisigProposal storage proposal = s.proposals[proposalId]; + + if (proposal.proposer == address(0)) revert ProposalNotFound(); + if (proposal.proposer != msg.sender) revert NotProposer(); + if (proposal.executed) revert ProposalAlreadyExecuted(); + + proposal.cancelled = true; + emit ProposalCancelled(proposalId); + } + + function addOwner(address newOwner) external onlyMultisigOwner { + if (newOwner == address(0)) revert InvalidAddress(); + LibAppStorage.MultisigStorage storage s = LibAppStorage.multisigStorage(); + + if (!s.isMultisigOwner[newOwner]) { + s.multisigOwners.push(newOwner); + s.isMultisigOwner[newOwner] = true; + } + emit OwnerAdded(newOwner); + } + + function removeOwner(address owner) external onlyMultisigOwner { + LibAppStorage.MultisigStorage storage s = LibAppStorage.multisigStorage(); + + if (s.multisigOwners.length <= s.multisigThreshold) + revert CannotRemoveLastOwner(); + if (!s.isMultisigOwner[owner]) revert NotMultisigOwner(); + + s.isMultisigOwner[owner] = false; + + for (uint256 i = 0; i < s.multisigOwners.length; i++) { + if (s.multisigOwners[i] == owner) { + s.multisigOwners[i] = s.multisigOwners[s.multisigOwners.length - 1]; + s.multisigOwners.pop(); + break; + } + } + emit OwnerRemoved(owner); + } + + function setThreshold(uint256 newThreshold) external onlyMultisigOwner { + LibAppStorage.MultisigStorage storage s = LibAppStorage.multisigStorage(); + if (newThreshold == 0 || newThreshold > s.multisigOwners.length) + revert InvalidThreshold(); + + uint256 oldThreshold = s.multisigThreshold; + s.multisigThreshold = newThreshold; + emit ThresholdUpdated(oldThreshold, newThreshold); + } + + function getProposal(uint256 proposalId) external view returns (IMultisigFacet.ProposalView memory) { + LibAppStorage.MultisigProposal storage p = LibAppStorage.multisigStorage().proposals[proposalId]; + return IMultisigFacet.ProposalView({ + proposer: p.proposer, + votesFor: p.votesFor, + votesAgainst: p.votesAgainst, + deadline: p.deadline, + executed: p.executed, + cancelled: p.cancelled + }); + } + + function hasVoted(uint256 proposalId, address voter) external view returns (bool) { + return LibAppStorage.multisigStorage().proposalVotes[proposalId][voter]; + } + + function isOwner(address account) external view returns (bool) { + return LibAppStorage.multisigStorage().isMultisigOwner[account]; + } + + function getOwners() external view returns (address[] memory) { + return LibAppStorage.multisigStorage().multisigOwners; + } + + function threshold() external view returns (uint256) { + return LibAppStorage.multisigStorage().multisigThreshold; + } + + function proposalCount() external view returns (uint256) { + return LibAppStorage.multisigStorage().multisigProposalCount; + } + + struct ProposalCutData { + address[] facetAddresses; + bytes4[][] selectors; + uint8[] actions; + address init; + bytes initCalldata; + } + + mapping(uint256 => ProposalCutData) private proposalCutData; + + function _storeProposalCut( + uint256 proposalId, + IDiamondCut.FacetCut[] calldata diamondCut, + address init, + bytes calldata initCalldata + ) internal { + ProposalCutData storage cutData = proposalCutData[proposalId]; + cutData.init = init; + cutData.initCalldata = initCalldata; + + for (uint256 i = 0; i < diamondCut.length; i++) { + cutData.facetAddresses.push(diamondCut[i].facetAddress); + cutData.actions.push(uint8(diamondCut[i].action)); + + bytes4[] memory sels = diamondCut[i].functionSelectors; + bytes4[] memory storedSels = new bytes4[](sels.length); + for (uint256 j = 0; j < sels.length; j++) { + storedSels[j] = sels[j]; + } + cutData.selectors.push(storedSels); + } + } + + function _executeProposalCut(uint256 proposalId) internal { + ProposalCutData storage cutData = proposalCutData[proposalId]; + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](cutData.facetAddresses.length); + + for (uint256 i = 0; i < cutData.facetAddresses.length; i++) { + cut[i] = IDiamondCut.FacetCut({ + facetAddress: cutData.facetAddresses[i], + action: IDiamondCut.FacetCutAction(cutData.actions[i]), + functionSelectors: cutData.selectors[i] + }); + } + + LibDiamond.diamondCut(cut, cutData.init, cutData.initCalldata); + } +} diff --git a/contracts/facets/NFTAdminFacet.sol b/contracts/facets/NFTAdminFacet.sol new file mode 100644 index 0000000..79f72aa --- /dev/null +++ b/contracts/facets/NFTAdminFacet.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {LibAppStorage} from "../libraries/LibAppStorage.sol"; +import {LibDiamond} from "../libraries/LibDiamond.sol"; + +contract NFTAdminFacet { + function setMintActive(bool active) external { + LibDiamond.enforceIsContractOwner(); + LibAppStorage.nftStorage().mintActive = active; + } + + function setMintPrice(uint256 price) external { + LibDiamond.enforceIsContractOwner(); + LibAppStorage.nftStorage().mintPrice = price; + } + + function setBaseTokenURI(string calldata uri) external { + LibDiamond.enforceIsContractOwner(); + LibAppStorage.nftStorage().baseTokenURI = uri; + } + + function setMaxSupply(uint256 supply) external { + LibDiamond.enforceIsContractOwner(); + LibAppStorage.nftStorage().maxSupply = supply; + } + + function withdraw() external { + LibDiamond.enforceIsContractOwner(); + (bool success, ) = LibDiamond.contractOwner().call{value: address(this).balance}(""); + require(success, "Withdraw failed"); + } +} diff --git a/contracts/facets/OnchainSVGFacet.sol b/contracts/facets/OnchainSVGFacet.sol new file mode 100644 index 0000000..86c9ad7 --- /dev/null +++ b/contracts/facets/OnchainSVGFacet.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IOnchainSVG} from "../interfaces/IOnchainSVG.sol"; +import {LibAppStorage} from "../libraries/LibAppStorage.sol"; +import {LibDiamond} from "../libraries/LibDiamond.sol"; +import {SVGHelper} from "../libraries/SVGHelper.sol"; + +contract OnchainSVGFacet is IOnchainSVG { + error TokenDoesNotExist(uint256 tokenId); + error Unauthorized(); + error InvalidShape(string shape); + + event ShapeSet(uint256 indexed tokenId, string shape); + event ColorsSet(uint256 indexed tokenId, string primary, string secondary); + + modifier onlyTokenOwner(uint256 tokenId) { + if (LibAppStorage.nftStorage().owners[tokenId] == address(0)) + revert TokenDoesNotExist(tokenId); + if (LibAppStorage.nftStorage().owners[tokenId] != msg.sender && + msg.sender != LibDiamond.contractOwner()) + revert Unauthorized(); + _; + } + + function setTokenShape(uint256 tokenId, string calldata shape) external onlyTokenOwner(tokenId) { + if (!_isValidShape(shape)) revert InvalidShape(shape); + LibAppStorage.svgStorage().tokenShapes[tokenId] = shape; + emit ShapeSet(tokenId, shape); + } + + function setTokenColors(uint256 tokenId, string calldata primary, string calldata secondary) external onlyTokenOwner(tokenId) { + LibAppStorage.SVGStorage storage s = LibAppStorage.svgStorage(); + s.tokenPrimaryColors[tokenId] = primary; + s.tokenSecondaryColors[tokenId] = secondary; + emit ColorsSet(tokenId, primary, secondary); + } + + function tokenShape(uint256 tokenId) external view returns (string memory) { + return LibAppStorage.svgStorage().tokenShapes[tokenId]; + } + + function tokenColors(uint256 tokenId) external view returns (string memory primary, string memory secondary) { + LibAppStorage.SVGStorage storage s = LibAppStorage.svgStorage(); + return (s.tokenPrimaryColors[tokenId], s.tokenSecondaryColors[tokenId]); + } + + function generateSVG(uint256 tokenId) external view returns (string memory) { + if (LibAppStorage.nftStorage().owners[tokenId] == address(0)) + revert TokenDoesNotExist(tokenId); + return getTokenSVG(tokenId); + } + + function getTokenSVG(uint256 tokenId) public view returns (string memory) { + LibAppStorage.SVGStorage storage s = LibAppStorage.svgStorage(); + string memory shape = s.tokenShapes[tokenId]; + string memory primary = s.tokenPrimaryColors[tokenId]; + string memory secondary = s.tokenSecondaryColors[tokenId]; + + if (bytes(shape).length == 0) shape = "circle"; + if (bytes(primary).length == 0) primary = "#FF6B6B"; + if (bytes(secondary).length == 0) secondary = "#4ECDC4"; + + string memory svgContent = SVGHelper.generateShape(shape, primary, secondary, tokenId); + + return SVGHelper.wrapSVG( + svgContent, + string(abi.encodePacked("Token #", _toString(tokenId))) + ); + } + + function _isValidShape(string memory shape) internal pure returns (bool) { + return ( + keccak256(bytes(shape)) == keccak256(bytes("circle")) || + keccak256(bytes(shape)) == keccak256(bytes("square")) || + keccak256(bytes(shape)) == keccak256(bytes("triangle")) || + keccak256(bytes(shape)) == keccak256(bytes("diamond")) || + keccak256(bytes(shape)) == keccak256(bytes("hexagon")) || + keccak256(bytes(shape)) == keccak256(bytes("star")) + ); + } + + function _toString(uint256 value) internal pure returns (string memory) { + if (value == 0) return "0"; + uint256 temp = value; + uint256 digits; + while (temp != 0) { + digits++; + temp /= 10; + } + bytes memory buffer = new bytes(digits); + while (value != 0) { + digits -= 1; + buffer[digits] = bytes1(uint8(48 + uint256(value % 10))); + value /= 10; + } + return string(buffer); + } +} diff --git a/contracts/facets/StakingFacet.sol b/contracts/facets/StakingFacet.sol new file mode 100644 index 0000000..e21b741 --- /dev/null +++ b/contracts/facets/StakingFacet.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IStakingFacet} from "../interfaces/IStakingFacet.sol"; +import {IERC721} from "../interfaces/IERC721.sol"; +import {LibAppStorage} from "../libraries/LibAppStorage.sol"; +import {LibDiamond} from "../libraries/LibDiamond.sol"; + +contract StakingFacet is IStakingFacet { + error TokenNotOwned(uint256 tokenId); + error TokenNotStaked(uint256 tokenId); + error TokenAlreadyStaked(uint256 tokenId); + error MinStakeDurationNotMet(uint256 tokenId); + error Unauthorized(); + + event Staked(address indexed user, uint256 indexed tokenId, uint256 timestamp); + event Unstaked(address indexed user, uint256 indexed tokenId, uint256 timestamp, uint256 reward); + event RewardClaimed(address indexed user, uint256 amount); + event RewardRateUpdated(uint256 oldRate, uint256 newRate); + event MinStakeDurationUpdated(uint256 oldDuration, uint256 newDuration); + + function stake(uint256 tokenId) external { + LibAppStorage.NFTStorage storage nft = LibAppStorage.nftStorage(); + LibAppStorage.StakingStorage storage stk = LibAppStorage.stakingStorage(); + + if (stk.stakes[tokenId].staker != address(0)) revert TokenAlreadyStaked(tokenId); + if (nft.owners[tokenId] != msg.sender) revert TokenNotOwned(tokenId); + + IERC721(address(this)).transferFrom(msg.sender, address(this), tokenId); + + stk.stakes[tokenId] = LibAppStorage.StakeInfo({ + staker: msg.sender, + timestamp: block.timestamp, + reward: 0 + }); + stk.totalStaked++; + + emit Staked(msg.sender, tokenId, block.timestamp); + } + + function unstake(uint256 tokenId) external { + LibAppStorage.NFTStorage storage nft = LibAppStorage.nftStorage(); + LibAppStorage.StakingStorage storage stk = LibAppStorage.stakingStorage(); + LibAppStorage.StakeInfo storage stake = stk.stakes[tokenId]; + + if (stake.staker == address(0)) revert TokenNotStaked(tokenId); + if (stake.staker != msg.sender) revert Unauthorized(); + if (block.timestamp < stake.timestamp + stk.minStakeDuration) + revert MinStakeDurationNotMet(tokenId); + + uint256 reward = _calculateReward(tokenId); + + LibAppStorage.ERC20Storage storage erc20 = LibAppStorage.erc20Storage(); + erc20.erc20Balances[msg.sender] += reward; + erc20.erc20TotalSupply += reward; + + nft.balances[msg.sender] += 1; + nft.owners[tokenId] = msg.sender; + + delete stk.stakes[tokenId]; + stk.totalStaked--; + + emit Unstaked(msg.sender, tokenId, block.timestamp, reward); + } + + function claimRewards() external { + LibAppStorage.NFTStorage storage nft = LibAppStorage.nftStorage(); + LibAppStorage.StakingStorage storage stk = LibAppStorage.stakingStorage(); + LibAppStorage.ERC20Storage storage erc20 = LibAppStorage.erc20Storage(); + uint256 totalReward; + + uint256 supply = nft.totalSupply; + for (uint256 i = 1; i <= supply; i++) { + if (stk.stakes[i].staker == msg.sender) { + uint256 reward = _calculateReward(i); + if (reward > 0) { + stk.stakes[i].reward += reward; + stk.stakes[i].timestamp = block.timestamp; + totalReward += reward; + } + } + } + + if (totalReward > 0) { + erc20.erc20Balances[msg.sender] += totalReward; + erc20.erc20TotalSupply += totalReward; + emit RewardClaimed(msg.sender, totalReward); + } + } + + function getStakingInfo(uint256 tokenId) external view returns (address staker, uint256 timestamp, uint256 reward) { + LibAppStorage.StakeInfo storage stake = LibAppStorage.stakingStorage().stakes[tokenId]; + return (stake.staker, stake.timestamp, stake.reward); + } + + function getPendingReward(uint256 tokenId) external view returns (uint256) { + return _calculateReward(tokenId); + } + + function totalStaked() external view returns (uint256) { + return LibAppStorage.stakingStorage().totalStaked; + } + + function rewardRate() external view returns (uint256) { + return LibAppStorage.stakingStorage().rewardRate; + } + + function minStakeDuration() external view returns (uint256) { + return LibAppStorage.stakingStorage().minStakeDuration; + } + + function setRewardRate(uint256 _rate) external { + LibDiamond.enforceIsContractOwner(); + uint256 oldRate = LibAppStorage.stakingStorage().rewardRate; + LibAppStorage.stakingStorage().rewardRate = _rate; + emit RewardRateUpdated(oldRate, _rate); + } + + function setMinStakeDuration(uint256 _duration) external { + LibDiamond.enforceIsContractOwner(); + uint256 oldDuration = LibAppStorage.stakingStorage().minStakeDuration; + LibAppStorage.stakingStorage().minStakeDuration = _duration; + emit MinStakeDurationUpdated(oldDuration, _duration); + } + + function _calculateReward(uint256 tokenId) internal view returns (uint256) { + LibAppStorage.StakingStorage storage stk = LibAppStorage.stakingStorage(); + LibAppStorage.StakeInfo storage stake = stk.stakes[tokenId]; + + if (stake.staker == address(0)) return 0; + + uint256 duration = block.timestamp - stake.timestamp; + return (duration * stk.rewardRate) / 1 days; + } +} diff --git a/contracts/interfaces/IBorrowerFacet.sol b/contracts/interfaces/IBorrowerFacet.sol new file mode 100644 index 0000000..2e14972 --- /dev/null +++ b/contracts/interfaces/IBorrowerFacet.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IBorrowerFacet { + function borrowToken(uint256 tokenId, uint256 duration) external payable; + function returnToken(uint256 tokenId) external; + function getBorrowInfo(uint256 tokenId) external view returns ( + address borrower, + uint256 deadline, + uint256 fee, + bool active + ); + function isBorrowed(uint256 tokenId) external view returns (bool); + function borrowFee() external view returns (uint256); + function maxBorrowDuration() external view returns (uint256); +} diff --git a/contracts/interfaces/IERC20.sol b/contracts/interfaces/IERC20.sol new file mode 100644 index 0000000..cf4c22b --- /dev/null +++ b/contracts/interfaces/IERC20.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IERC20 { + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + function totalSupplyERC20() external view returns (uint256); + function balanceOfERC20(address account) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); + function allowance(address owner, address spender) external view returns (uint256); + function approveERC20(address spender, uint256 amount) external returns (bool); + function transferFromERC20(address from, address to, uint256 amount) external returns (bool); +} + +interface IERC20Metadata is IERC20 { + function nameERC20() external view returns (string memory); + function symbolERC20() external view returns (string memory); + function decimalsERC20() external view returns (uint8); +} diff --git a/contracts/interfaces/IERC721.sol b/contracts/interfaces/IERC721.sol new file mode 100644 index 0000000..7c0d1c9 --- /dev/null +++ b/contracts/interfaces/IERC721.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IERC721 { + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId); + event ApprovalForAll(address indexed owner, address indexed operator, bool approved); + + function balanceOf(address owner) external view returns (uint256); + function ownerOf(uint256 tokenId) external view returns (address); + function safeTransferFrom(address from, address to, uint256 tokenId) external; + function transferFrom(address from, address to, uint256 tokenId) external; + function approve(address to, uint256 tokenId) external; + function setApprovalForAll(address operator, bool approved) external; + function getApproved(uint256 tokenId) external view returns (address); + function isApprovedForAll(address owner, address operator) external view returns (bool); +} + +interface IERC721Metadata { + function name() external view returns (string memory); + function symbol() external view returns (string memory); + function tokenURI(uint256 tokenId) external view returns (string memory); +} + +interface IERC721Receiver { + function onERC721Received( + address operator, + address from, + uint256 tokenId, + bytes calldata data + ) external returns (bytes4); +} diff --git a/contracts/interfaces/IMarketplaceFacet.sol b/contracts/interfaces/IMarketplaceFacet.sol new file mode 100644 index 0000000..59a7c8f --- /dev/null +++ b/contracts/interfaces/IMarketplaceFacet.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IMarketplaceFacet { + struct Listing { + uint256 tokenId; + address seller; + uint256 price; + bool active; + } + + function listNFT(uint256 tokenId, uint256 price) external; + function buyNFT(uint256 tokenId) external payable; + function delistNFT(uint256 tokenId) external; + function getListings() external view returns (Listing[] memory); + function getListing(uint256 tokenId) external view returns (Listing memory); + function isListed(uint256 tokenId) external view returns (bool); + function marketplaceFee() external view returns (uint256); +} diff --git a/contracts/interfaces/IMultisigFacet.sol b/contracts/interfaces/IMultisigFacet.sol new file mode 100644 index 0000000..338d398 --- /dev/null +++ b/contracts/interfaces/IMultisigFacet.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IDiamondCut} from "./IDiamondCut.sol"; + +interface IMultisigFacet { + struct ProposalView { + address proposer; + uint256 votesFor; + uint256 votesAgainst; + uint256 deadline; + bool executed; + bool cancelled; + } + + function createProposal( + IDiamondCut.FacetCut[] calldata diamondCut, + address init, + bytes calldata initCalldata, + string calldata description + ) external returns (uint256); + + function vote(uint256 proposalId, bool support) external; + function executeProposal(uint256 proposalId) external; + function cancelProposal(uint256 proposalId) external; + function addOwner(address newOwner) external; + function removeOwner(address owner) external; + function setThreshold(uint256 newThreshold) external; + function getProposal(uint256 proposalId) external view returns (ProposalView memory); + function hasVoted(uint256 proposalId, address voter) external view returns (bool); + function isOwner(address account) external view returns (bool); + function getOwners() external view returns (address[] memory); + function threshold() external view returns (uint256); + function proposalCount() external view returns (uint256); +} diff --git a/contracts/interfaces/IOnchainSVG.sol b/contracts/interfaces/IOnchainSVG.sol new file mode 100644 index 0000000..b6c7df2 --- /dev/null +++ b/contracts/interfaces/IOnchainSVG.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IOnchainSVG { + function generateSVG(uint256 tokenId) external view returns (string memory); + function getTokenSVG(uint256 tokenId) external view returns (string memory); + function setTokenShape(uint256 tokenId, string calldata shape) external; + function setTokenColors(uint256 tokenId, string calldata primary, string calldata secondary) external; + function tokenShape(uint256 tokenId) external view returns (string memory); + function tokenColors(uint256 tokenId) external view returns (string memory primary, string memory secondary); +} diff --git a/contracts/interfaces/IStakingFacet.sol b/contracts/interfaces/IStakingFacet.sol new file mode 100644 index 0000000..b76d210 --- /dev/null +++ b/contracts/interfaces/IStakingFacet.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IStakingFacet { + function stake(uint256 tokenId) external; + function unstake(uint256 tokenId) external; + function claimRewards() external; + function getStakingInfo(uint256 tokenId) external view returns (address staker, uint256 timestamp, uint256 reward); + function getPendingReward(uint256 tokenId) external view returns (uint256); + function totalStaked() external view returns (uint256); + function rewardRate() external view returns (uint256); + function minStakeDuration() external view returns (uint256); +} diff --git a/contracts/libraries/LibAppStorage.sol b/contracts/libraries/LibAppStorage.sol new file mode 100644 index 0000000..3466ebf --- /dev/null +++ b/contracts/libraries/LibAppStorage.sol @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +library LibAppStorage { + uint256 constant _NOT_ENTERED = 1; + uint256 constant _ENTERED = 2; + + bytes32 constant NFT_STORAGE_POSITION = keccak256("nft.diamond.storage"); + bytes32 constant ERC20_STORAGE_POSITION = keccak256("erc20.diamond.storage"); + bytes32 constant STAKING_STORAGE_POSITION = keccak256("staking.diamond.storage"); + bytes32 constant BORROW_STORAGE_POSITION = keccak256("borrow.diamond.storage"); + bytes32 constant MARKETPLACE_STORAGE_POSITION = keccak256("marketplace.diamond.storage"); + bytes32 constant SVG_STORAGE_POSITION = keccak256("svg.diamond.storage"); + bytes32 constant MULTISIG_STORAGE_POSITION = keccak256("multisig.diamond.storage"); + + // ==================== NFT Storage ==================== + + struct NFTStorage { + mapping(address => uint256) balances; + mapping(uint256 => address) owners; + mapping(uint256 => address) tokenApprovals; + mapping(address => mapping(address => bool)) operatorApprovals; + string name; + string symbol; + string baseTokenURI; + uint256 totalSupply; + uint256 maxSupply; + uint256 mintPrice; + bool mintActive; + uint256 reentrancyStatus; + } + + function nftStorage() internal pure returns (NFTStorage storage s) { + bytes32 position = NFT_STORAGE_POSITION; + assembly { s.slot := position } + } + + // ==================== ERC-20 Storage ==================== + + struct ERC20Storage { + string erc20Name; + string erc20Symbol; + uint8 erc20Decimals; + uint256 erc20TotalSupply; + uint256 erc20MaxSupply; + mapping(address => uint256) erc20Balances; + mapping(address => mapping(address => uint256)) erc20Allowances; + } + + function erc20Storage() internal pure returns (ERC20Storage storage s) { + bytes32 position = ERC20_STORAGE_POSITION; + assembly { s.slot := position } + } + + // ==================== Staking Storage ==================== + + struct StakeInfo { + address staker; + uint256 timestamp; + uint256 reward; + } + + struct StakingStorage { + mapping(uint256 => StakeInfo) stakes; + uint256 totalStaked; + uint256 rewardRate; + uint256 minStakeDuration; + } + + function stakingStorage() internal pure returns (StakingStorage storage s) { + bytes32 position = STAKING_STORAGE_POSITION; + assembly { s.slot := position } + } + + // ==================== Borrow Storage ==================== + + struct BorrowInfoStruct { + address borrower; + uint256 deadline; + uint256 fee; + bool active; + } + + struct BorrowStorage { + mapping(uint256 => BorrowInfoStruct) borrows; + mapping(uint256 => address) originalOwners; + uint256 borrowFee; + uint256 maxBorrowDuration; + } + + function borrowStorage() internal pure returns (BorrowStorage storage s) { + bytes32 position = BORROW_STORAGE_POSITION; + assembly { s.slot := position } + } + + // ==================== Marketplace Storage ==================== + + struct MarketplaceListing { + uint256 tokenId; + address seller; + uint256 price; + bool active; + } + + struct MarketplaceStorage { + mapping(uint256 => MarketplaceListing) listings; + uint256[] listingTokenIds; + uint256 marketplaceFee; + } + + function marketplaceStorage() internal pure returns (MarketplaceStorage storage s) { + bytes32 position = MARKETPLACE_STORAGE_POSITION; + assembly { s.slot := position } + } + + // ==================== SVG Storage ==================== + + struct SVGStorage { + mapping(uint256 => string) tokenShapes; + mapping(uint256 => string) tokenPrimaryColors; + mapping(uint256 => string) tokenSecondaryColors; + } + + function svgStorage() internal pure returns (SVGStorage storage s) { + bytes32 position = SVG_STORAGE_POSITION; + assembly { s.slot := position } + } + + // ==================== Multisig Storage ==================== + + struct MultisigProposal { + address proposer; + uint256 votesFor; + uint256 votesAgainst; + uint256 deadline; + bool executed; + bool cancelled; + } + + struct MultisigStorage { + address[] multisigOwners; + mapping(address => bool) isMultisigOwner; + mapping(uint256 => MultisigProposal) proposals; + mapping(uint256 => mapping(address => bool)) proposalVotes; + uint256 multisigProposalCount; + uint256 multisigThreshold; + } + + function multisigStorage() internal pure returns (MultisigStorage storage s) { + bytes32 position = MULTISIG_STORAGE_POSITION; + assembly { s.slot := position } + } + + // ==================== Reentrancy Modifier ==================== + + modifier nonReentrant() { + NFTStorage storage s = nftStorage(); + require(s.reentrancyStatus != _ENTERED, "ReentrancyGuard: reentrant call"); + s.reentrancyStatus = _ENTERED; + _; + s.reentrancyStatus = _NOT_ENTERED; + } +} diff --git a/contracts/libraries/SVGHelper.sol b/contracts/libraries/SVGHelper.sol new file mode 100644 index 0000000..64b4073 --- /dev/null +++ b/contracts/libraries/SVGHelper.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +library SVGHelper { + function wrapSVG(string memory content, string memory title) internal pure returns (string memory) { + return string( + abi.encodePacked( + '', + '', title, '', + '', + content, + '' + ) + ); + } + + function generateShape(string memory shape, string memory primary, string memory secondary, uint256 tokenId) internal pure returns (string memory) { + bytes32 seed = keccak256(abi.encodePacked(tokenId)); + uint256 x = 100 + (uint256(seed) % 200); + uint256 y = 100 + (uint256(keccak256(abi.encodePacked(seed, "y"))) % 200); + + if (keccak256(bytes(shape)) == keccak256(bytes("circle"))) { + return _circle(primary, secondary, x, y); + } else if (keccak256(bytes(shape)) == keccak256(bytes("square"))) { + return _square(primary, secondary, x, y); + } else if (keccak256(bytes(shape)) == keccak256(bytes("triangle"))) { + return _triangle(primary, secondary, x, y); + } else if (keccak256(bytes(shape)) == keccak256(bytes("diamond"))) { + return _diamond(primary, secondary, x, y); + } else if (keccak256(bytes(shape)) == keccak256(bytes("hexagon"))) { + return _hexagon(primary, secondary, x, y); + } else if (keccak256(bytes(shape)) == keccak256(bytes("star"))) { + return _star(primary, secondary, x, y); + } + return _circle(primary, secondary, x, y); + } + + function _circle(string memory primary, string memory secondary, uint256 x, uint256 y) internal pure returns (string memory) { + return string( + abi.encodePacked( + '', + '' + ) + ); + } + + function _square(string memory primary, string memory secondary, uint256 x, uint256 y) internal pure returns (string memory) { + return string( + abi.encodePacked( + '', + '' + ) + ); + } + + function _triangle(string memory primary, string memory secondary, uint256 x, uint256 y) internal pure returns (string memory) { + return string( + abi.encodePacked( + '', + '' + ) + ); + } + + function _diamond(string memory primary, string memory secondary, uint256 x, uint256 y) internal pure returns (string memory) { + return string( + abi.encodePacked( + '', + '' + ) + ); + } + + function _hexagon(string memory primary, string memory secondary, uint256 x, uint256 y) internal pure returns (string memory) { + return string( + abi.encodePacked( + '', + '' + ) + ); + } + + function _star(string memory primary, string memory secondary, uint256 x, uint256 y) internal pure returns (string memory) { + return string( + abi.encodePacked( + '', + '' + ) + ); + } + + function _uintToStr(uint256 value) internal pure returns (string memory) { + if (value == 0) return "0"; + uint256 temp = value; + uint256 digits; + while (temp != 0) { + digits++; + temp /= 10; + } + bytes memory buffer = new bytes(digits); + while (value != 0) { + digits -= 1; + buffer[digits] = bytes1(uint8(48 + uint256(value % 10))); + value /= 10; + } + return string(buffer); + } +} diff --git a/contracts/upgradeInitializers/DiamondInit.sol b/contracts/upgradeInitializers/DiamondInit.sol index 1a9ed48..9df01da 100644 --- a/contracts/upgradeInitializers/DiamondInit.sol +++ b/contracts/upgradeInitializers/DiamondInit.sol @@ -1,42 +1,71 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -/******************************************************************************\ -* Author: Nick Mudge (https://twitter.com/mudgen) -* EIP-2535 Diamonds: https://eips.ethereum.org/EIPS/eip-2535 -* -* Implementation of a diamond. -/******************************************************************************/ - import {LibDiamond} from "../libraries/LibDiamond.sol"; -import { IDiamondLoupe } from "../interfaces/IDiamondLoupe.sol"; -import { IDiamondCut } from "../interfaces/IDiamondCut.sol"; -import { IERC173 } from "../interfaces/IERC173.sol"; -import { IERC165 } from "../interfaces/IERC165.sol"; - -// It is exapected that this contract is customized if you want to deploy your diamond -// with data from a deployment script. Use the init function to initialize state variables -// of your diamond. Add parameters to the init funciton if you need to. +import {LibAppStorage} from "../libraries/LibAppStorage.sol"; +import {IDiamondLoupe} from "../interfaces/IDiamondLoupe.sol"; +import {IDiamondCut} from "../interfaces/IDiamondCut.sol"; +import {IERC173} from "../interfaces/IERC173.sol"; +import {IERC165} from "../interfaces/IERC165.sol"; +import {IERC721, IERC721Metadata} from "../interfaces/IERC721.sol"; +import {IERC20, IERC20Metadata} from "../interfaces/IERC20.sol"; -contract DiamondInit { +contract DiamondInit { + struct InitParams { + string name; + string symbol; + string baseTokenURI; + uint256 maxSupply; + uint256 mintPrice; + string erc20Name; + string erc20Symbol; + uint8 erc20Decimals; + uint256 erc20MaxSupply; + uint256 rewardRate; + uint256 minStakeDuration; + uint256 borrowFee; + uint256 maxBorrowDuration; + uint256 marketplaceFee; + } - // You can add parameters to this function in order to pass in - // data to set your own state variables - function init() external { - // adding ERC165 data + function init(InitParams calldata params) external { LibDiamond.DiamondStorage storage ds = LibDiamond.diamondStorage(); ds.supportedInterfaces[type(IERC165).interfaceId] = true; ds.supportedInterfaces[type(IDiamondCut).interfaceId] = true; ds.supportedInterfaces[type(IDiamondLoupe).interfaceId] = true; ds.supportedInterfaces[type(IERC173).interfaceId] = true; + ds.supportedInterfaces[type(IERC721).interfaceId] = true; + ds.supportedInterfaces[type(IERC721Metadata).interfaceId] = true; + ds.supportedInterfaces[type(IERC20).interfaceId] = true; + ds.supportedInterfaces[type(IERC20Metadata).interfaceId] = true; - // add your own state variables - // EIP-2535 specifies that the `diamondCut` function takes two optional - // arguments: address _init and bytes calldata _calldata - // These arguments are used to execute an arbitrary function using delegatecall - // in order to set state variables in the diamond during deployment or an upgrade - // More info here: https://eips.ethereum.org/EIPS/eip-2535#diamond-interface - } + LibAppStorage.NFTStorage storage nft = LibAppStorage.nftStorage(); + nft.name = params.name; + nft.symbol = params.symbol; + nft.baseTokenURI = params.baseTokenURI; + nft.maxSupply = params.maxSupply; + nft.mintPrice = params.mintPrice; + nft.totalSupply = 0; + nft.mintActive = false; + nft.reentrancyStatus = LibAppStorage._NOT_ENTERED; + LibAppStorage.ERC20Storage storage erc20 = LibAppStorage.erc20Storage(); + erc20.erc20Name = params.erc20Name; + erc20.erc20Symbol = params.erc20Symbol; + erc20.erc20Decimals = params.erc20Decimals; + erc20.erc20TotalSupply = 0; + erc20.erc20MaxSupply = params.erc20MaxSupply; -} \ No newline at end of file + LibAppStorage.StakingStorage storage staking = LibAppStorage.stakingStorage(); + staking.totalStaked = 0; + staking.rewardRate = params.rewardRate; + staking.minStakeDuration = params.minStakeDuration; + + LibAppStorage.BorrowStorage storage borrow = LibAppStorage.borrowStorage(); + borrow.borrowFee = params.borrowFee; + borrow.maxBorrowDuration = params.maxBorrowDuration; + + LibAppStorage.MarketplaceStorage storage mkt = LibAppStorage.marketplaceStorage(); + mkt.marketplaceFee = params.marketplaceFee; + } +} diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 0000000..e9701b8 --- /dev/null +++ b/foundry.lock @@ -0,0 +1,14 @@ +{ + "lib/chainlink-brownie-contracts": { + "tag": { + "name": "1.3.0", + "rev": "5cb41fbc9b525338b6098da5ea7dd0b7e92f89e4" + } + }, + "lib/forge-std": { + "rev": "564510058ab3db01577b772c275e081e678373f2" + }, + "lib/solidity-stringutils": { + "rev": "4b2fcc43fa0426e19ce88b1f1ec16f5903a2e461" + } +} \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index cc30df4..f4a3e8c 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,5 +6,12 @@ libs = [ ] ffi=true verbosity=5 +viaIR = true +optimizer = true +optimizer_runs = 10000 +evm_version = "paris" +[rpc_endpoints] +lisk_sepolia = "https://lisk.com" + # See more config options https://github.com/foundry-rs/foundry/tree/master/config \ No newline at end of file diff --git a/lib/chainlink-brownie-contracts b/lib/chainlink-brownie-contracts new file mode 160000 index 0000000..5cb41fb --- /dev/null +++ b/lib/chainlink-brownie-contracts @@ -0,0 +1 @@ +Subproject commit 5cb41fbc9b525338b6098da5ea7dd0b7e92f89e4 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6d5ac3d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "Foundry-Hardhat-Diamonds", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/script/DeployNFTDiamond.s.sol b/script/DeployNFTDiamond.s.sol new file mode 100644 index 0000000..5d4bec9 --- /dev/null +++ b/script/DeployNFTDiamond.s.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "forge-std/Script.sol"; +import {IDiamondCut} from "../contracts/interfaces/IDiamondCut.sol"; +import {Diamond} from "../contracts/Diamond.sol"; +import {DiamondCutFacet} from "../contracts/facets/DiamondCutFacet.sol"; +import {DiamondLoupeFacet} from "../contracts/facets/DiamondLoupeFacet.sol"; +import {OwnershipFacet} from "../contracts/facets/OwnershipFacet.sol"; +import {ERC721Facet} from "../contracts/facets/ERC721Facet.sol"; +import {MintFacet} from "../contracts/facets/MintFacet.sol"; +import {NFTAdminFacet} from "../contracts/facets/NFTAdminFacet.sol"; +import {ERC20Facet} from "../contracts/facets/ERC20Facet.sol"; +import {OnchainSVGFacet} from "../contracts/facets/OnchainSVGFacet.sol"; +import {StakingFacet} from "../contracts/facets/StakingFacet.sol"; +import {MultisigFacet} from "../contracts/facets/MultisigFacet.sol"; +import {BorrowerFacet} from "../contracts/facets/BorrowerFacet.sol"; +import {MarketplaceFacet} from "../contracts/facets/MarketplaceFacet.sol"; +import {DiamondInit} from "../contracts/upgradeInitializers/DiamondInit.sol"; +import {DiamondUpgradeHelper} from "../test/helpers/DiamondUpgradeHelper.sol"; + +contract DeployNFTDiamond is Script, DiamondUpgradeHelper { + function run() external { + vm.startBroadcast(); + + DiamondCutFacet diamondCutFacet = new DiamondCutFacet(); + Diamond diamond = new Diamond(msg.sender, address(diamondCutFacet)); + + console.log("DiamondCutFacet:", address(diamondCutFacet)); + console.log("Diamond:", address(diamond)); + + IDiamondCut.FacetCut[] memory allCuts = _buildCuts(address(diamond)); + + DiamondInit diamondInit = new DiamondInit(); + console.log("DiamondInit:", address(diamondInit)); + + bytes memory initCalldata = _encodeInit(diamondInit); + + executeDiamondCut( + IDiamondCut(address(diamond)), + allCuts, + address(diamondInit), + initCalldata + ); + + vm.stopBroadcast(); + + console.log("Deployment complete!"); + console.log("Diamond:", address(diamond)); + } + + function _buildCuts( + address diamond + ) internal returns (IDiamondCut.FacetCut[] memory) { + DiamondLoupeFacet diamondLoupeFacet = new DiamondLoupeFacet(); + OwnershipFacet ownershipFacet = new OwnershipFacet(); + ERC721Facet erc721Facet = new ERC721Facet(); + MintFacet mintFacet = new MintFacet(); + NFTAdminFacet adminFacet = new NFTAdminFacet(); + ERC20Facet erc20Facet = new ERC20Facet(); + OnchainSVGFacet svgFacet = new OnchainSVGFacet(); + StakingFacet stakingFacet = new StakingFacet(); + MultisigFacet multisigFacet = new MultisigFacet(); + BorrowerFacet borrowerFacet = new BorrowerFacet(); + MarketplaceFacet marketplaceFacet = new MarketplaceFacet(); + + console.log("DiamondLoupeFacet:", address(diamondLoupeFacet)); + console.log("OwnershipFacet:", address(ownershipFacet)); + console.log("ERC721Facet:", address(erc721Facet)); + console.log("MintFacet:", address(mintFacet)); + console.log("NFTAdminFacet:", address(adminFacet)); + console.log("ERC20Facet:", address(erc20Facet)); + console.log("OnchainSVGFacet:", address(svgFacet)); + console.log("StakingFacet:", address(stakingFacet)); + console.log("MultisigFacet:", address(multisigFacet)); + console.log("BorrowerFacet:", address(borrowerFacet)); + console.log("MarketplaceFacet:", address(marketplaceFacet)); + + address[] memory addrs = new address[](11); + addrs[0] = address(diamondLoupeFacet); + addrs[1] = address(ownershipFacet); + addrs[2] = address(erc721Facet); + addrs[3] = address(mintFacet); + addrs[4] = address(adminFacet); + addrs[5] = address(erc20Facet); + addrs[6] = address(svgFacet); + addrs[7] = address(stakingFacet); + addrs[8] = address(multisigFacet); + addrs[9] = address(borrowerFacet); + addrs[10] = address(marketplaceFacet); + + string[] memory names = new string[](11); + names[0] = "DiamondLoupeFacet"; + names[1] = "OwnershipFacet"; + names[2] = "ERC721Facet"; + names[3] = "MintFacet"; + names[4] = "NFTAdminFacet"; + names[5] = "ERC20Facet"; + names[6] = "OnchainSVGFacet"; + names[7] = "StakingFacet"; + names[8] = "MultisigFacet"; + names[9] = "BorrowerFacet"; + names[10] = "MarketplaceFacet"; + + return buildAddCutsByNames(addrs, names); + } + + function _encodeInit( + DiamondInit diamondInit + ) internal pure returns (bytes memory) { + return abi.encodeWithSelector( + diamondInit.init.selector, + DiamondInit.InitParams({ + name: "Diamond NFT", + symbol: "DNFT", + baseTokenURI: "https://ipfs.io/ipfs/QmRvSoppQ5MKfsT4p5Snheae1DG3Af2NhYXWpKNZBvz2Eo/00001.png", + maxSupply: 10000, + mintPrice: 0.05 ether, + erc20Name: "Diamond Token", + erc20Symbol: "DTKN", + erc20Decimals: 18, + erc20MaxSupply: 1000000 ether, + rewardRate: 10 ether, + minStakeDuration: 1 days, + borrowFee: 0.01 ether, + maxBorrowDuration: 30 days, + marketplaceFee: 250 + }) + ); + } +} diff --git a/test/BorrowerFacet.t.sol b/test/BorrowerFacet.t.sol new file mode 100644 index 0000000..77938c2 --- /dev/null +++ b/test/BorrowerFacet.t.sol @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "../contracts/interfaces/IDiamondCut.sol"; +import "../contracts/interfaces/IERC721.sol"; +import "../contracts/interfaces/IERC165.sol"; +import "../contracts/interfaces/IBorrowerFacet.sol"; +import "../contracts/libraries/LibDiamond.sol"; +import "../contracts/facets/DiamondCutFacet.sol"; +import "../contracts/facets/DiamondLoupeFacet.sol"; +import "../contracts/facets/OwnershipFacet.sol"; +import "../contracts/facets/ERC721Facet.sol"; +import "../contracts/facets/MintFacet.sol"; +import "../contracts/facets/NFTAdminFacet.sol"; +import "../contracts/facets/BorrowerFacet.sol"; +import "../contracts/Diamond.sol"; +import "../contracts/upgradeInitializers/DiamondInit.sol"; +import "../test/helpers/DiamondUpgradeHelper.sol"; + +contract BorrowerFacetTest is Test, DiamondUpgradeHelper { + Diamond diamond; + ERC721Facet erc721; + MintFacet mintFacet; + NFTAdminFacet adminFacet; + BorrowerFacet borrowerFacet; + + address owner = address(this); + address user1 = address(0xA11CE); + address user2 = address(0xB0B); + address user3 = address(0xC0D3); + + receive() external payable {} + + function setUp() public { + DiamondCutFacet diamondCutFacet = new DiamondCutFacet(); + diamond = new Diamond(owner, address(diamondCutFacet)); + + DiamondLoupeFacet diamondLoupeFacet = new DiamondLoupeFacet(); + OwnershipFacet ownershipFacet = new OwnershipFacet(); + ERC721Facet _erc721Facet = new ERC721Facet(); + MintFacet _mintFacet = new MintFacet(); + NFTAdminFacet _adminFacet = new NFTAdminFacet(); + BorrowerFacet _borrowerFacet = new BorrowerFacet(); + + DiamondInit diamondInit = new DiamondInit(); + + address[] memory facetAddresses = new address[](6); + facetAddresses[0] = address(diamondLoupeFacet); + facetAddresses[1] = address(ownershipFacet); + facetAddresses[2] = address(_erc721Facet); + facetAddresses[3] = address(_mintFacet); + facetAddresses[4] = address(_adminFacet); + facetAddresses[5] = address(_borrowerFacet); + + string[] memory facetNames = new string[](6); + facetNames[0] = "DiamondLoupeFacet"; + facetNames[1] = "OwnershipFacet"; + facetNames[2] = "ERC721Facet"; + facetNames[3] = "MintFacet"; + facetNames[4] = "NFTAdminFacet"; + facetNames[5] = "BorrowerFacet"; + + IDiamondCut.FacetCut[] memory cuts = buildAddCutsByNames( + facetAddresses, + facetNames + ); + + bytes memory initCalldata = abi.encodeWithSelector( + diamondInit.init.selector, + DiamondInit.InitParams({ + name: "Diamond NFT", + symbol: "DNFT", + baseTokenURI: "https://api.example.com/metadata/", + maxSupply: 100, + mintPrice: 0.05 ether, + erc20Name: "Diamond Token", + erc20Symbol: "DTKN", + erc20Decimals: 18, + erc20MaxSupply: 1000000 ether, + rewardRate: 10 ether, + minStakeDuration: 1 days, + borrowFee: 0.01 ether, + maxBorrowDuration: 30 days, + marketplaceFee: 250 + }) + ); + + executeDiamondCut( + IDiamondCut(address(diamond)), + cuts, + address(diamondInit), + initCalldata + ); + + erc721 = ERC721Facet(address(diamond)); + mintFacet = MintFacet(address(diamond)); + adminFacet = NFTAdminFacet(address(diamond)); + borrowerFacet = BorrowerFacet(address(diamond)); + + vm.deal(user1, 100 ether); + vm.deal(user2, 100 ether); + vm.deal(user3, 100 ether); + } + + function _mintToken(address to) internal returns (uint256) { + adminFacet.setMintActive(true); + uint256 beforeSupply = mintFacet.totalSupply(); + vm.prank(to); + mintFacet.mint{value: 0.05 ether}(); + return beforeSupply + 1; + } + + function test_initial_borrow_state() public { + assertEq(borrowerFacet.borrowFee(), 0.01 ether); + assertEq(borrowerFacet.maxBorrowDuration(), 30 days); + } + + function test_borrow_token() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user2); + borrowerFacet.borrowToken{value: 0.01 ether}(tokenId, 7 days); + + assertTrue(borrowerFacet.isBorrowed(tokenId)); + assertEq(erc721.ownerOf(tokenId), user2); + assertEq(erc721.balanceOf(user2), 1); + assertEq(erc721.balanceOf(user1), 0); + } + + function test_borrow_emits_event() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user2); + vm.expectEmit(true, true, false, true); + emit BorrowerFacet.TokenBorrowed(user2, tokenId, 7 days, 0.01 ether, block.timestamp + 7 days); + borrowerFacet.borrowToken{value: 0.01 ether}(tokenId, 7 days); + } + + function test_borrow_insufficient_fee() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user2); + vm.expectRevert( + abi.encodeWithSignature("InsufficientFee(uint256,uint256)", 0.005 ether, 0.01 ether) + ); + borrowerFacet.borrowToken{value: 0.005 ether}(tokenId, 7 days); + } + + function test_borrow_duration_too_long() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user2); + vm.expectRevert( + abi.encodeWithSignature("DurationTooLong(uint256)", 365 days) + ); + borrowerFacet.borrowToken{value: 0.01 ether}(tokenId, 365 days); + } + + function test_borrow_not_owned() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user1); + vm.expectRevert( + abi.encodeWithSignature("TokenNotOwned(uint256)", tokenId) + ); + borrowerFacet.borrowToken{value: 0.01 ether}(tokenId, 7 days); + } + + function test_borrow_already_borrowed() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user2); + borrowerFacet.borrowToken{value: 0.01 ether}(tokenId, 7 days); + + vm.prank(user3); + vm.expectRevert( + abi.encodeWithSignature("TokenAlreadyBorrowed(uint256)", tokenId) + ); + borrowerFacet.borrowToken{value: 0.01 ether}(tokenId, 7 days); + } + + function test_return_token() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user2); + borrowerFacet.borrowToken{value: 0.01 ether}(tokenId, 7 days); + + vm.warp(block.timestamp + 8 days); + + vm.prank(user2); + borrowerFacet.returnToken(tokenId); + + assertFalse(borrowerFacet.isBorrowed(tokenId)); + assertEq(erc721.ownerOf(tokenId), user1); + assertEq(erc721.balanceOf(user1), 1); + assertEq(erc721.balanceOf(user2), 0); + } + + function test_return_emits_event() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user2); + borrowerFacet.borrowToken{value: 0.01 ether}(tokenId, 7 days); + + vm.warp(block.timestamp + 8 days); + + vm.prank(user2); + vm.expectEmit(true, true, false, true); + emit BorrowerFacet.TokenReturned(user2, tokenId); + borrowerFacet.returnToken(tokenId); + } + + function test_return_before_deadline() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user2); + borrowerFacet.borrowToken{value: 0.01 ether}(tokenId, 7 days); + + vm.prank(user2); + vm.expectRevert( + abi.encodeWithSignature("BorrowNotExpired(uint256)", tokenId) + ); + borrowerFacet.returnToken(tokenId); + } + + function test_return_not_borrowed() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user1); + vm.expectRevert( + abi.encodeWithSignature("TokenNotBorrowed(uint256)", tokenId) + ); + borrowerFacet.returnToken(tokenId); + } + + function test_return_unauthorized() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user2); + borrowerFacet.borrowToken{value: 0.01 ether}(tokenId, 7 days); + + vm.warp(block.timestamp + 8 days); + + vm.prank(user3); + vm.expectRevert( + abi.encodeWithSignature("Unauthorized()") + ); + borrowerFacet.returnToken(tokenId); + } + + function test_borrow_info() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user2); + borrowerFacet.borrowToken{value: 0.01 ether}(tokenId, 7 days); + + (address borrower, uint256 deadline, uint256 fee, bool active) = borrowerFacet.getBorrowInfo(tokenId); + assertEq(borrower, user2); + assertEq(fee, 0.01 ether); + assertTrue(active); + assertEq(deadline, block.timestamp + 7 days); + } + + function test_borrow_info_not_borrowed() public { + (, , uint256 fee, bool active) = borrowerFacet.getBorrowInfo(1); + assertFalse(active); + assertEq(fee, 0); + } + + function test_set_borrow_fee() public { + borrowerFacet.setBorrowFee(0.05 ether); + assertEq(borrowerFacet.borrowFee(), 0.05 ether); + } + + function test_set_borrow_fee_emits_event() public { + vm.expectEmit(false, false, false, true); + emit BorrowerFacet.BorrowFeeUpdated(0.01 ether, 0.05 ether); + borrowerFacet.setBorrowFee(0.05 ether); + } + + function test_set_max_borrow_duration() public { + borrowerFacet.setMaxBorrowDuration(60 days); + assertEq(borrowerFacet.maxBorrowDuration(), 60 days); + } + + function test_set_max_borrow_duration_emits_event() public { + vm.expectEmit(false, false, false, true); + emit BorrowerFacet.MaxBorrowDurationUpdated(30 days, 60 days); + borrowerFacet.setMaxBorrowDuration(60 days); + } + + function test_borrow_with_exact_fee() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user2); + borrowerFacet.borrowToken{value: 0.01 ether}(tokenId, 7 days); + + assertTrue(borrowerFacet.isBorrowed(tokenId)); + } + + function test_borrow_with_extra_fee() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user2); + borrowerFacet.borrowToken{value: 0.05 ether}(tokenId, 7 days); + + assertTrue(borrowerFacet.isBorrowed(tokenId)); + } + + function test_multiple_borrows() public { + uint256 tokenId1 = _mintToken(user1); + uint256 tokenId2 = _mintToken(user1); + + vm.prank(user2); + borrowerFacet.borrowToken{value: 0.01 ether}(tokenId1, 7 days); + + vm.prank(user3); + borrowerFacet.borrowToken{value: 0.01 ether}(tokenId2, 14 days); + + assertTrue(borrowerFacet.isBorrowed(tokenId1)); + assertTrue(borrowerFacet.isBorrowed(tokenId2)); + } + + function test_return_first_borrow_second_still_active() public { + uint256 tokenId1 = _mintToken(user1); + uint256 tokenId2 = _mintToken(user1); + + vm.prank(user2); + borrowerFacet.borrowToken{value: 0.01 ether}(tokenId1, 7 days); + + vm.prank(user3); + borrowerFacet.borrowToken{value: 0.01 ether}(tokenId2, 14 days); + + vm.warp(block.timestamp + 8 days); + + vm.prank(user2); + borrowerFacet.returnToken(tokenId1); + + assertFalse(borrowerFacet.isBorrowed(tokenId1)); + assertTrue(borrowerFacet.isBorrowed(tokenId2)); + } +} diff --git a/test/ERC20Facet.t.sol b/test/ERC20Facet.t.sol new file mode 100644 index 0000000..c0767e8 --- /dev/null +++ b/test/ERC20Facet.t.sol @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "../contracts/interfaces/IDiamondCut.sol"; +import "../contracts/interfaces/IERC721.sol"; +import "../contracts/interfaces/IERC20.sol"; +import "../contracts/interfaces/IERC165.sol"; +import "../contracts/libraries/LibDiamond.sol"; +import "../contracts/libraries/LibAppStorage.sol"; +import "../contracts/facets/DiamondCutFacet.sol"; +import "../contracts/facets/DiamondLoupeFacet.sol"; +import "../contracts/facets/OwnershipFacet.sol"; +import "../contracts/facets/ERC721Facet.sol"; +import "../contracts/facets/MintFacet.sol"; +import "../contracts/facets/NFTAdminFacet.sol"; +import "../contracts/facets/ERC20Facet.sol"; +import "../contracts/Diamond.sol"; +import "../contracts/upgradeInitializers/DiamondInit.sol"; +import "../test/helpers/DiamondUpgradeHelper.sol"; + +contract ERC20FacetTest is Test, DiamondUpgradeHelper { + Diamond diamond; + ERC721Facet erc721; + MintFacet mintFacet; + NFTAdminFacet adminFacet; + DiamondLoupeFacet loupe; + ERC20Facet erc20; + + address owner = address(this); + address user1 = address(0xA11CE); + address user2 = address(0xB0B); + address user3 = address(0xC0D3); + + receive() external payable {} + + function setUp() public { + DiamondCutFacet diamondCutFacet = new DiamondCutFacet(); + diamond = new Diamond(owner, address(diamondCutFacet)); + + DiamondLoupeFacet diamondLoupeFacet = new DiamondLoupeFacet(); + OwnershipFacet ownershipFacet = new OwnershipFacet(); + ERC721Facet _erc721Facet = new ERC721Facet(); + MintFacet _mintFacet = new MintFacet(); + NFTAdminFacet _adminFacet = new NFTAdminFacet(); + ERC20Facet _erc20Facet = new ERC20Facet(); + + DiamondInit diamondInit = new DiamondInit(); + + address[] memory facetAddresses = new address[](6); + facetAddresses[0] = address(diamondLoupeFacet); + facetAddresses[1] = address(ownershipFacet); + facetAddresses[2] = address(_erc721Facet); + facetAddresses[3] = address(_mintFacet); + facetAddresses[4] = address(_adminFacet); + facetAddresses[5] = address(_erc20Facet); + + string[] memory facetNames = new string[](6); + facetNames[0] = "DiamondLoupeFacet"; + facetNames[1] = "OwnershipFacet"; + facetNames[2] = "ERC721Facet"; + facetNames[3] = "MintFacet"; + facetNames[4] = "NFTAdminFacet"; + facetNames[5] = "ERC20Facet"; + + IDiamondCut.FacetCut[] memory cuts = buildAddCutsByNames( + facetAddresses, + facetNames + ); + + bytes memory initCalldata = abi.encodeWithSelector( + diamondInit.init.selector, + DiamondInit.InitParams({ + name: "Diamond NFT", + symbol: "DNFT", + baseTokenURI: "https://api.example.com/metadata/", + maxSupply: 100, + mintPrice: 0.05 ether, + erc20Name: "Diamond Token", + erc20Symbol: "DTKN", + erc20Decimals: 18, + erc20MaxSupply: 1000000 ether, + rewardRate: 10 ether, + minStakeDuration: 1 days, + borrowFee: 0.01 ether, + maxBorrowDuration: 30 days, + marketplaceFee: 250 + }) + ); + + executeDiamondCut( + IDiamondCut(address(diamond)), + cuts, + address(diamondInit), + initCalldata + ); + + erc721 = ERC721Facet(address(diamond)); + mintFacet = MintFacet(address(diamond)); + adminFacet = NFTAdminFacet(address(diamond)); + loupe = DiamondLoupeFacet(address(diamond)); + erc20 = ERC20Facet(address(diamond)); + + vm.deal(user1, 100 ether); + vm.deal(user2, 100 ether); + vm.deal(user3, 100 ether); + } + + function test_erc20_name() public { + assertEq(erc20.nameERC20(), "Diamond Token"); + } + + function test_erc20_symbol() public { + assertEq(erc20.symbolERC20(), "DTKN"); + } + + function test_erc20_decimals() public { + assertEq(erc20.decimalsERC20(), 18); + } + + function test_erc20_initial_supply() public { + assertEq(erc20.totalSupplyERC20(), 0); + } + + function test_erc20_initial_balance() public { + assertEq(erc20.balanceOfERC20(user1), 0); + } + + function test_erc20_mint() public { + erc20.mintERC20(user1, 1000 ether); + assertEq(erc20.balanceOfERC20(user1), 1000 ether); + assertEq(erc20.totalSupplyERC20(), 1000 ether); + } + + function test_erc20_mint_exceeds_max() public { + vm.expectRevert( + abi.encodeWithSignature("ERC20ExceedsMaxSupply(uint256,uint256)", 1000001 ether, 1000000 ether) + ); + erc20.mintERC20(user1, 1000001 ether); + } + + function test_erc20_mint_zero_address() public { + vm.expectRevert( + abi.encodeWithSignature("ERC20InvalidReceiver(address)", address(0)) + ); + erc20.mintERC20(address(0), 100 ether); + } + + function test_erc20_transfer() public { + erc20.mintERC20(user1, 1000 ether); + vm.prank(user1); + erc20.transfer(user2, 500 ether); + assertEq(erc20.balanceOfERC20(user1), 500 ether); + assertEq(erc20.balanceOfERC20(user2), 500 ether); + } + + function test_erc20_transfer_insufficient() public { + erc20.mintERC20(user1, 100 ether); + vm.prank(user1); + vm.expectRevert( + abi.encodeWithSignature("ERC20InsufficientBalance(address,uint256,uint256)", user1, 100 ether, 200 ether) + ); + erc20.transfer(user2, 200 ether); + } + + function test_erc20_transfer_invalid_sender() public { + vm.expectRevert( + abi.encodeWithSignature("ERC20InsufficientBalance(address,uint256,uint256)", address(this), 0, 100 ether) + ); + erc20.transfer(user2, 100 ether); + } + + function test_erc20_transfer_invalid_receiver() public { + erc20.mintERC20(user1, 100 ether); + vm.prank(user1); + vm.expectRevert( + abi.encodeWithSignature("ERC20InvalidReceiver(address)", address(0)) + ); + erc20.transfer(address(0), 50 ether); + } + + function test_erc20_approve() public { + vm.prank(user1); + erc20.approveERC20(user2, 500 ether); + assertEq(erc20.allowance(user1, user2), 500 ether); + } + + function test_erc20_approve_and_transferFrom() public { + erc20.mintERC20(user1, 1000 ether); + vm.prank(user1); + erc20.approveERC20(user2, 500 ether); + + vm.prank(user2); + erc20.transferFromERC20(user1, user3, 300 ether); + assertEq(erc20.balanceOfERC20(user3), 300 ether); + assertEq(erc20.allowance(user1, user2), 200 ether); + } + + function test_erc20_transferFrom_insufficient_allowance() public { + erc20.mintERC20(user1, 1000 ether); + vm.prank(user1); + erc20.approveERC20(user2, 100 ether); + + vm.prank(user2); + vm.expectRevert( + abi.encodeWithSignature("ERC20InsufficientAllowance(address,uint256,uint256)", user2, 100 ether, 200 ether) + ); + erc20.transferFromERC20(user1, user3, 200 ether); + } + + function test_erc20_burn() public { + erc20.mintERC20(user1, 1000 ether); + erc20.burnERC20(user1, 500 ether); + assertEq(erc20.balanceOfERC20(user1), 500 ether); + assertEq(erc20.totalSupplyERC20(), 500 ether); + } + + function test_erc20_burn_self() public { + erc20.mintERC20(user1, 1000 ether); + vm.prank(user1); + erc20.burnERC20(); + assertEq(erc20.balanceOfERC20(user1), 0); + assertEq(erc20.totalSupplyERC20(), 0); + } + + function test_erc20_burn_insufficient() public { + erc20.mintERC20(user1, 100 ether); + vm.expectRevert( + abi.encodeWithSignature("ERC20InsufficientBalance(address,uint256,uint256)", user1, 100 ether, 200 ether) + ); + erc20.burnERC20(user1, 200 ether); + } + + function test_erc20_transfer_emits_event() public { + erc20.mintERC20(user1, 1000 ether); + vm.prank(user1); + vm.expectEmit(true, true, false, true); + emit IERC20.Transfer(user1, user2, 500 ether); + erc20.transfer(user2, 500 ether); + } + + function test_erc20_approve_emits_event() public { + vm.prank(user1); + vm.expectEmit(true, true, false, true); + emit IERC20.Approval(user1, user2, 500 ether); + erc20.approveERC20(user2, 500 ether); + } + + function test_erc20_supportsInterface() public { + assertTrue(loupe.supportsInterface(type(IERC20).interfaceId)); + } +} diff --git a/test/ERC721Diamond.t.sol b/test/ERC721Diamond.t.sol new file mode 100644 index 0000000..94f37e0 --- /dev/null +++ b/test/ERC721Diamond.t.sol @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "../contracts/interfaces/IDiamondCut.sol"; +import "../contracts/interfaces/IERC721.sol"; +import "../contracts/interfaces/IERC165.sol"; +import "../contracts/libraries/LibDiamond.sol"; +import "../contracts/facets/DiamondCutFacet.sol"; +import "../contracts/facets/DiamondLoupeFacet.sol"; +import "../contracts/facets/OwnershipFacet.sol"; +import "../contracts/facets/ERC721Facet.sol"; +import "../contracts/facets/MintFacet.sol"; +import "../contracts/facets/NFTAdminFacet.sol"; +import "../contracts/Diamond.sol"; +import "../contracts/upgradeInitializers/DiamondInit.sol"; +import "./helpers/DiamondUpgradeHelper.sol"; + +contract ERC721DiamondTest is Test, DiamondUpgradeHelper { + Diamond diamond; + ERC721Facet erc721; + MintFacet mintFacet; + NFTAdminFacet adminFacet; + DiamondLoupeFacet loupe; + + address owner = address(this); + address user1 = address(0xA11CE); + address user2 = address(0xB0B); + + receive() external payable {} + + function setUp() public { + // Deploy DiamondCutFacet + DiamondCutFacet diamondCutFacet = new DiamondCutFacet(); + + // Deploy Diamond + diamond = new Diamond(owner, address(diamondCutFacet)); + + // Deploy all facets + DiamondLoupeFacet diamondLoupeFacet = new DiamondLoupeFacet(); + OwnershipFacet ownershipFacet = new OwnershipFacet(); + ERC721Facet _erc721Facet = new ERC721Facet(); + MintFacet _mintFacet = new MintFacet(); + NFTAdminFacet _adminFacet = new NFTAdminFacet(); + + // Deploy DiamondInit + DiamondInit diamondInit = new DiamondInit(); + + // Build cuts + address[] memory facetAddresses = new address[](5); + facetAddresses[0] = address(diamondLoupeFacet); + facetAddresses[1] = address(ownershipFacet); + facetAddresses[2] = address(_erc721Facet); + facetAddresses[3] = address(_mintFacet); + facetAddresses[4] = address(_adminFacet); + + string[] memory facetNames = new string[](5); + facetNames[0] = "DiamondLoupeFacet"; + facetNames[1] = "OwnershipFacet"; + facetNames[2] = "ERC721Facet"; + facetNames[3] = "MintFacet"; + facetNames[4] = "NFTAdminFacet"; + + IDiamondCut.FacetCut[] memory cuts = buildAddCutsByNames( + facetAddresses, + facetNames + ); + + // Encode init calldata + bytes memory initCalldata = abi.encodeWithSelector( + diamondInit.init.selector, + DiamondInit.InitParams({ + name: "Diamond NFT", + symbol: "DNFT", + baseTokenURI: "https://api.example.com/metadata/", + maxSupply: 100, + mintPrice: 0.05 ether, + erc20Name: "Diamond Token", + erc20Symbol: "DTKN", + erc20Decimals: 18, + erc20MaxSupply: 1000000 ether, + rewardRate: 10 ether, + minStakeDuration: 1 days, + borrowFee: 0.01 ether, + maxBorrowDuration: 30 days, + marketplaceFee: 250 + }) + ); + + // Execute diamond cut with init + executeDiamondCut( + IDiamondCut(address(diamond)), + cuts, + address(diamondInit), + initCalldata + ); + + // Get facet references + erc721 = ERC721Facet(address(diamond)); + mintFacet = MintFacet(address(diamond)); + adminFacet = NFTAdminFacet(address(diamond)); + loupe = DiamondLoupeFacet(address(diamond)); + + // Fund test users + vm.deal(user1, 100 ether); + vm.deal(user2, 100 ether); + } + + // ==================== Initialization Tests ==================== + + function test_name() public { + assertEq(erc721.name(), "Diamond NFT"); + } + + function test_symbol() public { + assertEq(erc721.symbol(), "DNFT"); + } + + function test_totalSupply() public { + assertEq(mintFacet.totalSupply(), 0); + } + + function test_maxSupply() public { + assertEq(mintFacet.maxSupply(), 100); + } + + function test_mintPrice() public { + assertEq(mintFacet.mintPrice(), 0.05 ether); + } + + function test_mintActive_false() public { + assertFalse(mintFacet.mintActive()); + } + + // ==================== Minting Tests ==================== + + function test_mint_reverts_when_not_active() public { + vm.prank(user1); + vm.expectRevert(MintFacet.MintNotActive.selector); + mintFacet.mint{value: 0.05 ether}(); + } + + function test_mint_reverts_insufficient_payment() public { + adminFacet.setMintActive(true); + + vm.prank(user1); + vm.expectRevert(MintFacet.InsufficientPayment.selector); + mintFacet.mint{value: 0.01 ether}(); + } + + function test_mint_success() public { + adminFacet.setMintActive(true); + + vm.prank(user1); + mintFacet.mint{value: 0.05 ether}(); + + assertEq(mintFacet.totalSupply(), 1); + assertEq(erc721.balanceOf(user1), 1); + assertEq(erc721.ownerOf(1), user1); + } + + function test_mintTo_success() public { + adminFacet.setMintActive(true); + + vm.prank(user1); + mintFacet.mintTo{value: 0.05 ether}(user2); + + assertEq(mintFacet.totalSupply(), 1); + assertEq(erc721.balanceOf(user2), 1); + assertEq(erc721.ownerOf(1), user2); + } + + function test_mintTo_reverts_zero_address() public { + adminFacet.setMintActive(true); + + vm.prank(user1); + vm.expectRevert(MintFacet.InvalidAddress.selector); + mintFacet.mintTo{value: 0.05 ether}(address(0)); + } + + function test_mint_reverts_max_supply() public { + adminFacet.setMaxSupply(1); + adminFacet.setMintActive(true); + + vm.prank(user1); + mintFacet.mint{value: 0.05 ether}(); + + vm.prank(user2); + vm.expectRevert(MintFacet.MaxSupplyReached.selector); + mintFacet.mint{value: 0.05 ether}(); + } + + function test_multiple_mints() public { + adminFacet.setMintActive(true); + + vm.prank(user1); + mintFacet.mint{value: 0.05 ether}(); + vm.prank(user2); + mintFacet.mint{value: 0.05 ether}(); + vm.prank(user1); + mintFacet.mint{value: 0.05 ether}(); + + assertEq(mintFacet.totalSupply(), 3); + assertEq(erc721.balanceOf(user1), 2); + assertEq(erc721.balanceOf(user2), 1); + assertEq(erc721.ownerOf(1), user1); + assertEq(erc721.ownerOf(2), user2); + assertEq(erc721.ownerOf(3), user1); + } + + // ==================== Transfer Tests ==================== + + function _mintToken(address to) internal { + adminFacet.setMintActive(true); + vm.prank(to); + mintFacet.mint{value: 0.05 ether}(); + } + + function test_transferFrom() public { + _mintToken(user1); + + vm.prank(user1); + erc721.transferFrom(user1, user2, 1); + + assertEq(erc721.ownerOf(1), user2); + assertEq(erc721.balanceOf(user1), 0); + assertEq(erc721.balanceOf(user2), 1); + } + + function test_transferFrom_reverts_not_owner() public { + _mintToken(user1); + + vm.prank(user2); + vm.expectRevert( + abi.encodeWithSignature( + "ERC721IncorrectOwner(address,uint256,address)", + user2, + 1, + user1 + ) + ); + erc721.transferFrom(user2, user1, 1); + } + + function test_transferFrom_approved() public { + _mintToken(user1); + + vm.prank(user1); + erc721.approve(user2, 1); + + vm.prank(user2); + erc721.transferFrom(user1, user2, 1); + + assertEq(erc721.ownerOf(1), user2); + } + + function test_transferFrom_operator() public { + _mintToken(user1); + + vm.prank(user1); + erc721.setApprovalForAll(user2, true); + + vm.prank(user2); + erc721.transferFrom(user1, user2, 1); + + assertEq(erc721.ownerOf(1), user2); + } + + // ==================== Approval Tests ==================== + + function test_approve() public { + _mintToken(user1); + + vm.prank(user1); + erc721.approve(user2, 1); + + assertEq(erc721.getApproved(1), user2); + } + + function test_setApprovalForAll() public { + vm.prank(user1); + erc721.setApprovalForAll(user2, true); + assertTrue(erc721.isApprovedForAll(user1, user2)); + + vm.prank(user1); + erc721.setApprovalForAll(user2, false); + assertFalse(erc721.isApprovedForAll(user1, user2)); + } + + // ==================== Metadata Tests ==================== + + function test_tokenURI() public { + _mintToken(user1); + assertEq( + erc721.tokenURI(1), + "https://api.example.com/metadata/1" + ); + } + + function test_tokenURI_updates_with_base() public { + adminFacet.setBaseTokenURI("ipfs://QmHash/"); + _mintToken(user1); + assertEq(erc721.tokenURI(1), "ipfs://QmHash/1"); + } + + // ==================== Admin Tests ==================== + + function test_setMintActive() public { + adminFacet.setMintActive(true); + assertTrue(mintFacet.mintActive()); + + adminFacet.setMintActive(false); + assertFalse(mintFacet.mintActive()); + } + + function test_setMintPrice() public { + adminFacet.setMintPrice(0.1 ether); + assertEq(mintFacet.mintPrice(), 0.1 ether); + } + + function test_withdraw() public { + adminFacet.setMintActive(true); + + vm.prank(user1); + mintFacet.mint{value: 0.05 ether}(); + + uint256 balanceBefore = owner.balance; + adminFacet.withdraw(); + uint256 balanceAfter = owner.balance; + + assertEq(balanceAfter - balanceBefore, 0.05 ether); + } + + function test_withdraw_reverts_non_owner() public { + vm.prank(user1); + vm.expectRevert(LibDiamond.NotDiamondOwner.selector); + adminFacet.withdraw(); + } + + function test_admin_reverts_non_owner() public { + vm.prank(user1); + vm.expectRevert(LibDiamond.NotDiamondOwner.selector); + adminFacet.setMintActive(true); + } + + // ==================== ERC-165 Tests ==================== + + function test_supportsInterface_erc721() public { + assertTrue(loupe.supportsInterface(type(IERC721).interfaceId)); + } + + function test_supportsInterface_erc721_metadata() public { + assertTrue(loupe.supportsInterface(type(IERC721Metadata).interfaceId)); + } + + function test_supportsInterface_erc165() public { + assertTrue(loupe.supportsInterface(type(IERC165).interfaceId)); + } +} diff --git a/test/MarketplaceFacet.t.sol b/test/MarketplaceFacet.t.sol new file mode 100644 index 0000000..5c2de05 --- /dev/null +++ b/test/MarketplaceFacet.t.sol @@ -0,0 +1,446 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "../contracts/interfaces/IDiamondCut.sol"; +import "../contracts/interfaces/IERC721.sol"; +import "../contracts/interfaces/IERC20.sol"; +import "../contracts/interfaces/IERC165.sol"; +import "../contracts/interfaces/IMarketplaceFacet.sol"; +import "../contracts/libraries/LibDiamond.sol"; +import "../contracts/facets/DiamondCutFacet.sol"; +import "../contracts/facets/DiamondLoupeFacet.sol"; +import "../contracts/facets/OwnershipFacet.sol"; +import "../contracts/facets/ERC721Facet.sol"; +import "../contracts/facets/MintFacet.sol"; +import "../contracts/facets/NFTAdminFacet.sol"; +import "../contracts/facets/ERC20Facet.sol"; +import "../contracts/facets/MarketplaceFacet.sol"; +import "../contracts/Diamond.sol"; +import "../contracts/upgradeInitializers/DiamondInit.sol"; +import "../test/helpers/DiamondUpgradeHelper.sol"; + +contract MarketplaceFacetTest is Test, DiamondUpgradeHelper { + Diamond diamond; + ERC721Facet erc721; + MintFacet mintFacet; + NFTAdminFacet adminFacet; + ERC20Facet erc20; + MarketplaceFacet marketplaceFacet; + + address owner = address(this); + address user1 = address(0xA11CE); + address user2 = address(0xB0B); + address user3 = address(0xC0D3); + + receive() external payable {} + + function setUp() public { + DiamondCutFacet diamondCutFacet = new DiamondCutFacet(); + diamond = new Diamond(owner, address(diamondCutFacet)); + + DiamondLoupeFacet diamondLoupeFacet = new DiamondLoupeFacet(); + OwnershipFacet ownershipFacet = new OwnershipFacet(); + ERC721Facet _erc721Facet = new ERC721Facet(); + MintFacet _mintFacet = new MintFacet(); + NFTAdminFacet _adminFacet = new NFTAdminFacet(); + ERC20Facet _erc20Facet = new ERC20Facet(); + MarketplaceFacet _marketplaceFacet = new MarketplaceFacet(); + + DiamondInit diamondInit = new DiamondInit(); + + address[] memory facetAddresses = new address[](7); + facetAddresses[0] = address(diamondLoupeFacet); + facetAddresses[1] = address(ownershipFacet); + facetAddresses[2] = address(_erc721Facet); + facetAddresses[3] = address(_mintFacet); + facetAddresses[4] = address(_adminFacet); + facetAddresses[5] = address(_erc20Facet); + facetAddresses[6] = address(_marketplaceFacet); + + string[] memory facetNames = new string[](7); + facetNames[0] = "DiamondLoupeFacet"; + facetNames[1] = "OwnershipFacet"; + facetNames[2] = "ERC721Facet"; + facetNames[3] = "MintFacet"; + facetNames[4] = "NFTAdminFacet"; + facetNames[5] = "ERC20Facet"; + facetNames[6] = "MarketplaceFacet"; + + IDiamondCut.FacetCut[] memory cuts = buildAddCutsByNames( + facetAddresses, + facetNames + ); + + bytes memory initCalldata = abi.encodeWithSelector( + diamondInit.init.selector, + DiamondInit.InitParams({ + name: "Diamond NFT", + symbol: "DNFT", + baseTokenURI: "https://api.example.com/metadata/", + maxSupply: 100, + mintPrice: 0.05 ether, + erc20Name: "Diamond Token", + erc20Symbol: "DTKN", + erc20Decimals: 18, + erc20MaxSupply: 1000000 ether, + rewardRate: 10 ether, + minStakeDuration: 1 days, + borrowFee: 0.01 ether, + maxBorrowDuration: 30 days, + marketplaceFee: 250 + }) + ); + + executeDiamondCut( + IDiamondCut(address(diamond)), + cuts, + address(diamondInit), + initCalldata + ); + + erc721 = ERC721Facet(address(diamond)); + mintFacet = MintFacet(address(diamond)); + adminFacet = NFTAdminFacet(address(diamond)); + erc20 = ERC20Facet(address(diamond)); + marketplaceFacet = MarketplaceFacet(address(diamond)); + + vm.deal(user1, 100 ether); + vm.deal(user2, 100 ether); + vm.deal(user3, 100 ether); + } + + function _mintToken(address to) internal returns (uint256) { + adminFacet.setMintActive(true); + uint256 beforeSupply = mintFacet.totalSupply(); + vm.prank(to); + mintFacet.mint{value: 0.05 ether}(); + return beforeSupply + 1; + } + + function test_initial_marketplace_state() public { + assertEq(marketplaceFacet.marketplaceFee(), 250); + } + + function test_list_nft() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user1); + marketplaceFacet.listNFT(tokenId, 100 ether); + + assertTrue(marketplaceFacet.isListed(tokenId)); + IMarketplaceFacet.Listing memory listing = marketplaceFacet.getListing(tokenId); + assertEq(listing.seller, user1); + assertEq(listing.price, 100 ether); + assertTrue(listing.active); + } + + function test_list_nft_escrows_nft() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user1); + marketplaceFacet.listNFT(tokenId, 100 ether); + + assertEq(erc721.ownerOf(tokenId), address(diamond)); + assertEq(erc721.balanceOf(user1), 0); + } + + function test_list_nft_emits_event() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user1); + vm.expectEmit(true, true, false, true); + emit MarketplaceFacet.NFTListed(tokenId, user1, 100 ether); + marketplaceFacet.listNFT(tokenId, 100 ether); + } + + function test_list_not_owner() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user2); + vm.expectRevert( + abi.encodeWithSignature("TokenNotOwned(uint256)", tokenId) + ); + marketplaceFacet.listNFT(tokenId, 100 ether); + } + + function test_list_already_listed() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user1); + marketplaceFacet.listNFT(tokenId, 100 ether); + + vm.prank(user1); + vm.expectRevert( + abi.encodeWithSignature("TokenAlreadyListed(uint256)", tokenId) + ); + marketplaceFacet.listNFT(tokenId, 200 ether); + } + + function test_list_invalid_price() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user1); + vm.expectRevert( + abi.encodeWithSignature("InvalidPrice()") + ); + marketplaceFacet.listNFT(tokenId, 0); + } + + function test_buy_nft() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user1); + marketplaceFacet.listNFT(tokenId, 100 ether); + + erc20.mintERC20(user2, 200 ether); + + vm.prank(user2); + marketplaceFacet.buyNFT{value: 100 ether}(tokenId); + + assertEq(erc721.ownerOf(tokenId), user2); + assertEq(erc721.balanceOf(user2), 1); + assertFalse(marketplaceFacet.isListed(tokenId)); + } + + function test_buy_nft_emits_event() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user1); + marketplaceFacet.listNFT(tokenId, 100 ether); + + erc20.mintERC20(user2, 200 ether); + + vm.prank(user2); + vm.expectEmit(true, true, true, false); + emit MarketplaceFacet.NFTSold(tokenId, user1, user2, 100 ether); + marketplaceFacet.buyNFT{value: 100 ether}(tokenId); + } + + function test_buy_nft_with_fee() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user1); + marketplaceFacet.listNFT(tokenId, 100 ether); + + erc20.mintERC20(user2, 200 ether); + + uint256 sellerBalanceBefore = erc20.balanceOfERC20(user1); + address diamondOwner = owner; + uint256 ownerBalanceBefore = erc20.balanceOfERC20(diamondOwner); + + vm.prank(user2); + marketplaceFacet.buyNFT{value: 100 ether}(tokenId); + + uint256 fee = (100 ether * 250) / 10000; + assertEq(erc20.balanceOfERC20(user1) - sellerBalanceBefore, 100 ether - fee); + assertEq(erc20.balanceOfERC20(diamondOwner) - ownerBalanceBefore, fee); + } + + function test_buy_nft_deducts_buyer_balance() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user1); + marketplaceFacet.listNFT(tokenId, 100 ether); + + erc20.mintERC20(user2, 200 ether); + + vm.prank(user2); + marketplaceFacet.buyNFT{value: 100 ether}(tokenId); + + assertEq(erc20.balanceOfERC20(user2), 100 ether); + } + + function test_buy_not_listed() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user2); + vm.expectRevert( + abi.encodeWithSignature("ListingNotActive(uint256)", tokenId) + ); + marketplaceFacet.buyNFT{value: 100 ether}(tokenId); + } + + function test_buy_insufficient_payment() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user1); + marketplaceFacet.listNFT(tokenId, 100 ether); + + vm.prank(user2); + vm.expectRevert( + abi.encodeWithSignature("InsufficientPayment(uint256,uint256)", 50 ether, 100 ether) + ); + marketplaceFacet.buyNFT{value: 50 ether}(tokenId); + } + + function test_buy_insufficient_erc20_balance() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user1); + marketplaceFacet.listNFT(tokenId, 100 ether); + + erc20.mintERC20(user2, 50 ether); + + vm.prank(user2); + vm.expectRevert(); + marketplaceFacet.buyNFT{value: 100 ether}(tokenId); + } + + function test_delist_nft() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user1); + marketplaceFacet.listNFT(tokenId, 100 ether); + + vm.prank(user1); + marketplaceFacet.delistNFT(tokenId); + + assertFalse(marketplaceFacet.isListed(tokenId)); + assertEq(erc721.ownerOf(tokenId), user1); + assertEq(erc721.balanceOf(user1), 1); + } + + function test_delist_nft_emits_event() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user1); + marketplaceFacet.listNFT(tokenId, 100 ether); + + vm.prank(user1); + vm.expectEmit(true, true, false, true); + emit MarketplaceFacet.NFTDelisted(tokenId, user1); + marketplaceFacet.delistNFT(tokenId); + } + + function test_delist_unauthorized() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user1); + marketplaceFacet.listNFT(tokenId, 100 ether); + + vm.prank(user2); + vm.expectRevert( + abi.encodeWithSignature("Unauthorized()") + ); + marketplaceFacet.delistNFT(tokenId); + } + + function test_delist_not_active() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user1); + vm.expectRevert( + abi.encodeWithSignature("ListingNotActive(uint256)", tokenId) + ); + marketplaceFacet.delistNFT(tokenId); + } + + function test_get_listings() public { + uint256 tokenId1 = _mintToken(user1); + uint256 tokenId2 = _mintToken(user1); + + vm.prank(user1); + marketplaceFacet.listNFT(tokenId1, 100 ether); + vm.prank(user1); + marketplaceFacet.listNFT(tokenId2, 200 ether); + + IMarketplaceFacet.Listing[] memory listings = marketplaceFacet.getListings(); + assertEq(listings.length, 2); + } + + function test_get_listings_after_delist() public { + uint256 tokenId1 = _mintToken(user1); + uint256 tokenId2 = _mintToken(user1); + + vm.prank(user1); + marketplaceFacet.listNFT(tokenId1, 100 ether); + vm.prank(user1); + marketplaceFacet.listNFT(tokenId2, 200 ether); + + vm.prank(user1); + marketplaceFacet.delistNFT(tokenId1); + + IMarketplaceFacet.Listing[] memory listings = marketplaceFacet.getListings(); + assertEq(listings.length, 1); + assertEq(listings[0].tokenId, tokenId2); + } + + function test_get_listings_empty() public { + IMarketplaceFacet.Listing[] memory listings = marketplaceFacet.getListings(); + assertEq(listings.length, 0); + } + + function test_set_marketplace_fee() public { + marketplaceFacet.setMarketplaceFee(500); + assertEq(marketplaceFacet.marketplaceFee(), 500); + } + + function test_set_marketplace_fee_emits_event() public { + vm.expectEmit(false, false, false, true); + emit MarketplaceFacet.MarketplaceFeeUpdated(250, 500); + marketplaceFacet.setMarketplaceFee(500); + } + + function test_set_marketplace_fee_too_high() public { + vm.expectRevert( + abi.encodeWithSignature("InvalidFee()") + ); + marketplaceFacet.setMarketplaceFee(1001); + } + + function test_multiple_list_and_buy() public { + uint256 tokenId1 = _mintToken(user1); + uint256 tokenId2 = _mintToken(user1); + + vm.prank(user1); + marketplaceFacet.listNFT(tokenId1, 100 ether); + vm.prank(user1); + marketplaceFacet.listNFT(tokenId2, 200 ether); + + erc20.mintERC20(user2, 500 ether); + vm.deal(user2, 500 ether); + + vm.prank(user2); + marketplaceFacet.buyNFT{value: 100 ether}(tokenId1); + + vm.prank(user2); + marketplaceFacet.buyNFT{value: 200 ether}(tokenId2); + + assertEq(erc721.ownerOf(tokenId1), user2); + assertEq(erc721.ownerOf(tokenId2), user2); + assertFalse(marketplaceFacet.isListed(tokenId1)); + assertFalse(marketplaceFacet.isListed(tokenId2)); + } + + function test_buy_already_sold() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user1); + marketplaceFacet.listNFT(tokenId, 100 ether); + + erc20.mintERC20(user2, 200 ether); + + vm.prank(user2); + marketplaceFacet.buyNFT{value: 100 ether}(tokenId); + + vm.prank(user3); + vm.expectRevert( + abi.encodeWithSignature("ListingNotActive(uint256)", tokenId) + ); + marketplaceFacet.buyNFT{value: 100 ether}(tokenId); + } + + function test_listing_details() public { + uint256 tokenId = _mintToken(user1); + + vm.prank(user1); + marketplaceFacet.listNFT(tokenId, 100 ether); + + IMarketplaceFacet.Listing memory listing = marketplaceFacet.getListing(tokenId); + assertEq(listing.tokenId, tokenId); + assertEq(listing.seller, user1); + assertEq(listing.price, 100 ether); + assertTrue(listing.active); + } +} diff --git a/test/MultisigFacet.t.sol b/test/MultisigFacet.t.sol new file mode 100644 index 0000000..474c897 --- /dev/null +++ b/test/MultisigFacet.t.sol @@ -0,0 +1,696 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "../contracts/interfaces/IDiamondCut.sol"; +import "../contracts/interfaces/IERC721.sol"; +import "../contracts/interfaces/IERC165.sol"; +import "../contracts/interfaces/IMultisigFacet.sol"; +import "../contracts/libraries/LibDiamond.sol"; +import "../contracts/facets/DiamondCutFacet.sol"; +import "../contracts/facets/DiamondLoupeFacet.sol"; +import "../contracts/facets/OwnershipFacet.sol"; +import "../contracts/facets/ERC721Facet.sol"; +import "../contracts/facets/MintFacet.sol"; +import "../contracts/facets/NFTAdminFacet.sol"; +import "../contracts/facets/MultisigFacet.sol"; +import "../contracts/Diamond.sol"; +import "../contracts/upgradeInitializers/DiamondInit.sol"; +import "../test/helpers/DiamondUpgradeHelper.sol"; + +contract MultisigFacetTest is Test, DiamondUpgradeHelper { + Diamond diamond; + ERC721Facet erc721; + MintFacet mintFacet; + NFTAdminFacet adminFacet; + DiamondLoupeFacet loupe; + MultisigFacet multisigFacet; + + address owner = address(this); + address user1 = address(0xA11CE); + address user2 = address(0xB0B); + address user3 = address(0xC0D3); + address user4 = address(0xD4D4); + + receive() external payable {} + + function setUp() public { + DiamondCutFacet diamondCutFacet = new DiamondCutFacet(); + diamond = new Diamond(owner, address(diamondCutFacet)); + + DiamondLoupeFacet diamondLoupeFacet = new DiamondLoupeFacet(); + OwnershipFacet ownershipFacet = new OwnershipFacet(); + ERC721Facet _erc721Facet = new ERC721Facet(); + MintFacet _mintFacet = new MintFacet(); + NFTAdminFacet _adminFacet = new NFTAdminFacet(); + MultisigFacet _multisigFacet = new MultisigFacet(); + + DiamondInit diamondInit = new DiamondInit(); + + address[] memory facetAddresses = new address[](6); + facetAddresses[0] = address(diamondLoupeFacet); + facetAddresses[1] = address(ownershipFacet); + facetAddresses[2] = address(_erc721Facet); + facetAddresses[3] = address(_mintFacet); + facetAddresses[4] = address(_adminFacet); + facetAddresses[5] = address(_multisigFacet); + + string[] memory facetNames = new string[](6); + facetNames[0] = "DiamondLoupeFacet"; + facetNames[1] = "OwnershipFacet"; + facetNames[2] = "ERC721Facet"; + facetNames[3] = "MintFacet"; + facetNames[4] = "NFTAdminFacet"; + facetNames[5] = "MultisigFacet"; + + IDiamondCut.FacetCut[] memory cuts = buildAddCutsByNames( + facetAddresses, + facetNames + ); + + bytes memory initCalldata = abi.encodeWithSelector( + diamondInit.init.selector, + DiamondInit.InitParams({ + name: "Diamond NFT", + symbol: "DNFT", + baseTokenURI: "https://api.example.com/metadata/", + maxSupply: 100, + mintPrice: 0.05 ether, + erc20Name: "Diamond Token", + erc20Symbol: "DTKN", + erc20Decimals: 18, + erc20MaxSupply: 1000000 ether, + rewardRate: 10 ether, + minStakeDuration: 1 days, + borrowFee: 0.01 ether, + maxBorrowDuration: 30 days, + marketplaceFee: 250 + }) + ); + + executeDiamondCut( + IDiamondCut(address(diamond)), + cuts, + address(diamondInit), + initCalldata + ); + + erc721 = ERC721Facet(address(diamond)); + mintFacet = MintFacet(address(diamond)); + adminFacet = NFTAdminFacet(address(diamond)); + loupe = DiamondLoupeFacet(address(diamond)); + multisigFacet = MultisigFacet(address(diamond)); + + vm.deal(user1, 100 ether); + vm.deal(user2, 100 ether); + vm.deal(user3, 100 ether); + vm.deal(user4, 100 ether); + } + + function test_initialize_multisig() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + + multisigFacet.initializeMultisig(owners, 2); + + assertEq(multisigFacet.threshold(), 2); + assertTrue(multisigFacet.isOwner(user1)); + assertTrue(multisigFacet.isOwner(user2)); + assertTrue(multisigFacet.isOwner(user3)); + assertFalse(multisigFacet.isOwner(user4)); + } + + function test_initialize_multisig_owners_array() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + + multisigFacet.initializeMultisig(owners, 2); + + address[] memory returnedOwners = multisigFacet.getOwners(); + assertEq(returnedOwners.length, 3); + } + + function test_initialize_duplicate_owners() public { + address[] memory owners = new address[](4); + owners[0] = user1; + owners[1] = user1; + owners[2] = user2; + owners[3] = user3; + + multisigFacet.initializeMultisig(owners, 2); + + address[] memory returnedOwners = multisigFacet.getOwners(); + assertEq(returnedOwners.length, 3); + } + + function test_initialize_empty_owners() public { + address[] memory owners = new address[](0); + vm.expectRevert(); + multisigFacet.initializeMultisig(owners, 1); + } + + function test_initialize_invalid_threshold() public { + address[] memory owners = new address[](2); + owners[0] = user1; + owners[1] = user2; + + vm.expectRevert(); + multisigFacet.initializeMultisig(owners, 0); + } + + function test_initialize_threshold_too_high() public { + address[] memory owners = new address[](2); + owners[0] = user1; + owners[1] = user2; + + vm.expectRevert(); + multisigFacet.initializeMultisig(owners, 5); + } + + function test_create_proposal() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(user1); + uint256 proposalId = multisigFacet.createProposal(cut, address(0), "", "Test proposal"); + + assertEq(multisigFacet.proposalCount(), 1); + assertEq(proposalId, 1); + } + + function test_create_proposal_emits_event() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(user1); + vm.expectEmit(true, true, false, true); + emit MultisigFacet.ProposalCreated(1, user1, "Test proposal"); + multisigFacet.createProposal(cut, address(0), "", "Test proposal"); + } + + function test_create_proposal_non_owner() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(owner); + vm.expectRevert( + abi.encodeWithSignature("NotMultisigOwner()") + ); + multisigFacet.createProposal(cut, address(0), "", "Test"); + } + + function test_vote_for_proposal() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(user1); + uint256 proposalId = multisigFacet.createProposal(cut, address(0), "", "Test proposal"); + + vm.prank(user1); + multisigFacet.vote(proposalId, true); + + IMultisigFacet.ProposalView memory proposal = multisigFacet.getProposal(proposalId); + assertEq(proposal.votesFor, 1); + assertEq(proposal.votesAgainst, 0); + } + + function test_vote_against_proposal() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(user1); + uint256 proposalId = multisigFacet.createProposal(cut, address(0), "", "Test proposal"); + + vm.prank(user1); + multisigFacet.vote(proposalId, false); + + IMultisigFacet.ProposalView memory proposal = multisigFacet.getProposal(proposalId); + assertEq(proposal.votesFor, 0); + assertEq(proposal.votesAgainst, 1); + } + + function test_vote_emits_event() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(user1); + uint256 proposalId = multisigFacet.createProposal(cut, address(0), "", "Test"); + + vm.prank(user1); + vm.expectEmit(true, true, false, true); + emit MultisigFacet.VoteCast(proposalId, user1, true); + multisigFacet.vote(proposalId, true); + } + + function test_double_vote_reverts() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(user1); + uint256 proposalId = multisigFacet.createProposal(cut, address(0), "", "Test"); + + vm.prank(user1); + multisigFacet.vote(proposalId, true); + + vm.prank(user1); + vm.expectRevert( + abi.encodeWithSignature("AlreadyVoted()") + ); + multisigFacet.vote(proposalId, true); + } + + function test_non_owner_cannot_vote() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(user1); + uint256 proposalId = multisigFacet.createProposal(cut, address(0), "", "Test"); + + vm.prank(owner); + vm.expectRevert( + abi.encodeWithSignature("NotMultisigOwner()") + ); + multisigFacet.vote(proposalId, true); + } + + function test_has_voted() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(user1); + uint256 proposalId = multisigFacet.createProposal(cut, address(0), "", "Test"); + + assertFalse(multisigFacet.hasVoted(proposalId, user1)); + + vm.prank(user1); + multisigFacet.vote(proposalId, true); + + assertTrue(multisigFacet.hasVoted(proposalId, user1)); + assertFalse(multisigFacet.hasVoted(proposalId, user2)); + } + + function test_execute_proposal() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(user1); + uint256 proposalId = multisigFacet.createProposal(cut, address(0), "", "Test proposal"); + + vm.prank(user1); + multisigFacet.vote(proposalId, true); + + vm.prank(user2); + multisigFacet.vote(proposalId, true); + + vm.warp(block.timestamp + 8 days); + + multisigFacet.executeProposal(proposalId); + + IMultisigFacet.ProposalView memory proposal = multisigFacet.getProposal(proposalId); + assertTrue(proposal.executed); + } + + function test_execute_proposal_emits_event() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(user1); + uint256 proposalId = multisigFacet.createProposal(cut, address(0), "", "Test"); + + vm.prank(user1); + multisigFacet.vote(proposalId, true); + + vm.prank(user2); + multisigFacet.vote(proposalId, true); + + vm.warp(block.timestamp + 8 days); + + vm.expectEmit(true, false, false, true); + emit MultisigFacet.ProposalExecuted(proposalId); + multisigFacet.executeProposal(proposalId); + } + + function test_execute_before_deadline() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(user1); + uint256 proposalId = multisigFacet.createProposal(cut, address(0), "", "Test"); + + vm.prank(user1); + multisigFacet.vote(proposalId, true); + + vm.prank(user2); + multisigFacet.vote(proposalId, true); + + vm.expectRevert( + abi.encodeWithSignature("ProposalDeadlineNotExpired()") + ); + multisigFacet.executeProposal(proposalId); + } + + function test_execute_threshold_not_met() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(user1); + uint256 proposalId = multisigFacet.createProposal(cut, address(0), "", "Test"); + + vm.prank(user1); + multisigFacet.vote(proposalId, true); + + vm.warp(block.timestamp + 8 days); + + vm.expectRevert( + abi.encodeWithSignature("ThresholdNotMet()") + ); + multisigFacet.executeProposal(proposalId); + } + + function test_execute_already_executed() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(user1); + uint256 proposalId = multisigFacet.createProposal(cut, address(0), "", "Test"); + + vm.prank(user1); + multisigFacet.vote(proposalId, true); + + vm.prank(user2); + multisigFacet.vote(proposalId, true); + + vm.warp(block.timestamp + 8 days); + + multisigFacet.executeProposal(proposalId); + + vm.expectRevert( + abi.encodeWithSignature("ProposalAlreadyExecuted()") + ); + multisigFacet.executeProposal(proposalId); + } + + function test_cancel_proposal() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(user1); + uint256 proposalId = multisigFacet.createProposal(cut, address(0), "", "Test"); + + vm.prank(user1); + multisigFacet.cancelProposal(proposalId); + + IMultisigFacet.ProposalView memory proposal = multisigFacet.getProposal(proposalId); + assertTrue(proposal.cancelled); + } + + function test_cancel_proposal_emits_event() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(user1); + uint256 proposalId = multisigFacet.createProposal(cut, address(0), "", "Test"); + + vm.prank(user1); + vm.expectEmit(true, false, false, true); + emit MultisigFacet.ProposalCancelled(proposalId); + multisigFacet.cancelProposal(proposalId); + } + + function test_cancel_non_proposer() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(user1); + uint256 proposalId = multisigFacet.createProposal(cut, address(0), "", "Test"); + + vm.prank(user2); + vm.expectRevert( + abi.encodeWithSignature("NotProposer()") + ); + multisigFacet.cancelProposal(proposalId); + } + + function test_add_owner() public { + address[] memory owners = new address[](2); + owners[0] = user1; + owners[1] = user2; + multisigFacet.initializeMultisig(owners, 1); + + vm.prank(user1); + multisigFacet.addOwner(user3); + + assertTrue(multisigFacet.isOwner(user3)); + } + + function test_add_owner_emits_event() public { + address[] memory owners = new address[](2); + owners[0] = user1; + owners[1] = user2; + multisigFacet.initializeMultisig(owners, 1); + + vm.prank(user1); + vm.expectEmit(true, false, false, true); + emit MultisigFacet.OwnerAdded(user3); + multisigFacet.addOwner(user3); + } + + function test_remove_owner() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 1); + + vm.prank(user1); + multisigFacet.removeOwner(user3); + + assertFalse(multisigFacet.isOwner(user3)); + } + + function test_remove_owner_emits_event() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 1); + + vm.prank(user1); + vm.expectEmit(true, false, false, true); + emit MultisigFacet.OwnerRemoved(user3); + multisigFacet.removeOwner(user3); + } + + function test_remove_last_owner_reverts() public { + address[] memory owners = new address[](2); + owners[0] = user1; + owners[1] = user2; + multisigFacet.initializeMultisig(owners, 2); + + vm.prank(user1); + vm.expectRevert( + abi.encodeWithSignature("CannotRemoveLastOwner()") + ); + multisigFacet.removeOwner(user2); + } + + function test_set_threshold() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 1); + + vm.prank(user1); + multisigFacet.setThreshold(2); + + assertEq(multisigFacet.threshold(), 2); + } + + function test_set_threshold_emits_event() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 1); + + vm.prank(user1); + vm.expectEmit(false, false, false, true); + emit MultisigFacet.ThresholdUpdated(1, 2); + multisigFacet.setThreshold(2); + } + + function test_set_threshold_invalid() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 1); + + vm.prank(user1); + vm.expectRevert(); + multisigFacet.setThreshold(0); + } + + function test_vote_after_deadline() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(user1); + uint256 proposalId = multisigFacet.createProposal(cut, address(0), "", "Test"); + + vm.warp(block.timestamp + 8 days); + + vm.prank(user1); + vm.expectRevert( + abi.encodeWithSignature("ProposalDeadlineExpired()") + ); + multisigFacet.vote(proposalId, true); + } + + function test_cancel_already_executed() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(user1); + uint256 proposalId = multisigFacet.createProposal(cut, address(0), "", "Test"); + + vm.prank(user1); + multisigFacet.vote(proposalId, true); + + vm.prank(user2); + multisigFacet.vote(proposalId, true); + + vm.warp(block.timestamp + 8 days); + + multisigFacet.executeProposal(proposalId); + + vm.prank(user1); + vm.expectRevert( + abi.encodeWithSignature("ProposalAlreadyExecuted()") + ); + multisigFacet.cancelProposal(proposalId); + } + + function test_execute_cancelled_proposal() public { + address[] memory owners = new address[](3); + owners[0] = user1; + owners[1] = user2; + owners[2] = user3; + multisigFacet.initializeMultisig(owners, 2); + + IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](0); + + vm.prank(user1); + uint256 proposalId = multisigFacet.createProposal(cut, address(0), "", "Test"); + + vm.prank(user1); + multisigFacet.cancelProposal(proposalId); + + vm.warp(block.timestamp + 8 days); + + vm.expectRevert( + abi.encodeWithSignature("ProposalCancelledError()") + ); + multisigFacet.executeProposal(proposalId); + } +} diff --git a/test/OnchainSVGFacet.t.sol b/test/OnchainSVGFacet.t.sol new file mode 100644 index 0000000..bf1d21d --- /dev/null +++ b/test/OnchainSVGFacet.t.sol @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "../contracts/interfaces/IDiamondCut.sol"; +import "../contracts/interfaces/IERC721.sol"; +import "../contracts/interfaces/IERC165.sol"; +import "../contracts/interfaces/IOnchainSVG.sol"; +import "../contracts/libraries/LibDiamond.sol"; +import "../contracts/facets/DiamondCutFacet.sol"; +import "../contracts/facets/DiamondLoupeFacet.sol"; +import "../contracts/facets/OwnershipFacet.sol"; +import "../contracts/facets/ERC721Facet.sol"; +import "../contracts/facets/MintFacet.sol"; +import "../contracts/facets/NFTAdminFacet.sol"; +import "../contracts/facets/OnchainSVGFacet.sol"; +import "../contracts/Diamond.sol"; +import "../contracts/upgradeInitializers/DiamondInit.sol"; +import "../test/helpers/DiamondUpgradeHelper.sol"; + +contract OnchainSVGFacetTest is Test, DiamondUpgradeHelper { + Diamond diamond; + ERC721Facet erc721; + MintFacet mintFacet; + NFTAdminFacet adminFacet; + OnchainSVGFacet svgFacet; + + address owner = address(this); + address user1 = address(0xA11CE); + address user2 = address(0xB0B); + + receive() external payable {} + + function setUp() public { + DiamondCutFacet diamondCutFacet = new DiamondCutFacet(); + diamond = new Diamond(owner, address(diamondCutFacet)); + + DiamondLoupeFacet diamondLoupeFacet = new DiamondLoupeFacet(); + OwnershipFacet ownershipFacet = new OwnershipFacet(); + ERC721Facet _erc721Facet = new ERC721Facet(); + MintFacet _mintFacet = new MintFacet(); + NFTAdminFacet _adminFacet = new NFTAdminFacet(); + OnchainSVGFacet _svgFacet = new OnchainSVGFacet(); + + DiamondInit diamondInit = new DiamondInit(); + + address[] memory facetAddresses = new address[](6); + facetAddresses[0] = address(diamondLoupeFacet); + facetAddresses[1] = address(ownershipFacet); + facetAddresses[2] = address(_erc721Facet); + facetAddresses[3] = address(_mintFacet); + facetAddresses[4] = address(_adminFacet); + facetAddresses[5] = address(_svgFacet); + + string[] memory facetNames = new string[](6); + facetNames[0] = "DiamondLoupeFacet"; + facetNames[1] = "OwnershipFacet"; + facetNames[2] = "ERC721Facet"; + facetNames[3] = "MintFacet"; + facetNames[4] = "NFTAdminFacet"; + facetNames[5] = "OnchainSVGFacet"; + + IDiamondCut.FacetCut[] memory cuts = buildAddCutsByNames( + facetAddresses, + facetNames + ); + + bytes memory initCalldata = abi.encodeWithSelector( + diamondInit.init.selector, + DiamondInit.InitParams({ + name: "Diamond NFT", + symbol: "DNFT", + baseTokenURI: "https://api.example.com/metadata/", + maxSupply: 100, + mintPrice: 0.05 ether, + erc20Name: "Diamond Token", + erc20Symbol: "DTKN", + erc20Decimals: 18, + erc20MaxSupply: 1000000 ether, + rewardRate: 10 ether, + minStakeDuration: 1 days, + borrowFee: 0.01 ether, + maxBorrowDuration: 30 days, + marketplaceFee: 250 + }) + ); + + executeDiamondCut( + IDiamondCut(address(diamond)), + cuts, + address(diamondInit), + initCalldata + ); + + erc721 = ERC721Facet(address(diamond)); + mintFacet = MintFacet(address(diamond)); + adminFacet = NFTAdminFacet(address(diamond)); + svgFacet = OnchainSVGFacet(address(diamond)); + + vm.deal(user1, 100 ether); + vm.deal(user2, 100 ether); + } + + function _mintToken(address to) internal returns (uint256) { + adminFacet.setMintActive(true); + uint256 beforeSupply = mintFacet.totalSupply(); + vm.prank(to); + mintFacet.mint{value: 0.05 ether}(); + return beforeSupply + 1; + } + + function _contains(string memory haystack, string memory needle) internal pure returns (bool) { + bytes memory h = bytes(haystack); + bytes memory n = bytes(needle); + if (n.length > h.length) return false; + for (uint256 i = 0; i <= h.length - n.length; i++) { + bool found = true; + for (uint256 j = 0; j < n.length; j++) { + if (h[i + j] != n[j]) { + found = false; + break; + } + } + if (found) return true; + } + return false; + } + + function test_set_shape() public { + uint256 tokenId = _mintToken(user1); + vm.prank(user1); + svgFacet.setTokenShape(tokenId, "diamond"); + assertEq(svgFacet.tokenShape(tokenId), "diamond"); + } + + function test_set_colors() public { + uint256 tokenId = _mintToken(user1); + vm.prank(user1); + svgFacet.setTokenColors(tokenId, "#FF0000", "#00FF00"); + (string memory primary, string memory secondary) = svgFacet.tokenColors(tokenId); + assertEq(primary, "#FF0000"); + assertEq(secondary, "#00FF00"); + } + + function test_generate_svg() public { + uint256 tokenId = _mintToken(user1); + vm.prank(user1); + svgFacet.setTokenShape(tokenId, "star"); + vm.prank(user1); + svgFacet.setTokenColors(tokenId, "#FFD700", "#C0C0C0"); + + string memory svg = svgFacet.generateSVG(tokenId); + assertGt(bytes(svg).length, 0); + assertTrue(_contains(svg, "