diff --git a/src-upgradeable/README.md b/src-upgradeable/README.md index 71d8b03a..5e39916a 100644 --- a/src-upgradeable/README.md +++ b/src-upgradeable/README.md @@ -28,6 +28,19 @@ Located at [`scripts/deploy.ts`](./scripts/deploy.ts) Located at [`scripts/upgrade.ts`](./scripts/upgrade.ts) +## Testing + +The upgradeable package now ships with a dedicated Hardhat test suite that +exercises the proxy workflow and SeaDrop integration mirrors. From the project +root execute: + +``` +yarn test:upgradeable +``` + +This command uses `src-upgradeable/hardhat.config.ts`, deploys mocks, and +verifies that upgradeable tokens retain configuration through proxy upgrades. + ### Testnet / Mainnet We will use the Sepolia testnet as an example. diff --git a/src-upgradeable/hardhat.config.ts b/src-upgradeable/hardhat.config.ts index 93f39252..79acc568 100644 --- a/src-upgradeable/hardhat.config.ts +++ b/src-upgradeable/hardhat.config.ts @@ -28,5 +28,5 @@ module.exports = { // Obtain one at https://etherscan.io/ apiKey: process.env.ETHERSCAN_API_KEY, }, - paths: { sources: "./src" }, + paths: { sources: "./src", tests: "./test" }, }; diff --git a/src-upgradeable/src/mocks/ERC721SeaDropUpgradeableV2.sol b/src-upgradeable/src/mocks/ERC721SeaDropUpgradeableV2.sol new file mode 100644 index 00000000..c78b8fda --- /dev/null +++ b/src-upgradeable/src/mocks/ERC721SeaDropUpgradeableV2.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.17; + +import "../ERC721SeaDropUpgradeable.sol"; + +/** + * @dev Simple extension of ERC721SeaDropUpgradeable used to verify upgrade + * safety during testing. + */ +contract ERC721SeaDropUpgradeableV2 is ERC721SeaDropUpgradeable { + function version() external pure returns (string memory) { + return "ERC721SeaDropUpgradeable_V2"; + } +} diff --git a/src-upgradeable/src/mocks/MockSeaDropUpgradeable.sol b/src-upgradeable/src/mocks/MockSeaDropUpgradeable.sol new file mode 100644 index 00000000..ed618e60 --- /dev/null +++ b/src-upgradeable/src/mocks/MockSeaDropUpgradeable.sol @@ -0,0 +1,378 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.17; + +import { + INonFungibleSeaDropTokenUpgradeable +} from "../interfaces/INonFungibleSeaDropTokenUpgradeable.sol"; + +import { + ISeaDropUpgradeable +} from "../interfaces/ISeaDropUpgradeable.sol"; + +import { + AllowListData, + MintParams, + PublicDrop, + TokenGatedDropStage, + TokenGatedMintParams, + SignedMintValidationParams +} from "../lib/SeaDropStructsUpgradeable.sol"; + +/** + * @title MockSeaDropUpgradeable + * @notice Lightweight mock that stores configuration updates made by an + * `ERC721SeaDropUpgradeable` token and exposes helper getters so that + * tests can observe the forwarded state. Mint functions simply invoke + * the token's `mintSeaDrop` hook without enforcing business logic. + */ +contract MockSeaDropUpgradeable is ISeaDropUpgradeable { + /// @notice Track the public drop configuration per nft contract. + mapping(address => PublicDrop) private _publicDrops; + + /// @notice Track the allow list data per nft contract. + struct AllowListState { + bytes32 merkleRoot; + string allowListURI; + uint256 publicKeyURICount; + } + mapping(address => AllowListState) private _allowLists; + + /// @notice Track token gated drop stages per nft contract. + mapping(address => mapping(address => TokenGatedDropStage)) + private _tokenGatedDrops; + mapping(address => address[]) private _tokenGatedTokens; + + /// @notice Track creator payout addresses per nft contract. + mapping(address => address) private _creatorPayoutAddresses; + + /// @notice Track drop URIs per nft contract. + mapping(address => string) private _dropURIs; + + /// @notice Track allowed fee recipients per nft contract. + mapping(address => mapping(address => bool)) private _allowedFeeRecipients; + mapping(address => address[]) private _enumeratedFeeRecipients; + + /// @notice Track server-side signer params per nft contract. + mapping(address => mapping(address => SignedMintValidationParams)) + private _signedMintValidationParams; + mapping(address => address[]) private _enumeratedSigners; + + /// @notice Track payer permissions per nft contract. + mapping(address => mapping(address => bool)) private _allowedPayers; + mapping(address => address[]) private _enumeratedPayers; + + /// @notice Track token gated redemption status. + mapping(address => mapping(address => mapping(uint256 => bool))) + private _tokenGatedRedeemed; + + event MockMinted( + address indexed nftContract, + address indexed minter, + uint256 quantity + ); + + /*////////////////////////////////////////////////////////////// + Minting entry points + //////////////////////////////////////////////////////////////*/ + + function mintPublic( + address nftContract, + address, + address minterIfNotPayer, + uint256 quantity + ) external payable override { + address minter = minterIfNotPayer == address(0) + ? msg.sender + : minterIfNotPayer; + + INonFungibleSeaDropTokenUpgradeable(nftContract).mintSeaDrop( + minter, + quantity + ); + emit MockMinted(nftContract, minter, quantity); + } + + function mintAllowList( + address, + address, + address, + uint256, + MintParams calldata, + bytes32[] calldata + ) external payable override { + revert("UNIMPLEMENTED"); + } + + function mintSigned( + address, + address, + address, + uint256, + MintParams calldata, + uint256, + bytes calldata + ) external payable override { + revert("UNIMPLEMENTED"); + } + + function mintAllowedTokenHolder( + address nftContract, + address, + address minterIfNotPayer, + TokenGatedMintParams calldata mintParams + ) external payable override { + address minter = minterIfNotPayer == address(0) + ? msg.sender + : minterIfNotPayer; + + for (uint256 i = 0; i < mintParams.allowedNftTokenIds.length; ) { + _tokenGatedRedeemed[nftContract][mintParams.allowedNftToken][ + mintParams.allowedNftTokenIds[i] + ] = true; + unchecked { + ++i; + } + } + + INonFungibleSeaDropTokenUpgradeable(nftContract).mintSeaDrop( + minter, + mintParams.allowedNftTokenIds.length + ); + emit MockMinted( + nftContract, + minter, + mintParams.allowedNftTokenIds.length + ); + } + + /*////////////////////////////////////////////////////////////// + View helper methods + //////////////////////////////////////////////////////////////*/ + + function getPublicDrop(address nftContract) + external + view + override + returns (PublicDrop memory) + { + return _publicDrops[nftContract]; + } + + function getCreatorPayoutAddress(address nftContract) + external + view + override + returns (address) + { + return _creatorPayoutAddresses[nftContract]; + } + + function getAllowListMerkleRoot(address nftContract) + external + view + override + returns (bytes32) + { + return _allowLists[nftContract].merkleRoot; + } + + function getFeeRecipientIsAllowed(address nftContract, address feeRecipient) + external + view + override + returns (bool) + { + return _allowedFeeRecipients[nftContract][feeRecipient]; + } + + function getAllowedFeeRecipients(address nftContract) + external + view + override + returns (address[] memory) + { + return _enumeratedFeeRecipients[nftContract]; + } + + function getSigners(address nftContract) + external + view + override + returns (address[] memory) + { + return _enumeratedSigners[nftContract]; + } + + function getSignedMintValidationParams( + address nftContract, + address signer + ) external view override returns (SignedMintValidationParams memory) { + return _signedMintValidationParams[nftContract][signer]; + } + + function getPayers(address nftContract) + external + view + override + returns (address[] memory) + { + return _enumeratedPayers[nftContract]; + } + + function getPayerIsAllowed(address nftContract, address payer) + external + view + override + returns (bool) + { + return _allowedPayers[nftContract][payer]; + } + + function getTokenGatedAllowedTokens(address nftContract) + external + view + override + returns (address[] memory) + { + return _tokenGatedTokens[nftContract]; + } + + function getTokenGatedDrop(address nftContract, address allowedNftToken) + external + view + override + returns (TokenGatedDropStage memory) + { + return _tokenGatedDrops[nftContract][allowedNftToken]; + } + + function getAllowedNftTokenIdIsRedeemed( + address nftContract, + address allowedNftToken, + uint256 allowedNftTokenId + ) external view override returns (bool) { + return + _tokenGatedRedeemed[nftContract][allowedNftToken][ + allowedNftTokenId + ]; + } + + /*////////////////////////////////////////////////////////////// + Update forwarding methods + //////////////////////////////////////////////////////////////*/ + + function updateDropURI(string calldata dropURI) external override { + _dropURIs[msg.sender] = dropURI; + } + + function updatePublicDrop(PublicDrop calldata publicDrop) + external + override + { + _publicDrops[msg.sender] = publicDrop; + } + + function updateAllowList(AllowListData calldata allowListData) + external + override + { + _allowLists[msg.sender] = AllowListState({ + merkleRoot: allowListData.merkleRoot, + allowListURI: allowListData.allowListURI, + publicKeyURICount: allowListData.publicKeyURIs.length + }); + } + + function updateTokenGatedDrop( + address allowedNftToken, + TokenGatedDropStage calldata dropStage + ) external override { + if (dropStage.maxTotalMintableByWallet == 0) { + delete _tokenGatedDrops[msg.sender][allowedNftToken]; + _removeAddress(allowedNftToken, _tokenGatedTokens[msg.sender]); + return; + } + + if (_tokenGatedDrops[msg.sender][allowedNftToken].maxTotalMintableByWallet == 0) { + _tokenGatedTokens[msg.sender].push(allowedNftToken); + } + _tokenGatedDrops[msg.sender][allowedNftToken] = dropStage; + } + + function updateCreatorPayoutAddress(address payoutAddress) + external + override + { + _creatorPayoutAddresses[msg.sender] = payoutAddress; + } + + function updateAllowedFeeRecipient(address feeRecipient, bool allowed) + external + override + { + if (allowed && !_allowedFeeRecipients[msg.sender][feeRecipient]) { + _allowedFeeRecipients[msg.sender][feeRecipient] = true; + _enumeratedFeeRecipients[msg.sender].push(feeRecipient); + } else if ( + !allowed && _allowedFeeRecipients[msg.sender][feeRecipient] + ) { + _allowedFeeRecipients[msg.sender][feeRecipient] = false; + _removeAddress(feeRecipient, _enumeratedFeeRecipients[msg.sender]); + } + } + + function updateSignedMintValidationParams( + address signer, + SignedMintValidationParams calldata signedMintValidationParams + ) external override { + if ( + _signedMintValidationParams[msg.sender][signer].maxMaxTotalMintableByWallet == + 0 + ) { + _enumeratedSigners[msg.sender].push(signer); + } + _signedMintValidationParams[msg.sender][ + signer + ] = signedMintValidationParams; + } + + function updatePayer(address payer, bool allowed) external override { + if (allowed && !_allowedPayers[msg.sender][payer]) { + _allowedPayers[msg.sender][payer] = true; + _enumeratedPayers[msg.sender].push(payer); + } else if (!allowed && _allowedPayers[msg.sender][payer]) { + _allowedPayers[msg.sender][payer] = false; + _removeAddress(payer, _enumeratedPayers[msg.sender]); + } + } + + /*////////////////////////////////////////////////////////////// + Helpers + //////////////////////////////////////////////////////////////*/ + + function dropURI(address nftContract) external view returns (string memory) { + return _dropURIs[nftContract]; + } + + function allowListState(address nftContract) + external + view + returns (AllowListState memory) + { + return _allowLists[nftContract]; + } + + function _removeAddress(address target, address[] storage list) private { + uint256 length = list.length; + for (uint256 i = 0; i < length; ) { + if (list[i] == target) { + list[i] = list[length - 1]; + list.pop(); + break; + } + unchecked { + ++i; + } + } + } +} diff --git a/src-upgradeable/test/ERC721SeaDropUpgradeable.spec.ts b/src-upgradeable/test/ERC721SeaDropUpgradeable.spec.ts new file mode 100644 index 00000000..f61573b0 --- /dev/null +++ b/src-upgradeable/test/ERC721SeaDropUpgradeable.spec.ts @@ -0,0 +1,126 @@ +import { expect } from "chai"; +import { ethers, upgrades } from "hardhat"; + +const now = () => Math.floor(Date.now() / 1000); + +describe("ERC721SeaDropUpgradeable", () => { + const publicDrop = { + mintPrice: ethers.utils.parseEther("0.1"), + maxTotalMintableByWallet: 10, + startTime: now() - 100, + endTime: now() + 100, + feeBps: 1000, + restrictFeeRecipients: true, + }; + + async function deployFixture() { + const [owner, other, minter, feeRecipient] = await ethers.getSigners(); + + const SeaDrop = await ethers.getContractFactory("MockSeaDropUpgradeable"); + const seaDrop = await SeaDrop.deploy(); + await seaDrop.deployed(); + + const Token = await ethers.getContractFactory("ERC721SeaDropUpgradeable"); + const token = await upgrades.deployProxy( + Token, + ["Upgradeable SeaDrop", "USDP", [seaDrop.address]], + { initializer: "initialize" } + ); + + return { owner, other, minter, feeRecipient, seaDrop, token }; + } + + it("restricts privileged operations to the owner and forwards configuration to SeaDrop", async () => { + const { token, seaDrop, other, owner, feeRecipient } = await deployFixture(); + + await expect( + token.connect(other).updatePublicDrop(seaDrop.address, publicDrop) + ).to.be.revertedWith("OnlyOwner"); + + await expect( + token.updatePublicDrop(seaDrop.address, publicDrop) + ).to.not.be.reverted; + + const storedDrop = await seaDrop.getPublicDrop(token.address); + expect(storedDrop.mintPrice).to.equal(publicDrop.mintPrice); + expect(storedDrop.maxTotalMintableByWallet).to.equal( + publicDrop.maxTotalMintableByWallet + ); + + await expect( + token + .connect(other) + .updateCreatorPayoutAddress(seaDrop.address, owner.address) + ).to.be.revertedWith("OnlyOwner"); + + await token.updateCreatorPayoutAddress(seaDrop.address, owner.address); + expect(await seaDrop.getCreatorPayoutAddress(token.address)).to.equal( + owner.address + ); + + await token.updateAllowedFeeRecipient( + seaDrop.address, + feeRecipient.address, + true + ); + expect( + await seaDrop.getFeeRecipientIsAllowed(token.address, feeRecipient.address) + ).to.be.true; + + await token.updateAllowedFeeRecipient( + seaDrop.address, + feeRecipient.address, + false + ); + expect( + await seaDrop.getFeeRecipientIsAllowed(token.address, feeRecipient.address) + ).to.be.false; + }); + + it("only mints when invoked by an allowed SeaDrop contract", async () => { + const { token, seaDrop, other, minter } = await deployFixture(); + + await token.setMaxSupply(5); + + await expect( + token.connect(other).mintSeaDrop(minter.address, 1) + ).to.be.revertedWith("OnlyAllowedSeaDrop"); + + await seaDrop.mintPublic( + token.address, + minter.address, + minter.address, + 2 + ); + + expect(await token.totalSupply()).to.equal(2); + expect(await token.ownerOf(1)).to.equal(minter.address); + expect(await token.ownerOf(2)).to.equal(minter.address); + }); + + it("preserves state across upgrades", async () => { + const { token, seaDrop, minter } = await deployFixture(); + + await token.setBaseURI("ipfs://base/"); + await token.setContractURI("ipfs://contract.json"); + await token.setMaxSupply(10); + + const TokenV2 = await ethers.getContractFactory( + "ERC721SeaDropUpgradeableV2" + ); + const upgraded = await upgrades.upgradeProxy(token.address, TokenV2); + + expect(await upgraded.baseURI()).to.equal("ipfs://base/"); + expect(await upgraded.contractURI()).to.equal("ipfs://contract.json"); + expect(await upgraded.name()).to.equal("Upgradeable SeaDrop"); + expect(await upgraded.version()).to.equal("ERC721SeaDropUpgradeable_V2"); + + await seaDrop.mintPublic( + upgraded.address, + minter.address, + minter.address, + 1 + ); + expect(await upgraded.totalSupply()).to.equal(1); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 1ca3df43..9b25613c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,13 @@ "declaration": true, "resolveJsonModule": true }, - "include": ["./scripts", "./test", "./typechain-types", "./eip-712-types", "./*.config.ts"], + "include": [ + "./scripts", + "./test", + "./src-upgradeable/test", + "./typechain-types", + "./eip-712-types", + "./*.config.ts" + ], "files": ["./hardhat.config.ts"] }