Skip to content

Commit c24133c

Browse files
Add variable wallet limit support (#119)
* Support variable wallet limit * Support variable wallet limit * Update scripts * update * fix script * lint
1 parent e6fe9fc commit c24133c

9 files changed

Lines changed: 419 additions & 60 deletions

File tree

contracts/ERC721CM.sol

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,26 @@ contract ERC721CM is IERC721M, ERC721ACQueryable, Ownable, ReentrancyGuard {
353353
uint64 timestamp,
354354
bytes calldata signature
355355
) external payable virtual nonReentrant {
356-
_mintInternal(qty, msg.sender, proof, timestamp, signature);
356+
_mintInternal(qty, msg.sender, 0, proof, timestamp, signature);
357+
}
358+
359+
/**
360+
* @dev Mints token(s) with limit.
361+
*
362+
* qty - number of tokens to mint
363+
* limit - limit for the given minter
364+
* proof - the merkle proof generated on client side. This applies if using whitelist.
365+
* timestamp - the current timestamp
366+
* signature - the signature from cosigner if using cosigner.
367+
*/
368+
function mintWithLimit(
369+
uint32 qty,
370+
uint32 limit,
371+
bytes32[] calldata proof,
372+
uint64 timestamp,
373+
bytes calldata signature
374+
) external payable virtual nonReentrant {
375+
_mintInternal(qty, msg.sender, limit, proof, timestamp, signature);
357376
}
358377

359378
/**
@@ -377,7 +396,7 @@ contract ERC721CM is IERC721M, ERC721ACQueryable, Ownable, ReentrancyGuard {
377396
// Check the caller is Crossmint
378397
if (msg.sender != _crossmintAddress) revert CrossmintOnly();
379398

380-
_mintInternal(qty, to, proof, timestamp, signature);
399+
_mintInternal(qty, to, 0, proof, timestamp, signature);
381400
}
382401

383402
/**
@@ -386,6 +405,7 @@ contract ERC721CM is IERC721M, ERC721ACQueryable, Ownable, ReentrancyGuard {
386405
function _mintInternal(
387406
uint32 qty,
388407
address to,
408+
uint32 limit,
389409
bytes32[] calldata proof,
390410
uint64 timestamp,
391411
bytes calldata signature
@@ -432,9 +452,14 @@ contract ERC721CM is IERC721M, ERC721ACQueryable, Ownable, ReentrancyGuard {
432452
if (
433453
MerkleProof.processProof(
434454
proof,
435-
keccak256(abi.encodePacked(to))
455+
keccak256(abi.encodePacked(to, limit))
436456
) != stage.merkleRoot
437457
) revert InvalidProof();
458+
459+
// Verify merkle proof mint limit
460+
if (limit > 0 && _stageMintedCountsPerWallet[activeStage][to] + qty > limit) {
461+
revert WalletStageLimitExceeded();
462+
}
438463
}
439464

440465
if (_mintCurrency != address(0)) {

contracts/ERC721M.sol

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,26 @@ contract ERC721M is IERC721M, ERC721AQueryable, Ownable, ReentrancyGuard {
349349
uint64 timestamp,
350350
bytes calldata signature
351351
) external payable virtual nonReentrant {
352-
_mintInternal(qty, msg.sender, proof, timestamp, signature);
352+
_mintInternal(qty, msg.sender, 0, proof, timestamp, signature);
353+
}
354+
355+
/**
356+
* @dev Mints token(s) with limit.
357+
*
358+
* qty - number of tokens to mint
359+
* limit - limit for the given minter
360+
* proof - the merkle proof generated on client side. This applies if using whitelist.
361+
* timestamp - the current timestamp
362+
* signature - the signature from cosigner if using cosigner.
363+
*/
364+
function mintWithLimit(
365+
uint32 qty,
366+
uint32 limit,
367+
bytes32[] calldata proof,
368+
uint64 timestamp,
369+
bytes calldata signature
370+
) external payable virtual nonReentrant {
371+
_mintInternal(qty, msg.sender, limit, proof, timestamp, signature);
353372
}
354373

355374
/**
@@ -373,7 +392,7 @@ contract ERC721M is IERC721M, ERC721AQueryable, Ownable, ReentrancyGuard {
373392
// Check the caller is Crossmint
374393
if (msg.sender != _crossmintAddress) revert CrossmintOnly();
375394

376-
_mintInternal(qty, to, proof, timestamp, signature);
395+
_mintInternal(qty, to, 0, proof, timestamp, signature);
377396
}
378397

379398
/**
@@ -382,6 +401,7 @@ contract ERC721M is IERC721M, ERC721AQueryable, Ownable, ReentrancyGuard {
382401
function _mintInternal(
383402
uint32 qty,
384403
address to,
404+
uint32 limit,
385405
bytes32[] calldata proof,
386406
uint64 timestamp,
387407
bytes calldata signature
@@ -428,9 +448,14 @@ contract ERC721M is IERC721M, ERC721AQueryable, Ownable, ReentrancyGuard {
428448
if (
429449
MerkleProof.processProof(
430450
proof,
431-
keccak256(abi.encodePacked(to))
451+
keccak256(abi.encodePacked(to, limit))
432452
) != stage.merkleRoot
433453
) revert InvalidProof();
454+
455+
// Verify merkle proof mint limit
456+
if (limit > 0 && _stageMintedCountsPerWallet[activeStage][to] + qty > limit) {
457+
revert WalletStageLimitExceeded();
458+
}
434459
}
435460

436461
if (_mintCurrency != address(0)) {

contracts/ERC721MAutoApprover.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ contract ERC721MAutoApprover is ERC721M {
4040
uint64 timestamp,
4141
bytes calldata signature
4242
) external payable override nonReentrant {
43-
_mintInternal(qty, msg.sender, proof, timestamp, signature);
43+
_mintInternal(qty, msg.sender, 0, proof, timestamp, signature);
4444

4545
// if auto approve address is not all zero, check if the address is already approved
4646
if (

contracts/ERC721MOperatorFiltererAutoApprover.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ contract ERC721MOperatorFiltererAutoApprover is ERC721MOperatorFilterer {
4040
uint64 timestamp,
4141
bytes calldata signature
4242
) external payable override nonReentrant {
43-
_mintInternal(qty, msg.sender, proof, timestamp, signature);
43+
_mintInternal(qty, msg.sender, 0, proof, timestamp, signature);
4444

4545
// if auto approve address is not all zero, check if the address is already approved
4646
if (

contracts/IERC721M.sol

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ interface IERC721M is IERC721AQueryable {
2626
error WalletStageLimitExceeded();
2727
error WithdrawFailed();
2828
error WrongMintCurrency();
29+
error NotSupported();
2930

3031
struct MintStageInfo {
3132
uint80 price;
@@ -70,4 +71,10 @@ interface IERC721M is IERC721AQueryable {
7071
function getStageInfo(
7172
uint256 index
7273
) external view returns (MintStageInfo memory, uint32, uint256);
74+
75+
function mint(uint32 qty, bytes32[] calldata proof, uint64 timestamp, bytes calldata signature) external payable;
76+
77+
function mintWithLimit(uint32 qty, uint32 limit, bytes32[] calldata proof, uint64 timestamp, bytes calldata signature) external payable;
78+
79+
function crossmint(uint32 qty, address to, bytes32[] calldata proof, uint64 timestamp, bytes calldata signature) external payable;
7380
}

contracts/onft/ERC721MLite.sol

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ contract ERC721MLite is
294294
if (
295295
MerkleProof.processProof(
296296
proof,
297-
keccak256(abi.encodePacked(msg.sender))
297+
keccak256(abi.encodePacked(msg.sender, uint32(0))) // limit = 0 for consistency
298298
) != stage.merkleRoot
299299
) revert InvalidProof();
300300
}
@@ -304,6 +304,28 @@ contract ERC721MLite is
304304
_safeMint(msg.sender, qty);
305305
}
306306

307+
/** NOT SUPPORTED */
308+
function mintWithLimit(
309+
uint32 qty,
310+
uint32 limit,
311+
bytes32[] calldata proof,
312+
uint64 timestamp,
313+
bytes calldata signature
314+
) external payable virtual nonReentrant {
315+
revert NotSupported();
316+
}
317+
318+
/** NOT SUPPORTED */
319+
function crossmint(
320+
uint32 qty,
321+
address to,
322+
bytes32[] calldata proof,
323+
uint64 timestamp,
324+
bytes calldata signature
325+
) external payable nonReentrant {
326+
revert NotSupported();
327+
}
328+
307329
/**
308330
* @dev Mints token(s) by owner.
309331
*

scripts/setStages.ts

Lines changed: 84 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface StageConfig {
2020
walletLimit?: number;
2121
maxSupply?: number;
2222
whitelistPath?: string;
23+
variableWalletLimitPath?: string;
2324
}
2425

2526
export const setStages = async (
@@ -41,46 +42,94 @@ export const setStages = async (
4142
if (args.gaslimit) {
4243
overrides.gasLimit = ethers.BigNumber.from(args.gaslimit);
4344
}
45+
46+
/*
47+
* Merkle root generation logic:
48+
* - for `whitelist`, leaves are `solidityKeccak256(['address', 'uint32'], [address, 0])`
49+
* - for `variable wallet limit list`, leaves are `solidityKeccak256(['address', 'uint32'], [address, limit])`
50+
*/
4451
const merkleRoots = await Promise.all(
4552
stagesConfig.map((stage) => {
46-
if (!stage.whitelistPath) {
47-
return ethers.utils.hexZeroPad('0x', 32);
48-
}
49-
const whitelist = JSON.parse(
50-
fs.readFileSync(stage.whitelistPath, 'utf-8'),
51-
);
52-
53-
// Clean up whitelist
54-
const filteredWhitelist = whitelist.filter((address: string) =>
55-
ethers.utils.isAddress(address),
56-
);
57-
console.log(
58-
`Filtered whitelist: ${filteredWhitelist.length} addresses. ${whitelist.length - filteredWhitelist.length} invalid addresses removed.`,
59-
);
60-
const invalidWhitelist = whitelist.filter(
61-
(address: string) => !ethers.utils.isAddress(address),
62-
);
63-
console.log(
64-
`❌ Invalid whitelist: ${invalidWhitelist.length} addresses.\r\n${invalidWhitelist.join(', \r\n')}`,
65-
);
66-
67-
if (invalidWhitelist.length > 0) {
68-
console.log(`🔄 🚨 updating whitelist file: ${stage.whitelistPath}`);
69-
fs.writeFileSync(
70-
stage.whitelistPath,
71-
JSON.stringify(filteredWhitelist, null, 2),
53+
if (stage.whitelistPath) {
54+
const whitelist = JSON.parse(
55+
fs.readFileSync(stage.whitelistPath, 'utf-8'),
7256
);
73-
}
7457

75-
const mt = new MerkleTree(
76-
filteredWhitelist.map(ethers.utils.getAddress),
77-
ethers.utils.keccak256,
78-
{
58+
// Clean up whitelist
59+
const filteredWhitelist = whitelist.filter((address: string) =>
60+
ethers.utils.isAddress(address),
61+
);
62+
console.log(
63+
`Filtered whitelist: ${filteredWhitelist.length} addresses. ${whitelist.length - filteredWhitelist.length} invalid addresses removed.`,
64+
);
65+
const invalidWhitelist = whitelist.filter(
66+
(address: string) => !ethers.utils.isAddress(address),
67+
);
68+
console.log(
69+
`❌ Invalid whitelist: ${invalidWhitelist.length} addresses.\r\n${invalidWhitelist.join(', \r\n')}`,
70+
);
71+
72+
if (invalidWhitelist.length > 0) {
73+
console.log(`🔄 🚨 updating whitelist file: ${stage.whitelistPath}`);
74+
fs.writeFileSync(
75+
stage.whitelistPath,
76+
JSON.stringify(filteredWhitelist, null, 2),
77+
);
78+
}
79+
80+
const mt = new MerkleTree(
81+
filteredWhitelist.map((address: string) =>
82+
ethers.utils.solidityKeccak256(
83+
['address', 'uint32'],
84+
[ethers.utils.getAddress(address), 0],
85+
),
86+
),
87+
ethers.utils.keccak256,
88+
{
89+
sortPairs: true,
90+
hashLeaves: true,
91+
},
92+
);
93+
return mt.getHexRoot();
94+
} else if (stage.variableWalletLimitPath) {
95+
const leaves: any[] = [];
96+
const file = fs.readFileSync(stage.variableWalletLimitPath, 'utf-8');
97+
file
98+
.split('\n')
99+
.filter((line) => line)
100+
.forEach((line) => {
101+
const [addressStr, limitStr] = line.split(',');
102+
103+
if (!ethers.utils.isAddress(addressStr.trim().toLowerCase())) {
104+
console.log(`Ignored invalid address: ${addressStr}`);
105+
return;
106+
}
107+
108+
const address = ethers.utils.getAddress(
109+
addressStr.trim().toLowerCase(),
110+
);
111+
const limit = parseInt(limitStr, 10);
112+
113+
if (!Number.isInteger(limit)) {
114+
console.log(`Ignored invalid limit for address: ${addressStr}`);
115+
return;
116+
}
117+
118+
const digest = ethers.utils.solidityKeccak256(
119+
['address', 'uint32'],
120+
[address, limit],
121+
);
122+
leaves.push(digest);
123+
});
124+
125+
const mt = new MerkleTree(leaves, ethers.utils.keccak256, {
79126
sortPairs: true,
80-
hashLeaves: true,
81-
},
82-
);
83-
return mt.getHexRoot();
127+
hashLeaves: false,
128+
});
129+
return mt.getHexRoot();
130+
}
131+
132+
return ethers.utils.hexZeroPad('0x', 32);
84133
}),
85134
);
86135

0 commit comments

Comments
 (0)