diff --git a/contracts/modules/SuperMinterV1_1.sol b/contracts/modules/SuperMinterV1_1.sol index 5cf98d46..39b8a6da 100644 --- a/contracts/modules/SuperMinterV1_1.sol +++ b/contracts/modules/SuperMinterV1_1.sol @@ -100,6 +100,23 @@ contract SuperMinterV1_1 is ISuperMinterV1_1, EIP712 { ")" ); + /** + * @dev For EIP-712 presave signature digest calculation. + */ + bytes32 public constant PRESAVE_TYPEHASH = + // prettier-ignore + keccak256( + "Presave(" + "address edition," + "uint8 tier," + "uint8 scheduleNum," + "address[] to," + "uint32 signedQuantity," + "uint32 signedClaimTicket," + "uint32 signedDeadline" + ")" + ); + /** * @dev For EIP-712 signature digest calculation. */ @@ -120,6 +137,11 @@ contract SuperMinterV1_1 is ISuperMinterV1_1, EIP712 { */ uint8 public constant VERIFY_SIGNATURE = 2; + /** + * @dev The Presave mint mode. + */ + uint8 public constant PRESAVE = 3; + /** * @dev The denominator of all BPS calculations. */ @@ -240,6 +262,11 @@ contract SuperMinterV1_1 is ISuperMinterV1_1, EIP712 { _validateSigner(c.signer); c.merkleRoot = bytes32(0); c.maxMintablePerAccount = type(uint32).max; + } else if (mode == PRESAVE) { + _validateSigner(c.signer); + c.merkleRoot = bytes32(0); + c.maxMintablePerAccount = type(uint32).max; + c.price = 0; // Presave mode doesn't have a price. } else { revert InvalidMode(); } @@ -308,15 +335,13 @@ contract SuperMinterV1_1 is ISuperMinterV1_1, EIP712 { /* ------------------- CHECKS AND UPDATES ------------------- */ - // Check if the mint is open. - if (LibOps.or(block.timestamp < d.startTime, block.timestamp > d.endTime)) - revert MintNotOpen(block.timestamp, d.startTime, d.endTime); - if (_isPaused(d)) revert MintPaused(); // Check if the mint is not paused. + _requireMintOpen(d); // Perform the sub workflows depending on the mint mode. uint8 mode = d.mode; if (mode == VERIFY_MERKLE) _verifyMerkle(d, p); else if (mode == VERIFY_SIGNATURE) _verifyAndClaimSignature(d, p); + else if (mode == PRESAVE) revert InvalidMode(); _incrementMinted(mode, d, p); @@ -395,6 +420,35 @@ contract SuperMinterV1_1 is ISuperMinterV1_1, EIP712 { emit Minted(p.edition, p.tier, p.scheduleNum, p.to, l, p.attributionId); } + /** + * @inheritdoc ISuperMinterV1_1 + */ + function presave(Presave calldata p) public { + MintData storage d = _getMintData(LibOps.packId(p.edition, p.tier, p.scheduleNum)); + + /* ------------------- CHECKS AND UPDATES ------------------- */ + + _requireMintOpen(d); + + if (d.mode != PRESAVE) revert InvalidMode(); + _verifyAndClaimPresaveSignature(d, p); + + _incrementPresaveMinted(d, p); + + /* ------------------------- MINT --------------------------- */ + + unchecked { + ISoundEditionV2_1 edition = ISoundEditionV2_1(p.edition); + + uint256 toLength = p.to.length; + for (uint256 i; i != toLength; ++i) { + edition.mint(p.tier, p.to[i], p.signedQuantity); + } + } + + emit Presaved(p.edition, p.tier, p.scheduleNum, p.to, p.signedQuantity); + } + // Per edition mint parameter setters: // ----------------------------------- // These functions can only be called by the owner or admin of the edition. @@ -412,6 +466,8 @@ contract SuperMinterV1_1 is ISuperMinterV1_1, EIP712 { MintData storage d = _getMintData(mintId); // If the tier is GA and the `mode` is `VERIFY_SIGNATURE`, we'll use `gaPrice[platform]`. if (tier == GA_TIER && d.mode != VERIFY_SIGNATURE) revert NotConfigurable(); + // Presave mints will not have a price. + if (d.mode == PRESAVE) revert NotConfigurable(); d.price = price; emit PriceSet(edition, tier, scheduleNum, price); } @@ -510,6 +566,8 @@ contract SuperMinterV1_1 is ISuperMinterV1_1, EIP712 { if (tier == GA_TIER) revert NotConfigurable(); // Signature mints will have `type(uint32).max`. if (d.mode == VERIFY_SIGNATURE) revert NotConfigurable(); + // Presave mints will have `type(uint32).max`. + if (d.mode == PRESAVE) revert NotConfigurable(); _validateMaxMintablePerAccount(value); d.maxMintablePerAccount = value; emit MaxMintablePerAccountSet(edition, tier, scheduleNum, value); @@ -692,6 +750,24 @@ contract SuperMinterV1_1 is ISuperMinterV1_1, EIP712 { ))); } + /** + * @inheritdoc ISuperMinterV1_1 + */ + function computePresaveDigest(Presave calldata p) public view returns (bytes32) { + // prettier-ignore + return + _hashTypedData(keccak256(abi.encode( + PRESAVE_TYPEHASH, + p.edition, + p.tier, + p.scheduleNum, + keccak256(abi.encodePacked(p.to)), + p.signedQuantity, + p.signedClaimTicket, + p.signedDeadline + ))); + } + /** * @inheritdoc ISuperMinterV1_1 */ @@ -1040,6 +1116,39 @@ contract SuperMinterV1_1 is ISuperMinterV1_1, EIP712 { } } + /** + * @dev Increments the number minted in the mint and the number minted by the collector. + * @param d The mint data storage pointer. + * @param p The presave parameters. + */ + function _incrementPresaveMinted(MintData storage d, Presave calldata p) internal { + unchecked { + uint256 mintId = LibOps.packId(p.edition, p.tier, p.scheduleNum); + uint256 toLength = p.to.length; + + // Increment the number minted in the mint. + uint256 n = uint256(d.minted) + toLength * uint256(p.signedQuantity); // The next `minted`. + if (n > d.maxMintable) revert ExceedsMintSupply(); + d.minted = uint32(n); + + // Increment the number minted by the collectors. + for (uint256 i; i != toLength; ++i) { + LibMap.Uint32Map storage m = _numberMinted[p.to[i]]; + m.set(mintId, uint32(uint256(m.get(mintId)) + uint256(p.signedQuantity))); + } + } + } + + /** + * @dev Requires that the mint is open and not paused. + * @param d The mint data storage pointer. + */ + function _requireMintOpen(MintData storage d) internal view { + if (LibOps.or(block.timestamp < d.startTime, block.timestamp > d.endTime)) + revert MintNotOpen(block.timestamp, d.startTime, d.endTime); + if (_isPaused(d)) revert MintPaused(); // Check if the mint is not paused. + } + /** * @dev Verify the signature, and mark the signed claim ticket as claimed. * @param d The mint data storage pointer. @@ -1055,6 +1164,21 @@ contract SuperMinterV1_1 is ISuperMinterV1_1, EIP712 { if (!_claimsBitmaps[mintId].toggle(p.signedClaimTicket)) revert SignatureAlreadyUsed(); } + /** + * @dev Verify the presave signature, and mark the signed claim ticket as claimed. + * @param d The mint data storage pointer. + * @param p The presave parameters. + */ + function _verifyAndClaimPresaveSignature(MintData storage d, Presave calldata p) internal { + // Unlike regular signature mints, presave mints only used `signedQuantity`. + address signer = _effectiveSigner(d); + if (!SignatureCheckerLib.isValidSignatureNowCalldata(signer, computePresaveDigest(p), p.signature)) + revert InvalidSignature(); + if (block.timestamp > p.signedDeadline) revert SignatureExpired(); + uint256 mintId = LibOps.packId(p.edition, p.tier, p.scheduleNum); + if (!_claimsBitmaps[mintId].toggle(p.signedClaimTicket)) revert SignatureAlreadyUsed(); + } + /** * @dev Verify the Merkle proof. * @param d The mint data storage pointer. diff --git a/contracts/modules/interfaces/ISuperMinterV1_1.sol b/contracts/modules/interfaces/ISuperMinterV1_1.sol index d1d5beb5..b6ee8359 100644 --- a/contracts/modules/interfaces/ISuperMinterV1_1.sol +++ b/contracts/modules/interfaces/ISuperMinterV1_1.sol @@ -86,6 +86,28 @@ interface ISuperMinterV1_1 is IERC165 { uint256 attributionId; } + /** + * @dev A struct containing the arguments for presave. + */ + struct Presave { + // The mint ID. + address edition; + // The tier of the mint. + uint8 tier; + // The edition-tier schedule number. + uint8 scheduleNum; + // The addresses to mint to. + address[] to; + // The signed quantity. + uint32 signedQuantity; + // The signed claimed ticket. Used if `mode` is `VERIFY_SIGNATURE`. + uint32 signedClaimTicket; + // The expiry timestamp for the signature. Used if `mode` is `VERIFY_SIGNATURE`. + uint32 signedDeadline; + // The signature by the signer. Used if `mode` is `VERIFY_SIGNATURE`. + bytes signature; + } + /** * @dev A struct containing the total prices and fees. */ @@ -219,7 +241,7 @@ interface ISuperMinterV1_1 is IERC165 { bytes32 affiliateMerkleRoot; // The Merkle root hash, required if `mode` is `VERIFY_MERKLE`. bytes32 merkleRoot; - // The signer address, required if `mode` is `VERIFY_SIGNATURE`. + // The signer address, required if `mode` is `VERIFY_SIGNATURE` or `PRESAVE`. // This value will be the platform signer instead if it is configured to be `address(1)`. address signer; // Whether the platform signer is being used instead @@ -340,6 +362,16 @@ interface ISuperMinterV1_1 is IERC165 { uint256 indexed attributionId ); + /** + * @dev Emitted when tokens are minted for presave. + * @param edition The address of the Sound Edition. + * @param tier The tier. + * @param scheduleNum The edition-tier schedule number. + * @param to The recipients of the tokens minted. + * @param signedQuantity The amount of tokens per address. + */ + event Presaved(address indexed edition, uint8 tier, uint8 scheduleNum, address[] to, uint32 signedQuantity); + /** * @dev Emitted when the platform fee configuration for `tier` is updated. * @param platform The platform address. @@ -556,6 +588,12 @@ interface ISuperMinterV1_1 is IERC165 { */ function mintTo(MintTo calldata p) external payable; + /** + * @dev Performs a presave mint. + * @param p The presave parameters. + */ + function presave(Presave calldata p) external; + /** * @dev Sets the price of the mint. * @param edition The address of the Sound Edition. @@ -757,6 +795,12 @@ interface ISuperMinterV1_1 is IERC165 { */ function MINT_TO_TYPEHASH() external pure returns (bytes32); + /** + * @dev The EIP-712 typehash for presave mints. + * @return The constant value. + */ + function PRESAVE_TYPEHASH() external pure returns (bytes32); + /** * @dev The default mint mode. * @return The constant value. @@ -775,6 +819,12 @@ interface ISuperMinterV1_1 is IERC165 { */ function VERIFY_SIGNATURE() external pure returns (uint8); + /** + * @dev The mint mode for presave. + * @return The constant value. + */ + function PRESAVE() external pure returns (uint8); + /** * @dev The denominator used in BPS fee calculations. * @return The constant value. @@ -833,6 +883,13 @@ interface ISuperMinterV1_1 is IERC165 { */ function computeMintToDigest(MintTo calldata p) external view returns (bytes32); + /** + * @dev Returns the EIP-712 digest of the mint-to data for presave mints. + * @param p The presave parameters. + * @return The computed value. + */ + function computePresaveDigest(Presave calldata p) external view returns (bytes32); + /** * @dev Returns the total price and fees for the mint. * @param edition The address of the Sound Edition. diff --git a/tests/modules/SuperMinterV1_1.t.sol b/tests/modules/SuperMinterV1_1.t.sol index dd6e39a7..a21d0644 100644 --- a/tests/modules/SuperMinterV1_1.t.sol +++ b/tests/modules/SuperMinterV1_1.t.sol @@ -256,6 +256,56 @@ contract SuperMinterV1_1Tests is TestConfigV2_1 { } } + function test_presave(uint256) public { + (address signer, uint256 privateKey) = _randomSigner(); + + ISuperMinterV1_1.MintCreation memory c; + c.maxMintable = uint32(_bound(_random(), 1, 64)); + c.platform = address(this); + c.edition = address(edition); + c.startTime = 0; + c.tier = uint8(_random() % 2); + c.endTime = uint32(block.timestamp + 1000); + c.maxMintablePerAccount = uint32(_random()); // Doesn't matter, will be auto set to max. + c.mode = sm.PRESAVE(); + c.signer = signer; + assertEq(sm.createEditionMint(c), 0); + + unchecked { + ISuperMinterV1_1.Presave memory p; + p.edition = address(edition); + p.tier = c.tier; + p.scheduleNum = 0; + p.to = new address[](_bound(_random(), 0, 8)); + p.signedQuantity = uint32(_bound(_random(), 1, 8)); + p.signedClaimTicket = uint32(_bound(_random(), 0, type(uint32).max)); + p.signedDeadline = type(uint32).max; + for (uint256 i; i < p.to.length; ++i) { + p.to[i] = _randomNonZeroAddress(); + } + LibSort.sort(p.to); + LibSort.uniquifySorted(p.to); + p.signature = _generatePresaveSignature(p, privateKey); + + uint256 expectedMinted = p.signedQuantity * p.to.length; + if (expectedMinted > c.maxMintable) { + vm.expectRevert(ISuperMinterV1_1.ExceedsMintSupply.selector); + sm.presave(p); + return; + } + + sm.presave(p); + assertEq(sm.mintInfo(address(edition), p.tier, p.scheduleNum).minted, expectedMinted); + for (uint256 i; i < p.to.length; ++i) { + assertEq(edition.balanceOf(p.to[i]), p.signedQuantity); + assertEq(sm.numberMinted(address(edition), p.tier, p.scheduleNum, p.to[i]), p.signedQuantity); + } + + vm.expectRevert(ISuperMinterV1_1.SignatureAlreadyUsed.selector); + sm.presave(p); + } + } + function test_mintDefaultUpToMaxPerAccount() public { ISuperMinterV1_1.MintCreation memory c; c.maxMintable = type(uint32).max; @@ -988,6 +1038,15 @@ contract SuperMinterV1_1Tests is TestConfigV2_1 { signature = abi.encodePacked(r, s, v); } + function _generatePresaveSignature(ISuperMinterV1_1.Presave memory p, uint256 privateKey) + internal + returns (bytes memory signature) + { + bytes32 digest = sm.computePresaveDigest(p); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest); + signature = abi.encodePacked(r, s, v); + } + function test_mintGA(uint256) public { ISuperMinterV1_1.MintCreation memory c; c.maxMintable = type(uint32).max;