diff --git a/contracts/ERC721CM.sol b/contracts/ERC721CM.sol index a2011aa5..f551a474 100644 --- a/contracts/ERC721CM.sol +++ b/contracts/ERC721CM.sol @@ -353,7 +353,26 @@ contract ERC721CM is IERC721M, ERC721ACQueryable, Ownable, ReentrancyGuard { uint64 timestamp, bytes calldata signature ) external payable virtual nonReentrant { - _mintInternal(qty, msg.sender, proof, timestamp, signature); + _mintInternal(qty, msg.sender, 0, proof, timestamp, signature); + } + + /** + * @dev Mints token(s) with limit. + * + * qty - number of tokens to mint + * limit - limit for the given minter + * proof - the merkle proof generated on client side. This applies if using whitelist. + * timestamp - the current timestamp + * signature - the signature from cosigner if using cosigner. + */ + function mintWithLimit( + uint32 qty, + uint32 limit, + bytes32[] calldata proof, + uint64 timestamp, + bytes calldata signature + ) external payable virtual nonReentrant { + _mintInternal(qty, msg.sender, limit, proof, timestamp, signature); } /** @@ -377,7 +396,7 @@ contract ERC721CM is IERC721M, ERC721ACQueryable, Ownable, ReentrancyGuard { // Check the caller is Crossmint if (msg.sender != _crossmintAddress) revert CrossmintOnly(); - _mintInternal(qty, to, proof, timestamp, signature); + _mintInternal(qty, to, 0, proof, timestamp, signature); } /** @@ -386,6 +405,7 @@ contract ERC721CM is IERC721M, ERC721ACQueryable, Ownable, ReentrancyGuard { function _mintInternal( uint32 qty, address to, + uint32 limit, bytes32[] calldata proof, uint64 timestamp, bytes calldata signature @@ -432,9 +452,14 @@ contract ERC721CM is IERC721M, ERC721ACQueryable, Ownable, ReentrancyGuard { if ( MerkleProof.processProof( proof, - keccak256(abi.encodePacked(to)) + keccak256(abi.encodePacked(to, limit)) ) != stage.merkleRoot ) revert InvalidProof(); + + // Verify merkle proof mint limit + if (limit > 0 && _stageMintedCountsPerWallet[activeStage][to] + qty > limit) { + revert WalletStageLimitExceeded(); + } } if (_mintCurrency != address(0)) { diff --git a/contracts/ERC721M.sol b/contracts/ERC721M.sol index e2bea9b1..75e45d79 100644 --- a/contracts/ERC721M.sol +++ b/contracts/ERC721M.sol @@ -349,7 +349,26 @@ contract ERC721M is IERC721M, ERC721AQueryable, Ownable, ReentrancyGuard { uint64 timestamp, bytes calldata signature ) external payable virtual nonReentrant { - _mintInternal(qty, msg.sender, proof, timestamp, signature); + _mintInternal(qty, msg.sender, 0, proof, timestamp, signature); + } + + /** + * @dev Mints token(s) with limit. + * + * qty - number of tokens to mint + * limit - limit for the given minter + * proof - the merkle proof generated on client side. This applies if using whitelist. + * timestamp - the current timestamp + * signature - the signature from cosigner if using cosigner. + */ + function mintWithLimit( + uint32 qty, + uint32 limit, + bytes32[] calldata proof, + uint64 timestamp, + bytes calldata signature + ) external payable virtual nonReentrant { + _mintInternal(qty, msg.sender, limit, proof, timestamp, signature); } /** @@ -373,7 +392,7 @@ contract ERC721M is IERC721M, ERC721AQueryable, Ownable, ReentrancyGuard { // Check the caller is Crossmint if (msg.sender != _crossmintAddress) revert CrossmintOnly(); - _mintInternal(qty, to, proof, timestamp, signature); + _mintInternal(qty, to, 0, proof, timestamp, signature); } /** @@ -382,6 +401,7 @@ contract ERC721M is IERC721M, ERC721AQueryable, Ownable, ReentrancyGuard { function _mintInternal( uint32 qty, address to, + uint32 limit, bytes32[] calldata proof, uint64 timestamp, bytes calldata signature @@ -428,9 +448,14 @@ contract ERC721M is IERC721M, ERC721AQueryable, Ownable, ReentrancyGuard { if ( MerkleProof.processProof( proof, - keccak256(abi.encodePacked(to)) + keccak256(abi.encodePacked(to, limit)) ) != stage.merkleRoot ) revert InvalidProof(); + + // Verify merkle proof mint limit + if (limit > 0 && _stageMintedCountsPerWallet[activeStage][to] + qty > limit) { + revert WalletStageLimitExceeded(); + } } if (_mintCurrency != address(0)) { diff --git a/contracts/ERC721MAutoApprover.sol b/contracts/ERC721MAutoApprover.sol index 5e344c7c..4af23a80 100644 --- a/contracts/ERC721MAutoApprover.sol +++ b/contracts/ERC721MAutoApprover.sol @@ -40,7 +40,7 @@ contract ERC721MAutoApprover is ERC721M { uint64 timestamp, bytes calldata signature ) external payable override nonReentrant { - _mintInternal(qty, msg.sender, proof, timestamp, signature); + _mintInternal(qty, msg.sender, 0, proof, timestamp, signature); // if auto approve address is not all zero, check if the address is already approved if ( diff --git a/contracts/ERC721MOperatorFiltererAutoApprover.sol b/contracts/ERC721MOperatorFiltererAutoApprover.sol index 67a63c97..5d7d3e1f 100644 --- a/contracts/ERC721MOperatorFiltererAutoApprover.sol +++ b/contracts/ERC721MOperatorFiltererAutoApprover.sol @@ -40,7 +40,7 @@ contract ERC721MOperatorFiltererAutoApprover is ERC721MOperatorFilterer { uint64 timestamp, bytes calldata signature ) external payable override nonReentrant { - _mintInternal(qty, msg.sender, proof, timestamp, signature); + _mintInternal(qty, msg.sender, 0, proof, timestamp, signature); // if auto approve address is not all zero, check if the address is already approved if ( diff --git a/contracts/IERC721M.sol b/contracts/IERC721M.sol index 4b7b21b2..2ee94e66 100644 --- a/contracts/IERC721M.sol +++ b/contracts/IERC721M.sol @@ -26,6 +26,7 @@ interface IERC721M is IERC721AQueryable { error WalletStageLimitExceeded(); error WithdrawFailed(); error WrongMintCurrency(); + error NotSupported(); struct MintStageInfo { uint80 price; @@ -70,4 +71,10 @@ interface IERC721M is IERC721AQueryable { function getStageInfo( uint256 index ) external view returns (MintStageInfo memory, uint32, uint256); + + function mint(uint32 qty, bytes32[] calldata proof, uint64 timestamp, bytes calldata signature) external payable; + + function mintWithLimit(uint32 qty, uint32 limit, bytes32[] calldata proof, uint64 timestamp, bytes calldata signature) external payable; + + function crossmint(uint32 qty, address to, bytes32[] calldata proof, uint64 timestamp, bytes calldata signature) external payable; } diff --git a/contracts/onft/ERC721MLite.sol b/contracts/onft/ERC721MLite.sol index 51c11bb6..0eb746ba 100644 --- a/contracts/onft/ERC721MLite.sol +++ b/contracts/onft/ERC721MLite.sol @@ -294,7 +294,7 @@ contract ERC721MLite is if ( MerkleProof.processProof( proof, - keccak256(abi.encodePacked(msg.sender)) + keccak256(abi.encodePacked(msg.sender, uint32(0))) // limit = 0 for consistency ) != stage.merkleRoot ) revert InvalidProof(); } @@ -304,6 +304,28 @@ contract ERC721MLite is _safeMint(msg.sender, qty); } + /** NOT SUPPORTED */ + function mintWithLimit( + uint32 qty, + uint32 limit, + bytes32[] calldata proof, + uint64 timestamp, + bytes calldata signature + ) external payable virtual nonReentrant { + revert NotSupported(); + } + + /** NOT SUPPORTED */ + function crossmint( + uint32 qty, + address to, + bytes32[] calldata proof, + uint64 timestamp, + bytes calldata signature + ) external payable nonReentrant { + revert NotSupported(); + } + /** * @dev Mints token(s) by owner. * diff --git a/scripts/setStages.ts b/scripts/setStages.ts index 5023c3df..7abc60e7 100644 --- a/scripts/setStages.ts +++ b/scripts/setStages.ts @@ -20,6 +20,7 @@ interface StageConfig { walletLimit?: number; maxSupply?: number; whitelistPath?: string; + variableWalletLimitPath?: string; } export const setStages = async ( @@ -41,46 +42,94 @@ export const setStages = async ( if (args.gaslimit) { overrides.gasLimit = ethers.BigNumber.from(args.gaslimit); } + + /* + * Merkle root generation logic: + * - for `whitelist`, leaves are `solidityKeccak256(['address', 'uint32'], [address, 0])` + * - for `variable wallet limit list`, leaves are `solidityKeccak256(['address', 'uint32'], [address, limit])` + */ const merkleRoots = await Promise.all( stagesConfig.map((stage) => { - if (!stage.whitelistPath) { - return ethers.utils.hexZeroPad('0x', 32); - } - const whitelist = JSON.parse( - fs.readFileSync(stage.whitelistPath, 'utf-8'), - ); - - // Clean up whitelist - const filteredWhitelist = whitelist.filter((address: string) => - ethers.utils.isAddress(address), - ); - console.log( - `Filtered whitelist: ${filteredWhitelist.length} addresses. ${whitelist.length - filteredWhitelist.length} invalid addresses removed.`, - ); - const invalidWhitelist = whitelist.filter( - (address: string) => !ethers.utils.isAddress(address), - ); - console.log( - `❌ Invalid whitelist: ${invalidWhitelist.length} addresses.\r\n${invalidWhitelist.join(', \r\n')}`, - ); - - if (invalidWhitelist.length > 0) { - console.log(`🔄 🚨 updating whitelist file: ${stage.whitelistPath}`); - fs.writeFileSync( - stage.whitelistPath, - JSON.stringify(filteredWhitelist, null, 2), + if (stage.whitelistPath) { + const whitelist = JSON.parse( + fs.readFileSync(stage.whitelistPath, 'utf-8'), ); - } - const mt = new MerkleTree( - filteredWhitelist.map(ethers.utils.getAddress), - ethers.utils.keccak256, - { + // Clean up whitelist + const filteredWhitelist = whitelist.filter((address: string) => + ethers.utils.isAddress(address), + ); + console.log( + `Filtered whitelist: ${filteredWhitelist.length} addresses. ${whitelist.length - filteredWhitelist.length} invalid addresses removed.`, + ); + const invalidWhitelist = whitelist.filter( + (address: string) => !ethers.utils.isAddress(address), + ); + console.log( + `❌ Invalid whitelist: ${invalidWhitelist.length} addresses.\r\n${invalidWhitelist.join(', \r\n')}`, + ); + + if (invalidWhitelist.length > 0) { + console.log(`🔄 🚨 updating whitelist file: ${stage.whitelistPath}`); + fs.writeFileSync( + stage.whitelistPath, + JSON.stringify(filteredWhitelist, null, 2), + ); + } + + const mt = new MerkleTree( + filteredWhitelist.map((address: string) => + ethers.utils.solidityKeccak256( + ['address', 'uint32'], + [ethers.utils.getAddress(address), 0], + ), + ), + ethers.utils.keccak256, + { + sortPairs: true, + hashLeaves: true, + }, + ); + return mt.getHexRoot(); + } else if (stage.variableWalletLimitPath) { + const leaves: any[] = []; + const file = fs.readFileSync(stage.variableWalletLimitPath, 'utf-8'); + file + .split('\n') + .filter((line) => line) + .forEach((line) => { + const [addressStr, limitStr] = line.split(','); + + if (!ethers.utils.isAddress(addressStr.trim().toLowerCase())) { + console.log(`Ignored invalid address: ${addressStr}`); + return; + } + + const address = ethers.utils.getAddress( + addressStr.trim().toLowerCase(), + ); + const limit = parseInt(limitStr, 10); + + if (!Number.isInteger(limit)) { + console.log(`Ignored invalid limit for address: ${addressStr}`); + return; + } + + const digest = ethers.utils.solidityKeccak256( + ['address', 'uint32'], + [address, limit], + ); + leaves.push(digest); + }); + + const mt = new MerkleTree(leaves, ethers.utils.keccak256, { sortPairs: true, - hashLeaves: true, - }, - ); - return mt.getHexRoot(); + hashLeaves: false, + }); + return mt.getHexRoot(); + } + + return ethers.utils.hexZeroPad('0x', 32); }), ); diff --git a/test/ERC721CM.test.ts b/test/ERC721CM.test.ts index f243ebe9..2b848eda 100644 --- a/test/ERC721CM.test.ts +++ b/test/ERC721CM.test.ts @@ -4,9 +4,8 @@ import chaiAsPromised from 'chai-as-promised'; import { ethers } from 'hardhat'; import { MerkleTree } from 'merkletreejs'; import { ERC721CM } from '../typechain-types'; -import { setMintable } from '../scripts/setMintable'; -const { keccak256, getAddress } = ethers.utils; +const { getAddress } = ethers.utils; chai.use(chaiAsPromised); @@ -1197,16 +1196,23 @@ describe('ERC721CM', function () { it('enforces Merkle proof if required', async () => { const accounts = (await ethers.getSigners()).map((signer) => - getAddress(signer.address), + getAddress(signer.address).toLowerCase().trim(), + ); + const leaves = accounts.map((account) => + ethers.utils.solidityKeccak256(['address', 'uint32'], [account, 0]), ); const signerAddress = await ethers.provider.getSigner().getAddress(); - - const merkleTree = new MerkleTree(accounts, keccak256, { + const merkleTree = new MerkleTree(leaves, ethers.utils.keccak256, { sortPairs: true, - hashLeaves: true, + hashLeaves: false, }); const root = merkleTree.getHexRoot(); - const proof = merkleTree.getHexProof(keccak256(signerAddress)); + + const leaf = ethers.utils.solidityKeccak256( + ['address', 'uint32'], + [signerAddress.toLowerCase().trim(), 0], + ); + const proof = merkleTree.getHexProof(leaf); const block = await ethers.provider.getBlock( await ethers.provider.getBlockNumber(), @@ -1273,6 +1279,113 @@ describe('ERC721CM', function () { await expect(mint).to.be.revertedWith('InvalidProof'); }); + it('mint with limit', async () => { + const ownerAddress = await owner.getAddress(); + const readerAddress = await readonly.getAddress(); + const leaves = [ + ethers.utils.solidityKeccak256( + ['address', 'uint32'], + [ownerAddress, 2], + ), + ethers.utils.solidityKeccak256( + ['address', 'uint32'], + [readerAddress, 5], + ), + ]; + + const merkleTree = new MerkleTree(leaves, ethers.utils.keccak256, { + sortPairs: true, + hashLeaves: false, + }); + const root = merkleTree.getHexRoot(); + const ownerLeaf = ethers.utils.solidityKeccak256( + ['address', 'uint32'], + [ownerAddress, 2], + ); + const readerLeaf = ethers.utils.solidityKeccak256( + ['address', 'uint32'], + [readerAddress, 5], + ); + const ownerProof = merkleTree.getHexProof(ownerLeaf); + const readerProof = merkleTree.getHexProof(readerLeaf); + + const block = await ethers.provider.getBlock( + await ethers.provider.getBlockNumber(), + ); + // +10 is a number bigger than the count of transactions up to mint + const stageStart = block.timestamp + 10; + // Set stages + await contract.setStages([ + { + price: ethers.utils.parseEther('0.5'), + walletLimit: 10, + merkleRoot: root, + maxStageSupply: 100, + startTimeUnixSeconds: stageStart, + endTimeUnixSeconds: stageStart + 100, + }, + ]); + await contract.setMintable(true); + + // Setup the test context: Update block.timestamp to comply to the stage being active + await ethers.provider.send('evm_mine', [stageStart - 1]); + // Owner mints 1 token with valid proof + await contract.mintWithLimit(1, 2, ownerProof, 0, '0x00', { + value: ethers.utils.parseEther('0.5'), + }); + expect( + (await contract.totalMintedByAddress(owner.getAddress())).toNumber(), + ).to.equal(1); + + // Owner mints 1 token with wrong limit and should be reverted. + await expect( + contract.mintWithLimit(1, 3, ownerProof, 0, '0x00', { + value: ethers.utils.parseEther('0.5'), + }), + ).to.be.rejectedWith('InvalidProof'); + + // Owner mints 2 tokens with valid proof and reverts. + await expect( + contract.mintWithLimit(2, 2, ownerProof, 0, '0x00', { + value: ethers.utils.parseEther('1.0'), + }), + ).to.be.rejectedWith('WalletStageLimitExceeded'); + + // Owner mints 1 token with valid proof. Now owner reaches the limit. + await contract.mintWithLimit(1, 2, ownerProof, 0, '0x00', { + value: ethers.utils.parseEther('0.5'), + }); + expect( + (await contract.totalMintedByAddress(owner.getAddress())).toNumber(), + ).to.equal(2); + + // Owner tries to mint more and reverts. + await expect( + contract.mintWithLimit(1, 2, ownerProof, 0, '0x00', { + value: ethers.utils.parseEther('0.5'), + }), + ).to.be.rejectedWith('WalletStageLimitExceeded'); + + // Reader mints 6 tokens with valid proof and reverts. + await expect( + readonlyContract.mintWithLimit(6, 5, readerProof, 0, '0x00', { + value: ethers.utils.parseEther('3.0'), + }), + ).to.be.rejectedWith('WalletStageLimitExceeded'); + + // Reader mints 5 tokens with valid proof. + await readonlyContract.mintWithLimit(5, 5, readerProof, 0, '0x00', { + value: ethers.utils.parseEther('2.5'), + }); + + // Reader mints 1 token with valid proof and reverts. + await expect( + readonlyContract.mintWithLimit(1, 5, readerProof, 0, '0x00', { + value: ethers.utils.parseEther('0.5'), + }), + ).to.be.rejectedWith('WalletStageLimitExceeded'); + }); + it('crossmint', async () => { const crossmintAddressStr = '0xdAb1a1854214684acE522439684a145E62505233'; const ERC721CM = await ethers.getContractFactory('ERC721CM'); @@ -1423,12 +1536,14 @@ describe('ERC721CM', function () { ); const [_, recipient] = await ethers.getSigners(); - const merkleTree = new MerkleTree(accounts, keccak256, { + const merkleTree = new MerkleTree(accounts, ethers.utils.keccak256, { sortPairs: true, hashLeaves: true, }); const root = merkleTree.getHexRoot(); - const proof = merkleTree.getHexProof(keccak256(recipient.address)); + const proof = merkleTree.getHexProof( + ethers.utils.keccak256(recipient.address), + ); await contract.setStages([ { @@ -1462,12 +1577,14 @@ describe('ERC721CM', function () { ); const [_, recipient] = await ethers.getSigners(); - const merkleTree = new MerkleTree(accounts, keccak256, { + const merkleTree = new MerkleTree(accounts, ethers.utils.keccak256, { sortPairs: true, hashLeaves: true, }); const root = merkleTree.getHexRoot(); - const proof = merkleTree.getHexProof(keccak256(recipient.address)); + const proof = merkleTree.getHexProof( + ethers.utils.keccak256(recipient.address), + ); await contract.setStages([ { diff --git a/test/erc721m.test.ts b/test/erc721m.test.ts index 77a44ea6..b46f0e02 100644 --- a/test/erc721m.test.ts +++ b/test/erc721m.test.ts @@ -1198,16 +1198,23 @@ describe('ERC721M', function () { it('enforces Merkle proof if required', async () => { const accounts = (await ethers.getSigners()).map((signer) => - getAddress(signer.address), + getAddress(signer.address).toLowerCase().trim(), + ); + const leaves = accounts.map((account) => + ethers.utils.solidityKeccak256(['address', 'uint32'], [account, 0]), ); const signerAddress = await ethers.provider.getSigner().getAddress(); - - const merkleTree = new MerkleTree(accounts, keccak256, { + const merkleTree = new MerkleTree(leaves, ethers.utils.keccak256, { sortPairs: true, - hashLeaves: true, + hashLeaves: false, }); const root = merkleTree.getHexRoot(); - const proof = merkleTree.getHexProof(keccak256(signerAddress)); + + const leaf = ethers.utils.solidityKeccak256( + ['address', 'uint32'], + [signerAddress.toLowerCase().trim(), 0], + ); + const proof = merkleTree.getHexProof(leaf); const block = await ethers.provider.getBlock( await ethers.provider.getBlockNumber(), @@ -1274,6 +1281,113 @@ describe('ERC721M', function () { await expect(mint).to.be.revertedWith('InvalidProof'); }); + it('mint with limit', async () => { + const ownerAddress = await owner.getAddress(); + const readerAddress = await readonly.getAddress(); + const leaves = [ + ethers.utils.solidityKeccak256( + ['address', 'uint32'], + [ownerAddress, 2], + ), + ethers.utils.solidityKeccak256( + ['address', 'uint32'], + [readerAddress, 5], + ), + ]; + + const merkleTree = new MerkleTree(leaves, ethers.utils.keccak256, { + sortPairs: true, + hashLeaves: false, + }); + const root = merkleTree.getHexRoot(); + const ownerLeaf = ethers.utils.solidityKeccak256( + ['address', 'uint32'], + [ownerAddress, 2], + ); + const readerLeaf = ethers.utils.solidityKeccak256( + ['address', 'uint32'], + [readerAddress, 5], + ); + const ownerProof = merkleTree.getHexProof(ownerLeaf); + const readerProof = merkleTree.getHexProof(readerLeaf); + + const block = await ethers.provider.getBlock( + await ethers.provider.getBlockNumber(), + ); + // +10 is a number bigger than the count of transactions up to mint + const stageStart = block.timestamp + 10; + // Set stages + await contract.setStages([ + { + price: ethers.utils.parseEther('0.5'), + walletLimit: 10, + merkleRoot: root, + maxStageSupply: 100, + startTimeUnixSeconds: stageStart, + endTimeUnixSeconds: stageStart + 100, + }, + ]); + await contract.setMintable(true); + + // Setup the test context: Update block.timestamp to comply to the stage being active + await ethers.provider.send('evm_mine', [stageStart - 1]); + // Owner mints 1 token with valid proof + await contract.mintWithLimit(1, 2, ownerProof, 0, '0x00', { + value: ethers.utils.parseEther('0.5'), + }); + expect( + (await contract.totalMintedByAddress(owner.getAddress())).toNumber(), + ).to.equal(1); + + // Owner mints 1 token with wrong limit and should be reverted. + await expect( + contract.mintWithLimit(1, 3, ownerProof, 0, '0x00', { + value: ethers.utils.parseEther('0.5'), + }), + ).to.be.rejectedWith('InvalidProof'); + + // Owner mints 2 tokens with valid proof and reverts. + await expect( + contract.mintWithLimit(2, 2, ownerProof, 0, '0x00', { + value: ethers.utils.parseEther('1.0'), + }), + ).to.be.rejectedWith('WalletStageLimitExceeded'); + + // Owner mints 1 token with valid proof. Now owner reaches the limit. + await contract.mintWithLimit(1, 2, ownerProof, 0, '0x00', { + value: ethers.utils.parseEther('0.5'), + }); + expect( + (await contract.totalMintedByAddress(owner.getAddress())).toNumber(), + ).to.equal(2); + + // Owner tries to mint more and reverts. + await expect( + contract.mintWithLimit(1, 2, ownerProof, 0, '0x00', { + value: ethers.utils.parseEther('0.5'), + }), + ).to.be.rejectedWith('WalletStageLimitExceeded'); + + // Reader mints 6 tokens with valid proof and reverts. + await expect( + readonlyContract.mintWithLimit(6, 5, readerProof, 0, '0x00', { + value: ethers.utils.parseEther('3.0'), + }), + ).to.be.rejectedWith('WalletStageLimitExceeded'); + + // Reader mints 5 tokens with valid proof. + await readonlyContract.mintWithLimit(5, 5, readerProof, 0, '0x00', { + value: ethers.utils.parseEther('2.5'), + }); + + // Reader mints 1 token with valid proof and reverts. + await expect( + readonlyContract.mintWithLimit(1, 5, readerProof, 0, '0x00', { + value: ethers.utils.parseEther('0.5'), + }), + ).to.be.rejectedWith('WalletStageLimitExceeded'); + }); + it('crossmint', async () => { const crossmintAddressStr = '0xdAb1a1854214684acE522439684a145E62505233'; const ERC721M = await ethers.getContractFactory('ERC721M');