From 5902ebcb193bb622b13ba9ba46f0cfe39a65335d Mon Sep 17 00:00:00 2001 From: Equious Date: Fri, 3 Jan 2025 10:59:02 -0700 Subject: [PATCH] initial commit --- .github/workflows/test.yml | 45 +++ .gitignore | 14 + .gitmodules | 12 + MakeFile | 7 + README.md | 83 +++++ foundry.toml | 9 + script/DeployTokenDivider.s.sol | 17 + script/Interactions.s.sol | 110 ++++++ src/TokenDivider.sol | 327 ++++++++++++++++++ src/token/ERC20ToGenerateNftFraccion.sol | 18 + .../invariant/TokenDividerInvariantTest.t.sol | 49 +++ test/mocks/ERC721Mock.sol | 16 + test/unit/TokenDividerTest.t.sol | 154 +++++++++ 13 files changed, 861 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 MakeFile create mode 100644 README.md create mode 100644 foundry.toml create mode 100644 script/DeployTokenDivider.s.sol create mode 100644 script/Interactions.s.sol create mode 100644 src/TokenDivider.sol create mode 100644 src/token/ERC20ToGenerateNftFraccion.sol create mode 100644 test/invariant/TokenDividerInvariantTest.t.sol create mode 100644 test/mocks/ERC721Mock.sol create mode 100644 test/unit/TokenDividerTest.t.sol diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..762a296 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + strategy: + fail-fast: true + + name: Foundry project + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Show Forge version + run: | + forge --version + + - name: Run Forge fmt + run: | + forge fmt --check + id: fmt + + - name: Run Forge build + run: | + forge build --sizes + id: build + + - name: Run Forge tests + run: | + forge test -vvv + id: test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85198aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..ca8a164 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,12 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/openzeppelin/openzeppelin-contracts +[submodule "lib/foundry-devops"] + path = lib/foundry-devops + url = https://github.com/Cyfrin/foundry-devops +[submodule "lib/slither"] + path = lib/slither + url = https://github.com/crytic/slither diff --git a/MakeFile b/MakeFile new file mode 100644 index 0000000..174917a --- /dev/null +++ b/MakeFile @@ -0,0 +1,7 @@ +-include .env +.PHONY: all test clean deploy fund help install snapshot format anvil + +install: + @forge install OpenZeppelin/openzeppelin-contracts --no-commit + @forge install foundry-rs/forge-std --no-commit + @forge install foundry-rs/foundry-devops --no-commit \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fc2a47 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Pieces Protocol + +# Contest Details + +### Prize Pool + +- High - 100xp +- Medium - 20xp +- Low - 2xp + +- Starts: January 16, 2025 Noon UTC +- Ends: January 23, 2025 Noon UTC + +### Stats + +- nSLOC: +- Complexity Score: + +[//]: # "contest-details-open" + +## About the Project + +## Summary + +A new marketplace where users can buy a fraction of an nft and trade with this one, if some user has all the fractions of an nft, can claim the original nft that is locked. + +## Actors + +- Buyer: The purchaser of the nft fraction. +- Seller: The seller of the nft fraction + +[//]: # "contest-details-close" + +## Scope (contracts) + +``` +├── src +│ ├── TokenDivider.sol +│ └── token +│ └── ERC20ToGenerateNftFraccion.sol +``` + +Compatibilities: + +Blockchains: + +- Ethereum +- EVM Equivalent + +Tokens: + +- ETH +- ERC721 Standard + +[//]: # "scope-close" +[//]: # "getting-started-open" + +## Setup + +Build: + +```bash +git clone https://github.com/JuanPeVentura/NftFractionMarketplace.git + +make install + +forge build +``` + +Tests: + +```bash +forge test +``` + +[//]: # "getting-started-close" +[//]: # "known-issues-open" + +## Known Issues + +No known issues reported + +[//]: # "known-issues-close" diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..c38c108 --- /dev/null +++ b/foundry.toml @@ -0,0 +1,9 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +remappings=['@openzeppelin/contracts=lib/openzeppelin-contracts/contracts'] + +fs_permissions = [{ access = "read", path = "./broadcast" }, {access = "read", path = "./Images/"}] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/script/DeployTokenDivider.s.sol b/script/DeployTokenDivider.s.sol new file mode 100644 index 0000000..5097b0f --- /dev/null +++ b/script/DeployTokenDivider.s.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: SEE LICENSE IN LICENSE +pragma solidity ^0.8.18; + + +import {Script} from 'forge-std/Script.sol'; +import {TokenDivider} from 'src/TokenDivider.sol'; + +contract DeployTokenDivider is Script { + function run() external returns(TokenDivider){ + vm.startBroadcast(); + TokenDivider tokenDivider = new TokenDivider(); + vm.stopBroadcast(); + + return tokenDivider; + } + +} \ No newline at end of file diff --git a/script/Interactions.s.sol b/script/Interactions.s.sol new file mode 100644 index 0000000..f41817f --- /dev/null +++ b/script/Interactions.s.sol @@ -0,0 +1,110 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + + +import {Script, console} from "forge-std/Script.sol"; +import {DevOpsTools} from "lib/foundry-devops/src/DevOpsTools.sol"; +import {TokenDivider} from "src/TokenDivider.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + + + + +contract DivideNft is Script { + + function run(address nftAddress, uint256 tokenId, uint256 amount) public { + address mostRecentDelployment = DevOpsTools.get_most_recent_deployment("TokenDivider", block.chainid); + divideNft(mostRecentDelployment, nftAddress, tokenId, amount); + + } + + + function divideNft(address mostRecentDeployment, address nftAddress, uint256 tokenId, uint256 amount) public { + vm.startBroadcast(msg.sender); + IERC721(nftAddress).approve(mostRecentDeployment, tokenId); + vm.stopBroadcast(); + TokenDivider(mostRecentDeployment).divideNft(nftAddress, tokenId, amount); + + + } +} + +contract ClaimNft is Script { + + function run(address nftAddress) public { + address mostRecentDelployment = DevOpsTools.get_most_recent_deployment("TokenDivider", block.chainid); + claimNft(mostRecentDelployment, nftAddress); + } + + + function claimNft(address mostRecentDeployment, address nftAddress) public { + address erc20 = TokenDivider(mostRecentDeployment).getErc20InfoFromNft(nftAddress).erc20Address; + uint256 totalErc20MintedAmount = TokenDivider(mostRecentDeployment).getErc20TotalMintedAmount(erc20); + vm.startBroadcast(msg.sender); + IERC20(erc20).approve(mostRecentDeployment, totalErc20MintedAmount); + TokenDivider(mostRecentDeployment).claimNft(nftAddress); + vm.stopBroadcast(); + + + } +} + + +contract TransferErc20 is Script { + function run(address nftAddress, address to, uint256 amount) public { + address mostRecentDelployment = DevOpsTools.get_most_recent_deployment("TokenDivider", block.chainid); + transferErc20(mostRecentDelployment, nftAddress, to, amount); + } + + + function transferErc20(address mostRecentDeployment, address nftAddress, address to, uint256 amount) public { + address erc20 = TokenDivider(mostRecentDeployment).getErc20InfoFromNft(nftAddress).erc20Address; + + vm.startBroadcast(msg.sender); + IERC20(erc20).approve(mostRecentDeployment, amount); + TokenDivider(mostRecentDeployment).transferErcTokens(nftAddress, to, amount); + vm.stopBroadcast(); + + + + } +} + + +contract SellErc20 is Script { + function run(address nftAddress, uint256 price, uint256 amount) public { + address mostRecentDelployment = DevOpsTools.get_most_recent_deployment("TokenDivider", block.chainid); + sellErc20(mostRecentDelployment, nftAddress, price, amount); + } + + + function sellErc20(address mostRecentDeployment, address nftAddress, uint256 price, uint256 amount) public { + address erc20 = TokenDivider(mostRecentDeployment).getErc20InfoFromNft(nftAddress).erc20Address; + + vm.startBroadcast(msg.sender); + IERC20(erc20).approve(mostRecentDeployment, amount); + TokenDivider(mostRecentDeployment).sellErc20(nftAddress, price, amount); + vm.stopBroadcast(); + + + + } +} + +contract BuyErc20 is Script { + function run(uint256 orderIndex, address seller) public { + address mostRecentDelployment = DevOpsTools.get_most_recent_deployment("TokenDivider", block.chainid); + buyErc20(mostRecentDelployment, orderIndex, seller); + } + + + function buyErc20(address mostRecentDeployment, uint256 orderIndex ,address seller) public { + vm.startBroadcast(msg.sender); + TokenDivider(mostRecentDeployment).buyOrder{value: TokenDivider(mostRecentDeployment).getOrderPrice(seller, orderIndex) + ((TokenDivider(mostRecentDeployment).getOrderPrice(seller, orderIndex) / 100) / 2)}(orderIndex, seller); + vm.stopBroadcast(); + + + + } +} \ No newline at end of file diff --git a/src/TokenDivider.sol b/src/TokenDivider.sol new file mode 100644 index 0000000..43f803d --- /dev/null +++ b/src/TokenDivider.sol @@ -0,0 +1,327 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC20ToGenerateNftFraccion} from "src/token/ERC20ToGenerateNftFraccion.sol"; +import {IERC721, ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +/** + * @title TokenDivider + * @author Juan Pedro Ventura Baltian, 14 years old + * @notice This contracts was created, with the intention to make a new market of nft franctions + * There are a function to divide an nft, then you can sell and buy some fraction of nft, that are basicaly + * erc20 tokens, each nft pegged to an nft.There are some validations, to make the platforme the most secure + * as possible.This is the first project that i code alone, in blockchain, foundry and solidity. + * Thank you so much for read it. + */ + + +contract TokenDivider is IERC721Receiver,Ownable { + + error TokenDivider__NotFromNftOwner(); + error TokenDivider__NotEnoughErc20Balance(); + error TokenDivider__NftTransferFailed(); + error TokenDivider__InsuficientBalance(); + error TokenDivider__CantTransferToAddressZero(); + error TokenDivider__TransferFailed(); + error TokenDivider__NftAddressIsZero(); + error TokenDivider__AmountCantBeZero(); + error TokenDivider__InvalidSeller(); + error TokenDivier__InvalidAmount(); + error TokenDivider__IncorrectEtherAmount(); + error TokenDivider__InsuficientEtherForFees(); + + struct ERC20Info { + address erc20Address; + uint256 tokenId; + } + + struct SellOrder { + address seller; + address erc20Address; + uint256 price; + uint256 amount; + } + + + /** + * @dev balances Relates a user with an amount of a erc20 token, this erc20 tokens is an nft fraction + + @dev nftToErc20Info Relates an nft with the erc20 pegged, and othe data like the erc20 amount, or the tokenId + + @dev s_userToSellOrders Relates a user with an array of sell orders, that each sell order + has a seller, an erc20 that is the token to sell, a price and an amount of erc20 to sell + + */ + mapping(address user => mapping(address erc20Address => uint256 amount)) balances; + mapping(address nft => ERC20Info) nftToErc20Info; + mapping(address user => SellOrder[] orders) s_userToSellOrders; + mapping(address erc20 => address nft) erc20ToNft; + mapping(address erc20 => uint256 totalErc20Minted) erc20ToMintedAmount; + + event NftDivided(address indexed nftAddress, uint256 indexed amountErc20Minted, address indexed erc20Minted); + event NftClaimed(address indexed nftAddress); + event TokensTransfered(uint256 indexed amount, address indexed erc20Address); + event OrderPublished(uint256 indexed amount, address indexed seller, address indexed nftPegged); + event OrderSelled(address indexed buyer, uint256 price); + + + /** + * + * Only the owner of the nft can call a function with this modifier + */ + modifier onlyNftOwner(address nft, uint256 tokenId) { + if(msg.sender != IERC721(nft).ownerOf(tokenId)) { + revert TokenDivider__NotFromNftOwner(); + } + _; + } + + constructor() Ownable(msg.sender) {} + + + + /** + * @dev Handles the receipt of an ERC721 token. This function is called whenever an ERC721 token is transferred to this contract. + */ + function onERC721Received( + address /* operator */, + address /* from */, + uint256 /* tokenId */, + bytes calldata /* data */ + ) external pure override returns (bytes4) { + // Return this value to confirm the receipt of the NFT + return this.onERC721Received.selector; + } + + /** + * + * @param nftAddress The addres of the nft to divide + * @param tokenId The id of the token to divide + * @param amount The amount of erc20 tokens to mint for the nft + * + * @dev in this function, the nft passed as parameter, is locked by transfering it to this contract, then, it gives to the + * person calling this function an amount of erc20, beeing like a fraction of this nft. + */ + + function divideNft(address nftAddress, uint256 tokenId, uint256 amount) onlyNftOwner(nftAddress, tokenId) onlyNftOwner(nftAddress ,tokenId) external { + + + if(nftAddress == address(0)) { revert TokenDivider__NftAddressIsZero(); } + if(amount == 0) { revert TokenDivider__AmountCantBeZero(); } + + ERC20ToGenerateNftFraccion erc20Contract = new ERC20ToGenerateNftFraccion( + string(abi.encodePacked(ERC721(nftAddress).name(), "Fraccion")), + string(abi.encodePacked("F", ERC721(nftAddress).symbol()))); + + erc20Contract.mint(address(this), amount); + address erc20 = address(erc20Contract); + + IERC721(nftAddress).safeTransferFrom(msg.sender, address(this), tokenId, ""); + + if(IERC721(nftAddress).ownerOf(tokenId) == msg.sender) { revert TokenDivider__NftTransferFailed(); } + + balances[msg.sender][erc20] = amount; + nftToErc20Info[nftAddress] = ERC20Info({erc20Address: erc20, tokenId: tokenId}); + erc20ToMintedAmount[erc20] = amount; + erc20ToNft[erc20] = nftAddress; + + emit NftDivided(nftAddress, amount, erc20); + + bool transferSuccess = IERC20(erc20).transfer(msg.sender, amount); + if(!transferSuccess) { + revert TokenDivider__TransferFailed(); + } + } + + /** + * + * @param nftAddress The address of the nft to claim + * + * @dev in this function, if you have all the erc20 minted for the nft, you can call this function to claim the nft, + * giving to the contract all the erc20 and it will give you back the nft + */ + + function claimNft(address nftAddress) external { + + if(nftAddress == address(0)) { + revert TokenDivider__NftAddressIsZero(); + } + + ERC20Info storage tokenInfo = nftToErc20Info[nftAddress]; + + if(balances[msg.sender][tokenInfo.erc20Address] < erc20ToMintedAmount[tokenInfo.erc20Address]) { + revert TokenDivider__NotEnoughErc20Balance(); + } + + ERC20ToGenerateNftFraccion(tokenInfo.erc20Address).burnFrom(msg.sender, erc20ToMintedAmount[tokenInfo.erc20Address]); + + balances[msg.sender][tokenInfo.erc20Address] = 0; + erc20ToMintedAmount[tokenInfo.erc20Address] = 0; + + emit NftClaimed(nftAddress); + + IERC721(nftAddress).safeTransferFrom(address(this), msg.sender, tokenInfo.tokenId); + } + + + /** + * + * @param nftAddress The nft address pegged to the erc20 + * @param to The reciver of the erc20 + * @param amount The amount of erc20 to transfer + * + * @dev you can use this function to transfer nft franctions 100% securily and registered by te contract + */ + + function transferErcTokens(address nftAddress,address to, uint256 amount) external { + + if(nftAddress == address(0)) { + revert TokenDivider__NftAddressIsZero(); + } + + if(to == address(0)) { + revert TokenDivider__CantTransferToAddressZero(); + } + + if(amount == 0) { + revert TokenDivider__AmountCantBeZero(); + } + + ERC20Info memory tokenInfo = nftToErc20Info[nftAddress]; + + if(to == address(0)) { + revert TokenDivider__CantTransferToAddressZero(); + } + if(balances[msg.sender][tokenInfo.erc20Address] < amount) { + revert TokenDivider__NotEnoughErc20Balance(); + } + + balances[msg.sender][tokenInfo.erc20Address] -= amount; + balances[to][tokenInfo.erc20Address] += amount; + + emit TokensTransfered(amount, tokenInfo.erc20Address); + + IERC20(tokenInfo.erc20Address).transferFrom(msg.sender,to, amount); + } + + /** + * + * @param nftPegged The nft address pegged to the tokens to sell + * @param price The price of all the tokens to sell + * @param amount The amount of tokens to sell + * + * @dev this function creates a new order, is like publish you assets into a marketplace, where other persons can buy it. + * firstly, once you call this function, the amount of tokens that you passed into as a parameter, get blocked, by sending it + * to this contract, then a new order is created and published. + */ + + function sellErc20(address nftPegged, uint256 price,uint256 amount) external { + if(nftPegged == address(0)) { + revert TokenDivider__NftAddressIsZero(); + } + + if( amount == 0) { + revert TokenDivider__AmountCantBeZero(); + } + + ERC20Info memory tokenInfo = nftToErc20Info[nftPegged]; + if(balances[msg.sender][tokenInfo.erc20Address] < amount) { + revert TokenDivider__InsuficientBalance(); + } + + balances[msg.sender][tokenInfo.erc20Address] -= amount; + + s_userToSellOrders[msg.sender].push( + SellOrder({ + seller: msg.sender, + erc20Address: tokenInfo.erc20Address, + price: price, + amount: amount + }) + ); + + emit OrderPublished(amount,msg.sender, nftPegged); + + IERC20(tokenInfo.erc20Address).transferFrom(msg.sender,address(this), amount); + } + + + /** + * + * @param orderIndex The index of the order in all the orders array of the seller (the seller can have multiple orders active) + * @param seller The person who is selling this tokens + * + * @dev when the buyer call this function, the eth or any token accepted to pay, is sent to the seller + * if the transfer executed correctly, then this contract, wich has all the tokens, send the tokens to the msg.sender + */ + + function buyOrder(uint256 orderIndex, address seller) external payable { + if(seller == address(0)) { + revert TokenDivider__InvalidSeller(); + } + + + + SellOrder memory order = s_userToSellOrders[seller][orderIndex]; + + if(msg.value < order.price) { + revert TokenDivider__IncorrectEtherAmount(); + } + + uint256 fee = order.price / 100; + uint256 sellerFee = fee / 2; + + + if(msg.value < order.price + sellerFee) { + revert TokenDivider__InsuficientEtherForFees(); + } + + + balances[msg.sender][order.erc20Address] += order.amount; + + s_userToSellOrders[seller][orderIndex] = s_userToSellOrders[seller][s_userToSellOrders[seller].length - 1]; + s_userToSellOrders[seller].pop(); + + emit OrderSelled(msg.sender, order.price); + + // Transfer The Ether + + (bool success, ) = payable(order.seller).call{value: (order.price - sellerFee)}(""); + + if(!success) { + revert TokenDivider__TransferFailed(); + } + + (bool taxSuccess, ) = payable(owner()).call{value: fee}(""); + + + if(!taxSuccess) { + revert TokenDivider__TransferFailed(); + } + + IERC20(order.erc20Address).transfer(msg.sender, order.amount); + + } + + /** Getters */ + + function getBalanceOf(address user, address token) public view returns(uint256) { + return balances[user][token]; + } + + function getErc20TotalMintedAmount(address erc20) public view returns(uint256) { + return erc20ToMintedAmount[erc20]; + } + + function getErc20InfoFromNft(address nft) public view returns(ERC20Info memory) { + return nftToErc20Info[nft]; + } + + function getOrderPrice(address seller, uint256 index) public view returns(uint256 price) { + price = s_userToSellOrders[seller][index].price; + } + +} \ No newline at end of file diff --git a/src/token/ERC20ToGenerateNftFraccion.sol b/src/token/ERC20ToGenerateNftFraccion.sol new file mode 100644 index 0000000..dad4856 --- /dev/null +++ b/src/token/ERC20ToGenerateNftFraccion.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + + +import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + + +contract ERC20ToGenerateNftFraccion is ERC20, ERC20Burnable { + + constructor(string memory _name, string memory _symbol) ERC20(_name, _symbol) { + + } + + function mint(address _to, uint256 _amount) public { + _mint(_to, _amount); + } +} \ No newline at end of file diff --git a/test/invariant/TokenDividerInvariantTest.t.sol b/test/invariant/TokenDividerInvariantTest.t.sol new file mode 100644 index 0000000..8936873 --- /dev/null +++ b/test/invariant/TokenDividerInvariantTest.t.sol @@ -0,0 +1,49 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +import {StdInvariant} from 'forge-std/StdInvariant.sol'; +import {Test} from 'forge-std/Test.sol'; +import {ERC20Mock} from '@openzeppelin/contracts/mocks/token/ERC20Mock.sol'; +import {ERC721Mock} from '../mocks/ERC721Mock.sol'; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {TokenDivider} from 'src/TokenDivider.sol'; +import {DeployTokenDivider} from 'script/DeployTokenDivider.s.sol'; + + +contract TokenDividerInvariantTest is StdInvariant, Test { + DeployTokenDivider deployer; + ERC20Mock erc20; + ERC721Mock erc721; + TokenDivider tokenDivider; + + uint256 public constant AMOUNT = 10e18; + function setUp() public{ + + deployer = new DeployTokenDivider(); + tokenDivider = deployer.run(); + + erc721 = new ERC721Mock(); + erc20 = new ERC20Mock(); + + + erc20.mint(address(tokenDivider), AMOUNT); + erc721.mint(msg.sender); + + vm.prank(msg.sender); + erc721.approve(address(tokenDivider), 0); + + + targetContract(address(tokenDivider)); + } + + function invariant__allErc20TokensShouldAlwaysBeEqualToTheTotalSupplyOfThem() public view { + uint256 totalERC20Minted= erc20.totalSupply(); + + assertEq(totalERC20Minted, AMOUNT); + } + + function invariant__gettersShouldNeverRevert() public view { + tokenDivider.getBalanceOf(msg.sender, address(erc20)); + tokenDivider.getErc20TotalMintedAmount(address(erc20)); + } +} \ No newline at end of file diff --git a/test/mocks/ERC721Mock.sol b/test/mocks/ERC721Mock.sol new file mode 100644 index 0000000..2d9cc17 --- /dev/null +++ b/test/mocks/ERC721Mock.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +contract ERC721Mock is ERC721, Ownable { + uint256 private _tokenIdCounter; + + constructor() ERC721("MockToken", "MTK") Ownable(msg.sender) {} + + function mint(address to) public onlyOwner { + _safeMint(to, _tokenIdCounter); + _tokenIdCounter++; + } +} diff --git a/test/unit/TokenDividerTest.t.sol b/test/unit/TokenDividerTest.t.sol new file mode 100644 index 0000000..214863e --- /dev/null +++ b/test/unit/TokenDividerTest.t.sol @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + + +import {Test, console} from 'forge-std/Test.sol'; +import {DeployTokenDivider} from 'script/DeployTokenDivider.s.sol'; +import {TokenDivider} from 'src/TokenDivider.sol'; +import {ERC721Mock} from '../mocks/ERC721Mock.sol'; +import {ERC20Mock} from '@openzeppelin/contracts/mocks/token/ERC20Mock.sol'; +import {Ownable} from '@openzeppelin/contracts/access/Ownable.sol'; + + +contract TokenDiverTest is Test { + DeployTokenDivider deployer; + TokenDivider tokenDivider; + ERC721Mock erc721Mock; + + address public USER = makeAddr("user"); + address public USER2 = makeAddr("user2"); + uint256 constant public STARTING_USER_BALANCE = 10e18; + uint256 constant public AMOUNT = 2e18; + uint256 constant public TOKEN_ID = 0; + + function setUp() public { + deployer = new DeployTokenDivider(); + tokenDivider = deployer.run(); + + erc721Mock = new ERC721Mock(); + + erc721Mock.mint(USER); + vm.deal(USER2, STARTING_USER_BALANCE); + } + + function testDivideNft() public { + + vm.startPrank(USER); + erc721Mock.approve(address(tokenDivider), TOKEN_ID); + + tokenDivider.divideNft(address(erc721Mock), TOKEN_ID, AMOUNT); + vm.stopPrank(); + ERC20Mock erc20Mock = ERC20Mock(tokenDivider.getErc20InfoFromNft(address(erc721Mock)).erc20Address); + + console.log("ERC20 Token name is: ", erc20Mock.name()); + console.log("ERC20 Token symbol is: ", erc20Mock.symbol()); + assertEq(tokenDivider.getErc20TotalMintedAmount(address(erc20Mock)), AMOUNT); + assertEq(erc721Mock.ownerOf(TOKEN_ID), address(tokenDivider)); + assertEq(tokenDivider.getBalanceOf(USER, address(erc20Mock)), AMOUNT); + + } + + + modifier nftDivided() { + vm.startPrank(USER); + erc721Mock.approve(address(tokenDivider), TOKEN_ID); + tokenDivider.divideNft(address(erc721Mock), TOKEN_ID, AMOUNT); + vm.stopPrank(); + + _; + + } + + function testDivideNftFailsIsSenderIsNotNftOwner() public { + vm.prank(USER); + erc721Mock.approve(address(tokenDivider), TOKEN_ID); + + vm.startPrank(USER2); + vm.expectRevert(TokenDivider.TokenDivider__NotFromNftOwner.selector); + tokenDivider.divideNft(address(erc721Mock), TOKEN_ID, AMOUNT); + vm.stopPrank(); + } + + function testTransferErcTokensAndClaimNftFailsIfDontHaveAllTheErc20() public nftDivided { + ERC20Mock erc20Mock = ERC20Mock(tokenDivider.getErc20InfoFromNft(address(erc721Mock)).erc20Address); + vm.startPrank(USER); + // Arrange + + erc20Mock.approve(address(tokenDivider), AMOUNT); + + + // Act / Assert + tokenDivider.transferErcTokens(address(erc721Mock),USER2, AMOUNT); + + assertEq(tokenDivider.getBalanceOf(USER2, address(erc20Mock)), AMOUNT); + assertEq(tokenDivider.getBalanceOf(USER, address(erc20Mock)), 0); + + vm.expectRevert(TokenDivider.TokenDivider__NotEnoughErc20Balance.selector); + + tokenDivider.claimNft(address(erc721Mock)); + + vm.stopPrank(); + + } + + function testClaimNft() public nftDivided { + ERC20Mock erc20Mock = ERC20Mock(tokenDivider.getErc20InfoFromNft(address(erc721Mock)).erc20Address); + vm.startPrank(USER); + erc20Mock.approve(address(tokenDivider), AMOUNT); + tokenDivider.claimNft(address(erc721Mock)); + vm.stopPrank(); + + assertEq(erc20Mock.totalSupply(), 0); + assertEq(tokenDivider.getBalanceOf(USER, address(erc20Mock)), 0); + assertEq(erc721Mock.ownerOf(TOKEN_ID), USER); + } + + + function testSellErc20() public nftDivided{ + ERC20Mock erc20Mock = ERC20Mock(tokenDivider.getErc20InfoFromNft(address(erc721Mock)).erc20Address); + + vm.startPrank(USER); + erc20Mock.approve(address(tokenDivider), AMOUNT); + tokenDivider.sellErc20(address(erc721Mock), 1e18, AMOUNT); + vm.stopPrank(); + + assertEq(tokenDivider.getBalanceOf(USER, address(erc20Mock)), 0); + assertEq(erc20Mock.balanceOf(address(tokenDivider)), AMOUNT); + } + + function testBuyErc20() public nftDivided { + ERC20Mock erc20Mock = ERC20Mock(tokenDivider.getErc20InfoFromNft(address(erc721Mock)).erc20Address); + + uint256 ownerBalanceBefore = address(tokenDivider.owner()).balance; + uint256 userBalanceBefore = address(USER).balance; + uint256 user2TokenBalanceBefore = tokenDivider.getBalanceOf(USER2, address(erc20Mock)); + + vm.startPrank(USER); + + erc20Mock.approve(address(tokenDivider), AMOUNT); + + tokenDivider.sellErc20(address(erc721Mock), AMOUNT, 1e18); // Creamos una orden de venta por 1 ETH + + uint256 fees = AMOUNT / 100; + + vm.stopPrank(); + + + vm.prank(USER2); + tokenDivider.buyOrder{value: (3e18)}(0, USER); + + uint256 ownerBalanceAfter = address(tokenDivider.owner()).balance; + uint256 userBalanceAfter = address(USER).balance; + uint256 user2TokenBalanceAfter = tokenDivider.getBalanceOf(USER2, address(erc20Mock)); + + assertEq(user2TokenBalanceAfter - 1e18, user2TokenBalanceBefore); + assertEq(ownerBalanceAfter - fees, ownerBalanceBefore); + + if(block.chainid != 31337) { + assertEq(userBalanceAfter - AMOUNT + fees / 2, userBalanceBefore); + } else { + assertEq(user2TokenBalanceAfter, 1e18); + + } + } +} \ No newline at end of file