├── .gitmodules ├── README.md ├── foundry.toml ├── src ├── BucketFactory.sol └── ERC20Bucket.sol └── test ├── BucketFactory.t.sol ├── ERC20Bucket.t.sol └── helpers └── TestERC721.sol /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/forge-std"] 2 | path = lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | [submodule "lib/solady"] 5 | path = lib/solady 6 | url = https://github.com/vectorized/solady 7 | [submodule "lib/solarray"] 8 | path = lib/solarray 9 | url = https://github.com/evmcheb/solarray 10 | [submodule "lib/forge-gas-metering"] 11 | path = lib/forge-gas-metering 12 | url = https://github.com/emo-eth/forge-gas-metering 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Buckets 2 | 3 | > "Never half-ass two things. Whole-ass one thing." 4 | > 5 | > — Ron Swanson 6 | 7 | `Buckets` is a set of smart contracts for easily and efficiently fractionalizing _**any ERC721 NFT**_ into ERC20 tokens. There are no fees, no middlemen, and no trust involved. The contracts are unowned and the code is immutable. 8 | 9 | ## BucketFactory 10 | The `BucketFactory` allows users to deposit ERC721 NFTs and mint corresponding ERC20 tokens. If an ERC20 token does not yet exist for the ERC721 contract, the `BucketFactory` will deploy a new lightweight clone `ERC20Bucket` contract. 11 | 12 | Every deposited NFT mints 10,000 of the corresponding `ERC20Bucket` tokens to the minter or specified recipient. Why 10,000? Think basis points 13 | 14 | Anyone with a balance greater than 10,000 of an `ERC20Bucket` token can call `redeem` to burn their tokens and receive specific ERC721 NFT(s). 15 | 16 | ## ERC20Bucket 17 | An `ERC20Bucket` is an ERC20 token that represents a fraction of an NFT. Only the `BucketFactory` can mint new tokens or burn existing tokens. 18 | 19 | # Caveats 20 | 21 | - Nonstandard ERC721 contracts, especially those that do not have true immutable ownership or intentionally break composability of the ERC721 standard may be incompatible with `Buckets`. 22 | - `ERC20Bucket` `name`s and `symbol`s will break if someone tries to get cheeky and deploy a contract with a name or symbol longer than ~65,500 bytes. Consider it a feature, not a bug. -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = "src" 3 | out = "out" 4 | libs = ["lib"] 5 | remappings = ['solady/=lib/solady/src/', 'solarray/=lib/solarray/src/'] 6 | 7 | # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options 8 | -------------------------------------------------------------------------------- /src/BucketFactory.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.17; 3 | 4 | import {ERC721} from "solady/tokens/ERC721.sol"; 5 | import {ERC20Bucket} from "./ERC20Bucket.sol"; 6 | import {LibClone} from "solady/utils/LibClone.sol"; 7 | 8 | /** 9 | * @title BucketFactory 10 | * @author emo.eth 11 | * @notice A factory contract for minting ERC20 "bucket" tokens backed by non-fungible tokens belonging to any ERC721 contract. 12 | * On first deposit, the BucketFactory deploys a corresponding fungible ERC20 "bucket" token for the specific ERC721 contract, 13 | * which the factory can then use to mint and burn fungible tokens as ERC721 tokens are deposited and redeemed. 14 | * Each ERC721 token deposited into the factory contract will mint 10,000 fungible tokens (18 decimals) from the corresponding ERC20 15 | * "bucket" to the sender or specified recipient. 16 | * Conversely, any account can call the redeem functions to burn 10,000 fungible tokens from an ERC20 "bucket" and receive any corresponding 17 | * ERC721 token that the factory holds. 18 | */ 19 | contract BucketFactory { 20 | ///@notice The number of fungible tokens minted per NFT (18 decimal places) 21 | uint256 public constant FUNGIBLE_TOKENS_PER_NFT = 10_000 ether; 22 | address public immutable ERC20_BUCKET_IMPLEMENTATION; 23 | 24 | ///@notice A mapping from NFT contract addresses to its corresponding ERC20 bucket contract addresses 25 | mapping(address nftContract => address erc20BucketContract) public nftToErc20Bucket; 26 | 27 | ///@notice An error to be used when a bucket does not exist 28 | error BucketDoesNotExist(); 29 | 30 | constructor() { 31 | ERC20_BUCKET_IMPLEMENTATION = address(new ERC20Bucket()); 32 | } 33 | 34 | /** 35 | * @notice Deposit an NFT into the contract from the sender and mint the corresponding fungible tokens to the sender 36 | * @param nftContract The address of the NFT contract 37 | * @param tokenId The ID of the NFT to deposit 38 | */ 39 | function deposit(address nftContract, uint256 tokenId) external { 40 | deposit(nftContract, tokenId, msg.sender); 41 | } 42 | 43 | /** 44 | * @notice Deposit an NFT into the contract from the sender and mint the corresponding fungible tokens to the recipient 45 | * @param nftContract The address of the NFT contract 46 | * @param tokenId The ID of the NFT to deposit 47 | * @param recipient The address to mint the fungible tokens to 48 | */ 49 | function deposit(address nftContract, uint256 tokenId, address recipient) public { 50 | // take ownership of the nft 51 | ERC721(nftContract).transferFrom(msg.sender, address(this), tokenId); 52 | // mint fungible tokens to the sender 53 | _bucketMint(nftContract, recipient, FUNGIBLE_TOKENS_PER_NFT); 54 | } 55 | 56 | /** 57 | * @notice Deposit multiple NFTs into the contract from the sender and mint the corresponding fungible tokens to the sender 58 | * @param nftContract The address of the NFT contract 59 | * @param tokenIds The IDs of the NFTs to deposit 60 | */ 61 | function deposit(address nftContract, uint256[] calldata tokenIds) external { 62 | deposit(nftContract, tokenIds, msg.sender); 63 | } 64 | 65 | /** 66 | * @notice Deposit multiple NFTs into the contract from the sender and mint the corresponding fungible tokens to the recipient 67 | * @param nftContract The address of the NFT contract 68 | * @param tokenIds The IDs of the NFTs to deposit 69 | * @param recipient The address to mint the fungible tokens to 70 | */ 71 | function deposit(address nftContract, uint256[] calldata tokenIds, address recipient) public { 72 | // take ownership of the nfts 73 | for (uint256 i = 0; i < tokenIds.length; ++i) { 74 | ERC721(nftContract).transferFrom(msg.sender, address(this), tokenIds[i]); 75 | } 76 | // mint fungible tokens to the sender 77 | _bucketMint(nftContract, recipient, FUNGIBLE_TOKENS_PER_NFT * tokenIds.length); 78 | } 79 | 80 | /** 81 | * @notice Burn fungible tokens from the sender and transfer a corresponding NFT to the sender 82 | * @param nftContract The address of the NFT contract 83 | * @param tokenId The ID of the NFT to redeem 84 | */ 85 | function redeem(address nftContract, uint256 tokenId) external { 86 | redeem(nftContract, tokenId, msg.sender); 87 | } 88 | 89 | /** 90 | * @notice Burn fungible tokens from the sender and transfer a corresponding NFT to the recipient 91 | * @param nftContract The address of the NFT contract 92 | * @param tokenId The ID of the NFT to redeem 93 | * @param recipient The address to transfer the NFT to 94 | */ 95 | function redeem(address nftContract, uint256 tokenId, address recipient) public { 96 | // burn fungible tokens from msg.sender 97 | _bucketBurn(nftContract, FUNGIBLE_TOKENS_PER_NFT); 98 | // transfer the nft to the recipient 99 | ERC721(nftContract).transferFrom(address(this), recipient, tokenId); 100 | } 101 | 102 | /** 103 | * @notice Burn fungible tokens from the sender and transfer multiple corresponding NFTs to the sender 104 | * @param nftContract The address of the NFT contract 105 | * @param tokenIds The IDs of the NFTs to redeem 106 | */ 107 | function redeem(address nftContract, uint256[] calldata tokenIds) external { 108 | redeem(nftContract, tokenIds, msg.sender); 109 | } 110 | 111 | /** 112 | * @notice Burn fungible tokens from the sender and transfer multiple corresponding NFTs to the recipient 113 | * @param nftContract The address of the NFT contract 114 | * @param tokenIds The IDs of the NFTs to redeem 115 | * @param recipient The address to transfer the NFTs to 116 | */ 117 | function redeem(address nftContract, uint256[] calldata tokenIds, address recipient) public { 118 | // burn fungible tokens from msg.sender 119 | _bucketBurn(nftContract, FUNGIBLE_TOKENS_PER_NFT * tokenIds.length); 120 | // transfer the nfts to the recipient 121 | for (uint256 i = 0; i < tokenIds.length; ++i) { 122 | ERC721(nftContract).transferFrom(address(this), recipient, tokenIds[i]); 123 | } 124 | } 125 | 126 | /** 127 | * @notice Mint any outstanding fungible tokens to the sender 128 | * @param nftContract The address of the NFT contract 129 | */ 130 | function skim(address nftContract) public { 131 | skim(nftContract, msg.sender); 132 | } 133 | 134 | /** 135 | * @notice Mint any outstanding fungible tokens to the recipient 136 | * @param nftContract The address of the NFT contract 137 | * @param recipient The address to mint any outstanding fungible tokens to 138 | */ 139 | function skim(address nftContract, address recipient) public { 140 | uint256 nftBalance = ERC721(nftContract).balanceOf(address(this)); 141 | if (nftBalance > 0) { 142 | ERC20Bucket erc20Bucket = _getBucket(nftContract); 143 | uint256 actual = erc20Bucket.totalSupply(); 144 | uint256 expected = nftBalance * FUNGIBLE_TOKENS_PER_NFT; 145 | if (expected > actual) { 146 | erc20Bucket.mint(recipient, expected - actual); 147 | } 148 | } 149 | } 150 | 151 | /** 152 | * @notice Mint any outstanding fungible tokens to the sender for multiple NFT contracts 153 | * @param nftContracts The addresses of the NFT contracts 154 | */ 155 | function skim(address[] calldata nftContracts) external { 156 | skim(nftContracts, msg.sender); 157 | } 158 | 159 | /** 160 | * @notice Mint any outstanding fungible tokens to the recipient for multiple NFT contracts 161 | * @param nftContracts The addresses of the NFT contracts 162 | * @param recipient The address to mint any outstanding fungible tokens to 163 | */ 164 | function skim(address[] calldata nftContracts, address recipient) public { 165 | for (uint256 i = 0; i < nftContracts.length; ++i) { 166 | skim(nftContracts[i], recipient); 167 | } 168 | } 169 | 170 | /** 171 | * @dev Get or create the ERC20 bucket for the given NFT contract 172 | * @param nftContract The address of the NFT contract to get the bucket for 173 | */ 174 | function _getBucket(address nftContract) internal returns (ERC20Bucket) { 175 | ERC20Bucket erc20Bucket = ERC20Bucket(nftToErc20Bucket[nftContract]); 176 | // create a new bucket if one doesn't exist 177 | if (address(erc20Bucket) == address(0)) { 178 | string memory name = string.concat(ERC721(nftContract).name(), " (Bucket)"); 179 | string memory symbol = string.concat(ERC721(nftContract).symbol(), "(B)"); 180 | bytes memory immutableArgs = abi.encodePacked( 181 | address(this), 182 | address(nftContract), 183 | uint16(bytes(name).length), 184 | uint16(bytes(symbol).length), 185 | name, 186 | symbol 187 | ); 188 | address clone = 189 | LibClone.cloneDeterministic(ERC20_BUCKET_IMPLEMENTATION, immutableArgs, bytes32(bytes20(nftContract))); 190 | erc20Bucket = ERC20Bucket(clone); 191 | // store the address of the new bucket 192 | nftToErc20Bucket[nftContract] = address(erc20Bucket); 193 | } 194 | return erc20Bucket; 195 | } 196 | 197 | /** 198 | * @dev Check that the ERC20 bucket for the given NFT contract exists and return it 199 | * @param nftContract The address of the NFT contract to check the bucket for 200 | */ 201 | function _checkBucket(address nftContract) internal view returns (ERC20Bucket) { 202 | ERC20Bucket erc20Bucket = ERC20Bucket(nftToErc20Bucket[nftContract]); 203 | // revert if the bucket doesn't exist 204 | if (address(erc20Bucket) == address(0)) { 205 | revert BucketDoesNotExist(); 206 | } 207 | return erc20Bucket; 208 | } 209 | 210 | /** 211 | * @dev Mint fungible tokens to the recipient for the given NFT contract 212 | * @param nftContract The address of the NFT contract 213 | * @param recipient The address to mint the fungible tokens to 214 | * @param amount The amount of fungible tokens to mint 215 | */ 216 | function _bucketMint(address nftContract, address recipient, uint256 amount) internal { 217 | // get or create the bucket ERC20 for the nft 218 | ERC20Bucket erc20Bucket = _getBucket(nftContract); 219 | // mint fungible tokens to the recipient 220 | erc20Bucket.mint(recipient, amount); 221 | } 222 | 223 | /** 224 | * @dev Burn the corresponding fungible tokens from the sender for the given NFT contract 225 | * @param nftContract The address of the NFT contract 226 | * @param amount The amount of fungible tokens to burn 227 | */ 228 | function _bucketBurn(address nftContract, uint256 amount) internal { 229 | // check that the bucket exists 230 | ERC20Bucket erc20Bucket = _checkBucket(nftContract); 231 | // burn fungible tokens from the sender 232 | // ERC20 performs the necessary checks to ensure the sender has enough tokens 233 | erc20Bucket.burn(msg.sender, amount); 234 | } 235 | 236 | /** 237 | * @dev Whenever an {IERC721} `tokenId` token is transferred to this contract via {IERC721-safeTransferFrom} 238 | * by `operator` from `from`, this function is called. 239 | * 240 | * It must return its Solidity selector to confirm the token transfer. 241 | * If any other value is returned or the interface is not implemented by the recipient, the transfer will be 242 | * reverted. 243 | * 244 | * The selector can be obtained in Solidity with `IERC721Receiver.onERC721Received.selector`. 245 | */ 246 | function onERC721Received(address, address from, uint256, bytes calldata) external returns (bytes4) { 247 | ERC20Bucket erc20Bucket = _getBucket(msg.sender); 248 | erc20Bucket.mint(from, FUNGIBLE_TOKENS_PER_NFT); 249 | return this.onERC721Received.selector; 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/ERC20Bucket.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.17; 3 | 4 | import {ERC20} from "solady/tokens/ERC20.sol"; 5 | import {Clone} from "solady/utils/Clone.sol"; 6 | 7 | /** 8 | * @title ERC20Bucket 9 | * @author emo.eth 10 | * @notice A fungible ERC20 backed by tokens from a non-fungible ERC721 11 | */ 12 | contract ERC20Bucket is ERC20, Clone { 13 | ///@notice The address of the implementation contract. 14 | address public immutable IMPLEMENTATION; 15 | 16 | ///@notice An error to be used when the mint or burn are called by an unauthorized address 17 | error NotAuthorized(); 18 | 19 | ///@notice An error to be used when an account tries to interact with the implementation contract directly. 20 | error OnlyClones(); 21 | 22 | ///@notice modifier to restrict non-static calls to clones to prevent direct 23 | /// state-modifying calls to the implementation contract. 24 | modifier onlyClones() { 25 | // since IMPLEMENTATION is immutable, this check restricts calls to 26 | // DELEGATECALLS; DELEGATECALL'ing clones will have a different address 27 | // than is stored in the bytecode. 28 | // in this case, since there are no native DELEGATECALLs, there's no 29 | // actual danger in calling the implementation directly, but 30 | // it's best practice to restrict it anyway. 31 | if (address(this) == IMPLEMENTATION) { 32 | revert OnlyClones(); 33 | } 34 | _; 35 | } 36 | 37 | constructor() { 38 | // store the implementation address in the implementation bytecode 39 | IMPLEMENTATION = address(this); 40 | } 41 | 42 | /** 43 | * @inheritdoc ERC20 44 | */ 45 | function name() public pure override returns (string memory) { 46 | // note that behavior is undefined on the implementation contract 47 | // read the length of the name from the extra calldata appended by the clone proxy 48 | uint256 length = _getArgUint16(0x28); 49 | // read the packed bytes of name from the extra calldata appended by the clone proxy 50 | return string(_getArgBytes(0x2c, length)); 51 | } 52 | 53 | /** 54 | * @inheritdoc ERC20 55 | */ 56 | function symbol() public pure override returns (string memory) { 57 | // note that behavior is undefined on the implementation contract 58 | // read the length of the name to calculate the offset of the symbol 59 | uint256 nameLength = _getArgUint16(0x28); 60 | // read the length of the symbol from the extra calldata appended by the clone proxy 61 | uint256 length = _getArgUint16(0x2a); 62 | // read the packed bytes of symbol from the extra calldata appended by the clone proxy 63 | return string(_getArgBytes(0x2c + nameLength, length)); 64 | } 65 | 66 | /** 67 | * @notice Mint tokens to an address. Only the MINT_AUTHORITY can call this function. 68 | * @param to The address to mint tokens to 69 | * @param amount The amount of tokens to mint 70 | */ 71 | function mint(address to, uint256 amount) external onlyClones { 72 | if (msg.sender != _MINT_AUTHORITY()) { 73 | revert NotAuthorized(); 74 | } 75 | _mint(to, amount); 76 | } 77 | 78 | /** 79 | * @notice Burn tokens from an address. Only the MINT_AUTHORITY can call this function. 80 | * @param from The address to burn tokens from 81 | * @param amount The amount of tokens to burn 82 | */ 83 | function burn(address from, uint256 amount) external onlyClones { 84 | if (msg.sender != _MINT_AUTHORITY()) { 85 | revert NotAuthorized(); 86 | } 87 | _burn(from, amount); 88 | } 89 | 90 | /** 91 | * @notice Get the address of the MINT_AUTHORITY for this contract 92 | * Note that behavior is undefined on the implementation contract 93 | */ 94 | function MINT_AUTHORITY() external pure returns (address) { 95 | return _MINT_AUTHORITY(); 96 | } 97 | 98 | /** 99 | * @notice Get the address of the backing NFT_CONTRACT for this contract 100 | * Note that behavior is undefined on the implementation contract 101 | */ 102 | function NFT_CONTRACT() external pure returns (address) { 103 | return _NFT_CONTRACT(); 104 | } 105 | 106 | /** 107 | * @dev Read the MINT_AUTHORITY address from the extra calldata appended 108 | * by the clone proxy 109 | */ 110 | function _MINT_AUTHORITY() internal pure returns (address) { 111 | return _getArgAddress(0); 112 | } 113 | 114 | /** 115 | * @dev Read the NFT_CONTRACT address from the extra calldata appended 116 | * by the clone proxy 117 | */ 118 | function _NFT_CONTRACT() internal pure returns (address) { 119 | return _getArgAddress(0x14); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /test/BucketFactory.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.17; 3 | 4 | import {Test} from "forge-std/Test.sol"; 5 | import {BucketFactory} from "src/./BucketFactory.sol"; 6 | import {TestERC721} from "test/helpers/TestERC721.sol"; 7 | import {ERC20Bucket} from "src/./ERC20Bucket.sol"; 8 | import {Solarray} from "solarray/Solarray.sol"; 9 | import {ERC721} from "solady/tokens/ERC721.sol"; 10 | import {ERC20} from "solady/tokens/ERC20.sol"; 11 | 12 | contract BucketsTest is Test { 13 | TestERC721 token1; 14 | TestERC721 token2; 15 | BucketFactory factory; 16 | address alice; 17 | address bob; 18 | 19 | function setUp() public { 20 | token1 = new TestERC721(); 21 | token2 = new TestERC721(); 22 | factory = new BucketFactory(); 23 | alice = makeAddr("alice"); 24 | bob = makeAddr("bob"); 25 | 26 | token1.mint(address(this), 1); 27 | token1.mint(address(alice), 2); 28 | token1.mint(address(bob), 3); 29 | token2.mint(address(this), 11); 30 | token2.mint(address(alice), 12); 31 | token2.mint(address(bob), 13); 32 | 33 | _approveFactory(address(this)); 34 | _approveFactory(address(alice)); 35 | _approveFactory(address(bob)); 36 | } 37 | 38 | function _approveFactory(address approver) internal { 39 | vm.startPrank(approver); 40 | token1.setApprovalForAll(address(factory), true); 41 | token2.setApprovalForAll(address(factory), true); 42 | vm.stopPrank(); 43 | } 44 | 45 | function testDeposit() public { 46 | factory.deposit(address(token1), 1); 47 | assertNotEq(factory.nftToErc20Bucket(address(token1)), address(0)); 48 | ERC20Bucket bucket = ERC20Bucket(factory.nftToErc20Bucket(address(token1))); 49 | assertEq(bucket.balanceOf(address(this)), 10_000 ether); 50 | 51 | // subsequent deposits should not create a new bucket 52 | vm.prank(alice); 53 | factory.deposit(address(token1), 2); 54 | assertEq(factory.nftToErc20Bucket(address(token1)), address(bucket)); 55 | assertEq(bucket.balanceOf(address(alice)), 10_000 ether); 56 | assertEq(bucket.totalSupply(), 20_000 ether); 57 | } 58 | 59 | function testDeposit_recipient() public { 60 | factory.deposit(address(token1), 1, address(alice)); 61 | ERC20Bucket bucket = ERC20Bucket(factory.nftToErc20Bucket(address(token1))); 62 | assertEq(bucket.balanceOf(address(alice)), 10_000 ether); 63 | } 64 | 65 | function testDepositMany() public { 66 | token1.mint(address(this), 4); 67 | factory.deposit(address(token1), Solarray.uint256s(1, 4)); 68 | assertNotEq(factory.nftToErc20Bucket(address(token1)), address(0)); 69 | ERC20Bucket bucket = ERC20Bucket(factory.nftToErc20Bucket(address(token1))); 70 | assertEq(bucket.balanceOf(address(this)), 20_000 ether); 71 | } 72 | 73 | function testDeposit_approvedButUnowned() public { 74 | // try to deposit someone else's token when they have approved the factory 75 | vm.expectRevert(ERC721.TransferFromIncorrectOwner.selector); 76 | factory.deposit(address(token1), 2); 77 | } 78 | 79 | function testDeposit_unapproved() public { 80 | // try to deposit a token when the sender has not approved the factory 81 | token1.setApprovalForAll(address(factory), false); 82 | vm.expectRevert(ERC721.NotOwnerNorApproved.selector); 83 | factory.deposit(address(token1), 1); 84 | } 85 | 86 | function testRedeem() public { 87 | factory.deposit(address(token1), 1); 88 | vm.prank(alice); 89 | factory.deposit(address(token1), 2); 90 | ERC20Bucket bucket = ERC20Bucket(factory.nftToErc20Bucket(address(token1))); 91 | factory.redeem(address(token1), 2); 92 | assertEq(bucket.balanceOf(address(this)), 0); 93 | assertEq(bucket.balanceOf(address(alice)), 10_000 ether); 94 | assertEq(token1.ownerOf(2), address(this)); 95 | 96 | // redeem to a different recipient 97 | vm.prank(alice); 98 | factory.redeem(address(token1), 1, address(bob)); 99 | assertEq(bucket.balanceOf(address(alice)), 0); 100 | assertEq(token1.ownerOf(1), address(bob)); 101 | } 102 | 103 | function testRedeem_many() public { 104 | vm.prank(alice); 105 | token1.transferFrom(address(alice), address(this), 2); 106 | factory.deposit(address(token1), Solarray.uint256s(1, 2)); 107 | ERC20Bucket bucket = ERC20Bucket(factory.nftToErc20Bucket(address(token1))); 108 | factory.redeem(address(token1), Solarray.uint256s(1, 2)); 109 | assertEq(bucket.balanceOf(address(this)), 0); 110 | assertEq(token1.ownerOf(1), address(this)); 111 | assertEq(token1.ownerOf(2), address(this)); 112 | 113 | // re-deposit and redeem to a different recipient 114 | factory.deposit(address(token1), Solarray.uint256s(1, 2)); 115 | factory.redeem(address(token1), Solarray.uint256s(1, 2), address(bob)); 116 | assertEq(token1.ownerOf(1), address(bob)); 117 | assertEq(token1.ownerOf(2), address(bob)); 118 | assertEq(bucket.balanceOf(address(this)), 0); 119 | } 120 | 121 | function testRedeem_insufficientBalance() public { 122 | factory.deposit(address(token1), 1); 123 | vm.startPrank(alice); 124 | vm.expectRevert(ERC20.InsufficientBalance.selector); 125 | factory.redeem(address(token1), 1); 126 | vm.stopPrank(); 127 | ERC20Bucket bucket = ERC20Bucket(factory.nftToErc20Bucket(address(token1))); 128 | bucket.transfer(address(alice), 9_999 ether); 129 | vm.startPrank(alice); 130 | vm.expectRevert(ERC20.InsufficientBalance.selector); 131 | factory.redeem(address(token1), 1); 132 | } 133 | 134 | function testRedeem_bucketDoesNotExist() public { 135 | vm.expectRevert(BucketFactory.BucketDoesNotExist.selector); 136 | factory.redeem(address(token1), 1); 137 | } 138 | 139 | function testSkim() public { 140 | token1.transferFrom(address(this), address(factory), 1); 141 | ERC20Bucket bucket = ERC20Bucket(factory.nftToErc20Bucket(address(token1))); 142 | assertEq(address(bucket), address(0)); 143 | factory.skim(address(token1)); 144 | bucket = ERC20Bucket(factory.nftToErc20Bucket(address(token1))); 145 | assertNotEq(address(bucket), address(0)); 146 | assertEq(bucket.balanceOf(address(this)), 10_000 ether); 147 | 148 | // test skimming works when the bucket already exists 149 | vm.prank(alice); 150 | token1.transferFrom(address(alice), address(factory), 2); 151 | factory.skim(address(token1)); 152 | assertEq(bucket.balanceOf(address(this)), 20_000 ether); 153 | } 154 | 155 | function testSkimMany() public { 156 | token1.transferFrom(address(this), address(factory), 1); 157 | token2.transferFrom(address(this), address(factory), 11); 158 | ERC20Bucket bucket = ERC20Bucket(factory.nftToErc20Bucket(address(token1))); 159 | ERC20Bucket bucket2 = ERC20Bucket(factory.nftToErc20Bucket(address(token2))); 160 | assertEq(address(bucket), address(0)); 161 | assertEq(address(bucket2), address(0)); 162 | factory.skim(Solarray.addresses(address(token1), address(token2))); 163 | 164 | bucket = ERC20Bucket(factory.nftToErc20Bucket(address(token1))); 165 | bucket2 = ERC20Bucket(factory.nftToErc20Bucket(address(token2))); 166 | assertNotEq(address(bucket), address(0)); 167 | assertNotEq(address(bucket2), address(0)); 168 | assertEq(bucket.balanceOf(address(this)), 10_000 ether); 169 | assertEq(bucket2.balanceOf(address(this)), 10_000 ether); 170 | } 171 | 172 | function testSkim_doNothing() public { 173 | factory.skim(address(token1)); 174 | ERC20Bucket bucket = ERC20Bucket(factory.nftToErc20Bucket(address(token1))); 175 | assertEq(address(bucket), address(0)); 176 | 177 | factory.deposit(address(token1), 1); 178 | factory.skim(address(token1)); 179 | bucket = ERC20Bucket(factory.nftToErc20Bucket(address(token1))); 180 | assertEq(bucket.balanceOf(address(this)), 10_000 ether); 181 | } 182 | 183 | function testSkim_recipient() public { 184 | token1.transferFrom(address(this), address(factory), 1); 185 | ERC20Bucket bucket = ERC20Bucket(factory.nftToErc20Bucket(address(token1))); 186 | assertEq(address(bucket), address(0)); 187 | factory.skim(address(token1), address(alice)); 188 | bucket = ERC20Bucket(factory.nftToErc20Bucket(address(token1))); 189 | assertNotEq(address(bucket), address(0)); 190 | assertEq(bucket.balanceOf(address(alice)), 10_000 ether); 191 | } 192 | 193 | function testSkimMany_recipient() public { 194 | token1.transferFrom(address(this), address(factory), 1); 195 | token2.transferFrom(address(this), address(factory), 11); 196 | ERC20Bucket bucket = ERC20Bucket(factory.nftToErc20Bucket(address(token1))); 197 | ERC20Bucket bucket2 = ERC20Bucket(factory.nftToErc20Bucket(address(token2))); 198 | assertEq(address(bucket), address(0)); 199 | assertEq(address(bucket2), address(0)); 200 | factory.skim(Solarray.addresses(address(token1), address(token2)), address(alice)); 201 | 202 | bucket = ERC20Bucket(factory.nftToErc20Bucket(address(token1))); 203 | bucket2 = ERC20Bucket(factory.nftToErc20Bucket(address(token2))); 204 | assertNotEq(address(bucket), address(0)); 205 | assertNotEq(address(bucket2), address(0)); 206 | assertEq(bucket.balanceOf(address(alice)), 10_000 ether); 207 | assertEq(bucket2.balanceOf(address(alice)), 10_000 ether); 208 | } 209 | 210 | function testOnErc721Received() public { 211 | vm.prank(alice); 212 | token1.setApprovalForAll(address(this), true); 213 | token1.safeTransferFrom(address(alice), address(factory), 2, ""); 214 | ERC20Bucket bucket = ERC20Bucket(factory.nftToErc20Bucket(address(token1))); 215 | assertNotEq(address(bucket), address(0)); 216 | assertEq(bucket.balanceOf(address(alice)), 10_000 ether); 217 | } 218 | 219 | function testBucketConfiguration() public { 220 | factory.deposit(address(token1), 1); 221 | ERC20Bucket bucket = ERC20Bucket(factory.nftToErc20Bucket(address(token1))); 222 | assertEq(bucket.name(), "TestERC721 (Bucket)"); 223 | assertEq(bucket.symbol(), "TST(B)"); 224 | assertEq(bucket.MINT_AUTHORITY(), address(factory)); 225 | assertEq(bucket.NFT_CONTRACT(), address(token1)); 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /test/ERC20Bucket.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.17; 3 | 4 | import {Test} from "forge-std/Test.sol"; 5 | import {ERC20Bucket} from "src/./ERC20Bucket.sol"; 6 | import {LibClone} from "solady/utils/LibClone.sol"; 7 | 8 | contract ERC20BucketTest is Test { 9 | ERC20Bucket impl; 10 | ERC20Bucket bucket; 11 | 12 | function setUp() public { 13 | impl = new ERC20Bucket(); 14 | bucket = newERC20Bucket("Test", "TST", address(1234), address(this)); 15 | } 16 | 17 | function test_constructor() public { 18 | assertEq(bucket.name(), "Test"); 19 | assertEq(bucket.symbol(), "TST"); 20 | assertEq(bucket.decimals(), 18); 21 | assertEq(bucket.NFT_CONTRACT(), address(1234)); 22 | assertEq(bucket.MINT_AUTHORITY(), address(this)); 23 | } 24 | 25 | function test_msgSender_mintAuthority(address sender) public { 26 | vm.prank(sender); 27 | ERC20Bucket _bucket = newERC20Bucket("Test", "TST", address(1234), sender); 28 | assertEq(_bucket.MINT_AUTHORITY(), sender); 29 | } 30 | 31 | function test_mint_mintAuthority(address sender, address recipient) public { 32 | vm.startPrank(sender); 33 | ERC20Bucket _bucket = newERC20Bucket("Test", "TST", address(1234), sender); 34 | _bucket.mint(recipient, 100); 35 | vm.stopPrank(); 36 | assertEq(_bucket.balanceOf(recipient), 100); 37 | unchecked { 38 | vm.startPrank(address(uint160(sender) + 1)); 39 | } 40 | vm.expectRevert(ERC20Bucket.NotAuthorized.selector); 41 | _bucket.mint(recipient, 100); 42 | } 43 | 44 | function test_burn_mintAuthority(address sender, address recipient) public { 45 | vm.startPrank(sender); 46 | ERC20Bucket _bucket = newERC20Bucket("Test", "TST", address(1234), sender); 47 | _bucket.mint(recipient, 100); 48 | _bucket.burn(recipient, 100); 49 | assertEq(_bucket.balanceOf(recipient), 0); 50 | vm.stopPrank(); 51 | unchecked { 52 | vm.startPrank(address(uint160(sender) + 1)); 53 | } 54 | vm.expectRevert(ERC20Bucket.NotAuthorized.selector); 55 | _bucket.burn(recipient, 100); 56 | } 57 | 58 | function testEmptyNameSymbol() public { 59 | ERC20Bucket _bucket = newERC20Bucket("", "", address(1234), address(this)); 60 | assertEq(_bucket.name(), ""); 61 | assertEq(_bucket.symbol(), ""); 62 | } 63 | 64 | function newERC20Bucket(string memory name, string memory symbol, address nftContract, address authority) 65 | public 66 | returns (ERC20Bucket) 67 | { 68 | address clone = LibClone.clone( 69 | address(impl), 70 | abi.encodePacked( 71 | authority, 72 | nftContract, 73 | uint16(bytes(name).length), 74 | uint16(bytes(symbol).length), 75 | bytes(name), 76 | bytes(symbol) 77 | ) 78 | ); 79 | return ERC20Bucket(clone); 80 | } 81 | 82 | function testOnlyClones() public { 83 | vm.expectRevert(ERC20Bucket.OnlyClones.selector); 84 | impl.mint(address(this), 100); 85 | vm.expectRevert(ERC20Bucket.OnlyClones.selector); 86 | impl.burn(address(this), 100); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /test/helpers/TestERC721.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.17; 3 | 4 | import {ERC721} from "solady/tokens/ERC721.sol"; 5 | 6 | contract TestERC721 is ERC721 { 7 | function name() public pure override returns (string memory) { 8 | return "TestERC721"; 9 | } 10 | 11 | function symbol() public pure override returns (string memory) { 12 | return "TST"; 13 | } 14 | 15 | function tokenURI(uint256) public pure override returns (string memory) { 16 | return "https://example.com"; 17 | } 18 | 19 | function mint(address to, uint256 tokenId) external { 20 | _mint(to, tokenId); 21 | } 22 | 23 | function burn(uint256 tokenId) external { 24 | _burn(tokenId); 25 | } 26 | } 27 | --------------------------------------------------------------------------------