diff --git a/.github/workflows/subtree-sync.yml b/.github/workflows/subtree-sync.yml index 8fe9cd9..149ba0a 100644 --- a/.github/workflows/subtree-sync.yml +++ b/.github/workflows/subtree-sync.yml @@ -18,7 +18,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # gh picks this up automatically steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: {fetch-depth: 0} # full history, just in case - name: Ensure PR exists diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 25c3e34..451db1a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,7 +6,7 @@ jobs: tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: submodules: recursive diff --git a/deployments/addresses.json b/deployments/addresses.json index c3a44b1..3111f86 100644 --- a/deployments/addresses.json +++ b/deployments/addresses.json @@ -2,78 +2,132 @@ "actions": { "Allowlisted": [ { - "address": "0x157428DD791E03c20880D22C3dA2B66A36B5cF26", - "blockNumber": 33510607, - "paramsSchema": "bytes32 merkleRoot", - "transactionHash": "0x6af9ac700f1c9a38de57fa4bc13262162d9b674649fd417ccd50237b6cfbc178" + "address": "0x87f726ac5D1023919840bebde2Da2efb5Da816ad", + "blockNumber": 36441333, + "configureProductSchema": "bytes32 merkleRoot", + "configureProductTypeSchema": "not-supported", + "configureSlicerSchema": "not-supported", + "onProductPurchaseSchema": "", + "productPriceSchema": "not-supported", + "productTypePriceSchema": "not-supported", + "slicerProductPriceSchema": "not-supported", + "transactionHash": "0x57419dee52082559db1d966b1b139bb79ac59c4533de43153dd11fda30b765c1" } ], "ERC20Gated": [ { - "address": "0x26A1C86B555013995Fc72864D261fDe984752E7c", - "blockNumber": 33558792, - "paramsSchema": "(address erc20,uint256 amount)[] erc20Gates", - "transactionHash": "0x3a7c01ede05a34280073479d5cdf1f35e41d9f08c36a71f358b9c503ccc54526" + "address": "0x247ebf608A0028DE933f1F90eE3920864D3fC621", + "blockNumber": 36440973, + "configureProductSchema": "(address erc20,uint256 amount)[] erc20Gates", + "configureProductTypeSchema": "(address erc20,uint256 amount)[] erc20Gates", + "configureSlicerSchema": "(address erc20,uint256 amount)[] erc20Gates", + "onProductPurchaseSchema": "", + "productPriceSchema": "not-supported", + "productTypePriceSchema": "not-supported", + "slicerProductPriceSchema": "not-supported", + "transactionHash": "0xac40db0f5c63ec520e671db10b018018900a332d563c99bc9828a6cc20b48863" } ], "ERC20Mint": [ { - "address": "0x67f9799FaC1D53C63217BEE47f553150F5BB0836", - "blockNumber": 33520592, - "paramsSchema": "string name,string symbol,uint256 premintAmount,address premintReceiver,bool revertOnMaxSupplyReached,uint256 maxSupply,uint256 tokensPerUnit", - "transactionHash": "0xfcd7e8fe47aa509afa0acdef86b741656c2602626f3258f8a83b359c665d6b04" + "address": "0x8B03B4dE7F6FD7A7BDD60Cb00aa49E144e45cdd0", + "blockNumber": 36441450, + "configureProductSchema": "string name,string symbol,uint256 premintAmount,address premintReceiver,bool revertOnMaxSupplyReached,uint256 maxSupply,uint256 tokensPerUnit", + "configureProductTypeSchema": "string name,string symbol,uint256 premintAmount,address premintReceiver,bool revertOnMaxSupplyReached,uint256 maxSupply,uint256 tokensPerUnit", + "configureSlicerSchema": "string name,string symbol,uint256 premintAmount,address premintReceiver,bool revertOnMaxSupplyReached,uint256 maxSupply,uint256 tokensPerUnit", + "onProductPurchaseSchema": "", + "productPriceSchema": "not-supported", + "productTypePriceSchema": "not-supported", + "slicerProductPriceSchema": "not-supported", + "transactionHash": "0xfd77332c2aba9e05a71419b87ddb0a136f85675f7d0f13cf7c46c3e65c99f44c" } ], "ERC721Mint": [ { - "address": "0x2b6488115FAa50142E140172CbCd60e6370675F7", - "blockNumber": 33511082, - "paramsSchema": "string name,string symbol,address royaltyReceiver,uint256 royaltyFraction,string baseURI,string tokenURI,bool revertOnMaxSupplyReached,uint256 maxSupply", - "transactionHash": "0x4981b3b67c0b8abe6a942b3ca86643f2b1dfdbcce59d6c819ad20a82814956e0" + "address": "0x48eAf12a1dC2a6dfACcb4C03F381B07ad5D5961C", + "blockNumber": 36441490, + "configureProductSchema": "string name,string symbol,address royaltyReceiver,uint256 royaltyFraction,string baseURI,string tokenURI,bool revertOnMaxSupplyReached,uint256 maxSupply", + "configureProductTypeSchema": "string name,string symbol,address royaltyReceiver,uint256 royaltyFraction,string baseURI,string tokenURI,bool revertOnMaxSupplyReached,uint256 maxSupply", + "configureSlicerSchema": "string name,string symbol,address royaltyReceiver,uint256 royaltyFraction,string baseURI,string tokenURI,bool revertOnMaxSupplyReached,uint256 maxSupply", + "onProductPurchaseSchema": "", + "productPriceSchema": "not-supported", + "productTypePriceSchema": "not-supported", + "slicerProductPriceSchema": "not-supported", + "transactionHash": "0x4851d2daedfa5de5a6a60a44fb9709e8c865039660c373d6d96f06c4ec2369f5" } ], "NFTGated": [ { - "address": "0xD4eF7A46bF4c58036eaCA886119F5230e5a2C25d", - "blockNumber": 33563508, - "paramsSchema": "(address nft,uint8 nftType,uint80 id,uint8 minQuantity)[] nftGates,uint256 minOwned", - "transactionHash": "0x9dd101a6155b432849cb33f65c5d4b9f6875e6b1e7bf806005a8a6b0d070f634" + "address": "0x560463f7bF3DFDB4Cff4f96c3399313d610Dd349", + "blockNumber": 36441510, + "configureProductSchema": "(address nft,uint8 nftType,uint80 id,uint8 minQuantity)[] gates,uint256 minOwned", + "configureProductTypeSchema": "(address nft,uint8 nftType,uint80 id,uint8 minQuantity)[] gates,uint256 minOwned", + "configureSlicerSchema": "(address nft,uint8 nftType,uint80 id,uint8 minQuantity)[] gates,uint256 minOwned", + "onProductPurchaseSchema": "", + "productPriceSchema": "not-supported", + "productTypePriceSchema": "not-supported", + "slicerProductPriceSchema": "not-supported", + "transactionHash": "0x3bef5e07d303ab1028a7928f9ef1ffb1613b957aff4bfe481ae79a6fd1029e2d" } ] }, "pricing": { - "NFTDiscount": [ + "LinearVRGDAPrices": [ { - "address": "0xb830a457d2f51d4cA1136b97FB30DF6366CFe2f5", - "blockNumber": 33596708, - "paramsSchema": "(address nft,uint80 discount,uint8 minQuantity,uint8 nftType,uint256 tokenId)[] discounts", - "transactionHash": "0x966be5fa1da3fab7c5027c9acb3f118307997e30687188b4745d74fd26ad9e7e" + "address": "0x67AEdfe386a078DF350D4b1E033AB735c1fCD0C9", + "blockNumber": 36441538, + "configureProductSchema": "int184 priceDecayPercent,uint16 min,int256 perTimeUnit", + "configureProductTypeSchema": "not-supported", + "configureSlicerSchema": "not-supported", + "onProductPurchaseSchema": "not-supported", + "productPriceSchema": "", + "productTypePriceSchema": "not-supported", + "slicerProductPriceSchema": "not-supported", + "transactionHash": "0xd5ad41a2f994e0151edaf566e8114b703fa1843c4bbd4a8ed79b4312e22a4843" } ], - "LinearVRGDAPrices": [ + "LogisticVRGDAPrices": [ { - "address": "0xEC68E30182F4298b7032400B7ce809da613e4449", - "blockNumber": 33511188, - "paramsSchema": "(address currency,int128 targetPrice,uint128 min,int256 perTimeUnit)[] linearParams,int256 priceDecayPercent", - "transactionHash": "0xf2667ce20d07561e59c8d3e3de135bbd895c5b08bb57fe487c6c8197c91a0d73" + "address": "0x12b56C70fd76D45b578C50aD2eDB582b44d75cC1", + "blockNumber": 36441564, + "configureProductSchema": "int184 priceDecayPercent,uint16 min,int256 timescale", + "configureProductTypeSchema": "not-supported", + "configureSlicerSchema": "not-supported", + "onProductPurchaseSchema": "not-supported", + "productPriceSchema": "", + "productTypePriceSchema": "not-supported", + "slicerProductPriceSchema": "not-supported", + "transactionHash": "0x72fbbdd585b2fd5a2c5be0431e14c0ddee9a266f0c2c6d259dfc9b76a938122a" } ], - "LogisticVRGDAPrices": [ + "NFTDiscount": [ { - "address": "0x2b02cC8528EF18abf8185543CEC29A94F0542c8F", - "blockNumber": 33511209, - "paramsSchema": "(address currency,int128 targetPrice,uint128 min,int256 timeScale)[] logisticParams,int256 priceDecayPercent", - "transactionHash": "0x2b27380661a38b54dbccf634305622296034ddfccf5865a07fbfb7810ea41025" + "address": "0xD92820f4836b23DCe9f7696b12a5C877ec667128", + "blockNumber": 36441359, + "configureProductSchema": "(address nft,uint80 discount,uint8 minQuantity,uint8 nftType,uint256 tokenId)[] discounts", + "configureProductTypeSchema": "(address nft,uint80 discount,uint8 minQuantity,uint8 nftType,uint256 tokenId)[] discounts", + "configureSlicerSchema": "(address nft,uint80 discount,uint8 minQuantity,uint8 nftType,uint256 tokenId)[] discounts", + "onProductPurchaseSchema": "not-supported", + "productPriceSchema": "", + "productTypePriceSchema": "", + "slicerProductPriceSchema": "", + "transactionHash": "0xaf7bb92631cfa26e513b23bd48d60535244a9161e497d25d55ba139890414e3c" } ] }, "pricingActions": { "FirstForFree": [ { - "address": "0x2C18D37b8229233F672bF406bCe8799BCfD43B5A", - "blockNumber": 33510960, - "paramsSchema": "uint256 usdcPrice,(address tokenAddress,uint8 tokenType,uint88 tokenId,uint8 minQuantity)[] eligibleTokens,address mintToken,uint88 mintTokenId,uint8 freeUnits", - "transactionHash": "0x8bd0b9de8edd704899210f8450096adf7f4f853030a7ba0a2f64494bc1ba726e" + "address": "0x718308153116e90393804ae7a58a8e7d5BDb6482", + "blockNumber": 36441582, + "configureProductSchema": "(address tokenAddress,uint8 tokenType,uint80 tokenId,uint8 minQuantity)[] eligibleTokens,address mintToken,uint88 mintTokenId,uint8 freeUnits", + "configureProductTypeSchema": "not-supported", + "configureSlicerSchema": "not-supported", + "onProductPurchaseSchema": "", + "productPriceSchema": "", + "productTypePriceSchema": "not-supported", + "slicerProductPriceSchema": "not-supported", + "transactionHash": "0x59033c01e7228c3f28d25ce233bb729e595515923078498e726d052f038a87aa" } ] } diff --git a/foundry.toml b/foundry.toml index 0f37899..a8f247f 100644 --- a/foundry.toml +++ b/foundry.toml @@ -10,6 +10,7 @@ remappings = [ "slice/=dependencies/slice-0.0.9/", "@openzeppelin-4.8.0/=dependencies/@openzeppelin-contracts-4.8.0/", "@openzeppelin-upgradeable-4.8.0/=dependencies/@openzeppelin-contracts-upgradeable-4.8.0/", + "solady-0.1.26/=dependencies/solady-0.1.26/", "@erc721a/=dependencies/erc721a-4.3.0/contracts/", "forge-std/=dependencies/forge-std-1.9.7/src/", "@test/=test/", @@ -49,6 +50,7 @@ forge-std = "1.9.7" "@openzeppelin-contracts" = "4.8.0" "@openzeppelin-contracts-upgradeable" = "4.8.0" erc721a = "4.3.0" +solady = "0.1.26" [lint] ignore = ["test/**/*.sol","script/**/*.sol", "src/interfaces/*.sol", "src/utils/*.sol", "src/hooks/actions/actions.sol", "src/hooks/pricing/pricing.sol", "src/hooks/pricingActions/pricingActions.sol"] diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 184c75f..3af7b9b 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.30; -import {console} from "forge-std/console.sol"; import {VmSafe} from "forge-std/Vm.sol"; import {BaseScript, SetUpContractsList} from "./ScriptUtils.sol"; diff --git a/script/ScriptUtils.sol b/script/ScriptUtils.sol index b65a1d5..3976d91 100644 --- a/script/ScriptUtils.sol +++ b/script/ScriptUtils.sol @@ -7,7 +7,15 @@ import {VmSafe} from "forge-std/Vm.sol"; import {ISliceCore} from "slice/interfaces/ISliceCore.sol"; import {IProductsModule} from "slice/interfaces/IProductsModule.sol"; import {IFundsModule} from "slice/interfaces/IFundsModule.sol"; -import {IHookRegistry} from "slice/interfaces/hooks/IHookRegistry.sol"; +import { + IProductHookRegistry, + IProductTypeHookRegistry, + ISlicerHookRegistry, + IProductPrice, + IProductTypePrice, + ISlicerProductPrice, + IProductAction +} from "slice/interfaces.sol"; /** * Helper contract to enforce correct chain selection in scripts @@ -85,7 +93,12 @@ abstract contract SetUpContractsList is Script { struct ContractDeploymentData { address contractAddress; uint256 blockNumber; - string paramsSchema; + // TODO: Add other params to the json? such as configure[ProductType/Slicer]Schema and [product/productType/slicer]PriceSchema + string configureProductSchema; + string configureProductTypeSchema; + string configureSlicerSchema; + string onProductPurchaseSchema; + string productPriceSchema; bytes32 transactionHash; } @@ -134,9 +147,7 @@ abstract contract SetUpContractsList is Script { string[] memory arrStrings = new string[](existingData.length); for (uint256 j = 0; j < existingData.length; j++) { string memory idx = vm.toString(j); - vm.serializeAddress(idx, "address", existingData[j].contractAddress); - vm.serializeUint(idx, "blockNumber", existingData[j].blockNumber); - vm.serializeString(idx, "paramsSchema", existingData[j].paramsSchema); + _setFields(idx, existingData[j]); arrStrings[j] = vm.serializeBytes32(idx, "transactionHash", existingData[j].transactionHash); } @@ -198,6 +209,89 @@ abstract contract SetUpContractsList is Script { vm.writeJson(vm.serializeString("addresses", firstFolder, updatedGroupJson), ADDRESSES_PATH); } + function _setFields(string memory idx, ContractDeploymentData memory existingData) internal { + vm.serializeAddress(idx, "address", existingData.contractAddress); + vm.serializeUint(idx, "blockNumber", existingData.blockNumber); + vm.serializeString(idx, "configureProductSchema", existingData.configureProductSchema); + + // Keep the previously stored schema values to avoid overwriting valid data when reserializing + vm.serializeString(idx, "configureProductTypeSchema", existingData.configureProductTypeSchema); + vm.serializeString(idx, "configureSlicerSchema", existingData.configureSlicerSchema); + vm.serializeString(idx, "onProductPurchaseSchema", existingData.onProductPurchaseSchema); + vm.serializeString(idx, "productPriceSchema", existingData.productPriceSchema); + } + + function _isProductTypeHookRegistry(address contractAddress) internal view returns (bool) { + return IProductTypeHookRegistry(contractAddress).supportsInterface(type(IProductTypeHookRegistry).interfaceId); + } + + function _isSlicerHookRegistry(address contractAddress) internal view returns (bool) { + return ISlicerHookRegistry(contractAddress).supportsInterface(type(ISlicerHookRegistry).interfaceId); + } + + function _isProductPrice(address contractAddress) internal view returns (bool) { + return IProductPrice(contractAddress).supportsInterface(type(IProductPrice).interfaceId); + } + + function _isProductTypePrice(address contractAddress) internal view returns (bool) { + return IProductTypePrice(contractAddress).supportsInterface(type(IProductTypePrice).interfaceId); + } + + function _isSlicerProductPrice(address contractAddress) internal view returns (bool) { + return ISlicerProductPrice(contractAddress).supportsInterface(type(ISlicerProductPrice).interfaceId); + } + + function _isProductAction(address contractAddress) internal view returns (bool) { + return IProductAction(contractAddress).supportsInterface(type(IProductAction).interfaceId); + } + + function _serializeObject(string[] memory json, Tx1559 memory transaction, Receipt memory receipt) + internal + returns (string[] memory) + { + vm.serializeAddress("0", "address", transaction.contractAddress); + vm.serializeUint("0", "blockNumber", receipt.blockNumber); + vm.serializeString( + "0", "configureProductSchema", IProductHookRegistry(transaction.contractAddress).configureProductSchema() + ); + + vm.serializeString( + "0", + "configureProductTypeSchema", + _isProductTypeHookRegistry(transaction.contractAddress) + ? IProductTypeHookRegistry(transaction.contractAddress).configureProductTypeSchema() + : "not-supported" + ); + + vm.serializeString( + "0", + "configureSlicerSchema", + _isSlicerHookRegistry(transaction.contractAddress) + ? ISlicerHookRegistry(transaction.contractAddress).configureSlicerSchema() + : "not-supported" + ); + + vm.serializeString( + "0", + "onProductPurchaseSchema", + _isProductAction(transaction.contractAddress) + ? IProductAction(transaction.contractAddress).onProductPurchaseSchema() + : "not-supported" + ); + + vm.serializeString( + "0", + "productPriceSchema", + _isProductPrice(transaction.contractAddress) + ? IProductPrice(transaction.contractAddress).productPriceSchema() + : "not-supported" + ); + + json[0] = vm.serializeBytes32("0", "transactionHash", transaction.hash); + + return json; + } + function _buildJsonArray( string memory existingAddresses, string memory key, @@ -210,27 +304,17 @@ abstract contract SetUpContractsList is Script { ContractDeploymentData[] memory existingContractAddresses = abi.decode(contractAddressesJson, (ContractDeploymentData[])); - json = new string[](existingContractAddresses.length + 1); - vm.serializeAddress("0", "address", transaction.contractAddress); - vm.serializeUint("0", "blockNumber", receipt.blockNumber); - vm.serializeString("0", "paramsSchema", IHookRegistry(transaction.contractAddress).paramsSchema()); - json[0] = vm.serializeBytes32("0", "transactionHash", transaction.hash); + json = _serializeObject(new string[](existingContractAddresses.length + 1), transaction, receipt); for (uint256 i = 0; i < existingContractAddresses.length; i++) { ContractDeploymentData memory existingContractAddress = existingContractAddresses[i]; string memory index = vm.toString(i + 1); - vm.serializeAddress(index, "address", existingContractAddress.contractAddress); - vm.serializeUint(index, "blockNumber", existingContractAddress.blockNumber); - vm.serializeString(index, "paramsSchema", existingContractAddress.paramsSchema); + _setFields(index, existingContractAddress); json[i + 1] = vm.serializeBytes32(index, "transactionHash", existingContractAddress.transactionHash); } } else { - json = new string[](1); - vm.serializeAddress("0", "address", transaction.contractAddress); - vm.serializeUint("0", "blockNumber", receipt.blockNumber); - vm.serializeString("0", "paramsSchema", IHookRegistry(transaction.contractAddress).paramsSchema()); - json[0] = vm.serializeBytes32("0", "transactionHash", transaction.hash); + json = _serializeObject(new string[](1), transaction, receipt); } } diff --git a/script/Seed.s.sol b/script/Seed.s.sol index 7cc035a..fb3b45f 100644 --- a/script/Seed.s.sol +++ b/script/Seed.s.sol @@ -2,14 +2,14 @@ pragma solidity ^0.8.30; import {Script} from "forge-std/Script.sol"; -import {CommonStorage} from "slice/utils/CommonStorage.sol"; +import {Contracts} from "slice/utils/Contracts.sol"; import {Allowlisted, ERC20Gated, ERC20Mint, ERC721Mint, NFTGated} from "../src/hooks/actions/actions.sol"; import {NFTDiscount, LinearVRGDAPrices, LogisticVRGDAPrices} from "../src/hooks/pricing/pricing.sol"; import {FirstForFree} from "../src/hooks/pricingActions/pricingActions.sol"; // Script to seed the hooks contracts -contract SeedHooksScript is Script, CommonStorage { +contract SeedHooksScript is Script { struct Hook { address hookAddress; bytes code; @@ -23,42 +23,43 @@ contract SeedHooksScript is Script, CommonStorage { function run() external { vm.startBroadcast(); + string memory json = vm.readFile("./deployments/addresses.json"); Hook[] memory hooks = new Hook[](9); hooks[0] = Hook({ - hookAddress: 0x157428DD791E03c20880D22C3dA2B66A36B5cF26, - code: address(new Allowlisted(PRODUCTS_MODULE())).code + hookAddress: vm.parseJsonAddress(json, ".actions.Allowlisted.[0].address"), + code: address(new Allowlisted(Contracts.PRODUCTS_MODULE)).code }); hooks[1] = Hook({ - hookAddress: 0x26A1C86B555013995Fc72864D261fDe984752E7c, - code: address(new ERC20Gated(PRODUCTS_MODULE())).code + hookAddress: vm.parseJsonAddress(json, ".actions.ERC20Gated.[0].address"), + code: address(new ERC20Gated(Contracts.PRODUCTS_MODULE)).code }); hooks[2] = Hook({ - hookAddress: 0x67f9799FaC1D53C63217BEE47f553150F5BB0836, - code: address(new ERC20Mint(PRODUCTS_MODULE())).code + hookAddress: vm.parseJsonAddress(json, ".actions.ERC20Mint.[0].address"), + code: address(new ERC20Mint(Contracts.PRODUCTS_MODULE)).code }); hooks[3] = Hook({ - hookAddress: 0x2b6488115FAa50142E140172CbCd60e6370675F7, - code: address(new ERC721Mint(PRODUCTS_MODULE())).code + hookAddress: vm.parseJsonAddress(json, ".actions.ERC721Mint.[0].address"), + code: address(new ERC721Mint(Contracts.PRODUCTS_MODULE)).code }); hooks[4] = Hook({ - hookAddress: 0xD4eF7A46bF4c58036eaCA886119F5230e5a2C25d, - code: address(new NFTGated(PRODUCTS_MODULE())).code + hookAddress: vm.parseJsonAddress(json, ".actions.NFTGated.[0].address"), + code: address(new NFTGated(Contracts.PRODUCTS_MODULE)).code }); hooks[5] = Hook({ - hookAddress: 0xb830a457d2f51d4cA1136b97FB30DF6366CFe2f5, - code: address(new NFTDiscount(PRODUCTS_MODULE())).code + hookAddress: vm.parseJsonAddress(json, ".pricing.NFTDiscount.[0].address"), + code: address(new NFTDiscount(Contracts.PRODUCTS_MODULE)).code }); hooks[6] = Hook({ - hookAddress: 0xEC68E30182F4298b7032400B7ce809da613e4449, - code: address(new LinearVRGDAPrices(PRODUCTS_MODULE())).code + hookAddress: vm.parseJsonAddress(json, ".pricing.LinearVRGDAPrices.[0].address"), + code: address(new LinearVRGDAPrices(Contracts.PRODUCTS_MODULE)).code }); hooks[7] = Hook({ - hookAddress: 0x2b02cC8528EF18abf8185543CEC29A94F0542c8F, - code: address(new LogisticVRGDAPrices(PRODUCTS_MODULE())).code + hookAddress: vm.parseJsonAddress(json, ".pricing.LogisticVRGDAPrices.[0].address"), + code: address(new LogisticVRGDAPrices(Contracts.PRODUCTS_MODULE)).code }); hooks[8] = Hook({ - hookAddress: 0xEC68E30182F4298b7032400B7ce809da613e4449, - code: address(new FirstForFree(PRODUCTS_MODULE())).code + hookAddress: vm.parseJsonAddress(json, ".pricingActions.FirstForFree.[0].address"), + code: address(new FirstForFree(Contracts.PRODUCTS_MODULE)).code }); // Deploy hooks diff --git a/script/generate-hook.sh b/script/generate-hook.sh index 5b3fe30..7ad1914 100755 --- a/script/generate-hook.sh +++ b/script/generate-hook.sh @@ -165,10 +165,10 @@ pragma solidity ^0.8.30; import { RegistryProductAction, - HookRegistry, + ProductHookRegistry, IProductsModule, IProductAction, - IHookRegistry + IProductHookRegistry } from "@/utils/RegistryProductAction.sol"; /** @@ -193,15 +193,15 @@ contract CONTRACT_NAME is RegistryProductAction { function isPurchaseAllowed( uint256 slicerId, uint256 productId, - address account, + uint256 variantId, + address buyer, uint256 quantity, - bytes memory slicerCustomData, - bytes memory buyerCustomData - ) public view override returns (bool) { + bytes memory data + ) public view override returns (bool isAllowed) { // Your eligibility logic. Return true if eligible, false otherwise. // Returns true by default. - return true; + isAllowed = true; } /** @@ -210,25 +210,25 @@ contract CONTRACT_NAME is RegistryProductAction { function _onProductPurchase( uint256 slicerId, uint256 productId, + uint256 variantId, address buyer, uint256 quantity, - bytes memory slicerCustomData, - bytes memory buyerCustomData + bytes memory data ) internal override { // Your logic to be executed after product purchase. } /** - * @inheritdoc HookRegistry + * @inheritdoc ProductHookRegistry */ - function _configureProduct(uint256 slicerId, uint256 productId, bytes memory params) internal override { - // Decode params according to `paramsSchema` and store any data required for your logic. + function _configureProduct(uint256 slicerId, uint256 productId, uint256 variantId, bytes memory params) internal override { + // Decode params according to `configureProductSchema` and store any data required for your logic. } /** - * @inheritdoc IHookRegistry + * @inheritdoc IProductHookRegistry */ - function paramsSchema() external pure override returns (string memory) { + function configureProductSchema() public pure override returns (string memory) { // Define the schema for the parameters that will be passed to `_configureProduct`. return ""; } @@ -266,11 +266,11 @@ contract CONTRACT_NAME is ProductAction { function isPurchaseAllowed( uint256 slicerId, uint256 productId, + uint256 variantId, address buyer, uint256 quantity, - bytes memory slicerCustomData, - bytes memory buyerCustomData - ) public view override returns (bool) { + bytes memory data + ) public view override returns (bool isAllowed) { // Your eligibility logic. Return true if eligible, false otherwise. return true; } @@ -281,10 +281,10 @@ contract CONTRACT_NAME is ProductAction { function _onProductPurchase( uint256 slicerId, uint256 productId, + uint256 variantId, address buyer, uint256 quantity, - bytes memory slicerCustomData, - bytes memory buyerCustomData + bytes memory data ) internal override { // Your logic to be executed after product purchase. } @@ -298,10 +298,10 @@ pragma solidity ^0.8.30; import { RegistryProductPrice, - HookRegistry, + ProductHookRegistry, IProductsModule, IProductPrice, - IHookRegistry + IProductHookRegistry } from "@/utils/RegistryProductPrice.sol"; /** @@ -326,25 +326,27 @@ contract CONTRACT_NAME is RegistryProductPrice { function productPrice( uint256 slicerId, uint256 productId, + uint256 variantId, address currency, + uint256 basePrice, uint256 quantity, address buyer, bytes memory data - ) public view override returns (uint256 ethPrice, uint256 currencyPrice) { + ) public view override returns (uint256 price) { // Your pricing logic. Calculate and return the total price, depending on the passed quantity. } /** - * @inheritdoc HookRegistry + * @inheritdoc ProductHookRegistry */ - function _configureProduct(uint256 slicerId, uint256 productId, bytes memory params) internal override { - // Decode params according to `paramsSchema` and store any data required for your pricing logic. + function _configureProduct(uint256 slicerId, uint256 productId, uint256 variantId, bytes memory params) internal override { + // Decode params according to `configureProductSchema` and store any data required for your pricing logic. } /** - * @inheritdoc IHookRegistry + * @inheritdoc IProductHookRegistry */ - function paramsSchema() external pure override returns (string memory) { + function configureProductSchema() public pure override returns (string memory) { // Define the schema for the parameters that will be passed to `_configureProduct`. return ""; } @@ -382,11 +384,13 @@ contract CONTRACT_NAME is ProductPrice { function productPrice( uint256 slicerId, uint256 productId, + uint256 variantId, address currency, + uint256 basePrice, uint256 quantity, address buyer, bytes memory data - ) external view returns (uint256 ethPrice, uint256 currencyPrice) { + ) external view returns (uint256 price) { // Your pricing logic. Calculate and return the total price. } } @@ -400,12 +404,14 @@ pragma solidity ^0.8.30; import { RegistryProductPriceAction, RegistryProductAction, - HookRegistry, IProductsModule, IProductAction, - IProductPrice, - IHookRegistry + IProductPrice } from "@/utils/RegistryProductPriceAction.sol"; +import { + ProductHookRegistry, + IProductHookRegistry +} from "@/utils/RegistryProductAction.sol"; /** * @title CONTRACT_NAME @@ -429,11 +435,13 @@ contract CONTRACT_NAME is RegistryProductPriceAction { function productPrice( uint256 slicerId, uint256 productId, + uint256 variantId, address currency, + uint256 basePrice, uint256 quantity, address buyer, bytes memory data - ) public view override returns (uint256 ethPrice, uint256 currencyPrice) { + ) public view override returns (uint256 price) { // Your pricing logic. Calculate and return the total price, depending on the passed quantity. } @@ -443,15 +451,15 @@ contract CONTRACT_NAME is RegistryProductPriceAction { function isPurchaseAllowed( uint256 slicerId, uint256 productId, - address account, + uint256 variantId, + address buyer, uint256 quantity, - bytes memory slicerCustomData, - bytes memory buyerCustomData - ) public view override returns (bool) { + bytes memory data + ) public view override returns (bool isAllowed) { // Your eligibility logic. Return true if eligible, false otherwise. // Returns true by default. - return true; + isAllowed = true; } /** @@ -460,25 +468,25 @@ contract CONTRACT_NAME is RegistryProductPriceAction { function _onProductPurchase( uint256 slicerId, uint256 productId, + uint256 variantId, address buyer, uint256 quantity, - bytes memory slicerCustomData, - bytes memory buyerCustomData + bytes memory data ) internal override { // Your logic to be executed after product purchase. } /** - * @inheritdoc HookRegistry + * @inheritdoc ProductHookRegistry */ - function _configureProduct(uint256 slicerId, uint256 productId, bytes memory params) internal override { - // Decode params according to `paramsSchema` and store any data required for your logic. + function _configureProduct(uint256 slicerId, uint256 productId, uint256 variantId, bytes memory params) internal override { + // Decode params according to `configureProductSchema` and store any data required for your logic. } /** - * @inheritdoc IHookRegistry + * @inheritdoc IProductHookRegistry */ - function paramsSchema() external pure override returns (string memory) { + function configureProductSchema() public pure override returns (string memory) { // Define the schema for the parameters that will be passed to `_configureProduct`. return ""; } @@ -522,11 +530,13 @@ contract CONTRACT_NAME is ProductPriceAction { function productPrice( uint256 slicerId, uint256 productId, + uint256 variantId, address currency, + uint256 basePrice, uint256 quantity, address buyer, bytes memory data - ) external view returns (uint256 ethPrice, uint256 currencyPrice) { + ) external view returns (uint256 price) { // Your pricing logic. Calculate and return the total price. } @@ -536,11 +546,11 @@ contract CONTRACT_NAME is ProductPriceAction { function isPurchaseAllowed( uint256 slicerId, uint256 productId, + uint256 variantId, address buyer, uint256 quantity, - bytes memory slicerCustomData, - bytes memory buyerCustomData - ) public view override returns (bool) { + bytes memory data + ) public view override returns (bool isAllowed) { // Your eligibility logic. Return true if eligible, false otherwise. return true; } @@ -551,10 +561,10 @@ contract CONTRACT_NAME is ProductPriceAction { function _onProductPurchase( uint256 slicerId, uint256 productId, + uint256 variantId, address buyer, uint256 quantity, - bytes memory slicerCustomData, - bytes memory buyerCustomData + bytes memory data ) internal override { // Your logic to be executed after product purchase. } @@ -671,6 +681,7 @@ contract CONTRACT_NAMETest is RegistryProductActionTest { CONTRACT_NAME CONTRACT_VAR; uint256 slicerId = 1; uint256 productId = 1; + uint256 variantId = 0; function setUp() public { CONTRACT_VAR = new CONTRACT_NAME(PRODUCTS_MODULE); @@ -684,6 +695,7 @@ contract CONTRACT_NAMETest is RegistryProductActionTest { CONTRACT_VAR.configureProduct( slicerId, productId, + variantId, abi.encode( // Your params here ) @@ -701,6 +713,7 @@ contract CONTRACT_NAMETest is RegistryProductActionTest { CONTRACT_VAR.configureProduct( slicerId, productId, + variantId, abi.encode( // Your params here ) @@ -708,7 +721,7 @@ contract CONTRACT_NAMETest is RegistryProductActionTest { vm.stopPrank(); - bool isAllowed = CONTRACT_VAR.isPurchaseAllowed(slicerId, productId, buyer, 1, "", ""); + bool isAllowed = CONTRACT_VAR.isPurchaseAllowed(slicerId, productId, variantId, buyer, 1, ""); // Verify isAllowed value based on conditions } @@ -720,6 +733,7 @@ contract CONTRACT_NAMETest is RegistryProductActionTest { CONTRACT_VAR.configureProduct( slicerId, productId, + variantId, abi.encode( // Your params here ) @@ -728,7 +742,7 @@ contract CONTRACT_NAMETest is RegistryProductActionTest { vm.stopPrank(); vm.prank(address(PRODUCTS_MODULE)); - CONTRACT_VAR.onProductPurchase(slicerId, productId, buyer, 1, "", ""); + CONTRACT_VAR.onProductPurchase(slicerId, productId, variantId, buyer, 1, ""); // Verify after purchase logic } @@ -746,6 +760,7 @@ contract CONTRACT_NAMETest is RegistryProductPriceTest { CONTRACT_NAME CONTRACT_VAR; uint256 slicerId = 1; uint256 productId = 1; + uint256 variantId = 0; function setUp() public { CONTRACT_VAR = new CONTRACT_NAME(PRODUCTS_MODULE); @@ -759,6 +774,7 @@ contract CONTRACT_NAMETest is RegistryProductPriceTest { CONTRACT_VAR.configureProduct( slicerId, productId, + variantId, abi.encode( // Your params here ) @@ -776,6 +792,7 @@ contract CONTRACT_NAMETest is RegistryProductPriceTest { CONTRACT_VAR.configureProduct( slicerId, productId, + variantId, abi.encode( // Your params here ) @@ -783,7 +800,7 @@ contract CONTRACT_NAMETest is RegistryProductPriceTest { vm.stopPrank(); - (uint256 ethPrice, uint256 currencyPrice) = CONTRACT_VAR.productPrice(slicerId, productId, ETH, 1, buyer, ""); + (uint256 price) = CONTRACT_VAR.productPrice(slicerId, productId, variantId, ETH, 0, 1, buyer, ""); // Verify product price } @@ -801,6 +818,7 @@ contract CONTRACT_NAMETest is RegistryProductPriceActionTest { CONTRACT_NAME CONTRACT_VAR; uint256 slicerId = 1; uint256 productId = 1; + uint256 variantId = 0; function setUp() public { CONTRACT_VAR = new CONTRACT_NAME(PRODUCTS_MODULE); @@ -814,6 +832,7 @@ contract CONTRACT_NAMETest is RegistryProductPriceActionTest { CONTRACT_VAR.configureProduct( slicerId, productId, + variantId, abi.encode( // Your params here ) @@ -831,6 +850,7 @@ contract CONTRACT_NAMETest is RegistryProductPriceActionTest { CONTRACT_VAR.configureProduct( slicerId, productId, + variantId, abi.encode( // Your params here ) @@ -838,7 +858,7 @@ contract CONTRACT_NAMETest is RegistryProductPriceActionTest { vm.stopPrank(); - (uint256 ethPrice, uint256 currencyPrice) = CONTRACT_VAR.productPrice(slicerId, productId, ETH, 1, buyer, ""); + (uint256 price) = CONTRACT_VAR.productPrice(slicerId, productId, variantId, ETH, 0, 1, buyer, ""); // Verify product price } @@ -850,6 +870,7 @@ contract CONTRACT_NAMETest is RegistryProductPriceActionTest { CONTRACT_VAR.configureProduct( slicerId, productId, + variantId, abi.encode( // Your params here ) @@ -857,7 +878,7 @@ contract CONTRACT_NAMETest is RegistryProductPriceActionTest { vm.stopPrank(); - bool isAllowed = CONTRACT_VAR.isPurchaseAllowed(slicerId, productId, buyer, 1, "", ""); + bool isAllowed = CONTRACT_VAR.isPurchaseAllowed(slicerId, productId, variantId, buyer, 1, ""); // Verify isAllowed value based on conditions } @@ -869,6 +890,7 @@ contract CONTRACT_NAMETest is RegistryProductPriceActionTest { CONTRACT_VAR.configureProduct( slicerId, productId, + variantId, abi.encode( // Your params here ) @@ -877,7 +899,7 @@ contract CONTRACT_NAMETest is RegistryProductPriceActionTest { vm.stopPrank(); vm.prank(address(PRODUCTS_MODULE)); - CONTRACT_VAR.onProductPurchase(slicerId, productId, buyer, 1, "", ""); + CONTRACT_VAR.onProductPurchase(slicerId, productId, variantId, buyer, 1, ""); // Verify after purchase logic } diff --git a/soldeer.lock b/soldeer.lock index 5684e03..fb27390 100644 --- a/soldeer.lock +++ b/soldeer.lock @@ -32,3 +32,10 @@ version = "0.0.9" url = "https://soldeer-revisions.s3.amazonaws.com/slice/0_0_9_28-08-2025_10:52:54_src.zip" checksum = "7fb47bafed059724f2b4a56719eec2e131c231d9f9a8cbcde5d88958b649cd5d" integrity = "7ef2c94c7be0d971da96d01d514903e007f957721da4bc0bd9fba004cebafd20" + +[[dependencies]] +name = "solady" +version = "0.1.26" +url = "https://soldeer-revisions.s3.amazonaws.com/solady/0_1_26_25-08-2025_15:30:06_solady.zip" +checksum = "9872ac7cfd32c1eba32800508a1325c49f4a4aa8c6f670454db91971a583e26b" +integrity = "5da4b5ca9cbad98812a4b75ad528ff34c72a0b84433204be6d1420c81de1d6ff" diff --git a/src/custom/actions/BaseCafe/BaseCafe.sol b/src/custom/actions/BaseCafe/BaseCafe.sol index eae2663..3e2e05c 100644 --- a/src/custom/actions/BaseCafe/BaseCafe.sol +++ b/src/custom/actions/BaseCafe/BaseCafe.sol @@ -32,7 +32,7 @@ contract BaseCafe is ProductAction { * @inheritdoc ProductAction * @notice Mint `quantity` NFTs to `account` on purchase */ - function _onProductPurchase(uint256, uint256, address buyer, uint256 quantity, bytes memory, bytes memory) + function _onProductPurchase(uint256, uint256, uint256, address buyer, uint256 quantity, bytes memory) internal override { diff --git a/src/custom/actions/BaseGirlsScout/BaseGirlsScout.sol b/src/custom/actions/BaseGirlsScout/BaseGirlsScout.sol deleted file mode 100644 index 7588422..0000000 --- a/src/custom/actions/BaseGirlsScout/BaseGirlsScout.sol +++ /dev/null @@ -1,76 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.30; - -import {IProductsModule, ProductAction} from "@/utils/ProductAction.sol"; -import {Ownable} from "@openzeppelin-4.8.0/access/Ownable.sol"; -import {IERC1155} from "@openzeppelin-4.8.0/interfaces/IERC1155.sol"; - -/** - * @title BaseGirlsScout - * @notice Onchain action that mints Base Girls Scout NFTs to the buyer on every purchase. - * @author Slice - */ -contract BaseGirlsScout is ProductAction, Ownable { - /*////////////////////////////////////////////////////////////// - IMMUTABLE STORAGE - //////////////////////////////////////////////////////////////*/ - - ITokenERC1155 public MINT_NFT_COLLECTION = ITokenERC1155(0x7A110890DF5D95CefdB0151143E595b755B7c9b7); - uint256 public MINT_NFT_TOKEN_ID = 1; - - /*////////////////////////////////////////////////////////////// - STORAGE - //////////////////////////////////////////////////////////////*/ - - mapping(uint256 slicerId => bool allowed) public allowedSlicerIds; - - /*////////////////////////////////////////////////////////////// - CONSTRUCTOR - //////////////////////////////////////////////////////////////*/ - - constructor(IProductsModule productsModuleAddress, uint256 slicerId) - ProductAction(productsModuleAddress, slicerId) - Ownable() - { - allowedSlicerIds[2217] = true; - allowedSlicerIds[2218] = true; - } - - /*////////////////////////////////////////////////////////////// - FUNCTIONS - //////////////////////////////////////////////////////////////*/ - - /** - * @inheritdoc ProductAction - * @notice Mint `quantity` NFTs to `account` on purchase - */ - function _onProductPurchase(uint256, uint256, address buyer, uint256 quantity, bytes memory, bytes memory) - internal - override - { - MINT_NFT_COLLECTION.mintTo(buyer, MINT_NFT_TOKEN_ID, "", quantity); - } - - /*////////////////////////////////////////////////////////////// - FUNCTIONS - //////////////////////////////////////////////////////////////*/ - - /** - * @notice Called by contract owner to set allowed slicer Ids. - */ - function setAllowedSlicerId(uint256 slicerId, bool allowed) external onlyOwner { - allowedSlicerIds[slicerId] = allowed; - } - - /** - * @notice Called by contract owner to set the mint token collection and token ID. - */ - function setMintTokenId(address collection, uint256 tokenId) external onlyOwner { - MINT_NFT_COLLECTION = ITokenERC1155(collection); - MINT_NFT_TOKEN_ID = tokenId; - } -} - -interface ITokenERC1155 { - function mintTo(address to, uint256 tokenId, string calldata uri, uint256 amount) external; -} diff --git a/src/hooks/actions/Allowlisted/Allowlisted.sol b/src/hooks/actions/Allowlisted/Allowlisted.sol index ab4e539..ce76f0d 100644 --- a/src/hooks/actions/Allowlisted/Allowlisted.sol +++ b/src/hooks/actions/Allowlisted/Allowlisted.sol @@ -3,11 +3,11 @@ pragma solidity ^0.8.30; import {MerkleProof} from "@openzeppelin-4.8.0/utils/cryptography/MerkleProof.sol"; import { - IProductsModule, RegistryProductAction, - HookRegistry, + ProductHookRegistry, + IProductsModule, IProductAction, - IHookRegistry + IProductHookRegistry } from "@/utils/RegistryProductAction.sol"; /** @@ -20,7 +20,8 @@ contract Allowlisted is RegistryProductAction { MUTABLE STORAGE //////////////////////////////////////////////////////////////*/ - mapping(uint256 slicerId => mapping(uint256 productId => bytes32 merkleRoot)) public merkleRoots; + mapping(uint256 slicerId => mapping(uint256 productId => mapping(uint256 variantId => bytes32 merkleRoot))) public + merkleRoots; /*////////////////////////////////////////////////////////////// CONSTRUCTOR @@ -39,13 +40,13 @@ contract Allowlisted is RegistryProductAction { function isPurchaseAllowed( uint256 slicerId, uint256 productId, + uint256 variantId, address buyer, uint256, - bytes memory, - bytes memory buyerCustomData + bytes memory data ) public view override returns (bool isAllowed) { - // Get Merkle proof from buyerCustomData - bytes32[] memory proof = abi.decode(buyerCustomData, (bytes32[])); + // Get Merkle proof from data + bytes32[] memory proof = abi.decode(data, (bytes32[])); uint256 leafValue = uint256(uint160(buyer)); @@ -57,25 +58,28 @@ contract Allowlisted is RegistryProductAction { leaf := keccak256(0x00, 0x20) } - bytes32 root = merkleRoots[slicerId][productId]; + bytes32 root = merkleRoots[slicerId][productId][variantId]; // Check if Merkle proof is valid isAllowed = MerkleProof.verify(proof, root, leaf); } /** - * @inheritdoc HookRegistry + * @inheritdoc ProductHookRegistry * @dev Sets the Merkle root for the allowlist. */ - function _configureProduct(uint256 slicerId, uint256 productId, bytes memory params) internal override { + function _configureProduct(uint256 slicerId, uint256 productId, uint256 variantId, bytes memory params) + internal + override + { (bytes32 merkleRoot) = abi.decode(params, (bytes32)); - merkleRoots[slicerId][productId] = merkleRoot; + merkleRoots[slicerId][productId][variantId] = merkleRoot; } /** - * @inheritdoc IHookRegistry + * @inheritdoc IProductHookRegistry */ - function paramsSchema() external pure override returns (string memory) { + function configureProductSchema() public pure override returns (string memory) { return "bytes32 merkleRoot"; } } diff --git a/src/hooks/actions/ERC20Gated/ERC20Gated.sol b/src/hooks/actions/ERC20Gated/ERC20Gated.sol index f4964dc..78223a6 100644 --- a/src/hooks/actions/ERC20Gated/ERC20Gated.sol +++ b/src/hooks/actions/ERC20Gated/ERC20Gated.sol @@ -3,30 +3,36 @@ pragma solidity ^0.8.30; import {ERC20Gate} from "./types/ERC20Gate.sol"; import { - IProductsModule, - RegistryProductAction, HookRegistry, + IProductsModule, + RegistryAction, IProductAction, - IHookRegistry -} from "@/utils/RegistryProductAction.sol"; + IProductTypeAction, + ISlicerAction, + ProductLineItem +} from "@/utils/RegistryAction.sol"; +import {ProductHookRegistry, IProductHookRegistry} from "@/utils/RegistryProductAction.sol"; /** * @title ERC20Gated * @notice Onchain action registry for ERC20 gating. * @author Slice */ -contract ERC20Gated is RegistryProductAction { +contract ERC20Gated is RegistryAction { /*////////////////////////////////////////////////////////////// MUTABLE STORAGE //////////////////////////////////////////////////////////////*/ - mapping(uint256 slicerId => mapping(uint256 productId => ERC20Gate[] gates)) public tokenGates; + mapping(uint256 slicerId => mapping(uint256 productId => mapping(uint256 variantId => ERC20Gate[] gates))) public + tokenGates; + mapping(uint256 slicerId => mapping(uint256 productTypeId => ERC20Gate[] gates)) public productTypeTokenGates; + mapping(uint256 slicerId => ERC20Gate[] gates) public slicerTokenGates; /*////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////*/ - constructor(IProductsModule productsModuleAddress) RegistryProductAction(productsModuleAddress) {} + constructor(IProductsModule productsModuleAddress) RegistryAction(productsModuleAddress) {} /*////////////////////////////////////////////////////////////// CONFIGURATION @@ -36,43 +42,110 @@ contract ERC20Gated is RegistryProductAction { * @inheritdoc IProductAction * @dev Checks if `account` owns the required amount of all ERC20 tokens. */ - function isPurchaseAllowed(uint256 slicerId, uint256 productId, address buyer, uint256, bytes memory, bytes memory) + function isPurchaseAllowed( + uint256 slicerId, + uint256 productId, + uint256 variantId, + address buyer, + uint256, + bytes memory + ) public view override returns (bool) { + ERC20Gate[] memory gates = tokenGates[slicerId][productId][variantId]; + + return handleIsPurchaseAllowed(gates, buyer); + } + + /** + * @inheritdoc IProductTypeAction + * @dev Checks if `account` owns the required amount of all ERC20 tokens. + */ + function isProductTypePurchaseAllowed(uint256 slicerId, uint256 productTypeId, address buyer, uint256) public view override returns (bool) { - ERC20Gate[] memory gates = tokenGates[slicerId][productId]; + ERC20Gate[] memory gates = productTypeTokenGates[slicerId][productTypeId]; - for (uint256 i = 0; i < gates.length; i++) { - ERC20Gate memory gate = gates[i]; - uint256 accountBalance = gate.erc20.balanceOf(buyer); - if (accountBalance < gate.amount) { - return false; - } - } + return handleIsPurchaseAllowed(gates, buyer); + } - return true; + /** + * @inheritdoc ISlicerAction + * @dev Checks if `account` owns the required amount of all ERC20 tokens. + */ + function isSlicerPurchaseAllowed(uint256 slicerId, address buyer, ProductLineItem[] memory) + public + view + override + returns (bool) + { + ERC20Gate[] memory gates = slicerTokenGates[slicerId]; + + return handleIsPurchaseAllowed(gates, buyer); } /** - * @inheritdoc HookRegistry + * @inheritdoc ProductHookRegistry * @dev Sets the ERC20 gates for a product. */ - function _configureProduct(uint256 slicerId, uint256 productId, bytes memory params) internal override { - (ERC20Gate[] memory gates) = abi.decode(params, (ERC20Gate[])); + function _configureProduct(uint256 slicerId, uint256 productId, uint256 variantId, bytes memory params) + internal + override + { + delete tokenGates[slicerId][productId][variantId]; + + handleConfigure(tokenGates[slicerId][productId][variantId], params); + } - delete tokenGates[slicerId][productId]; + /** + * @inheritdoc HookRegistry + * @dev Sets the ERC20 gates for a product type. + */ + function _configureProductType(uint256 slicerId, uint256 productTypeId, bytes memory params) internal override { + delete productTypeTokenGates[slicerId][productTypeId]; - for (uint256 i = 0; i < gates.length; i++) { - tokenGates[slicerId][productId].push(gates[i]); - } + handleConfigure(productTypeTokenGates[slicerId][productTypeId], params); } /** - * @inheritdoc IHookRegistry + * @inheritdoc HookRegistry + * @dev Sets the ERC20 gates for a slicer. */ - function paramsSchema() external pure override returns (string memory) { + function _configureSlicer(uint256 slicerId, bytes memory params) internal override { + delete slicerTokenGates[slicerId]; + + handleConfigure(slicerTokenGates[slicerId], params); + } + + /** + * @inheritdoc IProductHookRegistry + */ + function configureProductSchema() public pure override returns (string memory) { return "(address erc20,uint256 amount)[] erc20Gates"; } + + /*////////////////////////////////////////////////////////////// + INTERNAL + //////////////////////////////////////////////////////////////*/ + function handleConfigure(ERC20Gate[] storage gatesStorage, bytes memory params) internal { + (ERC20Gate[] memory gates) = abi.decode(params, (ERC20Gate[])); + + for (uint256 i = 0; i < gates.length; i++) { + gatesStorage.push(gates[i]); + } + } + + function handleIsPurchaseAllowed(ERC20Gate[] memory gates, address buyer) internal view returns (bool) { + ERC20Gate memory gate; + for (uint256 i = 0; i < gates.length; i++) { + gate = gates[i]; + uint256 accountBalance = gate.erc20.balanceOf(buyer); + if (accountBalance < gate.amount) { + return false; + } + } + + return true; + } } diff --git a/src/hooks/actions/ERC20Mint/ERC20Mint.sol b/src/hooks/actions/ERC20Mint/ERC20Mint.sol index a6ab8d3..c690e56 100644 --- a/src/hooks/actions/ERC20Mint/ERC20Mint.sol +++ b/src/hooks/actions/ERC20Mint/ERC20Mint.sol @@ -2,12 +2,15 @@ pragma solidity ^0.8.30; import { - IProductsModule, - RegistryProductAction, HookRegistry, + IProductsModule, + RegistryAction, IProductAction, - IHookRegistry -} from "@/utils/RegistryProductAction.sol"; + IProductTypeAction, + ISlicerAction, + ProductLineItem +} from "@/utils/RegistryAction.sol"; +import {ProductHookRegistry, IProductHookRegistry} from "@/utils/RegistryProductAction.sol"; import {ERC20Data} from "./types/ERC20Data.sol"; import {ERC20Mint_BaseToken} from "./utils/ERC20Mint_BaseToken.sol"; @@ -17,7 +20,7 @@ import {ERC20Mint_BaseToken} from "./utils/ERC20Mint_BaseToken.sol"; * @dev If `revertOnMaxSupplyReached` is set to true, reverts when max supply is exceeded. * @author Slice */ -contract ERC20Mint is RegistryProductAction { +contract ERC20Mint is RegistryAction { /*////////////////////////////////////////////////////////////// ERRORS //////////////////////////////////////////////////////////////*/ @@ -28,13 +31,16 @@ contract ERC20Mint is RegistryProductAction { MUTABLE STORAGE //////////////////////////////////////////////////////////////*/ - mapping(uint256 slicerId => mapping(uint256 productId => ERC20Data tokenData)) public tokenData; + mapping(uint256 slicerId => mapping(uint256 productId => mapping(uint256 variantId => ERC20Data tokenData))) public + tokenData; + mapping(uint256 slicerId => mapping(uint256 productTypeId => ERC20Data tokenData)) public productTypeTokenData; + mapping(uint256 slicerId => ERC20Data tokenData) public slicerTokenData; /*////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////*/ - constructor(IProductsModule productsModuleAddress) RegistryProductAction(productsModuleAddress) {} + constructor(IProductsModule productsModuleAddress) RegistryAction(productsModuleAddress) {} /*////////////////////////////////////////////////////////////// CONFIGURATION @@ -47,51 +53,149 @@ contract ERC20Mint is RegistryProductAction { function isPurchaseAllowed( uint256 slicerId, uint256 productId, + uint256 variantId, address, uint256 quantity, - bytes memory, bytes memory ) public view virtual override returns (bool isAllowed) { - ERC20Data memory tokenData_ = tokenData[slicerId][productId]; + ERC20Data memory tokenData_ = tokenData[slicerId][productId][variantId]; - if (tokenData_.revertOnMaxSupplyReached) { - return - tokenData_.token.totalSupply() + (quantity * tokenData_.tokensPerUnit) <= tokenData_.token.maxSupply(); + return handleIsPurchaseAllowed(tokenData_, quantity, false); + } + + /** + * @inheritdoc IProductTypeAction + * @dev Checks if `account` owns the required amount of all ERC20 tokens. + */ + function isProductTypePurchaseAllowed(uint256 slicerId, uint256 productTypeId, address, uint256 quantity) + public + view + override + returns (bool) + { + ERC20Data memory tokenData_ = productTypeTokenData[slicerId][productTypeId]; + + return handleIsPurchaseAllowed(tokenData_, quantity, false); + } + + /** + * @inheritdoc ISlicerAction + * @dev Checks if `account` owns the required amount of all ERC20 tokens. + */ + function isSlicerPurchaseAllowed(uint256 slicerId, address, ProductLineItem[] memory productLineItems) + public + view + override + returns (bool) + { + ERC20Data memory tokenData_ = slicerTokenData[slicerId]; + + uint256 quantity; + for (uint256 i = 0; i < productLineItems.length;) { + quantity += productLineItems[i].quantity; + + unchecked { + ++i; + } } - return true; + return handleIsPurchaseAllowed(tokenData_, quantity, true); } /** - * @inheritdoc RegistryProductAction + * @inheritdoc RegistryAction * @notice Mint tokens to the buyer. * @dev If `revertOnMaxSupplyReached` is set to true, reverts when max supply is exceeded. */ function _onProductPurchase( uint256 slicerId, uint256 productId, + uint256 variantId, address buyer, uint256 quantity, - bytes memory, bytes memory ) internal override { - ERC20Data memory tokenData_ = tokenData[slicerId][productId]; + ERC20Data memory tokenData_ = tokenData[slicerId][productId][variantId]; - uint256 tokensToMint = quantity * tokenData_.tokensPerUnit; + handleOnProductPurchase(tokenData_, buyer, quantity); + } - (bool success,) = - address(tokenData_.token).call(abi.encodeWithSelector(tokenData_.token.mint.selector, buyer, tokensToMint)); + /** + * @inheritdoc RegistryAction + * @notice Mint tokens to the buyer. + * @dev If `revertOnMaxSupplyReached` is set to true, reverts when max supply is exceeded. + */ + function _onProductTypePurchase(uint256 slicerId, uint256 productTypeId, address buyer, uint256 quantity) + internal + override + { + ERC20Data memory tokenData_ = productTypeTokenData[slicerId][productTypeId]; + handleOnProductPurchase(tokenData_, buyer, quantity); + } - if (success) { - // Do nothing, just silence the warning + /** + * @inheritdoc RegistryAction + * @notice Mint tokens to the buyer. + * @dev If `revertOnMaxSupplyReached` is set to true, reverts when max supply is exceeded. + */ + function _onSlicerPurchase(uint256 slicerId, address buyer, ProductLineItem[] memory productLineItems) + internal + override + { + ERC20Data memory tokenData_ = slicerTokenData[slicerId]; + + uint256 quantity; + for (uint256 i = 0; i < productLineItems.length;) { + quantity += productLineItems[i].quantity; + + unchecked { + ++i; + } } + + handleOnProductPurchase(tokenData_, buyer, quantity); } /** - * @inheritdoc HookRegistry + * @inheritdoc ProductHookRegistry * @dev Set the ERC20 data for a product. */ - function _configureProduct(uint256 slicerId, uint256 productId, bytes memory params) internal override { + function _configureProduct(uint256 slicerId, uint256 productId, uint256 variantId, bytes memory params) + internal + override + { + handleConfigure(tokenData[slicerId][productId][variantId], params); + } + + /** + * @inheritdoc HookRegistry + * @dev Set the ERC20 data for a product type. + */ + function _configureProductType(uint256 slicerId, uint256 productTypeId, bytes memory params) internal override { + handleConfigure(productTypeTokenData[slicerId][productTypeId], params); + } + + /** + * @inheritdoc HookRegistry + * @dev Set the ERC20 data for a slicer. + */ + function _configureSlicer(uint256 slicerId, bytes memory params) internal override { + handleConfigure(slicerTokenData[slicerId], params); + } + + /** + * @inheritdoc IProductHookRegistry + */ + function configureProductSchema() public pure override returns (string memory) { + return + "string name,string symbol,uint256 premintAmount,address premintReceiver,bool revertOnMaxSupplyReached,uint256 maxSupply,uint256 tokensPerUnit"; + } + + /*////////////////////////////////////////////////////////////// + INTERNAL + //////////////////////////////////////////////////////////////*/ + + function handleConfigure(ERC20Data storage tokenDataStorage, bytes memory params) internal { ( string memory name, string memory symbol, @@ -104,7 +208,7 @@ contract ERC20Mint is RegistryProductAction { if (tokensPerUnit == 0) revert InvalidTokensPerUnit(); - ERC20Mint_BaseToken token = tokenData[slicerId][productId].token; + ERC20Mint_BaseToken token = tokenDataStorage.token; if (address(token) == address(0)) { token = new ERC20Mint_BaseToken(name, symbol, maxSupply); @@ -115,14 +219,32 @@ contract ERC20Mint is RegistryProductAction { token.setMaxSupply(maxSupply); } - tokenData[slicerId][productId] = ERC20Data(token, revertOnMaxSupplyReached, tokensPerUnit); + tokenDataStorage.token = token; + tokenDataStorage.revertOnMaxSupplyReached = revertOnMaxSupplyReached; + tokenDataStorage.tokensPerUnit = tokensPerUnit; } - /** - * @inheritdoc IHookRegistry - */ - function paramsSchema() external pure override returns (string memory) { - return - "string name,string symbol,uint256 premintAmount,address premintReceiver,bool revertOnMaxSupplyReached,uint256 maxSupply,uint256 tokensPerUnit"; + function handleIsPurchaseAllowed(ERC20Data memory tokenData_, uint256 quantity, bool isSlicerAction) + internal + view + returns (bool) + { + if (!isSlicerAction && tokenData_.revertOnMaxSupplyReached) { + return + tokenData_.token.totalSupply() + (quantity * tokenData_.tokensPerUnit) <= tokenData_.token.maxSupply(); + } + + return true; + } + + function handleOnProductPurchase(ERC20Data memory tokenData_, address buyer, uint256 quantity) internal { + uint256 tokensToMint = quantity * tokenData_.tokensPerUnit; + + (bool success,) = + address(tokenData_.token).call(abi.encodeWithSelector(tokenData_.token.mint.selector, buyer, tokensToMint)); + + if (success) { + // Do nothing, just silence the warning + } } } diff --git a/src/hooks/actions/ERC721Mint/ERC721Mint.sol b/src/hooks/actions/ERC721Mint/ERC721Mint.sol index 8d3a18f..de4d04f 100644 --- a/src/hooks/actions/ERC721Mint/ERC721Mint.sol +++ b/src/hooks/actions/ERC721Mint/ERC721Mint.sol @@ -1,7 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.30; -import {IProductsModule, RegistryProductAction, HookRegistry, IHookRegistry} from "@/utils/RegistryProductAction.sol"; +import {HookRegistry, IProductsModule, RegistryAction, ProductLineItem} from "@/utils/RegistryAction.sol"; +import {ProductHookRegistry, IProductHookRegistry} from "@/utils/RegistryProductAction.sol"; import {MAX_ROYALTY, ERC721Mint_BaseToken} from "./utils/ERC721Mint_BaseToken.sol"; import {ERC721Data} from "./types/ERC721Data.sol"; @@ -11,7 +12,7 @@ import {ERC721Data} from "./types/ERC721Data.sol"; * @dev If `revertOnMaxSupplyReached` is set to true, reverts when max supply is exceeded. * @author Slice */ -contract ERC721Mint is RegistryProductAction { +contract ERC721Mint is RegistryAction { /*////////////////////////////////////////////////////////////// ERRORS //////////////////////////////////////////////////////////////*/ @@ -23,46 +24,114 @@ contract ERC721Mint is RegistryProductAction { MUTABLE STORAGE //////////////////////////////////////////////////////////////*/ - mapping(uint256 slicerId => mapping(uint256 productId => ERC721Data tokenData)) public tokenData; + mapping(uint256 slicerId => mapping(uint256 productId => mapping(uint256 variantId => ERC721Data tokenData))) public + tokenData; + mapping(uint256 slicerId => mapping(uint256 productTypeId => ERC721Data tokenData)) public productTypeTokenData; + mapping(uint256 slicerId => ERC721Data tokenData) public slicerTokenData; /*////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////*/ - constructor(IProductsModule productsModuleAddress) RegistryProductAction(productsModuleAddress) {} + constructor(IProductsModule productsModuleAddress) RegistryAction(productsModuleAddress) {} /*////////////////////////////////////////////////////////////// CONFIGURATION //////////////////////////////////////////////////////////////*/ /** - * @inheritdoc RegistryProductAction + * @inheritdoc RegistryAction * @notice Mint tokens to the buyer. * @dev If `revertOnMaxSupplyReached` is set to true, reverts when max supply is exceeded. */ function _onProductPurchase( uint256 slicerId, uint256 productId, + uint256 variantId, address buyer, uint256 quantity, - bytes memory, bytes memory ) internal override { - ERC721Data memory tokenData_ = tokenData[slicerId][productId]; + ERC721Data memory tokenData_ = tokenData[slicerId][productId][variantId]; - (bool success,) = - address(tokenData_.token).call(abi.encodeWithSelector(tokenData_.token.mint.selector, buyer, quantity)); + handleOnProductPurchase(tokenData_, buyer, quantity, false); + } - if (tokenData_.revertOnMaxSupplyReached) { - if (!success) revert MaxSupplyExceeded(); + /** + * @inheritdoc RegistryAction + * @notice Mint tokens to the buyer. + * @dev If `revertOnMaxSupplyReached` is set to true, reverts when max supply is exceeded. + */ + function _onProductTypePurchase(uint256 slicerId, uint256 productTypeId, address buyer, uint256 quantity) + internal + override + { + ERC721Data memory tokenData_ = productTypeTokenData[slicerId][productTypeId]; + handleOnProductPurchase(tokenData_, buyer, quantity, false); + } + + /** + * @inheritdoc RegistryAction + * @notice Mint tokens to the buyer. + * @dev If `revertOnMaxSupplyReached` is set to true, reverts when max supply is exceeded. + */ + function _onSlicerPurchase(uint256 slicerId, address buyer, ProductLineItem[] memory productLineItems) + internal + override + { + ERC721Data memory tokenData_ = slicerTokenData[slicerId]; + + uint256 quantity; + for (uint256 i = 0; i < productLineItems.length;) { + quantity += productLineItems[i].quantity; + + unchecked { + ++i; + } } + + handleOnProductPurchase(tokenData_, buyer, quantity, true); } /** - * @inheritdoc HookRegistry + * @inheritdoc ProductHookRegistry * @dev Set the ERC721 data for a product. */ - function _configureProduct(uint256 slicerId, uint256 productId, bytes memory params) internal override { + function _configureProduct(uint256 slicerId, uint256 productId, uint256 variantId, bytes memory params) + internal + override + { + handleConfigure(tokenData[slicerId][productId][variantId], params); + } + + /** + * @inheritdoc HookRegistry + * @dev Set the ERC721 data for a product type. + */ + function _configureProductType(uint256 slicerId, uint256 productTypeId, bytes memory params) internal override { + handleConfigure(productTypeTokenData[slicerId][productTypeId], params); + } + + /** + * @inheritdoc HookRegistry + * @dev Set the ERC721 data for a slicer. + */ + function _configureSlicer(uint256 slicerId, bytes memory params) internal override { + handleConfigure(slicerTokenData[slicerId], params); + } + + /** + * @inheritdoc IProductHookRegistry + */ + function configureProductSchema() public pure override returns (string memory) { + return + "string name,string symbol,address royaltyReceiver,uint256 royaltyFraction,string baseURI,string tokenURI,bool revertOnMaxSupplyReached,uint256 maxSupply"; + } + + /*////////////////////////////////////////////////////////////// + INTERNAL + //////////////////////////////////////////////////////////////*/ + function handleConfigure(ERC721Data storage tokenDataStorage, bytes memory params) internal { ( string memory name_, string memory symbol_, @@ -76,7 +145,7 @@ contract ERC721Mint is RegistryProductAction { if (royaltyFraction_ > MAX_ROYALTY) revert InvalidRoyaltyFraction(); - ERC721Mint_BaseToken token = tokenData[slicerId][productId].token; + ERC721Mint_BaseToken token = tokenDataStorage.token; if (address(token) == address(0)) { token = new ERC721Mint_BaseToken( @@ -86,14 +155,18 @@ contract ERC721Mint is RegistryProductAction { token.setParams(maxSupply, royaltyReceiver_, royaltyFraction_, baseURI__, tokenURI__); } - tokenData[slicerId][productId] = ERC721Data(token, revertOnMaxSupplyReached); + tokenDataStorage.token = token; + tokenDataStorage.revertOnMaxSupplyReached = revertOnMaxSupplyReached; } - /** - * @inheritdoc IHookRegistry - */ - function paramsSchema() external pure override returns (string memory) { - return - "string name,string symbol,address royaltyReceiver,uint256 royaltyFraction,string baseURI,string tokenURI,bool revertOnMaxSupplyReached,uint256 maxSupply"; + function handleOnProductPurchase(ERC721Data memory tokenData_, address buyer, uint256 quantity, bool isSlicerAction) + internal + { + (bool success,) = + address(tokenData_.token).call(abi.encodeWithSelector(tokenData_.token.mint.selector, buyer, quantity)); + + if (!isSlicerAction && tokenData_.revertOnMaxSupplyReached) { + if (!success) revert MaxSupplyExceeded(); + } } } diff --git a/src/hooks/actions/NFTGated/NFTGated.sol b/src/hooks/actions/NFTGated/NFTGated.sol index b792296..65a82fb 100644 --- a/src/hooks/actions/NFTGated/NFTGated.sol +++ b/src/hooks/actions/NFTGated/NFTGated.sol @@ -4,12 +4,15 @@ pragma solidity ^0.8.30; import {IERC721} from "@openzeppelin-4.8.0/interfaces/IERC721.sol"; import {IERC1155} from "@openzeppelin-4.8.0/interfaces/IERC1155.sol"; import { - IProductsModule, - RegistryProductAction, HookRegistry, + IProductsModule, + RegistryAction, IProductAction, - IHookRegistry -} from "@/utils/RegistryProductAction.sol"; + IProductTypeAction, + ISlicerAction, + ProductLineItem +} from "@/utils/RegistryAction.sol"; +import {ProductHookRegistry, IProductHookRegistry} from "@/utils/RegistryProductAction.sol"; import {NftType, NFTGate, NFTGates} from "./types/NFTGate.sol"; /** @@ -17,18 +20,23 @@ import {NftType, NFTGate, NFTGates} from "./types/NFTGate.sol"; * @notice Onchain action registry for NFT gating. * @author Slice */ -contract NFTGated is RegistryProductAction { +contract NFTGated is RegistryAction { + error InvalidNFT(); + /*////////////////////////////////////////////////////////////// MUTABLE STORAGE //////////////////////////////////////////////////////////////*/ - mapping(uint256 slicerId => mapping(uint256 productId => NFTGates gates)) public nftGates; + mapping(uint256 slicerId => mapping(uint256 productId => mapping(uint256 variantId => NFTGates gates))) public + nftGates; + mapping(uint256 slicerId => mapping(uint256 productTypeId => NFTGates gates)) public productTypeNftGates; + mapping(uint256 slicerId => NFTGates gates) public slicerNftGates; /*////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////*/ - constructor(IProductsModule productsModuleAddress) RegistryProductAction(productsModuleAddress) {} + constructor(IProductsModule productsModuleAddress) RegistryAction(productsModuleAddress) {} /*////////////////////////////////////////////////////////////// CONFIGURATION @@ -38,13 +46,105 @@ contract NFTGated is RegistryProductAction { * @inheritdoc IProductAction * @dev Checks if `account` owns the required amount of NFT tokens. */ - function isPurchaseAllowed(uint256 slicerId, uint256 productId, address buyer, uint256, bytes memory, bytes memory) + function isPurchaseAllowed( + uint256 slicerId, + uint256 productId, + uint256 variantId, + address buyer, + uint256, + bytes memory + ) public view override returns (bool isAllowed) { + isAllowed = handleIsPurchaseAllowed(nftGates[slicerId][productId][variantId], buyer); + } + + /** + * @inheritdoc IProductTypeAction + * @dev Checks if `account` owns the required amount of NFT tokens. + */ + function isProductTypePurchaseAllowed(uint256 slicerId, uint256 productTypeId, address buyer, uint256) + public + view + override + returns (bool isAllowed) + { + isAllowed = handleIsPurchaseAllowed(productTypeNftGates[slicerId][productTypeId], buyer); + } + + /** + * @inheritdoc ISlicerAction + * @dev Checks if `account` owns the required amount of NFT tokens. + */ + function isSlicerPurchaseAllowed(uint256 slicerId, address buyer, ProductLineItem[] memory) public view override returns (bool isAllowed) { - NFTGates memory nftGates_ = nftGates[slicerId][productId]; + isAllowed = handleIsPurchaseAllowed(slicerNftGates[slicerId], buyer); + } + + /** + * @inheritdoc ProductHookRegistry + * @dev Set the NFT gates for a product. + */ + function _configureProduct(uint256 slicerId, uint256 productId, uint256 variantId, bytes memory params) + internal + override + { + delete nftGates[slicerId][productId][variantId].gates; + handleConfigure(nftGates[slicerId][productId][variantId], params); + } + + /** + * @inheritdoc HookRegistry + * @dev Set the NFT gates for a product type. + */ + function _configureProductType(uint256 slicerId, uint256 productTypeId, bytes memory params) internal override { + delete productTypeNftGates[slicerId][productTypeId].gates; + handleConfigure(productTypeNftGates[slicerId][productTypeId], params); + } + + /** + * @inheritdoc HookRegistry + * @dev Set the NFT gates for a slicer. + */ + function _configureSlicer(uint256 slicerId, bytes memory params) internal override { + delete slicerNftGates[slicerId].gates; + handleConfigure(slicerNftGates[slicerId], params); + } + + /** + * @inheritdoc IProductHookRegistry + */ + function configureProductSchema() public pure override returns (string memory) { + return "(address nft,uint8 nftType,uint80 id,uint8 minQuantity)[] gates,uint256 minOwned"; + } + + /*////////////////////////////////////////////////////////////// + INTERNAL + //////////////////////////////////////////////////////////////*/ + + function handleConfigure(NFTGates storage gatesStorage, bytes memory params) internal { + (NFTGate[] memory gates, uint256 minOwned) = abi.decode(params, (NFTGate[], uint256)); + + gatesStorage.minOwned = minOwned; + for (uint256 i = 0; i < gates.length; i++) { + if (gates[i].nftType == NftType.ERC721) { + if (!IERC721(gates[i].nft).supportsInterface(type(IERC721).interfaceId)) { + revert InvalidNFT(); + } + } else if (!IERC1155(gates[i].nft).supportsInterface(type(IERC1155).interfaceId)) { + revert InvalidNFT(); + } + gatesStorage.gates.push(gates[i]); + } + } + + function handleIsPurchaseAllowed(NFTGates memory nftGates_, address buyer) internal view returns (bool) { + // If no gates, allow purchase + if (nftGates_.gates.length == 0) { + return true; + } uint256 totalOwned; unchecked { @@ -64,27 +164,7 @@ contract NFTGated is RegistryProductAction { ++i; } } - } - /** - * @inheritdoc HookRegistry - * @dev Set the NFT gates for a product. - */ - function _configureProduct(uint256 slicerId, uint256 productId, bytes memory params) internal override { - (NFTGates memory nftGates_) = abi.decode(params, (NFTGates)); - - delete nftGates[slicerId][productId].gates; - - nftGates[slicerId][productId].minOwned = nftGates_.minOwned; - for (uint256 i = 0; i < nftGates_.gates.length; i++) { - nftGates[slicerId][productId].gates.push(nftGates_.gates[i]); - } - } - - /** - * @inheritdoc IHookRegistry - */ - function paramsSchema() external pure override returns (string memory) { - return "(address nft,uint8 nftType,uint80 id,uint8 minQuantity)[] nftGates,uint256 minOwned"; + return false; } } diff --git a/src/hooks/actions/README.md b/src/hooks/actions/README.md index 4770360..02f3417 100644 --- a/src/hooks/actions/README.md +++ b/src/hooks/actions/README.md @@ -32,7 +32,7 @@ The script will create your contract file with the proper template and add it to Actions inheriting from `RegistryProductAction` automatically support frontend integration through: - **Product configuration** via `configureProduct()` -- **Parameter validation** via `paramsSchema()` +- **Parameter validation** via `configureProductSchema()` ### Testing diff --git a/src/hooks/pricing/README.md b/src/hooks/pricing/README.md index 23825a9..acea57c 100644 --- a/src/hooks/pricing/README.md +++ b/src/hooks/pricing/README.md @@ -29,9 +29,15 @@ The script will create your contract file with the proper template and add it to ### Registry Integration -Strategies inheriting from `RegistryProductPrice` automatically support frontend integration through: -- **Product configuration** via `configureProduct()` -- **Parameter validation** via `paramsSchema()` +Strategies can inherit from two base contracts: + +**`RegistryProductPrice`** - Product-level configuration: +- Configure via `configureProduct(slicerId, productId, variantId, params)` +- Schema via `configureProductSchema()` + +**`RegistryPrice`** - Multi-level configuration (Product, ProductType, or Slicer): +- Configure at product, product type, or slicer level +- Same hooks can be reused across different configuration scopes ### Testing diff --git a/src/hooks/pricing/TieredDiscount/NFTDiscount/NFTDiscount.sol b/src/hooks/pricing/TieredDiscount/NFTDiscount/NFTDiscount.sol index 26a586f..62affe8 100644 --- a/src/hooks/pricing/TieredDiscount/NFTDiscount/NFTDiscount.sol +++ b/src/hooks/pricing/TieredDiscount/NFTDiscount/NFTDiscount.sol @@ -3,7 +3,8 @@ pragma solidity ^0.8.30; import {IERC721} from "@openzeppelin-4.8.0/token/ERC721/IERC721.sol"; import {IERC1155} from "@openzeppelin-4.8.0/token/ERC1155/IERC1155.sol"; -import {HookRegistry, IHookRegistry, IProductsModule} from "@/utils/RegistryProductPrice.sol"; +import {HookRegistry, IProductsModule} from "@/utils/RegistryPrice.sol"; +import {ProductHookRegistry, IProductHookRegistry} from "@/utils/RegistryProductPrice.sol"; import {DiscountParams, TieredDiscount} from "../TieredDiscount.sol"; import {NFTType} from "../types/DiscountParams.sol"; @@ -24,16 +25,51 @@ contract NFTDiscount is TieredDiscount { //////////////////////////////////////////////////////////////*/ /** - * @inheritdoc HookRegistry + * @inheritdoc ProductHookRegistry * @notice Set base price and NFT discounts for a product. * @dev Discounts must be sorted in descending order and expressed as a percentage of the base price as a 4 decimal fixed point number. */ - function _configureProduct(uint256 slicerId, uint256 productId, bytes memory params) internal override { - (DiscountParams[] memory newDiscounts) = abi.decode(params, (DiscountParams[])); + function _configureProduct(uint256 slicerId, uint256 productId, uint256 variantId, bytes memory params) + internal + override + { + delete discounts[slicerId][productId][variantId]; + handleConfigure(discounts[slicerId][productId][variantId], params); + } + + /** + * @inheritdoc HookRegistry + * @notice Set base price and NFT discounts for a product type. + * @dev Discounts must be sorted in descending order and expressed as a percentage of the base price as a 4 decimal fixed point number. + */ + function _configureProductType(uint256 slicerId, uint256 productTypeId, bytes memory params) internal override { + delete productTypeDiscounts[slicerId][productTypeId]; + handleConfigure(productTypeDiscounts[slicerId][productTypeId], params); + } + + /** + * @inheritdoc HookRegistry + * @notice Set base price and NFT discounts for a slicer. + * @dev Discounts must be sorted in descending order and expressed as a percentage of the base price as a 4 decimal fixed point number. + */ + function _configureSlicer(uint256 slicerId, bytes memory params) internal override { + delete slicerDiscounts[slicerId]; + handleConfigure(slicerDiscounts[slicerId], params); + } + + /** + * @inheritdoc IProductHookRegistry + */ + function configureProductSchema() public pure override returns (string memory) { + return "(address nft,uint80 discount,uint8 minQuantity,uint8 nftType,uint256 tokenId)[] discounts"; + } - DiscountParams[] storage productDiscount = discounts[slicerId][productId]; + /*////////////////////////////////////////////////////////////// + INTERNAL + //////////////////////////////////////////////////////////////*/ - delete discounts[slicerId][productId]; + function handleConfigure(DiscountParams[] storage storageDiscounts, bytes memory params) internal { + (DiscountParams[] memory newDiscounts) = abi.decode(params, (DiscountParams[])); uint256 prevDiscountValue; DiscountParams memory discountParam; @@ -57,7 +93,7 @@ contract NFTDiscount is TieredDiscount { } prevDiscountValue = discountParam.discount; - productDiscount.push(discountParam); + storageDiscounts.push(discountParam); unchecked { ++i; @@ -65,42 +101,22 @@ contract NFTDiscount is TieredDiscount { } } - /** - * @inheritdoc IHookRegistry - */ - function paramsSchema() external pure override returns (string memory) { - return "(address nft,uint80 discount,uint8 minQuantity,uint8 nftType,uint256 tokenId)[] discounts"; - } - /** * @inheritdoc TieredDiscount * @notice Base price is returned if user does not have a discount. */ - function _productPrice( - uint256, - uint256, - address currency, - uint256 quantity, - address buyer, - bytes memory, - uint256 basePrice, - DiscountParams[] memory discountParams - ) internal view virtual override returns (uint256 ethPrice, uint256 currencyPrice) { + function _productPrice(address buyer, uint256 basePriceAmount, DiscountParams[] memory discountParams) + internal + view + virtual + override + returns (uint256 price) + { uint256 discount = _getHighestDiscount(discountParams, buyer); - uint256 price = discount != 0 ? _getDiscountedPrice(basePrice, discount, quantity) : quantity * basePrice; - - if (currency == address(0)) { - ethPrice = price; - } else { - currencyPrice = price; - } + price = discount != 0 ? _getDiscountedPrice(basePriceAmount, discount) : basePriceAmount; } - /*////////////////////////////////////////////////////////////// - INTERNAL - //////////////////////////////////////////////////////////////*/ - /** * @notice Gets the highest discount available for a user, based on owned NFTs. * @@ -155,13 +171,12 @@ contract NFTDiscount is TieredDiscount { /** * @notice Calculate price based on `discountType` * - * @param basePrice Base price of the product + * @param basePriceAmount Base price of the product * @param discount Discount value based on `discountType` - * @param quantity Number of units purchased * * @return price of product inclusive of discount. */ - function _getDiscountedPrice(uint256 basePrice, uint256 discount, uint256 quantity) + function _getDiscountedPrice(uint256 basePriceAmount, uint256 discount) internal pure virtual @@ -173,6 +188,6 @@ contract NFTDiscount is TieredDiscount { k = 1e4 - discount; } - price = (basePrice * k * quantity) / 1e4; + price = (basePriceAmount * k) / 1e4; } } diff --git a/src/hooks/pricing/TieredDiscount/TieredDiscount.sol b/src/hooks/pricing/TieredDiscount/TieredDiscount.sol index 0d85557..8f95990 100644 --- a/src/hooks/pricing/TieredDiscount/TieredDiscount.sol +++ b/src/hooks/pricing/TieredDiscount/TieredDiscount.sol @@ -2,7 +2,13 @@ pragma solidity ^0.8.30; import {IProductsModule} from "slice/interfaces/IProductsModule.sol"; -import {RegistryProductPrice, IProductPrice} from "@/utils/RegistryProductPrice.sol"; +import { + RegistryPrice, + IProductPrice, + IProductTypePrice, + ISlicerProductPrice, + ProductLineItem +} from "@/utils/RegistryPrice.sol"; import {DiscountParams} from "./types/DiscountParams.sol"; /** @@ -10,7 +16,7 @@ import {DiscountParams} from "./types/DiscountParams.sol"; * @notice Tiered discounts based on asset ownership * @author Slice */ -abstract contract TieredDiscount is RegistryProductPrice { +abstract contract TieredDiscount is RegistryPrice { /*////////////////////////////////////////////////////////////// ERRORS //////////////////////////////////////////////////////////////*/ @@ -24,13 +30,16 @@ abstract contract TieredDiscount is RegistryProductPrice { MUTABLE STORAGE //////////////////////////////////////////////////////////////*/ - mapping(uint256 slicerId => mapping(uint256 productId => DiscountParams[])) public discounts; + mapping(uint256 slicerId => mapping(uint256 productId => mapping(uint256 variantId => DiscountParams[]))) public + discounts; + mapping(uint256 slicerId => mapping(uint256 productTypeId => DiscountParams[])) public productTypeDiscounts; + mapping(uint256 slicerId => DiscountParams[]) public slicerDiscounts; /*////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////*/ - constructor(IProductsModule productsModuleAddress) RegistryProductPrice(productsModuleAddress) {} + constructor(IProductsModule productsModuleAddress) RegistryPrice(productsModuleAddress) {} /*////////////////////////////////////////////////////////////// FUNCTIONS @@ -42,18 +51,53 @@ abstract contract TieredDiscount is RegistryProductPrice { function productPrice( uint256 slicerId, uint256 productId, - address currency, - uint256 quantity, + uint256 variantId, + address, + uint256 basePrice, + uint256, address buyer, - bytes memory data - ) public view override returns (uint256 ethPrice, uint256 currencyPrice) { - (uint256 basePriceEth, uint256 basePriceCurrency) = - PRODUCTS_MODULE.basePrice(slicerId, productId, currency, quantity); - uint256 basePrice = currency == address(0) ? basePriceEth : basePriceCurrency; + bytes memory + ) public view override returns (uint256) { + DiscountParams[] memory discountParams = discounts[slicerId][productId][variantId]; + + return _productPrice(buyer, basePrice, discountParams); + } + + /** + * @inheritdoc IProductTypePrice + */ + function productTypePrice( + uint256 slicerId, + uint256 productTypeId, + address, + uint256 basePrice, + uint256, + address buyer + ) public view override returns (uint256) { + DiscountParams[] memory discountParams = productTypeDiscounts[slicerId][productTypeId]; + + return _productPrice(buyer, basePrice, discountParams); + } - DiscountParams[] memory discountParams = discounts[slicerId][productId]; + /** + * @inheritdoc ISlicerProductPrice + */ + function slicerProductPrice( + uint256 slicerId, + address, + address buyer, + ProductLineItem[] memory productLineItems, + uint256[] memory basePrices + ) public view override returns (uint256[] memory updatedBasePrices) { + DiscountParams[] memory discountParams = slicerDiscounts[slicerId]; + updatedBasePrices = new uint256[](basePrices.length); - return _productPrice(slicerId, productId, currency, quantity, buyer, data, basePrice, discountParams); + for (uint256 i; i < productLineItems.length;) { + updatedBasePrices[i] = _productPrice(buyer, basePrices[i], discountParams); + unchecked { + ++i; + } + } } /*////////////////////////////////////////////////////////////// @@ -63,25 +107,15 @@ abstract contract TieredDiscount is RegistryProductPrice { /** * @notice Logic for calculating product price. To be implemented by child contracts. * - * @param slicerId ID of the slicer to set the price params for. - * @param productId ID of the product to set the price params for. - * @param currency Currency chosen for the purchase - * @param quantity Number of units purchased * @param buyer Address of the buyer. - * @param data Data passed to the productPrice function. - * @param basePrice Base price of the product. + * @param basePrice Base price of the product in selected currency. * @param discountParams Array of discount parameters. * - * @return ethPrice and currencyPrice of product. + * @return Price of product in selected currency. */ - function _productPrice( - uint256 slicerId, - uint256 productId, - address currency, - uint256 quantity, - address buyer, - bytes memory data, - uint256 basePrice, - DiscountParams[] memory discountParams - ) internal view virtual returns (uint256 ethPrice, uint256 currencyPrice); + function _productPrice(address buyer, uint256 basePrice, DiscountParams[] memory discountParams) + internal + view + virtual + returns (uint256); } diff --git a/src/hooks/pricing/VRGDA/LinearVRGDAPrices/LinearVRGDAPrices.sol b/src/hooks/pricing/VRGDA/LinearVRGDAPrices/LinearVRGDAPrices.sol index ab0ad08..adba315 100644 --- a/src/hooks/pricing/VRGDA/LinearVRGDAPrices/LinearVRGDAPrices.sol +++ b/src/hooks/pricing/VRGDA/LinearVRGDAPrices/LinearVRGDAPrices.sol @@ -1,10 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.30; -import {HookRegistry, IProductPrice, IHookRegistry, IProductsModule} from "@/utils/RegistryProductPrice.sol"; +import { + ProductHookRegistry, IProductPrice, IProductHookRegistry, IProductsModule +} from "@/utils/RegistryProductPrice.sol"; import {wadLn, unsafeWadDiv, toDaysWadUnsafe} from "@/utils/math/SignedWadMath.sol"; -import {LinearProductParams} from "../types/LinearProductParams.sol"; -import {LinearVRGDAParams} from "../types/LinearVRGDAParams.sol"; +import {LinearProduct} from "../types/LinearProduct.sol"; import {VRGDAPrices} from "../VRGDAPrices.sol"; /// @title LinearVRGDAPrices @@ -16,7 +17,8 @@ contract LinearVRGDAPrices is VRGDAPrices { //////////////////////////////////////////////////////////////*/ // Mapping from slicerId to productId to ProductParams - mapping(uint256 => mapping(uint256 => LinearProductParams)) private _productParams; + mapping(uint256 slicerId => mapping(uint256 productId => mapping(uint256 variantId => LinearProduct))) private + _productParams; /*////////////////////////////////////////////////////////////// CONSTRUCTOR @@ -29,12 +31,14 @@ contract LinearVRGDAPrices is VRGDAPrices { //////////////////////////////////////////////////////////////*/ /** - * @inheritdoc HookRegistry - * @notice Set LinearVRGDAParams for a product. + * @inheritdoc ProductHookRegistry + * @notice Set LinearProduct for a product. */ - function _configureProduct(uint256 slicerId, uint256 productId, bytes memory params) internal override { - (LinearVRGDAParams[] memory linearParams, int256 priceDecayPercent) = - abi.decode(params, (LinearVRGDAParams[], int256)); + function _configureProduct(uint256 slicerId, uint256 productId, uint256 variantId, bytes memory params) + internal + override + { + (int184 priceDecayPercent, uint16 min, int256 perTimeUnit) = abi.decode(params, (int184, uint16, int256)); int256 decayConstant = wadLn(1e18 - priceDecayPercent); // The decay constant must be negative for VRGDAs to work. @@ -42,25 +46,19 @@ contract LinearVRGDAPrices is VRGDAPrices { require(decayConstant >= type(int184).min, "MIN_DECAY_CONSTANT_EXCEEDED"); /// Get product availability and isInfinite - /// @dev available units is a uint32 - (uint256 availableUnits, bool isInfinite) = PRODUCTS_MODULE.availableUnits(slicerId, productId); + (uint256 stockUnits_, bool isInfinite) = getStockUnits(slicerId, productId, 0); // Product must not have infinite availability require(!isInfinite, "NOT_FINITE_AVAILABILITY"); // Set product params - _productParams[slicerId][productId].startTime = uint40(block.timestamp); - _productParams[slicerId][productId].startUnits = uint32(availableUnits); - _productParams[slicerId][productId].decayConstant = int184(decayConstant); - - // Set currency params - for (uint256 i; i < linearParams.length;) { - _productParams[slicerId][productId].pricingParams[linearParams[i].currency] = linearParams[i]; - - unchecked { - ++i; - } - } + _productParams[slicerId][productId][variantId] = LinearProduct({ + startTime: uint40(block.timestamp), + startUnits: uint32(stockUnits_), + decayConstant: int184(decayConstant), + min: min, + perTimeUnit: perTimeUnit + }); } /** @@ -69,53 +67,40 @@ contract LinearVRGDAPrices is VRGDAPrices { function productPrice( uint256 slicerId, uint256 productId, + uint256 variantId, address currency, + uint256, uint256 quantity, address, bytes memory - ) public view override returns (uint256 ethPrice, uint256 currencyPrice) { - // Add reference for product and pricing params - LinearProductParams storage productParams = _productParams[slicerId][productId]; - LinearVRGDAParams memory pricingParams = productParams.pricingParams[currency]; + ) public view override returns (uint256 price) { + LinearProduct storage productParams = _productParams[slicerId][productId][variantId]; require(productParams.startTime != 0, "PRODUCT_UNSET"); - // Get available units - (uint256 availableUnits,) = PRODUCTS_MODULE.availableUnits(slicerId, productId); - - // Calculate sold units from availableUnits - uint256 soldUnits = productParams.startUnits - availableUnits; - - // Set ethPrice or currencyPrice based on chosen currency - if (currency == address(0)) { - ethPrice = getAdjustedVRGDAPrice( - pricingParams.targetPrice, - productParams.decayConstant, - toDaysWadUnsafe(block.timestamp - productParams.startTime), - soldUnits, - pricingParams.perTimeUnit, - pricingParams.min, - quantity - ); - } else { - currencyPrice = getAdjustedVRGDAPrice( - pricingParams.targetPrice, - productParams.decayConstant, - toDaysWadUnsafe(block.timestamp - productParams.startTime), - soldUnits, - pricingParams.perTimeUnit, - pricingParams.min, - quantity - ); - } + // Get available units and base price + (uint256 stockUnits_,) = getStockUnits(slicerId, productId, variantId); + uint256 basePrice_ = getBasePrice(slicerId, productId, variantId, currency, 1); + + // Calculate sold units from stockUnits + uint256 soldUnits = productParams.startUnits - stockUnits_; + + price = getAdjustedVRGDAPrice( + int256(basePrice_), + productParams.decayConstant, + toDaysWadUnsafe(block.timestamp - productParams.startTime), + soldUnits, + productParams.perTimeUnit, + basePrice_ * productParams.min / 1e5, + quantity + ); } /** - * @inheritdoc IHookRegistry + * @inheritdoc IProductHookRegistry */ - function paramsSchema() external pure override returns (string memory) { - return - "(address currency,int128 targetPrice,uint128 min,int256 perTimeUnit)[] linearParams,int256 priceDecayPercent"; + function configureProductSchema() public pure override returns (string memory) { + return "int184 priceDecayPercent,uint16 min,int256 perTimeUnit"; } /*////////////////////////////////////////////////////////////// diff --git a/src/hooks/pricing/VRGDA/LogisticVRGDAPrices/LogisticVRGDAPrices.sol b/src/hooks/pricing/VRGDA/LogisticVRGDAPrices/LogisticVRGDAPrices.sol index 5a0a114..8dcbb5c 100644 --- a/src/hooks/pricing/VRGDA/LogisticVRGDAPrices/LogisticVRGDAPrices.sol +++ b/src/hooks/pricing/VRGDA/LogisticVRGDAPrices/LogisticVRGDAPrices.sol @@ -1,7 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.30; -import {HookRegistry, IProductPrice, IHookRegistry, IProductsModule} from "@/utils/RegistryProductPrice.sol"; +import { + ProductHookRegistry, IProductPrice, IProductHookRegistry, IProductsModule +} from "@/utils/RegistryProductPrice.sol"; import { wadMul, toWadUnsafe, @@ -12,8 +14,7 @@ import { wadExp, unsafeWadMul } from "@/utils/math/SignedWadMath.sol"; -import {LogisticProductParams} from "../types/LogisticProductParams.sol"; -import {LogisticVRGDAParams} from "../types/LogisticVRGDAParams.sol"; +import {LogisticProduct} from "../types/LogisticProduct.sol"; import {IProductsModule, VRGDAPrices} from "../VRGDAPrices.sol"; /// @title LogisticVRGDAPrices @@ -24,8 +25,9 @@ contract LogisticVRGDAPrices is VRGDAPrices { STORAGE //////////////////////////////////////////////////////////////*/ - // Mapping from slicerId to productId to LogisticProductParams - mapping(uint256 => mapping(uint256 => LogisticProductParams)) private _productParams; + // Mapping from slicerId to productId to LogisticProduct + mapping(uint256 slicerId => mapping(uint256 productId => mapping(uint256 variantId => LogisticProduct))) private + _productParams; /*////////////////////////////////////////////////////////////// CONSTRUCTOR @@ -38,12 +40,14 @@ contract LogisticVRGDAPrices is VRGDAPrices { //////////////////////////////////////////////////////////////*/ /** - * @inheritdoc HookRegistry + * @inheritdoc ProductHookRegistry * @notice Set LogisticVRGDAParams for a product. */ - function _configureProduct(uint256 slicerId, uint256 productId, bytes memory params) internal override { - (LogisticVRGDAParams[] memory logisticParams, int256 priceDecayPercent) = - abi.decode(params, (LogisticVRGDAParams[], int256)); + function _configureProduct(uint256 slicerId, uint256 productId, uint256 variantId, bytes memory params) + internal + override + { + (int184 priceDecayPercent, uint16 min, int256 timescale) = abi.decode(params, (int184, uint16, int256)); int256 decayConstant = wadLn(1e18 - priceDecayPercent); // The decay constant must be negative for VRGDAs to work. @@ -51,24 +55,19 @@ contract LogisticVRGDAPrices is VRGDAPrices { require(decayConstant >= type(int184).min, "MIN_DECAY_CONSTANT_EXCEEDED"); // Get product availability and isInfinite - (uint256 availableUnits, bool isInfinite) = PRODUCTS_MODULE.availableUnits(slicerId, productId); + (uint256 stockUnits_, bool isInfinite) = getStockUnits(slicerId, productId, 0); // Product must not have infinite availability require(!isInfinite, "NON_FINITE_AVAILABILITY"); // Set product params - _productParams[slicerId][productId].startTime = uint40(block.timestamp); - _productParams[slicerId][productId].startUnits = uint32(availableUnits); - _productParams[slicerId][productId].decayConstant = int184(decayConstant); - - // Set currency params - for (uint256 i; i < logisticParams.length;) { - _productParams[slicerId][productId].pricingParams[logisticParams[i].currency] = logisticParams[i]; - - unchecked { - ++i; - } - } + _productParams[slicerId][productId][variantId] = LogisticProduct({ + startTime: uint40(block.timestamp), + startUnits: uint32(stockUnits_), + decayConstant: int184(decayConstant), + min: min, + timeScale: timescale + }); } /** @@ -77,52 +76,41 @@ contract LogisticVRGDAPrices is VRGDAPrices { function productPrice( uint256 slicerId, uint256 productId, + uint256 variantId, address currency, + uint256, uint256 quantity, address, bytes memory - ) public view override returns (uint256 ethPrice, uint256 currencyPrice) { - // Add reference for product and pricing params - LogisticProductParams storage productParams = _productParams[slicerId][productId]; - LogisticVRGDAParams memory pricingParams = productParams.pricingParams[currency]; + ) public view override returns (uint256 price) { + LogisticProduct storage productParams = _productParams[slicerId][productId][variantId]; require(productParams.startTime != 0, "PRODUCT_UNSET"); - // Get available units - (uint256 availableUnits,) = PRODUCTS_MODULE.availableUnits(slicerId, productId); - - // Set ethPrice or currencyPrice based on chosen currency - if (currency == address(0)) { - ethPrice = getAdjustedVRGDALogisticPrice( - pricingParams.targetPrice, - productParams.decayConstant, - toDaysWadUnsafe(block.timestamp - productParams.startTime), - toWadUnsafe(productParams.startUnits + 1), - productParams.startUnits - availableUnits, - pricingParams.timeScale, - pricingParams.min, - quantity - ); - } else { - currencyPrice = getAdjustedVRGDALogisticPrice( - pricingParams.targetPrice, - productParams.decayConstant, - toDaysWadUnsafe(block.timestamp - productParams.startTime), - toWadUnsafe(productParams.startUnits + 1), - productParams.startUnits - availableUnits, - pricingParams.timeScale, - pricingParams.min, - quantity - ); - } + // Get available units and base price + (uint256 stockUnits_,) = getStockUnits(slicerId, productId, variantId); + uint256 basePrice_ = getBasePrice(slicerId, productId, variantId, currency, 1); + + // Calculate sold units from stockUnits + uint256 soldUnits = productParams.startUnits - stockUnits_; + + price = getAdjustedVRGDALogisticPrice( + int256(basePrice_), + productParams.decayConstant, + toDaysWadUnsafe(block.timestamp - productParams.startTime), + toWadUnsafe(productParams.startUnits + 1), + soldUnits, + productParams.timeScale, + basePrice_ * productParams.min / 1e5, + quantity + ); } /** - * @inheritdoc IHookRegistry + * @inheritdoc IProductHookRegistry */ - function paramsSchema() external pure override returns (string memory) { - return - "(address currency,int128 targetPrice,uint128 min,int256 timeScale)[] logisticParams,int256 priceDecayPercent"; + function configureProductSchema() public pure override returns (string memory) { + return "int184 priceDecayPercent,uint16 min,int256 timescale"; } /*////////////////////////////////////////////////////////////// diff --git a/src/hooks/pricing/VRGDA/types/LogisticProductParams.sol b/src/hooks/pricing/VRGDA/types/LinearProduct.sol similarity index 57% rename from src/hooks/pricing/VRGDA/types/LogisticProductParams.sol rename to src/hooks/pricing/VRGDA/types/LinearProduct.sol index fecc2bb..924d755 100644 --- a/src/hooks/pricing/VRGDA/types/LogisticProductParams.sol +++ b/src/hooks/pricing/VRGDA/types/LinearProduct.sol @@ -1,15 +1,15 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.30; -import {LogisticVRGDAParams} from "./LogisticVRGDAParams.sol"; - /// @param startTime Time when the VRGDA began. /// @param startUnits Units available at the time when product is set up. /// @param decayConstant Precomputed constant that allows us to rewrite a pow() as an exp(). -/// @param pricingParams See `LogisticVRGDAParams` -struct LogisticProductParams { +/// @param min minimum price to be paid for a token, in percentage of the target price (1e5 = 100%) +/// @param perTimeUnit The total number of products to target selling every full unit of time. +struct LinearProduct { uint40 startTime; uint32 startUnits; int184 decayConstant; - mapping(address => LogisticVRGDAParams) pricingParams; + uint16 min; + int256 perTimeUnit; } diff --git a/src/hooks/pricing/VRGDA/types/LinearVRGDAParams.sol b/src/hooks/pricing/VRGDA/types/LinearVRGDAParams.sol deleted file mode 100644 index 563c36b..0000000 --- a/src/hooks/pricing/VRGDA/types/LinearVRGDAParams.sol +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.30; - -/// @param targetPrice Target price for a product, to be scaled according to sales pace. -/// @param min minimum price to be paid for a token, scaled by 1e18 -/// @param perTimeUnit The total number of products to target selling every full unit of time. -struct LinearVRGDAParams { - address currency; - int128 targetPrice; - uint128 min; - int256 perTimeUnit; -} diff --git a/src/hooks/pricing/VRGDA/types/LinearProductParams.sol b/src/hooks/pricing/VRGDA/types/LogisticProduct.sol similarity index 62% rename from src/hooks/pricing/VRGDA/types/LinearProductParams.sol rename to src/hooks/pricing/VRGDA/types/LogisticProduct.sol index 365e992..2e596d0 100644 --- a/src/hooks/pricing/VRGDA/types/LinearProductParams.sol +++ b/src/hooks/pricing/VRGDA/types/LogisticProduct.sol @@ -1,15 +1,15 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.30; -import {LinearVRGDAParams} from "./LinearVRGDAParams.sol"; - /// @param startTime Time when the VRGDA began. /// @param startUnits Units available at the time when product is set up. /// @param decayConstant Precomputed constant that allows us to rewrite a pow() as an exp(). -/// @param pricingParams See `LinearVRGDAParams` -struct LinearProductParams { +/// @param min minimum price to be paid for a token, scaled by 1e18 +/// @param timeScale Time scale controls the steepness of the logistic curve, +struct LogisticProduct { uint40 startTime; uint32 startUnits; int184 decayConstant; - mapping(address => LinearVRGDAParams) pricingParams; + uint128 min; + int256 timeScale; } diff --git a/src/hooks/pricing/VRGDA/types/LogisticVRGDAParams.sol b/src/hooks/pricing/VRGDA/types/LogisticVRGDAParams.sol deleted file mode 100644 index 15da7a1..0000000 --- a/src/hooks/pricing/VRGDA/types/LogisticVRGDAParams.sol +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.30; - -/// @param targetPrice Target price for a product, to be scaled according to sales pace. -/// @param min minimum price to be paid for a token, scaled by 1e18 -/// @param timeScale Time scale controls the steepness of the logistic curve, -/// which affects how quickly we will reach the curve's asymptote. -struct LogisticVRGDAParams { - address currency; - int128 targetPrice; - uint128 min; - int256 timeScale; -} diff --git a/src/hooks/pricingActions/FirstForFree/FirstForFree.sol b/src/hooks/pricingActions/FirstForFree/FirstForFree.sol index ff2f870..6bda187 100644 --- a/src/hooks/pricingActions/FirstForFree/FirstForFree.sol +++ b/src/hooks/pricingActions/FirstForFree/FirstForFree.sol @@ -7,10 +7,10 @@ import { IProductPrice, RegistryProductAction, RegistryProductPriceAction, - IHookRegistry, + IProductHookRegistry, IProductsModule } from "@/utils/RegistryProductPriceAction.sol"; -import {HookRegistry} from "@/utils/RegistryProductAction.sol"; +import {ProductHookRegistry} from "@/utils/RegistryProductAction.sol"; import {ProductParams, TokenCondition} from "./types/ProductParams.sol"; import {TokenType} from "./types/TokenCondition.sol"; import {ITokenERC1155} from "./utils/ITokenERC1155.sol"; @@ -25,8 +25,8 @@ contract FirstForFree is RegistryProductPriceAction { MUTABLE STORAGE //////////////////////////////////////////////////////////////*/ - mapping(uint256 slicerId => mapping(uint256 productId => ProductParams price)) public usdcPrices; - mapping(address buyer => mapping(uint256 slicerId => uint256 purchases)) public purchases; + mapping(uint256 slicerId => mapping(uint256 productId => mapping(uint256 variantId => ProductParams price))) public + productParams; /*////////////////////////////////////////////////////////////// CONSTRUCTOR @@ -42,76 +42,76 @@ contract FirstForFree is RegistryProductPriceAction { * @inheritdoc IProductPrice * @notice Applies discount only for first N purchases on a slicer. */ - function productPrice(uint256 slicerId, uint256 productId, address, uint256 quantity, address buyer, bytes memory) - public - view - override - returns (uint256 ethPrice, uint256 currencyPrice) - { - ProductParams memory productParams = usdcPrices[slicerId][productId]; + function productPrice( + uint256 slicerId, + uint256 productId, + uint256 variantId, + address, + uint256 basePrice, + uint256 quantity, + address buyer, + bytes memory + ) public view override returns (uint256) { + ProductParams memory params = productParams[slicerId][productId][variantId]; - if (_isEligible(buyer, productParams.eligibleTokens)) { - uint256 totalPurchases = purchases[buyer][slicerId]; - if (totalPurchases < productParams.freeUnits) { + if (_isEligible(buyer, params.eligibleTokens)) { + uint256 totalPurchases = getPurchases(buyer, slicerId, productId, variantId); + if (totalPurchases < params.freeUnits) { unchecked { - uint256 freeUnitsLeft = productParams.freeUnits - totalPurchases; + uint256 freeUnitsLeft = params.freeUnits - totalPurchases; + if (quantity <= freeUnitsLeft) { - return (0, 0); + return 0; } else { - return (0, usdcPrices[slicerId][productId].usdcPrice * (quantity - freeUnitsLeft)); + return getBasePrice(slicerId, productId, variantId, address(0), quantity - freeUnitsLeft); } } } } - return (0, usdcPrices[slicerId][productId].usdcPrice * quantity); + return basePrice; } /** * @inheritdoc RegistryProductAction - * @notice Mint `quantity` NFTs to `account` on purchase. Keeps track of total purchases. + * @notice Mint `quantity` NFTs to `account` on purchase. */ function _onProductPurchase( uint256 slicerId, uint256 productId, + uint256 variantId, address buyer, uint256 quantity, - bytes memory, bytes memory ) internal override { - purchases[buyer][slicerId] += quantity; - - ProductParams memory productParams = usdcPrices[slicerId][productId]; - if (productParams.mintToken != address(0)) { - ITokenERC1155(productParams.mintToken).mintTo(buyer, productParams.mintTokenId, "", quantity); + ProductParams memory params = productParams[slicerId][productId][variantId]; + if (params.mintToken != address(0)) { + ITokenERC1155(params.mintToken).mintTo(buyer, params.mintTokenId, "", quantity); } } /** - * @inheritdoc HookRegistry + * @inheritdoc ProductHookRegistry * @notice Sets the product parameters. */ - function _configureProduct(uint256 slicerId, uint256 productId, bytes memory params) internal override { - ( - uint256 usdcPrice, - TokenCondition[] memory eligibleTokens, - address mintToken, - uint88 mintTokenId, - uint8 freeUnits - ) = abi.decode(params, (uint256, TokenCondition[], address, uint88, uint8)); - - ProductParams storage productParams = usdcPrices[slicerId][productId]; - - productParams.usdcPrice = usdcPrice; - productParams.mintToken = mintToken; - productParams.mintTokenId = mintTokenId; - productParams.freeUnits = freeUnits; + function _configureProduct(uint256 slicerId, uint256 productId, uint256 variantId, bytes memory params) + internal + override + { + (TokenCondition[] memory eligibleTokens, address mintToken, uint88 mintTokenId, uint8 freeUnits) = + abi.decode(params, (TokenCondition[], address, uint88, uint8)); + + ProductParams storage productParams_ = productParams[slicerId][productId][variantId]; + + productParams_.mintToken = mintToken; + productParams_.mintTokenId = mintTokenId; + productParams_.freeUnits = freeUnits; // Remove all discount tokens - delete productParams.eligibleTokens; + delete productParams_.eligibleTokens; for (uint256 i = 0; i < eligibleTokens.length;) { - productParams.eligibleTokens.push(eligibleTokens[i]); + productParams_.eligibleTokens.push(eligibleTokens[i]); unchecked { ++i; @@ -120,11 +120,11 @@ contract FirstForFree is RegistryProductPriceAction { } /** - * @inheritdoc IHookRegistry + * @inheritdoc IProductHookRegistry */ - function paramsSchema() external pure override returns (string memory) { + function configureProductSchema() public pure override returns (string memory) { return - "uint256 usdcPrice,(address tokenAddress,uint8 tokenType,uint88 tokenId,uint8 minQuantity)[] eligibleTokens,address mintToken,uint88 mintTokenId,uint8 freeUnits"; + "(address tokenAddress,uint8 tokenType,uint80 tokenId,uint8 minQuantity)[] eligibleTokens,address mintToken,uint88 mintTokenId,uint8 freeUnits"; } /*////////////////////////////////////////////////////////////// diff --git a/src/hooks/pricingActions/FirstForFree/types/ProductParams.sol b/src/hooks/pricingActions/FirstForFree/types/ProductParams.sol index 2e7c964..8066db9 100644 --- a/src/hooks/pricingActions/FirstForFree/types/ProductParams.sol +++ b/src/hooks/pricingActions/FirstForFree/types/ProductParams.sol @@ -4,7 +4,6 @@ pragma solidity ^0.8.30; import {TokenCondition} from "./TokenCondition.sol"; struct ProductParams { - uint256 usdcPrice; TokenCondition[] eligibleTokens; address mintToken; uint88 mintTokenId; diff --git a/src/hooks/pricingActions/README.md b/src/hooks/pricingActions/README.md index 80c5c86..d768adb 100644 --- a/src/hooks/pricingActions/README.md +++ b/src/hooks/pricingActions/README.md @@ -36,7 +36,7 @@ The script will create your contract file with the proper template and add it to Hooks inheriting from `RegistryProductPriceAction` automatically support frontend integration through: - **Product configuration** via `configureProduct()` -- **Parameter validation** via `paramsSchema()` +- **Parameter validation** via `configureProductSchema()` ### Testing diff --git a/src/interfaces/IHookRegistry.sol b/src/interfaces/IHookRegistry.sol index 1b04144..7019425 100644 --- a/src/interfaces/IHookRegistry.sol +++ b/src/interfaces/IHookRegistry.sol @@ -1,4 +1,4 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.30; -import "slice/interfaces/hooks/IHookRegistry.sol"; +import {IProductHookRegistry, IProductTypeHookRegistry, ISlicerHookRegistry} from "slice/interfaces.sol"; diff --git a/src/interfaces/IProductAction.sol b/src/interfaces/IProductAction.sol index 1fd0423..fd6fbf9 100644 --- a/src/interfaces/IProductAction.sol +++ b/src/interfaces/IProductAction.sol @@ -1,4 +1,4 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.30; -import "slice/interfaces/hooks/IProductAction.sol"; +import {IProductAction} from "slice/interfaces.sol"; diff --git a/src/interfaces/IProductPrice.sol b/src/interfaces/IProductPrice.sol index fb9c752..6d44d16 100644 --- a/src/interfaces/IProductPrice.sol +++ b/src/interfaces/IProductPrice.sol @@ -1,4 +1,4 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.30; -import "slice/interfaces/hooks/IProductPrice.sol"; +import {IProductPrice, IProductTypePrice, ISlicerProductPrice} from "slice/interfaces.sol"; diff --git a/src/utils/RegistryAction.sol b/src/utils/RegistryAction.sol new file mode 100644 index 0000000..1c95fa8 --- /dev/null +++ b/src/utils/RegistryAction.sol @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import "slice/utils/hooks/RegistryAction.sol"; diff --git a/src/utils/RegistryPrice.sol b/src/utils/RegistryPrice.sol new file mode 100644 index 0000000..56a727c --- /dev/null +++ b/src/utils/RegistryPrice.sol @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import "slice/utils/hooks/RegistryPrice.sol"; diff --git a/test/actions/Allowlisted/Allowlisted.t.sol b/test/actions/Allowlisted/Allowlisted.t.sol index a59108f..c95edab 100644 --- a/test/actions/Allowlisted/Allowlisted.t.sol +++ b/test/actions/Allowlisted/Allowlisted.t.sol @@ -7,6 +7,8 @@ import {Allowlisted} from "@/hooks/actions/Allowlisted/Allowlisted.sol"; uint256 constant slicerId = 0; uint256 constant productId = 1; +uint256 constant variantId = 0; +uint256 constant variantIdAlt = 1; contract AllowlistedTest is RegistryProductActionTest { Allowlisted allowlisted; @@ -29,28 +31,63 @@ contract AllowlistedTest is RegistryProductActionTest { bytes32 root = m.getRoot(data); vm.prank(productOwner); - allowlisted.configureProduct(slicerId, productId, abi.encode(root)); + allowlisted.configureProduct(slicerId, productId, variantId, abi.encode(root)); - assertTrue(allowlisted.merkleRoots(slicerId, productId) == root); + assertTrue(allowlisted.merkleRoots(slicerId, productId, variantId) == root); } function testIsPurchaseAllowed() public { bytes32 root = m.getRoot(data); vm.prank(productOwner); - allowlisted.configureProduct(slicerId, productId, abi.encode(root)); + allowlisted.configureProduct(slicerId, productId, variantId, abi.encode(root)); bytes32[] memory proof = m.getProof(data, 0); - assertTrue(allowlisted.isPurchaseAllowed(slicerId, productId, buyer, 0, "", abi.encode(proof))); + assertTrue(allowlisted.isPurchaseAllowed(slicerId, productId, variantId, buyer, 0, abi.encode(proof))); } function testIsPurchaseAllowed_wrongProof() public { bytes32 root = m.getRoot(data); vm.prank(productOwner); - allowlisted.configureProduct(slicerId, productId, abi.encode(root)); + allowlisted.configureProduct(slicerId, productId, variantId, abi.encode(root)); bytes32[] memory wrongProof = m.getProof(data, 1); - assertFalse(allowlisted.isPurchaseAllowed(slicerId, productId, buyer, 0, "", abi.encode(wrongProof))); + assertFalse(allowlisted.isPurchaseAllowed(slicerId, productId, variantId, buyer, 0, abi.encode(wrongProof))); + } + + function testIsPurchaseAllowed_variantSpecificConfiguration() public { + bytes32 root = m.getRoot(data); + + vm.prank(productOwner); + allowlisted.configureProduct(slicerId, productId, variantId, abi.encode(root)); + + address variantBuyer = address(0xBEEF); + bytes32[] memory variantLeaves = new bytes32[](4); + variantLeaves[0] = bytes32(keccak256(abi.encode(variantBuyer))); + variantLeaves[1] = bytes32(keccak256(abi.encode(address(0xCAFE)))); + variantLeaves[2] = bytes32(keccak256(abi.encode(address(0xD00D)))); + variantLeaves[3] = bytes32(keccak256(abi.encode(address(0xFEED)))); + bytes32 variantRoot = m.getRoot(variantLeaves); + + vm.prank(productOwner); + allowlisted.configureProduct(slicerId, productId, variantIdAlt, abi.encode(variantRoot)); + + bytes32[] memory productProof = m.getProof(data, 0); + assertTrue(allowlisted.isPurchaseAllowed(slicerId, productId, variantId, buyer, 0, abi.encode(productProof))); + + // Product-level allowlist should not apply to variant configuration + assertFalse( + allowlisted.isPurchaseAllowed(slicerId, productId, variantIdAlt, buyer, 0, abi.encode(productProof)) + ); + + bytes32[] memory variantLeavesForProof = new bytes32[](variantLeaves.length); + for (uint256 i; i < variantLeaves.length; ++i) { + variantLeavesForProof[i] = variantLeaves[i]; + } + bytes32[] memory variantProof = m.getProof(variantLeavesForProof, 0); + assertTrue( + allowlisted.isPurchaseAllowed(slicerId, productId, variantIdAlt, variantBuyer, 0, abi.encode(variantProof)) + ); } } diff --git a/test/actions/ERC20Gated/ERC20Gated.t.sol b/test/actions/ERC20Gated/ERC20Gated.t.sol index a4d8ea3..92ba68f 100644 --- a/test/actions/ERC20Gated/ERC20Gated.t.sol +++ b/test/actions/ERC20Gated/ERC20Gated.t.sol @@ -5,9 +5,14 @@ import {RegistryProductActionTest} from "@test/utils/RegistryProductActionTest.s import {MockERC20Gated} from "./mocks/MockERC20Gated.sol"; import {ERC20Gate} from "@/hooks/actions/ERC20Gated/ERC20Gated.sol"; import {IERC20, MockERC20} from "@test/utils/mocks/MockERC20.sol"; +import {HookRegistry, ProductLineItem} from "@/utils/RegistryAction.sol"; +import {SliceContext} from "@/utils/RegistryProductAction.sol"; uint256 constant slicerId = 0; uint256 constant productId = 1; +uint256 constant variantId = 0; +uint256 constant variantIdAlt = 1; +uint256 constant productTypeId = 2; contract ERC20GatedTest is RegistryProductActionTest { MockERC20Gated erc20Gated; @@ -24,9 +29,9 @@ contract ERC20GatedTest is RegistryProductActionTest { gates[0] = ERC20Gate(token, 100); vm.prank(productOwner); - erc20Gated.configureProduct(slicerId, productId, abi.encode(gates)); + erc20Gated.configureProduct(slicerId, productId, variantId, abi.encode(gates)); - (IERC20 tokenAddr, uint256 amount) = erc20Gated.tokenGates(slicerId, productId, 0); + (IERC20 tokenAddr, uint256 amount) = erc20Gated.tokenGates(slicerId, productId, variantId, 0); assertTrue(address(tokenAddr) == address(token)); assertTrue(amount == 100); } @@ -37,22 +42,22 @@ contract ERC20GatedTest is RegistryProductActionTest { gates[1] = ERC20Gate(token2, 200); vm.startPrank(productOwner); - erc20Gated.configureProduct(slicerId, productId, abi.encode(gates)); + erc20Gated.configureProduct(slicerId, productId, variantId, abi.encode(gates)); - assertEq(address(erc20Gated.gates(slicerId, productId)[0].erc20), address(token)); - assertEq(erc20Gated.gates(slicerId, productId)[0].amount, 100); - assertEq(address(erc20Gated.gates(slicerId, productId)[1].erc20), address(token2)); - assertEq(erc20Gated.gates(slicerId, productId)[1].amount, 200); - assertEq(erc20Gated.gates(slicerId, productId).length, 2); + assertEq(address(erc20Gated.gates(slicerId, productId, variantId)[0].erc20), address(token)); + assertEq(erc20Gated.gates(slicerId, productId, variantId)[0].amount, 100); + assertEq(address(erc20Gated.gates(slicerId, productId, variantId)[1].erc20), address(token2)); + assertEq(erc20Gated.gates(slicerId, productId, variantId)[1].amount, 200); + assertEq(erc20Gated.gates(slicerId, productId, variantId).length, 2); MockERC20 token3 = new MockERC20("Test3", "TST3", 18); gates = new ERC20Gate[](1); gates[0] = ERC20Gate(token3, 300); - erc20Gated.configureProduct(slicerId, productId, abi.encode(gates)); - assertEq(address(erc20Gated.gates(slicerId, productId)[0].erc20), address(token3)); - assertEq(erc20Gated.gates(slicerId, productId)[0].amount, 300); - assertEq(erc20Gated.gates(slicerId, productId).length, 1); + erc20Gated.configureProduct(slicerId, productId, variantId, abi.encode(gates)); + assertEq(address(erc20Gated.gates(slicerId, productId, variantId)[0].erc20), address(token3)); + assertEq(erc20Gated.gates(slicerId, productId, variantId)[0].amount, 300); + assertEq(erc20Gated.gates(slicerId, productId, variantId).length, 1); vm.stopPrank(); } @@ -62,11 +67,273 @@ contract ERC20GatedTest is RegistryProductActionTest { gates[0] = ERC20Gate(token, 100); vm.prank(productOwner); - erc20Gated.configureProduct(slicerId, productId, abi.encode(gates)); + erc20Gated.configureProduct(slicerId, productId, variantId, abi.encode(gates)); - assertFalse(erc20Gated.isPurchaseAllowed(slicerId, productId, buyer, 0, "", "")); + assertFalse(erc20Gated.isPurchaseAllowed(slicerId, productId, variantId, buyer, 0, "")); token.mint(buyer, 100); - assertTrue(erc20Gated.isPurchaseAllowed(slicerId, productId, buyer, 0, "", "")); + assertTrue(erc20Gated.isPurchaseAllowed(slicerId, productId, variantId, buyer, 0, "")); + } + + function testIsPurchaseAllowed_variantSpecificConfiguration() public { + ERC20Gate[] memory productGates = new ERC20Gate[](1); + productGates[0] = ERC20Gate(token, 100); + + ERC20Gate[] memory variantGates = new ERC20Gate[](1); + variantGates[0] = ERC20Gate(token2, 200); + + vm.startPrank(productOwner); + erc20Gated.configureProduct(slicerId, productId, variantId, abi.encode(productGates)); + erc20Gated.configureProduct(slicerId, productId, variantIdAlt, abi.encode(variantGates)); + vm.stopPrank(); + + assertFalse(erc20Gated.isPurchaseAllowed(slicerId, productId, variantId, buyer, 0, "")); + assertFalse(erc20Gated.isPurchaseAllowed(slicerId, productId, variantIdAlt, buyer, 0, "")); + + token.mint(buyer, 100); + assertTrue(erc20Gated.isPurchaseAllowed(slicerId, productId, variantId, buyer, 0, "")); + // Variant gate still unmet as it requires a different token + assertFalse(erc20Gated.isPurchaseAllowed(slicerId, productId, variantIdAlt, buyer, 0, "")); + + token2.mint(buyer, 200); + assertTrue(erc20Gated.isPurchaseAllowed(slicerId, productId, variantIdAlt, buyer, 0, "")); + + (IERC20 tokenAddrDefault, uint256 amountDefault) = erc20Gated.tokenGates(slicerId, productId, variantId, 0); + (IERC20 tokenAddrVariant, uint256 amountVariant) = erc20Gated.tokenGates(slicerId, productId, variantIdAlt, 0); + + assertEq(address(tokenAddrDefault), address(token)); + assertEq(amountDefault, 100); + assertEq(address(tokenAddrVariant), address(token2)); + assertEq(amountVariant, 200); + } + + /*////////////////////////////////////////////////////////////// + PRODUCT TYPE TESTS + //////////////////////////////////////////////////////////////*/ + + function testConfigureProductType() public { + ERC20Gate[] memory gates = new ERC20Gate[](2); + gates[0] = ERC20Gate(token2, 200); + gates[1] = ERC20Gate(token, 100); + + bytes memory params = abi.encode(gates); + + vm.expectEmit(true, true, true, true, address(erc20Gated)); + emit HookRegistry.ProductTypeConfigured(slicerId, productTypeId, params); + + vm.prank(productOwner); + erc20Gated.configureProductType(slicerId, productTypeId, params); + + assertEq(address(erc20Gated.productTypeGates(slicerId, productTypeId)[0].erc20), address(token2)); + assertEq(erc20Gated.productTypeGates(slicerId, productTypeId)[0].amount, 200); + assertEq(address(erc20Gated.productTypeGates(slicerId, productTypeId)[1].erc20), address(token)); + assertEq(erc20Gated.productTypeGates(slicerId, productTypeId)[1].amount, 100); + assertEq(erc20Gated.productTypeGates(slicerId, productTypeId).length, 2); + + // Buyer doesn't have tokens yet + assertFalse(erc20Gated.isProductTypePurchaseAllowed(slicerId, productTypeId, buyer, 0)); + + // Buyer has token but not token2 + token.mint(buyer, 100); + assertFalse(erc20Gated.isProductTypePurchaseAllowed(slicerId, productTypeId, buyer, 0)); + + // Buyer has both tokens + token2.mint(buyer, 200); + assertTrue(erc20Gated.isProductTypePurchaseAllowed(slicerId, productTypeId, buyer, 0)); + } + + function testConfigureProductType_AccessControl() public { + vm.expectRevert(abi.encodeWithSelector(SliceContext.NotAuthorized.selector, bytes32(uint256(1 << 1 | 1 << 3)))); + + erc20Gated.configureProductType(slicerId, productTypeId, ""); + } + + function testConfigureProductTypeSchema() public view { + string memory schema = erc20Gated.configureProductTypeSchema(); + + assertEq(schema, erc20Gated.configureProductSchema()); + assertTrue(bytes(schema).length > 0); + } + + function testIsProductTypePurchaseAllowed_NoGates() public { + vm.prank(productOwner); + erc20Gated.configureProductType(slicerId, productTypeId, abi.encode(new ERC20Gate[](0))); + + assertTrue(erc20Gated.isProductTypePurchaseAllowed(slicerId, productTypeId, buyer2, 0)); + } + + function testConfigureProductType__Edit_Remove() public { + ERC20Gate[] memory gates = new ERC20Gate[](2); + gates[0] = ERC20Gate(token2, 200); + gates[1] = ERC20Gate(token, 100); + + vm.prank(productOwner); + erc20Gated.configureProductType(slicerId, productTypeId, abi.encode(gates)); + + token.mint(buyer, 100); + token2.mint(buyer, 200); + assertTrue(erc20Gated.isProductTypePurchaseAllowed(slicerId, productTypeId, buyer, 0)); + + // Remove gates + vm.prank(productOwner); + erc20Gated.configureProductType(slicerId, productTypeId, abi.encode(new ERC20Gate[](0))); + + assertTrue(erc20Gated.isProductTypePurchaseAllowed(slicerId, productTypeId, buyer, 0)); + assertEq(erc20Gated.productTypeGates(slicerId, productTypeId).length, 0); + } + + function testOnProductTypePurchase() public { + ERC20Gate[] memory gates = new ERC20Gate[](1); + gates[0] = ERC20Gate(token, 100); + + vm.prank(productOwner); + erc20Gated.configureProductType(slicerId, productTypeId, abi.encode(gates)); + + // Buyer doesn't have tokens - should revert + vm.expectRevert(); + vm.prank(address(PRODUCTS_MODULE)); + erc20Gated.onProductTypePurchase(slicerId, productTypeId, buyer, 1); + + // Buyer has tokens - should succeed + token.mint(buyer, 100); + vm.prank(address(PRODUCTS_MODULE)); + erc20Gated.onProductTypePurchase(slicerId, productTypeId, buyer, 1); + } + + function testRevert_OnProductTypePurchase_NotPurchase() public { + ERC20Gate[] memory gates = new ERC20Gate[](1); + gates[0] = ERC20Gate(token, 100); + + vm.prank(productOwner); + erc20Gated.configureProductType(slicerId, productTypeId, abi.encode(gates)); + + token.mint(buyer, 100); + + vm.expectRevert(); + erc20Gated.onProductTypePurchase(slicerId, productTypeId, buyer, 1); + } + + /*////////////////////////////////////////////////////////////// + SLICER TESTS + //////////////////////////////////////////////////////////////*/ + + function testConfigureSlicer() public { + ERC20Gate[] memory gates = new ERC20Gate[](2); + gates[0] = ERC20Gate(token2, 200); + gates[1] = ERC20Gate(token, 100); + + bytes memory params = abi.encode(gates); + + vm.expectEmit(true, true, true, true, address(erc20Gated)); + emit HookRegistry.SlicerConfigured(slicerId, params); + + vm.prank(productOwner); + erc20Gated.configureSlicer(slicerId, params); + + assertEq(address(erc20Gated.slicerGates(slicerId)[0].erc20), address(token2)); + assertEq(erc20Gated.slicerGates(slicerId)[0].amount, 200); + assertEq(address(erc20Gated.slicerGates(slicerId)[1].erc20), address(token)); + assertEq(erc20Gated.slicerGates(slicerId)[1].amount, 100); + assertEq(erc20Gated.slicerGates(slicerId).length, 2); + + // Buyer doesn't have tokens yet + ProductLineItem[] memory productLineItems = new ProductLineItem[](1); + productLineItems[0] = + ProductLineItem({productId: uint32(productId), variantId: uint32(variantId), quantity: uint32(5)}); + assertFalse(erc20Gated.isSlicerPurchaseAllowed(slicerId, buyer, productLineItems)); + + // Buyer has token but not token2 + token.mint(buyer, 100); + assertFalse(erc20Gated.isSlicerPurchaseAllowed(slicerId, buyer, productLineItems)); + + // Buyer has both tokens + token2.mint(buyer, 200); + assertTrue(erc20Gated.isSlicerPurchaseAllowed(slicerId, buyer, productLineItems)); + } + + function testConfigureSlicer_AccessControl() public { + vm.expectRevert(abi.encodeWithSelector(SliceContext.NotAuthorized.selector, bytes32(uint256(1 << 1 | 1 << 3)))); + + erc20Gated.configureSlicer(slicerId, ""); + } + + function testConfigureSlicerSchema() public view { + string memory schema = erc20Gated.configureSlicerSchema(); + + assertEq(schema, erc20Gated.configureProductSchema()); + assertTrue(bytes(schema).length > 0); + } + + function testIsSlicerPurchaseAllowed_NoGates() public { + vm.prank(productOwner); + erc20Gated.configureSlicer(slicerId, abi.encode(new ERC20Gate[](0))); + + ProductLineItem[] memory productLineItems = new ProductLineItem[](1); + productLineItems[0] = + ProductLineItem({productId: uint32(productId), variantId: uint32(variantId), quantity: uint32(5)}); + assertTrue(erc20Gated.isSlicerPurchaseAllowed(slicerId, buyer3, productLineItems)); + } + + function testConfigureSlicer__Edit_Remove() public { + ERC20Gate[] memory gates = new ERC20Gate[](2); + gates[0] = ERC20Gate(token2, 200); + gates[1] = ERC20Gate(token, 100); + + vm.prank(productOwner); + erc20Gated.configureSlicer(slicerId, abi.encode(gates)); + + token.mint(buyer, 100); + token2.mint(buyer, 200); + + ProductLineItem[] memory productLineItems = new ProductLineItem[](1); + productLineItems[0] = + ProductLineItem({productId: uint32(productId), variantId: uint32(variantId), quantity: uint32(5)}); + assertTrue(erc20Gated.isSlicerPurchaseAllowed(slicerId, buyer, productLineItems)); + + // Remove gates + vm.prank(productOwner); + erc20Gated.configureSlicer(slicerId, abi.encode(new ERC20Gate[](0))); + + assertTrue(erc20Gated.isSlicerPurchaseAllowed(slicerId, buyer, productLineItems)); + assertEq(erc20Gated.slicerGates(slicerId).length, 0); + } + + function testOnSlicerPurchase() public { + ERC20Gate[] memory gates = new ERC20Gate[](1); + gates[0] = ERC20Gate(token, 100); + + vm.prank(productOwner); + erc20Gated.configureSlicer(slicerId, abi.encode(gates)); + + ProductLineItem[] memory productLineItems = new ProductLineItem[](1); + productLineItems[0] = + ProductLineItem({productId: uint32(productId), variantId: uint32(variantId), quantity: uint32(5)}); + + // Buyer doesn't have tokens - should revert + vm.expectRevert(); + vm.prank(address(PRODUCTS_MODULE)); + erc20Gated.onSlicerPurchase(slicerId, buyer, productLineItems); + + // Buyer has tokens - should succeed + token.mint(buyer, 100); + vm.prank(address(PRODUCTS_MODULE)); + erc20Gated.onSlicerPurchase(slicerId, buyer, productLineItems); + } + + function testRevert_OnSlicerPurchase_NotPurchase() public { + ERC20Gate[] memory gates = new ERC20Gate[](1); + gates[0] = ERC20Gate(token, 100); + + vm.prank(productOwner); + erc20Gated.configureSlicer(slicerId, abi.encode(gates)); + + token.mint(buyer, 100); + + ProductLineItem[] memory productLineItems = new ProductLineItem[](1); + productLineItems[0] = + ProductLineItem({productId: uint32(productId), variantId: uint32(variantId), quantity: uint32(5)}); + + vm.expectRevert(); + erc20Gated.onSlicerPurchase(slicerId, buyer, productLineItems); } } diff --git a/test/actions/ERC20Gated/mocks/MockERC20Gated.sol b/test/actions/ERC20Gated/mocks/MockERC20Gated.sol index ea96650..f6eedc8 100644 --- a/test/actions/ERC20Gated/mocks/MockERC20Gated.sol +++ b/test/actions/ERC20Gated/mocks/MockERC20Gated.sol @@ -6,7 +6,15 @@ import {IProductsModule, ERC20Gated, ERC20Gate} from "@/hooks/actions/ERC20Gated contract MockERC20Gated is ERC20Gated { constructor(IProductsModule productsModuleAddress) ERC20Gated(productsModuleAddress) {} - function gates(uint256 slicerId, uint256 productId) public view returns (ERC20Gate[] memory) { - return tokenGates[slicerId][productId]; + function gates(uint256 slicerId, uint256 productId, uint256 variantId) public view returns (ERC20Gate[] memory) { + return tokenGates[slicerId][productId][variantId]; + } + + function productTypeGates(uint256 slicerId, uint256 productTypeId) public view returns (ERC20Gate[] memory) { + return productTypeTokenGates[slicerId][productTypeId]; + } + + function slicerGates(uint256 slicerId) public view returns (ERC20Gate[] memory) { + return slicerTokenGates[slicerId]; } } diff --git a/test/actions/ERC20Mint/ERC20Mint.t.sol b/test/actions/ERC20Mint/ERC20Mint.t.sol index 48921cc..80830f9 100644 --- a/test/actions/ERC20Mint/ERC20Mint.t.sol +++ b/test/actions/ERC20Mint/ERC20Mint.t.sol @@ -5,10 +5,13 @@ import {RegistryProductAction, RegistryProductActionTest} from "@test/utils/Regi import {ERC20Mint} from "@/hooks/actions/ERC20Mint/ERC20Mint.sol"; import {ERC20Data} from "@/hooks/actions/ERC20Mint/types/ERC20Data.sol"; import {ERC20Mint_BaseToken} from "@/hooks/actions/ERC20Mint/utils/ERC20Mint_BaseToken.sol"; - -import {console2} from "forge-std/console2.sol"; +import {HookRegistry, ProductLineItem} from "@/utils/RegistryAction.sol"; +import {SliceContext} from "@/utils/RegistryProductAction.sol"; uint256 constant slicerId = 0; +uint256 constant variantId = 0; +uint256 constant variantIdAlt = 1; +uint256 constant productTypeId = 2; contract ERC20MintTest is RegistryProductActionTest { ERC20Mint erc20Mint; @@ -27,6 +30,7 @@ contract ERC20MintTest is RegistryProductActionTest { erc20Mint.configureProduct( slicerId, productIds[0], + variantId, abi.encode( "Test Token 1", // name "TT1", // symbol @@ -42,6 +46,7 @@ contract ERC20MintTest is RegistryProductActionTest { erc20Mint.configureProduct( slicerId, productIds[1], + variantId, abi.encode( "Test Token 2", // name "TT2", // symbol @@ -57,6 +62,7 @@ contract ERC20MintTest is RegistryProductActionTest { erc20Mint.configureProduct( slicerId, productIds[2], + variantId, abi.encode( "Test Token 3", // name "TT3", // symbol @@ -72,7 +78,7 @@ contract ERC20MintTest is RegistryProductActionTest { // Verify tokenData is set correctly (ERC20Mint_BaseToken token1, bool revertOnMaxSupply1, uint256 tokensPerUnit1) = - erc20Mint.tokenData(slicerId, productIds[0]); + erc20Mint.tokenData(slicerId, productIds[0], variantId); assertEq(revertOnMaxSupply1, true); assertEq(tokensPerUnit1, 100); assertEq(token1.name(), "Test Token 1"); @@ -82,7 +88,7 @@ contract ERC20MintTest is RegistryProductActionTest { assertEq(token1.balanceOf(productOwner), 1000); (ERC20Mint_BaseToken token2, bool revertOnMaxSupply2, uint256 tokensPerUnit2) = - erc20Mint.tokenData(slicerId, productIds[1]); + erc20Mint.tokenData(slicerId, productIds[1], variantId); assertEq(revertOnMaxSupply2, false); assertEq(tokensPerUnit2, 50); assertEq(token2.name(), "Test Token 2"); @@ -91,7 +97,7 @@ contract ERC20MintTest is RegistryProductActionTest { assertEq(token2.totalSupply(), 0); // no premint (ERC20Mint_BaseToken token3, bool revertOnMaxSupply3, uint256 tokensPerUnit3) = - erc20Mint.tokenData(slicerId, productIds[2]); + erc20Mint.tokenData(slicerId, productIds[2], variantId); assertEq(revertOnMaxSupply3, true); assertEq(tokensPerUnit3, 1); assertEq(token3.totalSupply(), 500); // premint amount @@ -105,6 +111,7 @@ contract ERC20MintTest is RegistryProductActionTest { erc20Mint.configureProduct( slicerId, productIds[0], + variantId, abi.encode( "Test Token", // name "TT", // symbol @@ -116,13 +123,14 @@ contract ERC20MintTest is RegistryProductActionTest { ) ); - (ERC20Mint_BaseToken token1,,) = erc20Mint.tokenData(slicerId, productIds[0]); + (ERC20Mint_BaseToken token1,,) = erc20Mint.tokenData(slicerId, productIds[0], variantId); address tokenAddress = address(token1); // Second configuration - should update existing token erc20Mint.configureProduct( slicerId, productIds[0], + variantId, abi.encode( "Updated Token", // name (ignored for existing token) "UT", // symbol (ignored for existing token) @@ -135,7 +143,7 @@ contract ERC20MintTest is RegistryProductActionTest { ); (ERC20Mint_BaseToken token2, bool revertOnMaxSupply2, uint256 tokensPerUnit2) = - erc20Mint.tokenData(slicerId, productIds[0]); + erc20Mint.tokenData(slicerId, productIds[0], variantId); // Token address should be the same assertEq(address(token2), tokenAddress); @@ -157,6 +165,7 @@ contract ERC20MintTest is RegistryProductActionTest { erc20Mint.configureProduct( slicerId, productIds[0], + variantId, abi.encode( "Test Token", // name "TT", // symbol @@ -178,6 +187,7 @@ contract ERC20MintTest is RegistryProductActionTest { erc20Mint.configureProduct( slicerId, productIds[0], + variantId, abi.encode( "Test Token", // name "TT", // symbol @@ -193,6 +203,7 @@ contract ERC20MintTest is RegistryProductActionTest { erc20Mint.configureProduct( slicerId, productIds[1], + variantId, abi.encode( "Test Token 2", // name "TT2", // symbol @@ -210,15 +221,15 @@ contract ERC20MintTest is RegistryProductActionTest { // Current supply: 800, max supply: 1000 // Available: 200 tokens, with 10 tokens per unit = 20 units max - assertTrue(erc20Mint.isPurchaseAllowed(slicerId, productIds[0], buyer, 1, "", "")); // 10 tokens needed - assertTrue(erc20Mint.isPurchaseAllowed(slicerId, productIds[0], buyer, 10, "", "")); // 100 tokens needed - assertTrue(erc20Mint.isPurchaseAllowed(slicerId, productIds[0], buyer, 20, "", "")); // 200 tokens needed (exactly at limit) - assertFalse(erc20Mint.isPurchaseAllowed(slicerId, productIds[0], buyer, 21, "", "")); // 210 tokens needed (exceeds limit) + assertTrue(erc20Mint.isPurchaseAllowed(slicerId, productIds[0], 0, buyer, 1, "")); // 10 tokens needed + assertTrue(erc20Mint.isPurchaseAllowed(slicerId, productIds[0], 0, buyer, 10, "")); // 100 tokens needed + assertTrue(erc20Mint.isPurchaseAllowed(slicerId, productIds[0], 0, buyer, 20, "")); // 200 tokens needed (exactly at limit) + assertFalse(erc20Mint.isPurchaseAllowed(slicerId, productIds[0], 0, buyer, 21, "")); // 210 tokens needed (exceeds limit) // Test product 2 (unlimited supply) - assertTrue(erc20Mint.isPurchaseAllowed(slicerId, productIds[1], buyer, 1, "", "")); - assertTrue(erc20Mint.isPurchaseAllowed(slicerId, productIds[1], buyer, 1000, "", "")); - assertTrue(erc20Mint.isPurchaseAllowed(slicerId, productIds[1], buyer, type(uint256).max, "", "")); + assertTrue(erc20Mint.isPurchaseAllowed(slicerId, productIds[1], 0, buyer, 1, "")); + assertTrue(erc20Mint.isPurchaseAllowed(slicerId, productIds[1], 0, buyer, 1000, "")); + assertTrue(erc20Mint.isPurchaseAllowed(slicerId, productIds[1], 0, buyer, type(uint256).max, "")); } function testOnProductPurchase() public { @@ -228,6 +239,7 @@ contract ERC20MintTest is RegistryProductActionTest { erc20Mint.configureProduct( slicerId, productIds[0], + variantId, abi.encode( "Test Token 1", // name "TT1", // symbol @@ -242,6 +254,7 @@ contract ERC20MintTest is RegistryProductActionTest { erc20Mint.configureProduct( slicerId, productIds[1], + variantId, abi.encode( "Test Token 2", // name "TT2", // symbol @@ -255,15 +268,15 @@ contract ERC20MintTest is RegistryProductActionTest { vm.stopPrank(); - (ERC20Mint_BaseToken token1,,) = erc20Mint.tokenData(slicerId, productIds[0]); - (ERC20Mint_BaseToken token2,,) = erc20Mint.tokenData(slicerId, productIds[1]); + (ERC20Mint_BaseToken token1,,) = erc20Mint.tokenData(slicerId, productIds[0], variantId); + (ERC20Mint_BaseToken token2,,) = erc20Mint.tokenData(slicerId, productIds[1], variantId); // Test minting for product 1 uint256 initialBalance1 = token1.balanceOf(buyer); uint256 initialSupply1 = token1.totalSupply(); vm.prank(address(PRODUCTS_MODULE)); - erc20Mint.onProductPurchase(slicerId, productIds[0], buyer, 3, "", ""); + erc20Mint.onProductPurchase(slicerId, productIds[0], variantId, buyer, 3, ""); assertEq(token1.balanceOf(buyer), initialBalance1 + 300); // 3 * 100 assertEq(token1.totalSupply(), initialSupply1 + 300); @@ -273,14 +286,14 @@ contract ERC20MintTest is RegistryProductActionTest { uint256 initialSupply2 = token2.totalSupply(); vm.prank(address(PRODUCTS_MODULE)); - erc20Mint.onProductPurchase(slicerId, productIds[1], buyer2, 5, "", ""); + erc20Mint.onProductPurchase(slicerId, productIds[1], variantId, buyer2, 5, ""); assertEq(token2.balanceOf(buyer2), initialBalance2 + 250); // 5 * 50 assertEq(token2.totalSupply(), initialSupply2 + 250); // Test multiple purchases vm.prank(address(PRODUCTS_MODULE)); - erc20Mint.onProductPurchase(slicerId, productIds[0], buyer3, 2, "", ""); + erc20Mint.onProductPurchase(slicerId, productIds[0], variantId, buyer3, 2, ""); assertEq(token1.balanceOf(buyer3), 200); // 2 * 100 assertEq(token1.totalSupply(), initialSupply1 + 500); // 300 + 200 } @@ -292,6 +305,7 @@ contract ERC20MintTest is RegistryProductActionTest { erc20Mint.configureProduct( slicerId, productIds[0], + variantId, abi.encode( "Test Token", // name "TT", // symbol @@ -306,12 +320,12 @@ contract ERC20MintTest is RegistryProductActionTest { vm.stopPrank(); // This should succeed but not mint tokens (exceeds max supply) - (ERC20Mint_BaseToken token,,) = erc20Mint.tokenData(slicerId, productIds[0]); + (ERC20Mint_BaseToken token,,) = erc20Mint.tokenData(slicerId, productIds[0], variantId); uint256 initialBalance = token.balanceOf(buyer); uint256 initialSupply = token.totalSupply(); vm.prank(address(PRODUCTS_MODULE)); - erc20Mint.onProductPurchase(slicerId, productIds[0], buyer, 2, "", ""); + erc20Mint.onProductPurchase(slicerId, productIds[0], variantId, buyer, 2, ""); // Balance and supply should remain unchanged (mint failed silently) assertEq(token.balanceOf(buyer), initialBalance); @@ -325,6 +339,7 @@ contract ERC20MintTest is RegistryProductActionTest { erc20Mint.configureProduct( slicerId, productIds[0], + variantId, abi.encode( "Test Token", // name "TT", // symbol @@ -340,14 +355,312 @@ contract ERC20MintTest is RegistryProductActionTest { // This should succeed (50 tokens available, need 50) vm.prank(address(PRODUCTS_MODULE)); - erc20Mint.onProductPurchase(slicerId, productIds[0], buyer, 5, "", ""); + erc20Mint.onProductPurchase(slicerId, productIds[0], 0, buyer, 5, ""); - (ERC20Mint_BaseToken token,,) = erc20Mint.tokenData(slicerId, productIds[0]); + (ERC20Mint_BaseToken token,,) = erc20Mint.tokenData(slicerId, productIds[0], variantId); assertEq(token.totalSupply(), 1000); // at max supply // This should revert (no tokens available) vm.expectRevert(RegistryProductAction.NotAllowed.selector); vm.prank(address(PRODUCTS_MODULE)); - erc20Mint.onProductPurchase(slicerId, productIds[0], buyer, 1, "", ""); + erc20Mint.onProductPurchase(slicerId, productIds[0], variantId, buyer, 1, ""); + } + + function testVariantSpecificTokenConfiguration() public { + vm.startPrank(productOwner); + + erc20Mint.configureProduct( + slicerId, + productIds[0], + variantId, + abi.encode( + "Product Token", // name + "PT0", // symbol + 0, // premintAmount + address(0), // premintReceiver + true, // revertOnMaxSupplyReached + 1000, // maxSupply + 10 // tokensPerUnit + ) + ); + + erc20Mint.configureProduct( + slicerId, + productIds[0], + variantIdAlt, + abi.encode( + "Variant Token", // name + "VT1", // symbol + 0, // premintAmount + address(0), // premintReceiver + false, // revertOnMaxSupplyReached + 0, // maxSupply (unlimited) + 5 // tokensPerUnit + ) + ); + + vm.stopPrank(); + + (ERC20Mint_BaseToken productToken, bool revertProduct, uint256 tokensPerUnitProduct) = + erc20Mint.tokenData(slicerId, productIds[0], variantId); + (ERC20Mint_BaseToken variantToken, bool revertVariant, uint256 tokensPerUnitVariant) = + erc20Mint.tokenData(slicerId, productIds[0], variantIdAlt); + + assertTrue(address(productToken) != address(variantToken)); + assertTrue(revertProduct); + assertFalse(revertVariant); + assertEq(tokensPerUnitProduct, 10); + assertEq(tokensPerUnitVariant, 5); + + vm.prank(address(PRODUCTS_MODULE)); + erc20Mint.onProductPurchase(slicerId, productIds[0], variantId, buyer, 2, ""); + + vm.prank(address(PRODUCTS_MODULE)); + erc20Mint.onProductPurchase(slicerId, productIds[0], variantIdAlt, buyer2, 3, ""); + + assertEq(productToken.balanceOf(buyer), 20); + assertEq(variantToken.balanceOf(buyer2), 15); + assertEq(variantToken.balanceOf(buyer), 0); + } + + /*////////////////////////////////////////////////////////////// + PRODUCT TYPE TESTS + //////////////////////////////////////////////////////////////*/ + + function testConfigureProductType() public { + bytes memory params = abi.encode( + "ProductType Token", // name + "PTT", // symbol + 1000, // premintAmount + productOwner, // premintReceiver + true, // revertOnMaxSupplyReached + 10000, // maxSupply + 100 // tokensPerUnit + ); + + vm.expectEmit(true, true, true, true, address(erc20Mint)); + emit HookRegistry.ProductTypeConfigured(slicerId, productTypeId, params); + + vm.prank(productOwner); + erc20Mint.configureProductType(slicerId, productTypeId, params); + + (ERC20Mint_BaseToken token, bool revertOnMaxSupply, uint256 tokensPerUnit) = + erc20Mint.productTypeTokenData(slicerId, productTypeId); + assertEq(revertOnMaxSupply, true); + assertEq(tokensPerUnit, 100); + assertEq(token.name(), "ProductType Token"); + assertEq(token.symbol(), "PTT"); + assertEq(token.maxSupply(), 10000); + assertEq(token.totalSupply(), 1000); // premint amount + assertEq(token.balanceOf(productOwner), 1000); + } + + function testConfigureProductType_AccessControl() public { + vm.expectRevert(abi.encodeWithSelector(SliceContext.NotAuthorized.selector, bytes32(uint256(1 << 1 | 1 << 3)))); + + erc20Mint.configureProductType(slicerId, productTypeId, ""); + } + + function testConfigureProductTypeSchema() public view { + string memory schema = erc20Mint.configureProductTypeSchema(); + + assertEq(schema, erc20Mint.configureProductSchema()); + assertTrue(bytes(schema).length > 0); + } + + function testIsProductTypePurchaseAllowed() public { + vm.prank(productOwner); + erc20Mint.configureProductType( + slicerId, + productTypeId, + abi.encode( + "Test Token", // name + "TT", // symbol + 800, // premintAmount + productOwner, // premintReceiver + true, // revertOnMaxSupplyReached + 1000, // maxSupply + 10 // tokensPerUnit + ) + ); + + // Current supply: 800, max supply: 1000 + // Available: 200 tokens, with 10 tokens per unit = 20 units max + assertTrue(erc20Mint.isProductTypePurchaseAllowed(slicerId, productTypeId, buyer, 1)); // 10 tokens needed + assertTrue(erc20Mint.isProductTypePurchaseAllowed(slicerId, productTypeId, buyer, 20)); // 200 tokens needed (exactly at limit) + assertFalse(erc20Mint.isProductTypePurchaseAllowed(slicerId, productTypeId, buyer, 21)); // 210 tokens needed (exceeds limit) + } + + function testOnProductTypePurchase() public { + vm.prank(productOwner); + erc20Mint.configureProductType( + slicerId, + productTypeId, + abi.encode( + "Test Token", // name + "TT", // symbol + 0, // premintAmount + address(0), // premintReceiver + true, // revertOnMaxSupplyReached + 1000, // maxSupply + 100 // tokensPerUnit + ) + ); + + (ERC20Mint_BaseToken token,,) = erc20Mint.productTypeTokenData(slicerId, productTypeId); + uint256 initialBalance = token.balanceOf(buyer); + uint256 initialSupply = token.totalSupply(); + + vm.prank(address(PRODUCTS_MODULE)); + erc20Mint.onProductTypePurchase(slicerId, productTypeId, buyer, 3); + + assertEq(token.balanceOf(buyer), initialBalance + 300); // 3 * 100 + assertEq(token.totalSupply(), initialSupply + 300); + } + + function testRevert_OnProductTypePurchase_NotPurchase() public { + vm.prank(productOwner); + erc20Mint.configureProductType( + slicerId, + productTypeId, + abi.encode( + "Test Token", // name + "TT", // symbol + 0, // premintAmount + address(0), // premintReceiver + true, // revertOnMaxSupplyReached + 1000, // maxSupply + 100 // tokensPerUnit + ) + ); + + vm.expectRevert(); + erc20Mint.onProductTypePurchase(slicerId, productTypeId, buyer, 1); + } + + /*////////////////////////////////////////////////////////////// + SLICER TESTS + //////////////////////////////////////////////////////////////*/ + + function testConfigureSlicer() public { + bytes memory params = abi.encode( + "Slicer Token", // name + "ST", // symbol + 500, // premintAmount + buyer, // premintReceiver + false, // revertOnMaxSupplyReached + 0, // maxSupply (unlimited) + 50 // tokensPerUnit + ); + + vm.expectEmit(true, true, true, true, address(erc20Mint)); + emit HookRegistry.SlicerConfigured(slicerId, params); + + vm.prank(productOwner); + erc20Mint.configureSlicer(slicerId, params); + + (ERC20Mint_BaseToken token, bool revertOnMaxSupply, uint256 tokensPerUnit) = erc20Mint.slicerTokenData(slicerId); + assertEq(revertOnMaxSupply, false); + assertEq(tokensPerUnit, 50); + assertEq(token.name(), "Slicer Token"); + assertEq(token.symbol(), "ST"); + assertEq(token.maxSupply(), type(uint256).max); + assertEq(token.totalSupply(), 500); // premint amount + assertEq(token.balanceOf(buyer), 500); + } + + function testConfigureSlicer_AccessControl() public { + vm.expectRevert(abi.encodeWithSelector(SliceContext.NotAuthorized.selector, bytes32(uint256(1 << 1 | 1 << 3)))); + + erc20Mint.configureSlicer(slicerId, ""); + } + + function testConfigureSlicerSchema() public view { + string memory schema = erc20Mint.configureSlicerSchema(); + + assertEq(schema, erc20Mint.configureProductSchema()); + assertTrue(bytes(schema).length > 0); + } + + function testIsSlicerPurchaseAllowed() public { + vm.prank(productOwner); + erc20Mint.configureSlicer( + slicerId, + abi.encode( + "Test Token", // name + "TT", // symbol + 0, // premintAmount + address(0), // premintReceiver + false, // revertOnMaxSupplyReached + 0, // maxSupply (unlimited) + 50 // tokensPerUnit + ) + ); + + ProductLineItem[] memory productLineItems = new ProductLineItem[](2); + productLineItems[0] = + ProductLineItem({productId: uint32(productIds[0]), variantId: uint32(variantId), quantity: uint32(5)}); + productLineItems[1] = + ProductLineItem({productId: uint32(productIds[1]), variantId: uint32(variantId), quantity: uint32(3)}); + // Total quantity: 8, tokens needed: 8 * 50 = 400 + + // Unlimited supply, so should always return true + assertTrue(erc20Mint.isSlicerPurchaseAllowed(slicerId, buyer, productLineItems)); + } + + function testOnSlicerPurchase() public { + vm.prank(productOwner); + erc20Mint.configureSlicer( + slicerId, + abi.encode( + "Test Token", // name + "TT", // symbol + 0, // premintAmount + address(0), // premintReceiver + false, // revertOnMaxSupplyReached + 0, // maxSupply (unlimited) + 50 // tokensPerUnit + ) + ); + + ProductLineItem[] memory productLineItems = new ProductLineItem[](2); + productLineItems[0] = + ProductLineItem({productId: uint32(productIds[0]), variantId: uint32(variantId), quantity: uint32(2)}); + productLineItems[1] = + ProductLineItem({productId: uint32(productIds[1]), variantId: uint32(variantId), quantity: uint32(3)}); + // Total quantity: 5, tokens to mint: 5 * 50 = 250 + + (ERC20Mint_BaseToken token,,) = erc20Mint.slicerTokenData(slicerId); + uint256 initialBalance = token.balanceOf(buyer); + uint256 initialSupply = token.totalSupply(); + + vm.prank(address(PRODUCTS_MODULE)); + erc20Mint.onSlicerPurchase(slicerId, buyer, productLineItems); + + assertEq(token.balanceOf(buyer), initialBalance + 250); // 5 * 50 + assertEq(token.totalSupply(), initialSupply + 250); + } + + function testRevert_OnSlicerPurchase_NotPurchase() public { + vm.prank(productOwner); + erc20Mint.configureSlicer( + slicerId, + abi.encode( + "Test Token", // name + "TT", // symbol + 0, // premintAmount + address(0), // premintReceiver + false, // revertOnMaxSupplyReached + 0, // maxSupply (unlimited) + 50 // tokensPerUnit + ) + ); + + ProductLineItem[] memory productLineItems = new ProductLineItem[](1); + productLineItems[0] = + ProductLineItem({productId: uint32(productIds[0]), variantId: uint32(variantId), quantity: uint32(5)}); + + vm.expectRevert(); + erc20Mint.onSlicerPurchase(slicerId, buyer, productLineItems); } } diff --git a/test/actions/ERC721Mint/ERC721Mint.t.sol b/test/actions/ERC721Mint/ERC721Mint.t.sol index 9a25bc2..b592afd 100644 --- a/test/actions/ERC721Mint/ERC721Mint.t.sol +++ b/test/actions/ERC721Mint/ERC721Mint.t.sol @@ -5,10 +5,13 @@ import {RegistryProductActionTest} from "@test/utils/RegistryProductActionTest.s import {ERC721Mint} from "@/hooks/actions/ERC721Mint/ERC721Mint.sol"; import {ERC721Data} from "@/hooks/actions/ERC721Mint/types/ERC721Data.sol"; import {ERC721Mint_BaseToken, MAX_ROYALTY} from "@/hooks/actions/ERC721Mint/utils/ERC721Mint_BaseToken.sol"; - -import {console2} from "forge-std/console2.sol"; +import {HookRegistry, ProductLineItem} from "@/utils/RegistryAction.sol"; +import {SliceContext} from "@/utils/RegistryProductAction.sol"; uint256 constant slicerId = 0; +uint256 constant variantId = 0; +uint256 constant variantIdAlt = 1; +uint256 constant productTypeId = 2; contract ERC721MintTest is RegistryProductActionTest { ERC721Mint erc721Mint; @@ -27,6 +30,7 @@ contract ERC721MintTest is RegistryProductActionTest { erc721Mint.configureProduct( slicerId, productIds[0], + variantId, abi.encode( "Test NFT 1", // name "TNT1", // symbol @@ -43,6 +47,7 @@ contract ERC721MintTest is RegistryProductActionTest { erc721Mint.configureProduct( slicerId, productIds[1], + variantId, abi.encode( "Test NFT 2", // name "TNT2", // symbol @@ -59,6 +64,7 @@ contract ERC721MintTest is RegistryProductActionTest { erc721Mint.configureProduct( slicerId, productIds[2], + variantId, abi.encode( "Test NFT 3", // name "TNT3", // symbol @@ -74,7 +80,8 @@ contract ERC721MintTest is RegistryProductActionTest { vm.stopPrank(); // Verify tokenData is set correctly - (ERC721Mint_BaseToken token1, bool revertOnMaxSupply1) = erc721Mint.tokenData(slicerId, productIds[0]); + (ERC721Mint_BaseToken token1, bool revertOnMaxSupply1) = + erc721Mint.tokenData(slicerId, productIds[0], variantId); assertEq(revertOnMaxSupply1, true); assertEq(token1.name(), "Test NFT 1"); assertEq(token1.symbol(), "TNT1"); @@ -85,7 +92,8 @@ contract ERC721MintTest is RegistryProductActionTest { assertEq(token1.baseURI_(), "https://api.example.com/metadata/"); assertEq(token1.tokenURI_(), "https://api.example.com/fallback.json"); - (ERC721Mint_BaseToken token2, bool revertOnMaxSupply2) = erc721Mint.tokenData(slicerId, productIds[1]); + (ERC721Mint_BaseToken token2, bool revertOnMaxSupply2) = + erc721Mint.tokenData(slicerId, productIds[1], variantId); assertEq(revertOnMaxSupply2, false); assertEq(token2.name(), "Test NFT 2"); assertEq(token2.symbol(), "TNT2"); @@ -96,7 +104,8 @@ contract ERC721MintTest is RegistryProductActionTest { assertEq(token2.baseURI_(), ""); assertEq(token2.tokenURI_(), "https://api.example.com/single.json"); - (ERC721Mint_BaseToken token3, bool revertOnMaxSupply3) = erc721Mint.tokenData(slicerId, productIds[2]); + (ERC721Mint_BaseToken token3, bool revertOnMaxSupply3) = + erc721Mint.tokenData(slicerId, productIds[2], variantId); assertEq(revertOnMaxSupply3, true); assertEq(token3.maxSupply(), 100); assertEq(token3.royaltyReceiver(), buyer); @@ -112,6 +121,7 @@ contract ERC721MintTest is RegistryProductActionTest { erc721Mint.configureProduct( slicerId, productIds[0], + variantId, abi.encode( "Test NFT", // name "TNT", // symbol @@ -124,13 +134,14 @@ contract ERC721MintTest is RegistryProductActionTest { ) ); - (ERC721Mint_BaseToken token1,) = erc721Mint.tokenData(slicerId, productIds[0]); + (ERC721Mint_BaseToken token1,) = erc721Mint.tokenData(slicerId, productIds[0], variantId); address tokenAddress = address(token1); // Second configuration - should update existing token erc721Mint.configureProduct( slicerId, productIds[0], + variantId, abi.encode( "Updated NFT", // name (ignored for existing token) "UNT", // symbol (ignored for existing token) @@ -143,7 +154,8 @@ contract ERC721MintTest is RegistryProductActionTest { ) ); - (ERC721Mint_BaseToken token2, bool revertOnMaxSupply2) = erc721Mint.tokenData(slicerId, productIds[0]); + (ERC721Mint_BaseToken token2, bool revertOnMaxSupply2) = + erc721Mint.tokenData(slicerId, productIds[0], variantId); // Token address should be the same assertEq(address(token2), tokenAddress); @@ -167,6 +179,7 @@ contract ERC721MintTest is RegistryProductActionTest { erc721Mint.configureProduct( slicerId, productIds[0], + variantId, abi.encode( "Test NFT", // name "TNT", // symbol @@ -187,6 +200,7 @@ contract ERC721MintTest is RegistryProductActionTest { erc721Mint.configureProduct( slicerId, productIds[0], + variantId, abi.encode( "Test NFT 1", // name "TNT1", // symbol @@ -202,6 +216,7 @@ contract ERC721MintTest is RegistryProductActionTest { erc721Mint.configureProduct( slicerId, productIds[1], + variantId, abi.encode( "Test NFT 2", // name "TNT2", // symbol @@ -216,15 +231,15 @@ contract ERC721MintTest is RegistryProductActionTest { vm.stopPrank(); - (ERC721Mint_BaseToken token1,) = erc721Mint.tokenData(slicerId, productIds[0]); - (ERC721Mint_BaseToken token2,) = erc721Mint.tokenData(slicerId, productIds[1]); + (ERC721Mint_BaseToken token1,) = erc721Mint.tokenData(slicerId, productIds[0], variantId); + (ERC721Mint_BaseToken token2,) = erc721Mint.tokenData(slicerId, productIds[1], variantId); // Test minting for product 1 uint256 initialBalance1 = token1.balanceOf(buyer); uint256 initialSupply1 = token1.totalSupply(); vm.prank(address(PRODUCTS_MODULE)); - erc721Mint.onProductPurchase(slicerId, productIds[0], buyer, 3, "", ""); + erc721Mint.onProductPurchase(slicerId, productIds[0], variantId, buyer, 3, ""); assertEq(token1.balanceOf(buyer), initialBalance1 + 3); assertEq(token1.totalSupply(), initialSupply1 + 3); @@ -234,14 +249,14 @@ contract ERC721MintTest is RegistryProductActionTest { uint256 initialSupply2 = token2.totalSupply(); vm.prank(address(PRODUCTS_MODULE)); - erc721Mint.onProductPurchase(slicerId, productIds[1], buyer2, 5, "", ""); + erc721Mint.onProductPurchase(slicerId, productIds[1], variantId, buyer2, 5, ""); assertEq(token2.balanceOf(buyer2), initialBalance2 + 5); assertEq(token2.totalSupply(), initialSupply2 + 5); // Test multiple purchases vm.prank(address(PRODUCTS_MODULE)); - erc721Mint.onProductPurchase(slicerId, productIds[0], buyer3, 2, "", ""); + erc721Mint.onProductPurchase(slicerId, productIds[0], variantId, buyer3, 2, ""); assertEq(token1.balanceOf(buyer3), 2); assertEq(token1.totalSupply(), initialSupply1 + 5); // 3 + 2 } @@ -253,6 +268,7 @@ contract ERC721MintTest is RegistryProductActionTest { erc721Mint.configureProduct( slicerId, productIds[0], + variantId, abi.encode( "Test NFT", // name "TNT", // symbol @@ -265,16 +281,16 @@ contract ERC721MintTest is RegistryProductActionTest { ) ); - (ERC721Mint_BaseToken token,) = erc721Mint.tokenData(slicerId, productIds[0]); + (ERC721Mint_BaseToken token,) = erc721Mint.tokenData(slicerId, productIds[0], variantId); // First purchase - should succeed vm.prank(address(PRODUCTS_MODULE)); - erc721Mint.onProductPurchase(slicerId, productIds[0], buyer, 3, "", ""); + erc721Mint.onProductPurchase(slicerId, productIds[0], variantId, buyer, 3, ""); assertEq(token.totalSupply(), 3); // Second purchase - should succeed vm.prank(address(PRODUCTS_MODULE)); - erc721Mint.onProductPurchase(slicerId, productIds[0], buyer2, 2, "", ""); + erc721Mint.onProductPurchase(slicerId, productIds[0], variantId, buyer2, 2, ""); assertEq(token.totalSupply(), 5); // Third purchase - exceeds max supply but should not revert (mint will fail silently) @@ -282,7 +298,7 @@ contract ERC721MintTest is RegistryProductActionTest { uint256 supplyBefore = token.totalSupply(); vm.prank(address(PRODUCTS_MODULE)); - erc721Mint.onProductPurchase(slicerId, productIds[0], buyer3, 2, "", ""); + erc721Mint.onProductPurchase(slicerId, productIds[0], variantId, buyer3, 2, ""); // Balance and supply should remain unchanged (mint failed silently) assertEq(token.balanceOf(buyer3), balanceBefore); @@ -296,6 +312,7 @@ contract ERC721MintTest is RegistryProductActionTest { erc721Mint.configureProduct( slicerId, productIds[0], + variantId, abi.encode( "Test NFT", // name "TNT", // symbol @@ -310,20 +327,20 @@ contract ERC721MintTest is RegistryProductActionTest { // First purchase - should succeed vm.prank(address(PRODUCTS_MODULE)); - erc721Mint.onProductPurchase(slicerId, productIds[0], buyer, 3, "", ""); + erc721Mint.onProductPurchase(slicerId, productIds[0], 0, buyer, 3, ""); - (ERC721Mint_BaseToken token,) = erc721Mint.tokenData(slicerId, productIds[0]); + (ERC721Mint_BaseToken token,) = erc721Mint.tokenData(slicerId, productIds[0], variantId); assertEq(token.totalSupply(), 3); // Second purchase - should succeed (exactly at max supply) vm.prank(address(PRODUCTS_MODULE)); - erc721Mint.onProductPurchase(slicerId, productIds[0], buyer2, 2, "", ""); + erc721Mint.onProductPurchase(slicerId, productIds[0], variantId, buyer2, 2, ""); assertEq(token.totalSupply(), 5); // Third purchase - should revert (exceeds max supply) vm.expectRevert(ERC721Mint.MaxSupplyExceeded.selector); vm.prank(address(PRODUCTS_MODULE)); - erc721Mint.onProductPurchase(slicerId, productIds[0], buyer3, 1, "", ""); + erc721Mint.onProductPurchase(slicerId, productIds[0], variantId, buyer3, 1, ""); } function testTokenURI() public { @@ -333,6 +350,7 @@ contract ERC721MintTest is RegistryProductActionTest { erc721Mint.configureProduct( slicerId, productIds[0], + variantId, abi.encode( "Test NFT", // name "TNT", // symbol @@ -349,6 +367,7 @@ contract ERC721MintTest is RegistryProductActionTest { erc721Mint.configureProduct( slicerId, productIds[1], + variantId, abi.encode( "Test NFT 2", // name "TNT2", // symbol @@ -363,15 +382,15 @@ contract ERC721MintTest is RegistryProductActionTest { vm.stopPrank(); - (ERC721Mint_BaseToken token1,) = erc721Mint.tokenData(slicerId, productIds[0]); - (ERC721Mint_BaseToken token2,) = erc721Mint.tokenData(slicerId, productIds[1]); + (ERC721Mint_BaseToken token1,) = erc721Mint.tokenData(slicerId, productIds[0], variantId); + (ERC721Mint_BaseToken token2,) = erc721Mint.tokenData(slicerId, productIds[1], variantId); // Mint some tokens vm.prank(address(PRODUCTS_MODULE)); - erc721Mint.onProductPurchase(slicerId, productIds[0], buyer, 2, "", ""); + erc721Mint.onProductPurchase(slicerId, productIds[0], variantId, buyer, 2, ""); vm.prank(address(PRODUCTS_MODULE)); - erc721Mint.onProductPurchase(slicerId, productIds[1], buyer, 1, "", ""); + erc721Mint.onProductPurchase(slicerId, productIds[1], variantId, buyer, 1, ""); // Test token URIs for product 1 (with baseURI) assertEq(token1.tokenURI(0), "https://api.example.com/metadata/0"); @@ -381,11 +400,69 @@ contract ERC721MintTest is RegistryProductActionTest { assertEq(token2.tokenURI(0), "https://single-token.json"); } + function testVariantSpecificTokenConfiguration() public { + vm.startPrank(productOwner); + + erc721Mint.configureProduct( + slicerId, + productIds[0], + variantId, + abi.encode( + "Variant Zero", // name + "V0", // symbol + productOwner, // royaltyReceiver + 500, // royaltyFraction (5%) + "ipfs://base0/", // baseURI + "", // tokenURI + true, // revertOnMaxSupplyReached + 100 // maxSupply + ) + ); + + erc721Mint.configureProduct( + slicerId, + productIds[0], + variantIdAlt, + abi.encode( + "Variant One", // name + "V1", // symbol + buyer, // royaltyReceiver + 0, // royaltyFraction (0%) + "", // baseURI + "https://static.example/token.json", // tokenURI + false, // revertOnMaxSupplyReached + 0 // maxSupply (unlimited) + ) + ); + + vm.stopPrank(); + + (ERC721Mint_BaseToken productToken, bool revertProduct) = + erc721Mint.tokenData(slicerId, productIds[0], variantId); + (ERC721Mint_BaseToken variantToken, bool revertVariant) = + erc721Mint.tokenData(slicerId, productIds[0], variantIdAlt); + + assertTrue(address(productToken) != address(variantToken)); + assertTrue(revertProduct); + assertFalse(revertVariant); + assertEq(productToken.royaltyReceiver(), productOwner); + assertEq(variantToken.royaltyReceiver(), buyer); + assertEq(productToken.baseURI_(), "ipfs://base0/"); + assertEq(variantToken.tokenURI_(), "https://static.example/token.json"); + + vm.prank(address(PRODUCTS_MODULE)); + erc721Mint.onProductPurchase(slicerId, productIds[0], variantIdAlt, buyer2, 2, ""); + + assertEq(variantToken.balanceOf(buyer2), 2); + assertEq(productToken.balanceOf(buyer2), 0); + } + function testRoyaltyInfo() public { vm.prank(productOwner); erc721Mint.configureProduct( slicerId, productIds[0], + variantId, abi.encode( "Test NFT", // name "TNT", // symbol @@ -398,7 +475,7 @@ contract ERC721MintTest is RegistryProductActionTest { ) ); - (ERC721Mint_BaseToken token,) = erc721Mint.tokenData(slicerId, productIds[0]); + (ERC721Mint_BaseToken token,) = erc721Mint.tokenData(slicerId, productIds[0], variantId); // Test royalty calculation (address receiver, uint256 royaltyAmount) = token.royaltyInfo(0, 1000); @@ -409,4 +486,292 @@ contract ERC721MintTest is RegistryProductActionTest { assertEq(receiver, productOwner); assertEq(royaltyAmount, 100); // 5% of 2000 } + + /*////////////////////////////////////////////////////////////// + PRODUCT TYPE TESTS + //////////////////////////////////////////////////////////////*/ + + function testConfigureProductType() public { + bytes memory params = abi.encode( + "ProductType NFT", // name + "PTN", // symbol + productOwner, // royaltyReceiver + 500, // royaltyFraction (5%) + "https://api.example.com/metadata/", // baseURI + "https://api.example.com/fallback.json", // tokenURI + true, // revertOnMaxSupplyReached + 1000 // maxSupply + ); + + vm.expectEmit(true, true, true, true, address(erc721Mint)); + emit HookRegistry.ProductTypeConfigured(slicerId, productTypeId, params); + + vm.prank(productOwner); + erc721Mint.configureProductType(slicerId, productTypeId, params); + + (ERC721Mint_BaseToken token, bool revertOnMaxSupply) = erc721Mint.productTypeTokenData(slicerId, productTypeId); + assertEq(revertOnMaxSupply, true); + assertEq(token.name(), "ProductType NFT"); + assertEq(token.symbol(), "PTN"); + assertEq(token.maxSupply(), 1000); + assertEq(token.totalSupply(), 0); + assertEq(token.royaltyReceiver(), productOwner); + assertEq(token.royaltyFraction(), 500); + assertEq(token.baseURI_(), "https://api.example.com/metadata/"); + assertEq(token.tokenURI_(), "https://api.example.com/fallback.json"); + } + + function testConfigureProductType_AccessControl() public { + vm.expectRevert(abi.encodeWithSelector(SliceContext.NotAuthorized.selector, bytes32(uint256(1 << 1 | 1 << 3)))); + + erc721Mint.configureProductType(slicerId, productTypeId, ""); + } + + function testConfigureProductTypeSchema() public view { + string memory schema = erc721Mint.configureProductTypeSchema(); + + assertEq(schema, erc721Mint.configureProductSchema()); + assertTrue(bytes(schema).length > 0); + } + + function testOnProductTypePurchase() public { + vm.prank(productOwner); + erc721Mint.configureProductType( + slicerId, + productTypeId, + abi.encode( + "Test NFT", // name + "TNT", // symbol + productOwner, // royaltyReceiver + 500, // royaltyFraction + "https://api.example.com/", // baseURI + "", // tokenURI + true, // revertOnMaxSupplyReached + 1000 // maxSupply + ) + ); + + (ERC721Mint_BaseToken token,) = erc721Mint.productTypeTokenData(slicerId, productTypeId); + uint256 initialBalance = token.balanceOf(buyer); + uint256 initialSupply = token.totalSupply(); + + vm.prank(address(PRODUCTS_MODULE)); + erc721Mint.onProductTypePurchase(slicerId, productTypeId, buyer, 3); + + assertEq(token.balanceOf(buyer), initialBalance + 3); + assertEq(token.totalSupply(), initialSupply + 3); + } + + function testRevert_OnProductTypePurchase_NotPurchase() public { + vm.prank(productOwner); + erc721Mint.configureProductType( + slicerId, + productTypeId, + abi.encode( + "Test NFT", // name + "TNT", // symbol + productOwner, // royaltyReceiver + 500, // royaltyFraction + "https://api.example.com/", // baseURI + "", // tokenURI + true, // revertOnMaxSupplyReached + 1000 // maxSupply + ) + ); + + vm.expectRevert(); + erc721Mint.onProductTypePurchase(slicerId, productTypeId, buyer, 1); + } + + function testRevert_OnProductTypePurchase_MaxSupplyReached() public { + vm.prank(productOwner); + erc721Mint.configureProductType( + slicerId, + productTypeId, + abi.encode( + "Test NFT", // name + "TNT", // symbol + productOwner, // royaltyReceiver + 0, // royaltyFraction + "", // baseURI + "", // tokenURI + true, // revertOnMaxSupplyReached + 5 // maxSupply + ) + ); + + // First purchase - should succeed + vm.prank(address(PRODUCTS_MODULE)); + erc721Mint.onProductTypePurchase(slicerId, productTypeId, buyer, 3); + + (ERC721Mint_BaseToken token,) = erc721Mint.productTypeTokenData(slicerId, productTypeId); + assertEq(token.totalSupply(), 3); + + // Second purchase - should succeed (exactly at max supply) + vm.prank(address(PRODUCTS_MODULE)); + erc721Mint.onProductTypePurchase(slicerId, productTypeId, buyer2, 2); + assertEq(token.totalSupply(), 5); + + // Third purchase - should revert (exceeds max supply) + vm.expectRevert(ERC721Mint.MaxSupplyExceeded.selector); + vm.prank(address(PRODUCTS_MODULE)); + erc721Mint.onProductTypePurchase(slicerId, productTypeId, buyer3, 1); + } + + /*////////////////////////////////////////////////////////////// + SLICER TESTS + //////////////////////////////////////////////////////////////*/ + + function testConfigureSlicer() public { + bytes memory params = abi.encode( + "Slicer NFT", // name + "SN", // symbol + buyer, // royaltyReceiver + 0, // royaltyFraction + "", // baseURI + "https://slicer.json", // tokenURI + false, // revertOnMaxSupplyReached + 0 // maxSupply (unlimited) + ); + + vm.expectEmit(true, true, true, true, address(erc721Mint)); + emit HookRegistry.SlicerConfigured(slicerId, params); + + vm.prank(productOwner); + erc721Mint.configureSlicer(slicerId, params); + + (ERC721Mint_BaseToken token, bool revertOnMaxSupply) = erc721Mint.slicerTokenData(slicerId); + assertEq(revertOnMaxSupply, false); + assertEq(token.name(), "Slicer NFT"); + assertEq(token.symbol(), "SN"); + assertEq(token.maxSupply(), type(uint256).max); + assertEq(token.totalSupply(), 0); + assertEq(token.royaltyReceiver(), buyer); + assertEq(token.royaltyFraction(), 0); + assertEq(token.baseURI_(), ""); + assertEq(token.tokenURI_(), "https://slicer.json"); + } + + function testConfigureSlicer_AccessControl() public { + vm.expectRevert(abi.encodeWithSelector(SliceContext.NotAuthorized.selector, bytes32(uint256(1 << 1 | 1 << 3)))); + + erc721Mint.configureSlicer(slicerId, ""); + } + + function testConfigureSlicerSchema() public view { + string memory schema = erc721Mint.configureSlicerSchema(); + + assertEq(schema, erc721Mint.configureProductSchema()); + assertTrue(bytes(schema).length > 0); + } + + function testOnSlicerPurchase() public { + vm.prank(productOwner); + erc721Mint.configureSlicer( + slicerId, + abi.encode( + "Test NFT", // name + "TNT", // symbol + productOwner, // royaltyReceiver + 500, // royaltyFraction + "https://api.example.com/", // baseURI + "", // tokenURI + false, // revertOnMaxSupplyReached + 0 // maxSupply (unlimited) + ) + ); + + ProductLineItem[] memory productLineItems = new ProductLineItem[](2); + productLineItems[0] = + ProductLineItem({productId: uint32(productIds[0]), variantId: uint32(variantId), quantity: uint32(2)}); + productLineItems[1] = + ProductLineItem({productId: uint32(productIds[1]), variantId: uint32(variantId), quantity: uint32(3)}); + // Total quantity: 5 + + (ERC721Mint_BaseToken token,) = erc721Mint.slicerTokenData(slicerId); + uint256 initialBalance = token.balanceOf(buyer); + uint256 initialSupply = token.totalSupply(); + + vm.prank(address(PRODUCTS_MODULE)); + erc721Mint.onSlicerPurchase(slicerId, buyer, productLineItems); + + assertEq(token.balanceOf(buyer), initialBalance + 5); // 2 + 3 + assertEq(token.totalSupply(), initialSupply + 5); + } + + function testRevert_OnSlicerPurchase_NotPurchase() public { + vm.prank(productOwner); + erc721Mint.configureSlicer( + slicerId, + abi.encode( + "Test NFT", // name + "TNT", // symbol + productOwner, // royaltyReceiver + 500, // royaltyFraction + "https://api.example.com/", // baseURI + "", // tokenURI + false, // revertOnMaxSupplyReached + 0 // maxSupply (unlimited) + ) + ); + + ProductLineItem[] memory productLineItems = new ProductLineItem[](1); + productLineItems[0] = + ProductLineItem({productId: uint32(productIds[0]), variantId: uint32(variantId), quantity: uint32(5)}); + + vm.expectRevert(); + erc721Mint.onSlicerPurchase(slicerId, buyer, productLineItems); + } + + function testRevert_OnSlicerPurchase_MaxSupplyReached() public { + vm.prank(productOwner); + erc721Mint.configureSlicer( + slicerId, + abi.encode( + "Test NFT", // name + "TNT", // symbol + productOwner, // royaltyReceiver + 0, // royaltyFraction + "", // baseURI + "", // tokenURI + true, // revertOnMaxSupplyReached + 5 // maxSupply + ) + ); + + ProductLineItem[] memory productLineItems1 = new ProductLineItem[](1); + productLineItems1[0] = + ProductLineItem({productId: uint32(productIds[0]), variantId: uint32(variantId), quantity: uint32(3)}); + + // First purchase - should succeed + vm.prank(address(PRODUCTS_MODULE)); + erc721Mint.onSlicerPurchase(slicerId, buyer, productLineItems1); + + (ERC721Mint_BaseToken token,) = erc721Mint.slicerTokenData(slicerId); + assertEq(token.totalSupply(), 3); + + ProductLineItem[] memory productLineItems2 = new ProductLineItem[](1); + productLineItems2[0] = + ProductLineItem({productId: uint32(productIds[0]), variantId: uint32(variantId), quantity: uint32(2)}); + + // Second purchase - should succeed (exactly at max supply) + vm.prank(address(PRODUCTS_MODULE)); + erc721Mint.onSlicerPurchase(slicerId, buyer2, productLineItems2); + assertEq(token.totalSupply(), 5); + + ProductLineItem[] memory productLineItems3 = new ProductLineItem[](1); + productLineItems3[0] = + ProductLineItem({productId: uint32(productIds[0]), variantId: uint32(variantId), quantity: uint32(1)}); + + // Third purchase - should fail silently (slicer actions don't revert on max supply) + uint256 balanceBefore = token.balanceOf(buyer3); + uint256 supplyBefore = token.totalSupply(); + + vm.prank(address(PRODUCTS_MODULE)); + erc721Mint.onSlicerPurchase(slicerId, buyer3, productLineItems3); + + // Balance and supply should remain unchanged (mint failed silently) + assertEq(token.balanceOf(buyer3), balanceBefore); + assertEq(token.totalSupply(), supplyBefore); + } } diff --git a/test/actions/NFTGated/NFTGated.t.sol b/test/actions/NFTGated/NFTGated.t.sol index 10e2b23..f65da29 100644 --- a/test/actions/NFTGated/NFTGated.t.sol +++ b/test/actions/NFTGated/NFTGated.t.sol @@ -1,15 +1,20 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.30; +import {ERC20} from "@openzeppelin-4.8.0/token/ERC20/ERC20.sol"; import {RegistryProductActionTest} from "@test/utils/RegistryProductActionTest.sol"; -import {MockNFTGated} from "./mocks/MockNFTGated.sol"; -import {NFTGates, NFTGate, NftType} from "@/hooks/actions/NFTGated/NFTGated.sol"; +import {MockNFTGated, NFTGated} from "./mocks/MockNFTGated.sol"; +import {NFTGate, NftType} from "@/hooks/actions/NFTGated/NFTGated.sol"; +import {NFTGates} from "@/hooks/actions/NFTGated/types/NFTGate.sol"; import {MockERC721} from "@test/utils/mocks/MockERC721.sol"; import {MockERC1155} from "@test/utils/mocks/MockERC1155.sol"; - -import {console2} from "forge-std/console2.sol"; +import {HookRegistry, ProductLineItem} from "@/utils/RegistryAction.sol"; +import {SliceContext} from "@/utils/RegistryProductAction.sol"; uint256 constant slicerId = 0; +uint256 constant variantId = 0; +uint256 constant variantIdAlt = 1; +uint256 constant productTypeId = 2; contract NFTGatedTest is RegistryProductActionTest { MockNFTGated nftGated; @@ -28,8 +33,8 @@ contract NFTGatedTest is RegistryProductActionTest { vm.startPrank(productOwner); for (uint256 i = 0; i < productIds.length; i++) { - nftGated.configureProduct(slicerId, productIds[i], abi.encode(nftGates[i])); - assertEq(nftGated.nftGates(slicerId, productIds[i]), nftGates[i].minOwned); + nftGated.configureProduct(slicerId, productIds[i], variantId, encodeNFTGates(nftGates[i])); + assertEq(nftGated.nftGates(slicerId, productIds[i], variantId), nftGates[i].minOwned); } vm.stopPrank(); } @@ -39,14 +44,14 @@ contract NFTGatedTest is RegistryProductActionTest { vm.startPrank(productOwner); - nftGated.configureProduct(slicerId, productIds[2], abi.encode(nftGates[2])); - assertEq(nftGated.gates(slicerId, productIds[2])[0].nft, address(nft721)); - assertEq(nftGated.gates(slicerId, productIds[2])[1].nft, address(nft1155)); - assertEq(nftGated.gates(slicerId, productIds[2]).length, 2); + nftGated.configureProduct(slicerId, productIds[2], variantId, encodeNFTGates(nftGates[2])); + assertEq(nftGated.gates(slicerId, productIds[2], variantId)[0].nft, address(nft721)); + assertEq(nftGated.gates(slicerId, productIds[2], variantId)[1].nft, address(nft1155)); + assertEq(nftGated.gates(slicerId, productIds[2], variantId).length, 2); - nftGated.configureProduct(slicerId, productIds[2], abi.encode(nftGates[1])); - assertEq(nftGated.gates(slicerId, productIds[2])[0].nft, address(nft1155)); - assertEq(nftGated.gates(slicerId, productIds[2]).length, 1); + nftGated.configureProduct(slicerId, productIds[2], variantId, encodeNFTGates(nftGates[1])); + assertEq(nftGated.gates(slicerId, productIds[2], variantId)[0].nft, address(nft1155)); + assertEq(nftGated.gates(slicerId, productIds[2], variantId).length, 1); vm.stopPrank(); } @@ -56,7 +61,7 @@ contract NFTGatedTest is RegistryProductActionTest { vm.startPrank(productOwner); for (uint256 i = 0; i < productIds.length; i++) { - nftGated.configureProduct(slicerId, productIds[i], abi.encode(nftGates[i])); + nftGated.configureProduct(slicerId, productIds[i], variantId, encodeNFTGates(nftGates[i])); } vm.stopPrank(); @@ -67,22 +72,307 @@ contract NFTGatedTest is RegistryProductActionTest { nft1155.mint(buyer3); // buyer should be able to purchase all products - assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[0], buyer, 0, "", "")); - assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[1], buyer, 0, "", "")); - assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[2], buyer, 0, "", "")); - assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[3], buyer, 0, "", "")); + assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[0], variantId, buyer, 0, "")); + assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[1], variantId, buyer, 0, "")); + assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[2], variantId, buyer, 0, "")); + assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[3], variantId, buyer, 0, "")); // buyer2 should be able to purchase all products except product 2 and 4 - assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[0], buyer2, 0, "", "")); - assertFalse(nftGated.isPurchaseAllowed(slicerId, productIds[1], buyer2, 0, "", "")); - assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[2], buyer2, 0, "", "")); - assertFalse(nftGated.isPurchaseAllowed(slicerId, productIds[3], buyer2, 0, "", "")); + assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[0], variantId, buyer2, 0, "")); + assertFalse(nftGated.isPurchaseAllowed(slicerId, productIds[1], variantId, buyer2, 0, "")); + assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[2], variantId, buyer2, 0, "")); + assertFalse(nftGated.isPurchaseAllowed(slicerId, productIds[3], variantId, buyer2, 0, "")); // buyer3 should be able to purchase all products except product 1 and 4 - assertFalse(nftGated.isPurchaseAllowed(slicerId, productIds[0], buyer3, 0, "", "")); - assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[1], buyer3, 0, "", "")); - assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[2], buyer3, 0, "", "")); - assertFalse(nftGated.isPurchaseAllowed(slicerId, productIds[3], buyer3, 0, "", "")); + assertFalse(nftGated.isPurchaseAllowed(slicerId, productIds[0], variantId, buyer3, 0, "")); + assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[1], variantId, buyer3, 0, "")); + assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[2], variantId, buyer3, 0, "")); + assertFalse(nftGated.isPurchaseAllowed(slicerId, productIds[3], variantId, buyer3, 0, "")); + } + + function testIsPurchaseAllowed_variantSpecificConfiguration() public { + NFTGate[] memory productVariantGates = new NFTGate[](1); + productVariantGates[0] = NFTGate(address(nft721), NftType.ERC721, 1, 1); + NFTGate[] memory variantSpecificGates = new NFTGate[](1); + variantSpecificGates[0] = NFTGate(address(nft1155), NftType.ERC1155, 1, 1); + + vm.startPrank(productOwner); + nftGated.configureProduct(slicerId, productIds[0], variantId, encodeNFTGates(NFTGates(productVariantGates, 1))); + nftGated.configureProduct( + slicerId, productIds[0], variantIdAlt, encodeNFTGates(NFTGates(variantSpecificGates, 1)) + ); + vm.stopPrank(); + + nft721.mint(buyer); + assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[0], variantId, buyer, 0, "")); + assertFalse(nftGated.isPurchaseAllowed(slicerId, productIds[0], variantIdAlt, buyer, 0, "")); + + nft1155.mint(buyer); + assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[0], variantIdAlt, buyer, 0, "")); + + nft1155.mint(buyer2); + assertFalse(nftGated.isPurchaseAllowed(slicerId, productIds[0], variantId, buyer2, 0, "")); + assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[0], variantIdAlt, buyer2, 0, "")); + } + + function testRevert_ConfigureProduct_InvalidNFT() public { + NFTGates[] memory nftGates = new NFTGates[](1); + + // Only 721 is required + NFTGate[] memory gates1 = new NFTGate[](1); + gates1[0] = NFTGate(address(new TestERC20()), NftType.ERC721, 1, 1); + nftGates[0] = NFTGates(gates1, 1); + + vm.startPrank(productOwner); + + vm.expectRevert(NFTGated.InvalidNFT.selector); + nftGated.configureProduct(slicerId, productIds[0], variantId, encodeNFTGates(nftGates[0])); + + vm.stopPrank(); + } + + /*////////////////////////////////////////////////////////////// + PRODUCT TYPE TESTS + //////////////////////////////////////////////////////////////*/ + + function testConfigureProductType() public { + NFTGates[] memory nftGates = generateNFTGates(); + + bytes memory params = encodeNFTGates(nftGates[2]); + + vm.expectEmit(true, true, true, true, address(nftGated)); + emit HookRegistry.ProductTypeConfigured(slicerId, productTypeId, params); + + vm.prank(productOwner); + nftGated.configureProductType(slicerId, productTypeId, params); + + assertEq(nftGated.productTypeMinOwned(slicerId, productTypeId), 1); + assertEq(nftGated.productTypeGates(slicerId, productTypeId)[0].nft, address(nft721)); + assertEq(nftGated.productTypeGates(slicerId, productTypeId)[1].nft, address(nft1155)); + assertEq(nftGated.productTypeGates(slicerId, productTypeId).length, 2); + + // Buyer doesn't have NFTs yet + assertFalse(nftGated.isProductTypePurchaseAllowed(slicerId, productTypeId, buyer, 0)); + + // Buyer has only ERC721 + nft721.mint(buyer); + assertTrue(nftGated.isProductTypePurchaseAllowed(slicerId, productTypeId, buyer, 0)); + + // Buyer2 has only ERC1155 + nft1155.mint(buyer2); + assertTrue(nftGated.isProductTypePurchaseAllowed(slicerId, productTypeId, buyer2, 0)); + } + + function testConfigureProductType_AccessControl() public { + vm.expectRevert(abi.encodeWithSelector(SliceContext.NotAuthorized.selector, bytes32(uint256(1 << 1 | 1 << 3)))); + + nftGated.configureProductType(slicerId, productTypeId, ""); + } + + function testConfigureProductTypeSchema() public view { + string memory schema = nftGated.configureProductTypeSchema(); + + assertEq(schema, nftGated.configureProductSchema()); + assertTrue(bytes(schema).length > 0); + } + + function testIsProductTypePurchaseAllowed_NoGates() public { + vm.prank(productOwner); + NFTGates memory emptyGates = NFTGates(new NFTGate[](0), 0); + nftGated.configureProductType(slicerId, productTypeId, encodeNFTGates(emptyGates)); + + assertTrue(nftGated.isProductTypePurchaseAllowed(slicerId, productTypeId, buyer2, 0)); + } + + function testConfigureProductType__Edit_Remove() public { + NFTGates[] memory nftGates = generateNFTGates(); + + vm.prank(productOwner); + nftGated.configureProductType(slicerId, productTypeId, encodeNFTGates(nftGates[2])); + + nft721.mint(buyer); + assertTrue(nftGated.isProductTypePurchaseAllowed(slicerId, productTypeId, buyer, 0)); + + // Remove gates + vm.prank(productOwner); + NFTGates memory emptyGates = NFTGates(new NFTGate[](0), 0); + nftGated.configureProductType(slicerId, productTypeId, encodeNFTGates(emptyGates)); + + assertTrue(nftGated.isProductTypePurchaseAllowed(slicerId, productTypeId, buyer, 0)); + assertEq(nftGated.productTypeGates(slicerId, productTypeId).length, 0); + } + + function testOnProductTypePurchase() public { + NFTGates[] memory nftGates = generateNFTGates(); + + vm.prank(productOwner); + nftGated.configureProductType(slicerId, productTypeId, encodeNFTGates(nftGates[0])); + + // Buyer doesn't have NFT - should revert + vm.expectRevert(); + vm.prank(address(PRODUCTS_MODULE)); + nftGated.onProductTypePurchase(slicerId, productTypeId, buyer, 1); + + // Buyer has NFT - should succeed + nft721.mint(buyer); + vm.prank(address(PRODUCTS_MODULE)); + nftGated.onProductTypePurchase(slicerId, productTypeId, buyer, 1); + } + + function testRevert_OnProductTypePurchase_NotPurchase() public { + NFTGates[] memory nftGates = generateNFTGates(); + + vm.prank(productOwner); + nftGated.configureProductType(slicerId, productTypeId, encodeNFTGates(nftGates[0])); + + nft721.mint(buyer); + + vm.expectRevert(); + nftGated.onProductTypePurchase(slicerId, productTypeId, buyer, 1); + } + + function testRevert_ConfigureProductType_InvalidNFT() public { + NFTGate[] memory gates1 = new NFTGate[](1); + gates1[0] = NFTGate(address(new TestERC20()), NftType.ERC721, 1, 1); + NFTGates memory nftGates = NFTGates(gates1, 1); + + vm.startPrank(productOwner); + + vm.expectRevert(NFTGated.InvalidNFT.selector); + nftGated.configureProductType(slicerId, productTypeId, encodeNFTGates(nftGates)); + + vm.stopPrank(); + } + + /*////////////////////////////////////////////////////////////// + SLICER TESTS + //////////////////////////////////////////////////////////////*/ + + function testConfigureSlicer() public { + NFTGates[] memory nftGates = generateNFTGates(); + + bytes memory params = encodeNFTGates(nftGates[2]); + + vm.expectEmit(true, true, true, true, address(nftGated)); + emit HookRegistry.SlicerConfigured(slicerId, params); + + vm.prank(productOwner); + nftGated.configureSlicer(slicerId, params); + + assertEq(nftGated.slicerMinOwned(slicerId), 1); + assertEq(nftGated.slicerGates(slicerId)[0].nft, address(nft721)); + assertEq(nftGated.slicerGates(slicerId)[1].nft, address(nft1155)); + assertEq(nftGated.slicerGates(slicerId).length, 2); + + // Buyer doesn't have NFTs yet + ProductLineItem[] memory productLineItems = new ProductLineItem[](1); + productLineItems[0] = + ProductLineItem({productId: uint32(productIds[0]), variantId: uint32(variantId), quantity: uint32(5)}); + assertFalse(nftGated.isSlicerPurchaseAllowed(slicerId, buyer, productLineItems)); + + // Buyer has only ERC721 + nft721.mint(buyer); + assertTrue(nftGated.isSlicerPurchaseAllowed(slicerId, buyer, productLineItems)); + + // Buyer2 has only ERC1155 + nft1155.mint(buyer2); + assertTrue(nftGated.isSlicerPurchaseAllowed(slicerId, buyer2, productLineItems)); + } + + function testConfigureSlicer_AccessControl() public { + vm.expectRevert(abi.encodeWithSelector(SliceContext.NotAuthorized.selector, bytes32(uint256(1 << 1 | 1 << 3)))); + + nftGated.configureSlicer(slicerId, ""); + } + + function testConfigureSlicerSchema() public view { + string memory schema = nftGated.configureSlicerSchema(); + + assertEq(schema, nftGated.configureProductSchema()); + assertTrue(bytes(schema).length > 0); + } + + function testIsSlicerPurchaseAllowed_NoGates() public { + vm.prank(productOwner); + NFTGates memory emptyGates = NFTGates(new NFTGate[](0), 0); + nftGated.configureSlicer(slicerId, encodeNFTGates(emptyGates)); + + ProductLineItem[] memory productLineItems = new ProductLineItem[](1); + productLineItems[0] = + ProductLineItem({productId: uint32(productIds[0]), variantId: uint32(variantId), quantity: uint32(5)}); + assertTrue(nftGated.isSlicerPurchaseAllowed(slicerId, buyer3, productLineItems)); + } + + function testConfigureSlicer__Edit_Remove() public { + NFTGates[] memory nftGates = generateNFTGates(); + + vm.prank(productOwner); + nftGated.configureSlicer(slicerId, encodeNFTGates(nftGates[2])); + + nft721.mint(buyer); + + ProductLineItem[] memory productLineItems = new ProductLineItem[](1); + productLineItems[0] = + ProductLineItem({productId: uint32(productIds[0]), variantId: uint32(variantId), quantity: uint32(5)}); + assertTrue(nftGated.isSlicerPurchaseAllowed(slicerId, buyer, productLineItems)); + + // Remove gates + vm.prank(productOwner); + NFTGates memory emptyGates = NFTGates(new NFTGate[](0), 0); + nftGated.configureSlicer(slicerId, encodeNFTGates(emptyGates)); + + assertTrue(nftGated.isSlicerPurchaseAllowed(slicerId, buyer, productLineItems)); + assertEq(nftGated.slicerGates(slicerId).length, 0); + } + + function testOnSlicerPurchase() public { + NFTGates[] memory nftGates = generateNFTGates(); + + vm.prank(productOwner); + nftGated.configureSlicer(slicerId, encodeNFTGates(nftGates[0])); + + ProductLineItem[] memory productLineItems = new ProductLineItem[](1); + productLineItems[0] = + ProductLineItem({productId: uint32(productIds[0]), variantId: uint32(variantId), quantity: uint32(5)}); + + // Buyer doesn't have NFT - should revert + vm.expectRevert(); + vm.prank(address(PRODUCTS_MODULE)); + nftGated.onSlicerPurchase(slicerId, buyer, productLineItems); + + // Buyer has NFT - should succeed + nft721.mint(buyer); + vm.prank(address(PRODUCTS_MODULE)); + nftGated.onSlicerPurchase(slicerId, buyer, productLineItems); + } + + function testRevert_OnSlicerPurchase_NotPurchase() public { + NFTGates[] memory nftGates = generateNFTGates(); + + vm.prank(productOwner); + nftGated.configureSlicer(slicerId, encodeNFTGates(nftGates[0])); + + nft721.mint(buyer); + + ProductLineItem[] memory productLineItems = new ProductLineItem[](1); + productLineItems[0] = + ProductLineItem({productId: uint32(productIds[0]), variantId: uint32(variantId), quantity: uint32(5)}); + + vm.expectRevert(); + nftGated.onSlicerPurchase(slicerId, buyer, productLineItems); + } + + function testRevert_ConfigureSlicer_InvalidNFT() public { + NFTGate[] memory gates1 = new NFTGate[](1); + gates1[0] = NFTGate(address(new TestERC20()), NftType.ERC721, 1, 1); + NFTGates memory nftGates = NFTGates(gates1, 1); + + vm.startPrank(productOwner); + + vm.expectRevert(NFTGated.InvalidNFT.selector); + nftGated.configureSlicer(slicerId, encodeNFTGates(nftGates)); + + vm.stopPrank(); } /*////////////////////////////////////////////////////////////// @@ -117,4 +407,16 @@ contract NFTGatedTest is RegistryProductActionTest { gates4[1] = gate1155; nftGates[3] = NFTGates(gates4, 2); } + + function encodeNFTGates(NFTGates memory nftGates_) internal pure returns (bytes memory) { + return abi.encode(nftGates_.gates, nftGates_.minOwned); + } +} + +contract TestERC20 is ERC20 { + constructor() ERC20("Test", "TST") {} + + function supportsInterface(bytes4) public pure returns (bool) { + return false; + } } diff --git a/test/actions/NFTGated/mocks/MockNFTGated.sol b/test/actions/NFTGated/mocks/MockNFTGated.sol index d246076..2a2a89f 100644 --- a/test/actions/NFTGated/mocks/MockNFTGated.sol +++ b/test/actions/NFTGated/mocks/MockNFTGated.sol @@ -6,7 +6,23 @@ import {IProductsModule, NFTGated, NFTGate} from "@/hooks/actions/NFTGated/NFTGa contract MockNFTGated is NFTGated { constructor(IProductsModule productsModuleAddress) NFTGated(productsModuleAddress) {} - function gates(uint256 slicerId, uint256 productId) public view returns (NFTGate[] memory) { - return nftGates[slicerId][productId].gates; + function gates(uint256 slicerId, uint256 productId, uint256 variantId) public view returns (NFTGate[] memory) { + return nftGates[slicerId][productId][variantId].gates; + } + + function productTypeGates(uint256 slicerId, uint256 productTypeId) public view returns (NFTGate[] memory) { + return productTypeNftGates[slicerId][productTypeId].gates; + } + + function slicerGates(uint256 slicerId) public view returns (NFTGate[] memory) { + return slicerNftGates[slicerId].gates; + } + + function productTypeMinOwned(uint256 slicerId, uint256 productTypeId) public view returns (uint256) { + return productTypeNftGates[slicerId][productTypeId].minOwned; + } + + function slicerMinOwned(uint256 slicerId) public view returns (uint256) { + return slicerNftGates[slicerId].minOwned; } } diff --git a/test/pricing/TieredDiscount/NFTDiscount.t.sol b/test/pricing/TieredDiscount/NFTDiscount.t.sol index 0aaab39..520954a 100644 --- a/test/pricing/TieredDiscount/NFTDiscount.t.sol +++ b/test/pricing/TieredDiscount/NFTDiscount.t.sol @@ -2,19 +2,23 @@ pragma solidity ^0.8.30; import {RegistryProductPriceTest} from "@test/utils/RegistryProductPriceTest.sol"; -import {console2} from "forge-std/console2.sol"; import { IProductsModule, + HookRegistry, NFTDiscount, DiscountParams, NFTType } from "@/hooks/pricing/TieredDiscount/NFTDiscount/NFTDiscount.sol"; +import {ProductLineItem} from "@/hooks/pricing/TieredDiscount/TieredDiscount.sol"; +import {SliceContext} from "slice/utils/SliceContext.sol"; import {MockERC721} from "@test/utils/mocks/MockERC721.sol"; import {MockERC1155} from "@test/utils/mocks/MockERC1155.sol"; address constant USDC = address(1); uint256 constant slicerId = 0; uint256 constant productId = 1; +uint256 constant variantId = 0; +uint256 constant productTypeId = 2; uint80 constant percentDiscountOne = 1000; // 10% uint80 constant percentDiscountTwo = 2000; // 20% @@ -25,7 +29,7 @@ contract NFTDiscountTest is RegistryProductPriceTest { MockERC721 nftThree = new MockERC721(); MockERC1155 nft1155 = new MockERC1155(); - uint256 quantity = 1; + uint256 quantity = 5; uint8 minNftQuantity = 1; function setUp() public { @@ -35,6 +39,221 @@ contract NFTDiscountTest is RegistryProductPriceTest { nftOne.mint(buyer); } + function testConfigureProductType() public { + DiscountParams[] memory discountParams = new DiscountParams[](2); + + discountParams[0] = DiscountParams({ + nft: address(nftTwo), + discount: percentDiscountTwo, + minQuantity: minNftQuantity, + nftType: NFTType.ERC721, + tokenId: 0 + }); + discountParams[1] = DiscountParams({ + nft: address(nftOne), + discount: percentDiscountOne, + minQuantity: minNftQuantity, + nftType: NFTType.ERC721, + tokenId: 0 + }); + + bytes memory params = abi.encode(discountParams); + + vm.expectEmit(true, true, true, true, address(erc721GatedDiscount)); + emit HookRegistry.ProductTypeConfigured(slicerId, productTypeId, params); + + vm.prank(productOwner); + erc721GatedDiscount.configureProductType(slicerId, productTypeId, params); + + uint256 basePrice = PRODUCTS_MODULE.basePrice(slicerId, productId, variantId, ETH, quantity); + + uint256 price = erc721GatedDiscount.productTypePrice(slicerId, productTypeId, ETH, basePrice, quantity, buyer); + + assertEq(price, (basePrice - (basePrice * percentDiscountOne) / 1e4)); + + nftTwo.mint(buyer); + + uint256 priceWithHigherDiscount = + erc721GatedDiscount.productTypePrice(slicerId, productTypeId, ETH, basePrice, quantity, buyer); + + assertEq(priceWithHigherDiscount, (basePrice - (basePrice * percentDiscountTwo) / 1e4)); + } + + function testConfigureProductType_AccessControl() public { + vm.expectRevert(abi.encodeWithSelector(SliceContext.NotAuthorized.selector, bytes32(uint256(1 << 1 | 1 << 3)))); + + erc721GatedDiscount.configureProductType(slicerId, productTypeId, ""); + } + + function testConfigureProductTypeSchema() public view { + string memory schema = erc721GatedDiscount.configureProductTypeSchema(); + + assertEq(schema, erc721GatedDiscount.configureProductSchema()); + assertTrue(bytes(schema).length > 0); + } + + function testProductTypePrice_NoDiscount() public { + vm.prank(productOwner); + erc721GatedDiscount.configureProductType(slicerId, productTypeId, abi.encode(new DiscountParams[](0))); + + uint256 basePrice = PRODUCTS_MODULE.basePrice(slicerId, productId, variantId, ETH, quantity); + uint256 price = erc721GatedDiscount.productTypePrice(slicerId, productTypeId, ETH, basePrice, quantity, buyer2); + + assertEq(price, basePrice); + } + + function testConfigureProductType__Edit_Remove() public { + DiscountParams[] memory discountParams = new DiscountParams[](2); + + discountParams[0] = DiscountParams({ + nft: address(nftTwo), + discount: percentDiscountTwo, + minQuantity: minNftQuantity, + nftType: NFTType.ERC721, + tokenId: 0 + }); + discountParams[1] = DiscountParams({ + nft: address(nftOne), + discount: percentDiscountOne, + minQuantity: minNftQuantity, + nftType: NFTType.ERC721, + tokenId: 0 + }); + + vm.prank(productOwner); + erc721GatedDiscount.configureProductType(slicerId, productTypeId, abi.encode(discountParams)); + + uint256 basePrice = PRODUCTS_MODULE.basePrice(slicerId, productId, variantId, ETH, quantity); + + uint256 price = erc721GatedDiscount.productTypePrice(slicerId, productTypeId, ETH, basePrice, quantity, buyer); + + assertEq(price, (basePrice - (basePrice * percentDiscountOne) / 1e4)); + + vm.prank(productOwner); + erc721GatedDiscount.configureProductType(slicerId, productTypeId, abi.encode(new DiscountParams[](0))); + + uint256 resetPrice = + erc721GatedDiscount.productTypePrice(slicerId, productTypeId, ETH, basePrice, quantity, buyer); + + assertEq(resetPrice, basePrice); + } + + function testConfigureSlicer() public { + DiscountParams[] memory discountParams = new DiscountParams[](2); + + discountParams[0] = DiscountParams({ + nft: address(nftTwo), + discount: percentDiscountTwo, + minQuantity: minNftQuantity, + nftType: NFTType.ERC721, + tokenId: 0 + }); + discountParams[1] = DiscountParams({ + nft: address(nftOne), + discount: percentDiscountOne, + minQuantity: minNftQuantity, + nftType: NFTType.ERC721, + tokenId: 0 + }); + + bytes memory params = abi.encode(discountParams); + + vm.expectEmit(true, true, true, true, address(erc721GatedDiscount)); + emit HookRegistry.SlicerConfigured(slicerId, params); + + vm.prank(productOwner); + erc721GatedDiscount.configureSlicer(slicerId, params); + + uint256 basePrice = PRODUCTS_MODULE.basePrice(slicerId, productId, variantId, ETH, quantity); + + ProductLineItem[] memory productLineItems = new ProductLineItem[](1); + productLineItems[0] = + ProductLineItem({productId: uint32(productId), variantId: uint32(variantId), quantity: uint32(quantity)}); + uint256[] memory basePrices = new uint256[](1); + basePrices[0] = basePrice; + uint256[] memory newBasePrices = + erc721GatedDiscount.slicerProductPrice(slicerId, ETH, buyer, productLineItems, basePrices); + + assertEq(newBasePrices[0], (basePrice - (basePrice * percentDiscountOne) / 1e4)); + + nftTwo.mint(buyer); + + newBasePrices = erc721GatedDiscount.slicerProductPrice(slicerId, ETH, buyer, productLineItems, basePrices); + + assertEq(newBasePrices[0], (basePrice - (basePrice * percentDiscountTwo) / 1e4)); + } + + function testConfigureSlicer_AccessControl() public { + vm.expectRevert(abi.encodeWithSelector(SliceContext.NotAuthorized.selector, bytes32(uint256(1 << 1 | 1 << 3)))); + + erc721GatedDiscount.configureSlicer(slicerId, ""); + } + + function testConfigureSlicerSchema() public view { + string memory schema = erc721GatedDiscount.configureSlicerSchema(); + + assertEq(schema, erc721GatedDiscount.configureProductSchema()); + assertTrue(bytes(schema).length > 0); + } + + function testSlicerProductPrice_NoDiscount() public { + vm.prank(productOwner); + erc721GatedDiscount.configureSlicer(slicerId, abi.encode(new DiscountParams[](0))); + + uint256 basePrice = PRODUCTS_MODULE.basePrice(slicerId, productId, variantId, ETH, quantity); + + ProductLineItem[] memory productLineItems = new ProductLineItem[](1); + productLineItems[0] = + ProductLineItem({productId: uint32(productId), variantId: uint32(variantId), quantity: uint32(quantity)}); + uint256[] memory basePrices = new uint256[](1); + basePrices[0] = basePrice; + uint256[] memory newBasePrices = + erc721GatedDiscount.slicerProductPrice(slicerId, ETH, buyer3, productLineItems, basePrices); + + assertEq(newBasePrices[0], basePrice); + } + + function testConfigureSlicer__Edit_Remove() public { + DiscountParams[] memory discountParams = new DiscountParams[](2); + + discountParams[0] = DiscountParams({ + nft: address(nftTwo), + discount: percentDiscountTwo, + minQuantity: minNftQuantity, + nftType: NFTType.ERC721, + tokenId: 0 + }); + discountParams[1] = DiscountParams({ + nft: address(nftOne), + discount: percentDiscountOne, + minQuantity: minNftQuantity, + nftType: NFTType.ERC721, + tokenId: 0 + }); + + vm.prank(productOwner); + erc721GatedDiscount.configureSlicer(slicerId, abi.encode(discountParams)); + + uint256 basePrice = PRODUCTS_MODULE.basePrice(slicerId, productId, variantId, ETH, quantity); + + ProductLineItem[] memory productLineItems = new ProductLineItem[](1); + productLineItems[0] = + ProductLineItem({productId: uint32(productId), variantId: uint32(variantId), quantity: uint32(quantity)}); + uint256[] memory basePrices = new uint256[](1); + basePrices[0] = basePrice; + uint256[] memory newBasePrices = + erc721GatedDiscount.slicerProductPrice(slicerId, ETH, buyer, productLineItems, basePrices); + + assertEq(newBasePrices[0], (basePrice - (basePrice * percentDiscountOne) / 1e4)); + + vm.prank(productOwner); + erc721GatedDiscount.configureSlicer(slicerId, abi.encode(new DiscountParams[](0))); + + newBasePrices = erc721GatedDiscount.slicerProductPrice(slicerId, ETH, buyer, productLineItems, basePrices); + + assertEq(newBasePrices[0], basePrice); + } + function testConfigureProduct__ETH() public { DiscountParams[] memory discountParams = new DiscountParams[](1); @@ -48,16 +267,15 @@ contract NFTDiscountTest is RegistryProductPriceTest { }); vm.prank(productOwner); - erc721GatedDiscount.configureProduct(slicerId, productId, abi.encode(discountParams)); + erc721GatedDiscount.configureProduct(slicerId, productId, variantId, abi.encode(discountParams)); - /// check product price - (uint256 ethPrice, uint256 currencyPrice) = - erc721GatedDiscount.productPrice(slicerId, productId, ETH, quantity, buyer, ""); + uint256 basePrice = PRODUCTS_MODULE.basePrice(slicerId, productId, variantId, ETH, quantity); - (uint256 baseEthPrice,) = PRODUCTS_MODULE.basePrice(slicerId, productId, ETH, quantity); + /// check product price + uint256 price = + erc721GatedDiscount.productPrice(slicerId, productId, variantId, ETH, basePrice, quantity, buyer, ""); - assertEq(ethPrice, quantity * (baseEthPrice - (baseEthPrice * percentDiscountOne) / 1e4)); - assertEq(currencyPrice, 0); + assertEq(price, (basePrice - (basePrice * percentDiscountOne) / 1e4)); } function testConfigureProduct__ERC20() public { @@ -73,16 +291,15 @@ contract NFTDiscountTest is RegistryProductPriceTest { }); vm.prank(productOwner); - erc721GatedDiscount.configureProduct(slicerId, productId, abi.encode(discountParams)); + erc721GatedDiscount.configureProduct(slicerId, productId, variantId, abi.encode(discountParams)); - /// check product price - (uint256 ethPrice, uint256 currencyPrice) = - erc721GatedDiscount.productPrice(slicerId, productId, USDC, quantity, buyer, ""); + uint256 basePrice = PRODUCTS_MODULE.basePrice(slicerId, productId, variantId, USDC, quantity); - (, uint256 baseCurrencyPrice) = PRODUCTS_MODULE.basePrice(slicerId, productId, USDC, quantity); + /// check product price + uint256 price = + erc721GatedDiscount.productPrice(slicerId, productId, variantId, USDC, basePrice, quantity, buyer, ""); - assertEq(currencyPrice, quantity * (baseCurrencyPrice - (baseCurrencyPrice * percentDiscountOne) / 1e4)); - assertTrue(ethPrice == 0); + assertEq(price, (basePrice - (basePrice * percentDiscountOne) / 1e4)); } function testConfigureProduct__ERC1155() public { @@ -98,23 +315,21 @@ contract NFTDiscountTest is RegistryProductPriceTest { }); vm.prank(productOwner); - erc721GatedDiscount.configureProduct(slicerId, productId, abi.encode(discountParams)); + erc721GatedDiscount.configureProduct(slicerId, productId, variantId, abi.encode(discountParams)); - /// check product price - (uint256 ethPrice, uint256 currencyPrice) = - erc721GatedDiscount.productPrice(slicerId, productId, USDC, quantity, buyer, ""); + uint256 basePrice = PRODUCTS_MODULE.basePrice(slicerId, productId, variantId, USDC, quantity); - (, uint256 baseCurrencyPrice) = PRODUCTS_MODULE.basePrice(slicerId, productId, USDC, quantity); + /// check product price + uint256 price = + erc721GatedDiscount.productPrice(slicerId, productId, variantId, USDC, basePrice, quantity, buyer, ""); - assertEq(currencyPrice, quantity * baseCurrencyPrice); - assertEq(ethPrice, 0); + assertEq(price, basePrice); nft1155.mint(buyer); - (ethPrice, currencyPrice) = erc721GatedDiscount.productPrice(slicerId, productId, USDC, quantity, buyer, ""); + price = erc721GatedDiscount.productPrice(slicerId, productId, variantId, USDC, basePrice, quantity, buyer, ""); - assertEq(currencyPrice, quantity * (baseCurrencyPrice - (baseCurrencyPrice * percentDiscountOne) / 1e4)); - assertEq(ethPrice, 0); + assertEq(price, (basePrice - (basePrice * percentDiscountOne) / 1e4)); } function testConfigureProduct__HigherDiscount() public { @@ -136,23 +351,21 @@ contract NFTDiscountTest is RegistryProductPriceTest { }); vm.prank(productOwner); - erc721GatedDiscount.configureProduct(slicerId, productId, abi.encode(discountParams)); + erc721GatedDiscount.configureProduct(slicerId, productId, variantId, abi.encode(discountParams)); - /// check product price for ETH - (uint256 ethPrice, uint256 currencyPrice) = - erc721GatedDiscount.productPrice(slicerId, productId, ETH, quantity, buyer, ""); + uint256 basePrice = PRODUCTS_MODULE.basePrice(slicerId, productId, variantId, ETH, quantity); - (uint256 baseEthPrice,) = PRODUCTS_MODULE.basePrice(slicerId, productId, ETH, quantity); + /// check product price for ETH + uint256 price = + erc721GatedDiscount.productPrice(slicerId, productId, variantId, ETH, basePrice, quantity, buyer, ""); - assertEq(ethPrice, quantity * (baseEthPrice - (baseEthPrice * percentDiscountOne) / 1e4)); - assertEq(currencyPrice, 0); + assertEq(price, (basePrice - (basePrice * percentDiscountOne) / 1e4)); nft1155.mint(buyer); - (ethPrice, currencyPrice) = erc721GatedDiscount.productPrice(slicerId, productId, ETH, quantity, buyer, ""); + price = erc721GatedDiscount.productPrice(slicerId, productId, variantId, ETH, basePrice, quantity, buyer, ""); - assertEq(ethPrice, quantity * (baseEthPrice - (baseEthPrice * percentDiscountTwo) / 1e4)); - assertEq(currencyPrice, 0); + assertEq(price, (basePrice - (basePrice * percentDiscountTwo) / 1e4)); } function testRevert_ProductPrice__NotNFTOwner() public { @@ -168,16 +381,15 @@ contract NFTDiscountTest is RegistryProductPriceTest { }); vm.prank(productOwner); - erc721GatedDiscount.configureProduct(slicerId, productId, abi.encode(discountParams)); + erc721GatedDiscount.configureProduct(slicerId, productId, variantId, abi.encode(discountParams)); - /// check product price - (uint256 ethPrice, uint256 currencyPrice) = - erc721GatedDiscount.productPrice(slicerId, productId, ETH, quantity, buyer, ""); + uint256 basePrice = PRODUCTS_MODULE.basePrice(slicerId, productId, variantId, ETH, quantity); - (uint256 baseEthPrice,) = PRODUCTS_MODULE.basePrice(slicerId, productId, ETH, quantity); + /// check product price + uint256 price = + erc721GatedDiscount.productPrice(slicerId, productId, variantId, ETH, basePrice, quantity, buyer, ""); - assertEq(ethPrice, quantity * baseEthPrice); - assertEq(currencyPrice, 0); + assertEq(price, basePrice); } function testProductPrice__MinQuantity() public { @@ -193,26 +405,24 @@ contract NFTDiscountTest is RegistryProductPriceTest { }); vm.prank(productOwner); - erc721GatedDiscount.configureProduct(slicerId, productId, abi.encode(discountParams)); + erc721GatedDiscount.configureProduct(slicerId, productId, variantId, abi.encode(discountParams)); - /// check product price - (uint256 ethPrice, uint256 currencyPrice) = - erc721GatedDiscount.productPrice(slicerId, productId, ETH, quantity, buyer, ""); + uint256 basePrice = PRODUCTS_MODULE.basePrice(slicerId, productId, variantId, ETH, quantity); - (uint256 baseEthPrice,) = PRODUCTS_MODULE.basePrice(slicerId, productId, ETH, quantity); + /// check product price + uint256 price = + erc721GatedDiscount.productPrice(slicerId, productId, variantId, ETH, basePrice, quantity, buyer, ""); - assertEq(ethPrice, quantity * baseEthPrice); - assertEq(currencyPrice, 0); + assertEq(price, basePrice); /// Buyer owns 2 NFTs, minQuantity is 2 nftOne.mint(buyer); /// check product price - (uint256 secondEthPrice, uint256 secondCurrencyPrice) = - erc721GatedDiscount.productPrice(slicerId, productId, ETH, quantity, buyer, ""); + uint256 secondPrice = + erc721GatedDiscount.productPrice(slicerId, productId, variantId, ETH, basePrice, quantity, buyer, ""); - assertEq(secondEthPrice, quantity * (baseEthPrice - (baseEthPrice * percentDiscountOne) / 1e4)); - assertEq(secondCurrencyPrice, 0); + assertEq(secondPrice, (basePrice - (basePrice * percentDiscountOne) / 1e4)); } function testProductPrice__MultipleBoughtQuantity() public { @@ -227,19 +437,18 @@ contract NFTDiscountTest is RegistryProductPriceTest { }); vm.prank(productOwner); - erc721GatedDiscount.configureProduct(slicerId, productId, abi.encode(discountParams)); + erc721GatedDiscount.configureProduct(slicerId, productId, variantId, abi.encode(discountParams)); // buy multiple products quantity = 6; - /// check product price - (uint256 ethPrice, uint256 currencyPrice) = - erc721GatedDiscount.productPrice(slicerId, productId, ETH, quantity, buyer, ""); + uint256 basePrice = PRODUCTS_MODULE.basePrice(slicerId, productId, variantId, ETH, quantity); - (uint256 baseEthPrice,) = PRODUCTS_MODULE.basePrice(slicerId, productId, ETH, quantity); + /// check product price + uint256 price = + erc721GatedDiscount.productPrice(slicerId, productId, variantId, ETH, basePrice, quantity, buyer, ""); - assertEq(ethPrice, quantity * (baseEthPrice - (baseEthPrice * percentDiscountOne) / 1e4)); - assertEq(currencyPrice, 0); + assertEq(price, (basePrice - (basePrice * percentDiscountOne) / 1e4)); } function testConfigureProduct__Edit_Add() public { @@ -254,19 +463,18 @@ contract NFTDiscountTest is RegistryProductPriceTest { }); vm.prank(productOwner); - erc721GatedDiscount.configureProduct(slicerId, productId, abi.encode(discountParams)); + erc721GatedDiscount.configureProduct(slicerId, productId, variantId, abi.encode(discountParams)); // mint NFT 2 nftTwo.mint(buyer); - /// check product price - (uint256 ethPrice, uint256 currencyPrice) = - erc721GatedDiscount.productPrice(slicerId, productId, ETH, quantity, buyer, ""); + uint256 basePrice = PRODUCTS_MODULE.basePrice(slicerId, productId, variantId, ETH, quantity); - (uint256 baseEthPrice,) = PRODUCTS_MODULE.basePrice(slicerId, productId, ETH, quantity); + /// check product price + uint256 price = + erc721GatedDiscount.productPrice(slicerId, productId, variantId, ETH, basePrice, quantity, buyer, ""); - assertEq(ethPrice, quantity * (baseEthPrice - (baseEthPrice * percentDiscountTwo) / 1e4)); - assertEq(currencyPrice, 0); + assertEq(price, (basePrice - (basePrice * percentDiscountTwo) / 1e4)); discountParams = new DiscountParams[](2); @@ -288,14 +496,13 @@ contract NFTDiscountTest is RegistryProductPriceTest { }); vm.prank(productOwner); - erc721GatedDiscount.configureProduct(slicerId, productId, abi.encode(discountParams)); + erc721GatedDiscount.configureProduct(slicerId, productId, variantId, abi.encode(discountParams)); /// check product price - (uint256 secondEthPrice, uint256 secondCurrencyPrice) = - erc721GatedDiscount.productPrice(slicerId, productId, ETH, quantity, buyer, ""); + uint256 secondPrice = + erc721GatedDiscount.productPrice(slicerId, productId, variantId, ETH, basePrice, quantity, buyer, ""); - assertEq(secondEthPrice, quantity * (baseEthPrice - (baseEthPrice * percentDiscountOne) / 1e4)); - assertEq(secondCurrencyPrice, 0); + assertEq(secondPrice, (basePrice - (basePrice * percentDiscountOne) / 1e4)); } function testConfigureProduct__Edit_Remove() public { @@ -322,27 +529,25 @@ contract NFTDiscountTest is RegistryProductPriceTest { }); vm.prank(productOwner); - erc721GatedDiscount.configureProduct(slicerId, productId, abi.encode(discountParams)); + erc721GatedDiscount.configureProduct(slicerId, productId, variantId, abi.encode(discountParams)); - /// check product price - (uint256 secondEthPrice, uint256 secondCurrencyPrice) = - erc721GatedDiscount.productPrice(slicerId, productId, ETH, quantity, buyer, ""); + uint256 basePrice = PRODUCTS_MODULE.basePrice(slicerId, productId, variantId, ETH, quantity); - (uint256 baseEthPrice,) = PRODUCTS_MODULE.basePrice(slicerId, productId, ETH, quantity); + /// check product price + uint256 secondPrice = + erc721GatedDiscount.productPrice(slicerId, productId, variantId, ETH, basePrice, quantity, buyer, ""); - assertEq(secondEthPrice, quantity * (baseEthPrice - (baseEthPrice * percentDiscountOne) / 1e4)); - assertEq(secondCurrencyPrice, 0); + assertEq(secondPrice, (basePrice - (basePrice * percentDiscountOne) / 1e4)); discountParams = new DiscountParams[](0); vm.prank(productOwner); - erc721GatedDiscount.configureProduct(slicerId, productId, abi.encode(discountParams)); + erc721GatedDiscount.configureProduct(slicerId, productId, variantId, abi.encode(discountParams)); /// check product price - (uint256 ethPrice, uint256 currencyPrice) = - erc721GatedDiscount.productPrice(slicerId, productId, ETH, quantity, buyer, ""); + uint256 price = + erc721GatedDiscount.productPrice(slicerId, productId, variantId, ETH, basePrice, quantity, buyer, ""); - assertEq(ethPrice, quantity * baseEthPrice); - assertEq(currencyPrice, 0); + assertEq(price, basePrice); } } diff --git a/test/pricing/VRGDA/LinearVRGDA.t.sol b/test/pricing/VRGDA/LinearVRGDA.t.sol index 1b57588..905051a 100644 --- a/test/pricing/VRGDA/LinearVRGDA.t.sol +++ b/test/pricing/VRGDA/LinearVRGDA.t.sol @@ -13,26 +13,29 @@ uint256 constant MAX_SELLABLE = 6392; uint256 constant slicerId = 0; uint256 constant productId = 1; -int128 constant targetPriceConstant = 69.42e18; -uint128 constant min = 1e18; -int256 constant priceDecayPercent = 0.31e18; +uint256 constant variantId = 0; +uint16 constant min = 1e4; +int184 constant priceDecayPercent = 0.31e18; int256 constant perTimeUnit = 2e18; contract LinearVRGDATest is RegistryProductPriceTest { MockLinearVRGDAPrices vrgda; + int128 targetPriceConstant = + int128(int256(PRODUCTS_MODULE.basePrice(slicerId, productId, variantId, address(0), 1))); + int128 targetPriceConstant_Erc20 = + int128(int256(PRODUCTS_MODULE.basePrice(slicerId, productId, variantId, address(20), 1))); + + uint256 minAmount = uint256(int256(targetPriceConstant)) * min / 1e5; + uint256 minAmount_Erc20 = uint256(int256(targetPriceConstant_Erc20)) * min / 1e5; function setUp() public { vrgda = new MockLinearVRGDAPrices(PRODUCTS_MODULE); _setHook(address(vrgda)); - LinearVRGDAParams[] memory linearParams = new LinearVRGDAParams[](2); - linearParams[0] = LinearVRGDAParams(address(0), targetPriceConstant, min, perTimeUnit); - linearParams[1] = LinearVRGDAParams(address(20), targetPriceConstant, min, perTimeUnit); - - bytes memory params = abi.encode(linearParams, priceDecayPercent); + bytes memory params = abi.encode(priceDecayPercent, min, perTimeUnit); vm.startPrank(productOwner); - vrgda.configureProduct(slicerId, productId, params); + vrgda.configureProduct(slicerId, productId, variantId, params); vm.stopPrank(); } @@ -42,7 +45,7 @@ contract LinearVRGDATest is RegistryProductPriceTest { int256 decayConstant = wadLn(1e18 - priceDecayPercent); uint256 cost = vrgda.getVRGDAPrice( - targetPriceConstant, decayConstant, toDaysWadUnsafe(block.timestamp), 0, perTimeUnit, min + targetPriceConstant, decayConstant, toDaysWadUnsafe(block.timestamp), 0, perTimeUnit, minAmount ); assertApproxEqAbs(cost, uint256(uint128(targetPriceConstant)), 5e14); } @@ -56,7 +59,7 @@ contract LinearVRGDATest is RegistryProductPriceTest { int256 decayConstant = wadLn(1e18 - priceDecayPercent); uint256 cost = vrgda.getVRGDAPrice( - targetPriceConstant, decayConstant, toDaysWadUnsafe(block.timestamp), numMint, perTimeUnit, min + targetPriceConstant, decayConstant, toDaysWadUnsafe(block.timestamp), numMint, perTimeUnit, minAmount ); assertApproxEqAbs(cost, uint256(uint128(targetPriceConstant)), 5e14); } @@ -71,9 +74,9 @@ contract LinearVRGDATest is RegistryProductPriceTest { int256 decayConstant = wadLn(1e18 - priceDecayPercent); uint256 cost = vrgda.getVRGDAPrice( - targetPriceConstant, decayConstant, toDaysWadUnsafe(block.timestamp), numMint, perTimeUnit, min + targetPriceConstant, decayConstant, toDaysWadUnsafe(block.timestamp), numMint, perTimeUnit, minAmount ); - assertEq(cost, min); + assertEq(cost, minAmount); } function testPricingAdjustedByQuantity() public { @@ -86,16 +89,16 @@ contract LinearVRGDATest is RegistryProductPriceTest { int256 decayConstant = wadLn(1e18 - priceDecayPercent); uint256 costProduct1 = vrgda.getAdjustedVRGDAPrice( - targetPriceConstant, decayConstant, toDaysWadUnsafe(block.timestamp), numMint, perTimeUnit, min, 1 + targetPriceConstant, decayConstant, toDaysWadUnsafe(block.timestamp), numMint, perTimeUnit, minAmount, 1 ); uint256 costProduct2 = vrgda.getAdjustedVRGDAPrice( - targetPriceConstant, decayConstant, toDaysWadUnsafe(block.timestamp), numMint + 1, perTimeUnit, min, 1 + targetPriceConstant, decayConstant, toDaysWadUnsafe(block.timestamp), numMint + 1, perTimeUnit, minAmount, 1 ); uint256 costProduct3 = vrgda.getAdjustedVRGDAPrice( - targetPriceConstant, decayConstant, toDaysWadUnsafe(block.timestamp), numMint + 2, perTimeUnit, min, 1 + targetPriceConstant, decayConstant, toDaysWadUnsafe(block.timestamp), numMint + 2, perTimeUnit, minAmount, 1 ); uint256 costMultiple = vrgda.getAdjustedVRGDAPrice( - targetPriceConstant, decayConstant, toDaysWadUnsafe(block.timestamp), numMint, perTimeUnit, min, 3 + targetPriceConstant, decayConstant, toDaysWadUnsafe(block.timestamp), numMint, perTimeUnit, minAmount, 3 ); assertApproxEqAbs(costMultiple, uint256(costProduct1 + costProduct2 + costProduct3), 0.00001e18); @@ -112,7 +115,7 @@ contract LinearVRGDATest is RegistryProductPriceTest { vrgda.getTargetSaleTime(toWadUnsafe(sold + 1), perTimeUnit), sold, perTimeUnit, - min + minAmount ), uint256(uint128(targetPriceConstant)), 0.00001e18 @@ -121,14 +124,11 @@ contract LinearVRGDATest is RegistryProductPriceTest { function testSetMultiplePrices() public { uint256 productId_ = 2; - LinearVRGDAParams[] memory linearParams = new LinearVRGDAParams[](2); - linearParams[0] = LinearVRGDAParams(address(0), targetPriceConstant, min, perTimeUnit); - linearParams[1] = LinearVRGDAParams(address(20), targetPriceConstant, min, perTimeUnit); - bytes memory params = abi.encode(linearParams, priceDecayPercent); + bytes memory params = abi.encode(priceDecayPercent, min, perTimeUnit); vm.startPrank(productOwner); - vrgda.configureProduct(slicerId, productId_, params); + vrgda.configureProduct(slicerId, productId_, variantId, params); vm.stopPrank(); // Our VRGDA targets this number of mints at given time. @@ -136,17 +136,13 @@ contract LinearVRGDATest is RegistryProductPriceTest { vm.warp(block.timestamp + timeDelta); - (uint256 ethPrice, uint256 currencyPrice) = - vrgda.productPrice(slicerId, productId_, address(0), 1, address(0), ""); + uint256 price = vrgda.productPrice(slicerId, productId_, variantId, address(0), 0, 1, address(0), ""); - assertApproxEqAbs(uint256(uint128(targetPriceConstant)), ethPrice, 0.00001e18); - assertEq(currencyPrice, 0); + assertApproxEqAbs(uint256(uint128(targetPriceConstant)), price, 0.00001e18); - (uint256 ethPrice2, uint256 currencyPrice2) = - vrgda.productPrice(slicerId, productId_, address(20), 1, address(0), ""); + price = vrgda.productPrice(slicerId, productId_, variantId, address(20), 0, 1, address(0), ""); - assertEq(ethPrice2, 0); - assertApproxEqAbs(uint256(uint128(targetPriceConstant)), currencyPrice2, 0.00001e18); + assertApproxEqAbs(uint256(uint128(targetPriceConstant_Erc20)), price, 0.00001e18); } function testProductPriceEth() public { @@ -160,14 +156,12 @@ contract LinearVRGDATest is RegistryProductPriceTest { int256 decayConstant = wadLn(1e18 - priceDecayPercent); uint256 cost = vrgda.getAdjustedVRGDAPrice( - targetPriceConstant, decayConstant, toDaysWadUnsafe(block.timestamp), 0, perTimeUnit, min, 1 + targetPriceConstant, decayConstant, toDaysWadUnsafe(block.timestamp), 0, perTimeUnit, minAmount, 1 ); - (uint256 ethPrice, uint256 currencyPrice) = - vrgda.productPrice(slicerId, productId, ethCurrency, 1, address(0), ""); + uint256 price = vrgda.productPrice(slicerId, productId, variantId, ethCurrency, 0, 1, address(0), ""); - assertApproxEqAbs(cost, ethPrice, 0.00001e18); - assertEq(currencyPrice, 0); + assertApproxEqAbs(cost, price, 0.00001e18); } function testProductPriceErc20() public { @@ -181,14 +175,18 @@ contract LinearVRGDATest is RegistryProductPriceTest { int256 decayConstant = wadLn(1e18 - priceDecayPercent); uint256 cost = vrgda.getAdjustedVRGDAPrice( - targetPriceConstant, decayConstant, toDaysWadUnsafe(block.timestamp), 0, perTimeUnit, min, 1 + targetPriceConstant_Erc20, + decayConstant, + toDaysWadUnsafe(block.timestamp), + 0, + perTimeUnit, + minAmount_Erc20, + 1 ); - (uint256 ethPrice, uint256 currencyPrice) = - vrgda.productPrice(slicerId, productId, erc20Currency, 1, address(0), ""); + uint256 price = vrgda.productPrice(slicerId, productId, variantId, erc20Currency, 0, 1, address(0), ""); - assertApproxEqAbs(cost, currencyPrice, 0.00001e18); - assertEq(ethPrice, 0); + assertApproxEqAbs(cost, price, 0.00001e18); } function testProductPriceMultiple() public { @@ -202,10 +200,10 @@ contract LinearVRGDATest is RegistryProductPriceTest { int256 decayConstant = wadLn(1e18 - priceDecayPercent); uint256 costMultiple = vrgda.getAdjustedVRGDAPrice( - targetPriceConstant, decayConstant, toDaysWadUnsafe(block.timestamp), 0, perTimeUnit, min, 3 + targetPriceConstant, decayConstant, toDaysWadUnsafe(block.timestamp), 0, perTimeUnit, minAmount, 3 ); - (uint256 ethPrice,) = vrgda.productPrice(slicerId, productId, ethCurrency, 3, address(0), ""); + uint256 price = vrgda.productPrice(slicerId, productId, variantId, ethCurrency, 0, 3, address(0), ""); - assertApproxEqAbs(costMultiple, ethPrice, 5e14); + assertApproxEqAbs(costMultiple, price, 5e14); } } diff --git a/test/pricing/VRGDA/LogisticVRGDA.t.sol b/test/pricing/VRGDA/LogisticVRGDA.t.sol index 654e3af..76e9642 100644 --- a/test/pricing/VRGDA/LogisticVRGDA.t.sol +++ b/test/pricing/VRGDA/LogisticVRGDA.t.sol @@ -3,9 +3,7 @@ pragma solidity ^0.8.30; import {RegistryProductPriceTest} from "@test/utils/RegistryProductPriceTest.sol"; import {unsafeDiv, wadLn, toWadUnsafe, toDaysWadUnsafe, fromDaysWadUnsafe} from "@/utils/math/SignedWadMath.sol"; - import "./mocks/MockLogisticVRGDAPrices.sol"; -import "forge-std/console2.sol"; uint256 constant ONE_THOUSAND_YEARS = 356 days * 1000; @@ -13,28 +11,31 @@ uint256 constant MAX_SELLABLE = 6392; uint256 constant slicerId = 0; uint256 constant productId = 1; -int128 constant targetPriceConstant = 69.42e18; -uint128 constant min = 1e18; -int256 constant priceDecayPercent = 0.31e18; +uint256 constant variantId = 0; +uint16 constant min = 1e4; +int184 constant priceDecayPercent = 0.31e18; int256 constant timeScale = 0.0023e18; int256 constant logisticLimitAdjusted = int256((MAX_SELLABLE + 1) * 2e18); int256 constant logisticLimitDoubled = int256((MAX_SELLABLE + 1e18) * 2e18); contract LogisticVRGDATest is RegistryProductPriceTest { MockLogisticVRGDAPrices vrgda; + int128 targetPriceConstant = + int128(int256(PRODUCTS_MODULE.basePrice(slicerId, productId, variantId, address(0), 1))); + int128 targetPriceConstant_Erc20 = + int128(int256(PRODUCTS_MODULE.basePrice(slicerId, productId, variantId, address(20), 1))); + + uint256 minAmount = uint256(int256(targetPriceConstant)) * min / 1e5; + uint256 minAmount_Erc20 = uint256(int256(targetPriceConstant_Erc20)) * min / 1e5; function setUp() public { vrgda = new MockLogisticVRGDAPrices(PRODUCTS_MODULE); _setHook(address(vrgda)); - LogisticVRGDAParams[] memory logisticParams = new LogisticVRGDAParams[](2); - logisticParams[0] = LogisticVRGDAParams(address(0), targetPriceConstant, min, timeScale); - logisticParams[1] = LogisticVRGDAParams(address(20), targetPriceConstant, min, timeScale); - - bytes memory params = abi.encode(logisticParams, priceDecayPercent); + bytes memory params = abi.encode(priceDecayPercent, min, timeScale); vm.startPrank(productOwner); - vrgda.configureProduct(slicerId, productId, params); + vrgda.configureProduct(slicerId, productId, variantId, params); vm.stopPrank(); } @@ -55,7 +56,7 @@ contract LogisticVRGDATest is RegistryProductPriceTest { logisticLimit * 2e18, sold, timeScale, - min + minAmount ); assertApproxEqAbs(cost, uint256(uint128(targetPriceConstant)), 1e13); } @@ -77,7 +78,7 @@ contract LogisticVRGDATest is RegistryProductPriceTest { logisticLimit * 2e18, numMint, timeScale, - min + minAmount ); // Equal within 2 percent since num mint is rounded from true decimal amount. @@ -101,9 +102,9 @@ contract LogisticVRGDATest is RegistryProductPriceTest { logisticLimit * 2e18, numMint, timeScale, - min + minAmount ); - assertEq(cost, min); + assertEq(cost, minAmount); } function testPricingAdjustedByQuantity() public { @@ -121,7 +122,7 @@ contract LogisticVRGDATest is RegistryProductPriceTest { toWadUnsafe(MAX_SELLABLE + 1), numMint, timeScale, - min, + minAmount, 1 ); uint256 costProduct2 = vrgda.getAdjustedVRGDALogisticPrice( @@ -131,7 +132,7 @@ contract LogisticVRGDATest is RegistryProductPriceTest { toWadUnsafe(MAX_SELLABLE + 1), numMint + 1, timeScale, - min, + minAmount, 1 ); uint256 costProduct3 = vrgda.getAdjustedVRGDALogisticPrice( @@ -141,7 +142,7 @@ contract LogisticVRGDATest is RegistryProductPriceTest { toWadUnsafe(MAX_SELLABLE + 1), numMint + 2, timeScale, - min, + minAmount, 1 ); uint256 costMultiple = vrgda.getAdjustedVRGDALogisticPrice( @@ -151,7 +152,7 @@ contract LogisticVRGDATest is RegistryProductPriceTest { toWadUnsafe(MAX_SELLABLE + 1), numMint, timeScale, - min, + minAmount, 3 ); @@ -161,30 +162,23 @@ contract LogisticVRGDATest is RegistryProductPriceTest { function testSetMultiplePrices() public { // uint256 targetPriceTest = 7.3013e18; uint256 productIdTest = 2; - LogisticVRGDAParams[] memory logisticParams = new LogisticVRGDAParams[](2); - logisticParams[0] = LogisticVRGDAParams(address(0), targetPriceConstant, min, timeScale); - logisticParams[1] = LogisticVRGDAParams(address(20), targetPriceConstant, min, timeScale); - bytes memory params = abi.encode(logisticParams, priceDecayPercent); + bytes memory params = abi.encode(priceDecayPercent, min, timeScale); vm.startPrank(productOwner); - vrgda.configureProduct(slicerId, productIdTest, params); + vrgda.configureProduct(slicerId, productIdTest, variantId, params); vm.stopPrank(); vm.warp(block.timestamp + 10 days); - (uint256 ethPrice, uint256 currencyPrice) = - vrgda.productPrice(slicerId, productIdTest, address(0), 1, address(0), ""); + uint256 price = vrgda.productPrice(slicerId, productIdTest, variantId, address(0), 0, 1, address(0), ""); // assertApproxEqAbs(uint256(targetPriceTest), ethPrice, 1e18); - assertNotEq(ethPrice, 0); - assertEq(currencyPrice, 0); + assertNotEq(price, 0); - (uint256 ethPrice2, uint256 currencyPrice2) = - vrgda.productPrice(slicerId, productId, address(20), 1, address(0), ""); + uint256 price2 = vrgda.productPrice(slicerId, productId, variantId, address(20), 0, 1, address(0), ""); - assertEq(ethPrice2, 0); - assertNotEq(currencyPrice2, 0); + assertNotEq(price2, 0); } function testProductPriceEth() public { @@ -203,15 +197,13 @@ contract LogisticVRGDATest is RegistryProductPriceTest { toWadUnsafe(MAX_SELLABLE + 1), 0, timeScale, - min, + minAmount, 1 ); - (uint256 ethPrice, uint256 currencyPrice) = - vrgda.productPrice(slicerId, productId, ethCurrency, 1, address(0), ""); + uint256 price = vrgda.productPrice(slicerId, productId, variantId, ethCurrency, 0, 1, address(0), ""); - assertApproxEqAbs(cost, ethPrice, 0.00001e18); - assertEq(currencyPrice, 0); + assertApproxEqAbs(cost, price, 0.00001e18); } function testProductPriceErc20() public { @@ -224,21 +216,19 @@ contract LogisticVRGDATest is RegistryProductPriceTest { vm.warp(block.timestamp + timeDelta); uint256 cost = vrgda.getAdjustedVRGDALogisticPrice( - targetPriceConstant, + targetPriceConstant_Erc20, decayConstant, toDaysWadUnsafe(block.timestamp), toWadUnsafe(MAX_SELLABLE + 1), 0, timeScale, - min, + minAmount_Erc20, 1 ); - (uint256 ethPrice, uint256 currencyPrice) = - vrgda.productPrice(slicerId, productId, erc20Currency, 1, address(0), ""); + uint256 price = vrgda.productPrice(slicerId, productId, variantId, erc20Currency, 0, 1, address(0), ""); - assertApproxEqAbs(cost, currencyPrice, 0.00001e18); - assertEq(ethPrice, 0); + assertApproxEqAbs(cost, price, 0.00001e18); } function testProductPriceMultiple() public { @@ -257,12 +247,12 @@ contract LogisticVRGDATest is RegistryProductPriceTest { toWadUnsafe(MAX_SELLABLE + 1), 0, timeScale, - min, + minAmount, 3 ); - (uint256 ethPrice,) = vrgda.productPrice(slicerId, productId, ethCurrency, 3, address(0), ""); + uint256 price = vrgda.productPrice(slicerId, productId, variantId, ethCurrency, 0, 3, address(0), ""); - assertApproxEqAbs(costMultiple, ethPrice, 5e14); + assertApproxEqAbs(costMultiple, price, 5e14); } function testGetTargetSaleTimeDoesNotRevertEarly() public view { @@ -292,7 +282,7 @@ contract LogisticVRGDATest is RegistryProductPriceTest { logisticLimit * 2e18, bound(sold, 0, 1730), timeScale, - min + minAmount ); } @@ -308,7 +298,7 @@ contract LogisticVRGDATest is RegistryProductPriceTest { logisticLimit * 2e18, bound(sold, 0, 6391), timeScale, - min + minAmount ); } @@ -325,7 +315,7 @@ contract LogisticVRGDATest is RegistryProductPriceTest { logisticLimit * 2e18, bound(sold, 6392, type(uint128).max), timeScale, - min + minAmount ); } @@ -344,7 +334,7 @@ contract LogisticVRGDATest is RegistryProductPriceTest { logisticLimit * 2e18, sold, timeScale, - min + minAmount ), uint256(uint128(targetPriceConstant)), 0.00001e18 diff --git a/test/pricing/VRGDA/correctness/LinearVRGDACorrectness.t.sol b/test/pricing/VRGDA/correctness/LinearVRGDACorrectness.t.sol index 105432f..3f15239 100644 --- a/test/pricing/VRGDA/correctness/LinearVRGDACorrectness.t.sol +++ b/test/pricing/VRGDA/correctness/LinearVRGDACorrectness.t.sol @@ -1,12 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.30; -import {console} from "forge-std/console.sol"; import {Vm} from "forge-std/Vm.sol"; import {RegistryProductPriceTest} from "@test/utils/RegistryProductPriceTest.sol"; import {wadLn, toWadUnsafe} from "@/utils/math/SignedWadMath.sol"; import {IProductsModule} from "@/utils/ProductPrice.sol"; -import {MockLinearVRGDAPrices, LinearVRGDAParams} from "../mocks/MockLinearVRGDAPrices.sol"; +import {MockLinearVRGDAPrices} from "../mocks/MockLinearVRGDAPrices.sol"; contract LinearVRGDACorrectnessTest is RegistryProductPriceTest { // Sample parameters for differential fuzzing campaign. @@ -15,9 +14,10 @@ contract LinearVRGDACorrectnessTest is RegistryProductPriceTest { uint256 constant slicerId = 0; uint256 constant productId = 1; + uint256 constant variantId = 0; int128 constant targetPriceConstant = 69.42e18; - uint128 constant min = 1e18; - int256 constant priceDecayPercent = 0.31e18; + uint16 constant min = 1e3; + int184 constant priceDecayPercent = 0.31e18; int256 constant perTimeUnit = 2e18; MockLinearVRGDAPrices vrgda; @@ -26,12 +26,9 @@ contract LinearVRGDACorrectnessTest is RegistryProductPriceTest { vrgda = new MockLinearVRGDAPrices(PRODUCTS_MODULE); _setHook(address(vrgda)); - LinearVRGDAParams[] memory linearParams = new LinearVRGDAParams[](1); - linearParams[0] = LinearVRGDAParams(address(0), targetPriceConstant, min, perTimeUnit); - vm.prank(productOwner); - bytes memory params = abi.encode(linearParams, priceDecayPercent); - vrgda.configureProduct(slicerId, productId, params); + bytes memory params = abi.encode(priceDecayPercent, min, perTimeUnit); + vrgda.configureProduct(slicerId, productId, variantId, params); } function testFFICorrectness() public { @@ -48,9 +45,6 @@ contract LinearVRGDACorrectnessTest is RegistryProductPriceTest { uint256 expectedPrice = calculatePrice(targetPriceConstant, priceDecayPercent, perTimeUnit, timeSinceStart, numSold); - console.log("actual price", actualPrice); - console.log("expected price", expectedPrice); - // Check approximate equality. assertApproxEqAbs(expectedPrice, actualPrice, 0.00001e18); diff --git a/test/pricingActions/FirstForFree/FirstForFree.t.sol b/test/pricingActions/FirstForFree/FirstForFree.t.sol index a4bbfa9..6be2046 100644 --- a/test/pricingActions/FirstForFree/FirstForFree.t.sol +++ b/test/pricingActions/FirstForFree/FirstForFree.t.sol @@ -8,10 +8,6 @@ import {TokenCondition, TokenType} from "@/hooks/pricingActions/FirstForFree/typ import {ITokenERC1155} from "@/hooks/pricingActions/FirstForFree/utils/ITokenERC1155.sol"; import {MockERC721} from "@test/utils/mocks/MockERC721.sol"; -import {console2} from "forge-std/console2.sol"; - -uint256 constant slicerId = 0; - contract MockERC1155Token is ITokenERC1155 { mapping(address => mapping(uint256 => uint256)) public balanceOf; mapping(address => uint256) public mintedAmounts; @@ -32,8 +28,22 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { MockERC1155Token mockERC1155; MockERC1155Token mockMintToken; + uint256 slicerId = 0; uint256[] productIds = [1, 2, 3, 4]; - uint256 constant USDC_PRICE = 1000000; // 1 USDC (6 decimals) + uint256 constant variantId = 0; + uint256 constant variantIdAlt = 1; + + function _basePrice(uint256 slicerId_, uint256 productId_, uint256 quantity) internal view returns (uint256) { + return _basePriceForVariant(slicerId_, productId_, variantId, quantity); + } + + function _basePriceForVariant(uint256 slicerId_, uint256 productId_, uint256 variantId_, uint256 quantity) + internal + view + returns (uint256) + { + return PRODUCTS_MODULE.basePrice(slicerId_, productId_, variantId_, ETH, quantity); + } function setUp() public { firstForFree = new FirstForFree(PRODUCTS_MODULE); @@ -52,8 +62,8 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { firstForFree.configureProduct( slicerId, productIds[0], + variantId, abi.encode( - USDC_PRICE, // usdcPrice noConditions, // eligibleTokens (empty) address(mockMintToken), // mintToken uint88(1), // mintTokenId @@ -73,8 +83,8 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { firstForFree.configureProduct( slicerId, productIds[1], + variantId, abi.encode( - USDC_PRICE * 2, // usdcPrice erc721Conditions, // eligibleTokens address(0), // mintToken (no minting) uint88(0), // mintTokenId @@ -94,8 +104,8 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { firstForFree.configureProduct( slicerId, productIds[2], + variantId, abi.encode( - USDC_PRICE / 2, // usdcPrice erc1155Conditions, // eligibleTokens address(mockMintToken), // mintToken uint88(2), // mintTokenId @@ -117,8 +127,8 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { firstForFree.configureProduct( slicerId, productIds[3], + variantId, abi.encode( - USDC_PRICE * 3, // usdcPrice multipleConditions, // eligibleTokens address(mockMintToken), // mintToken uint88(3), // mintTokenId @@ -129,38 +139,59 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { vm.stopPrank(); // Verify product 1 configuration - (uint256 usdcPrice1, address mintToken1, uint88 mintTokenId1, uint8 freeUnits1) = - firstForFree.usdcPrices(slicerId, productIds[0]); - assertEq(usdcPrice1, USDC_PRICE); + (address mintToken1, uint88 mintTokenId1, uint8 freeUnits1) = + firstForFree.productParams(slicerId, productIds[0], variantId); assertEq(mintToken1, address(mockMintToken)); assertEq(mintTokenId1, 1); assertEq(freeUnits1, 3); // Verify product 2 configuration - (uint256 usdcPrice2, address mintToken2, uint88 mintTokenId2, uint8 freeUnits2) = - firstForFree.usdcPrices(slicerId, productIds[1]); - assertEq(usdcPrice2, USDC_PRICE * 2); + (address mintToken2, uint88 mintTokenId2, uint8 freeUnits2) = + firstForFree.productParams(slicerId, productIds[1], variantId); assertEq(mintToken2, address(0)); assertEq(mintTokenId2, 0); assertEq(freeUnits2, 2); // Verify product 3 configuration - (uint256 usdcPrice3, address mintToken3, uint88 mintTokenId3, uint8 freeUnits3) = - firstForFree.usdcPrices(slicerId, productIds[2]); - assertEq(usdcPrice3, USDC_PRICE / 2); + (address mintToken3, uint88 mintTokenId3, uint8 freeUnits3) = + firstForFree.productParams(slicerId, productIds[2], variantId); assertEq(mintToken3, address(mockMintToken)); assertEq(mintTokenId3, 2); assertEq(freeUnits3, 1); // Verify product 4 configuration - (uint256 usdcPrice4, address mintToken4, uint88 mintTokenId4, uint8 freeUnits4) = - firstForFree.usdcPrices(slicerId, productIds[3]); - assertEq(usdcPrice4, USDC_PRICE * 3); + (address mintToken4, uint88 mintTokenId4, uint8 freeUnits4) = + firstForFree.productParams(slicerId, productIds[3], variantId); assertEq(mintToken4, address(mockMintToken)); assertEq(mintTokenId4, 3); assertEq(freeUnits4, 5); } + function testConfigureProduct_variantSpecific() public { + TokenCondition[] memory noConditions = new TokenCondition[](0); + + vm.startPrank(productOwner); + firstForFree.configureProduct( + slicerId, productIds[0], variantId, abi.encode(noConditions, address(0), uint88(0), uint8(1)) + ); + firstForFree.configureProduct( + slicerId, productIds[0], variantIdAlt, abi.encode(noConditions, address(mockMintToken), uint88(7), uint8(2)) + ); + vm.stopPrank(); + + (address mintTokenBase, uint88 mintTokenIdBase, uint8 freeUnitsBase) = + firstForFree.productParams(slicerId, productIds[0], variantId); + assertEq(mintTokenBase, address(0)); + assertEq(mintTokenIdBase, 0); + assertEq(freeUnitsBase, 1); + + (address mintTokenAlt, uint88 mintTokenIdAlt, uint8 freeUnitsAlt) = + firstForFree.productParams(slicerId, productIds[0], variantIdAlt); + assertEq(mintTokenAlt, address(mockMintToken)); + assertEq(mintTokenIdAlt, 7); + assertEq(freeUnitsAlt, 2); + } + function testProductPrice_NoConditions() public { vm.prank(productOwner); @@ -169,8 +200,8 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { firstForFree.configureProduct( slicerId, productIds[0], + variantId, abi.encode( - USDC_PRICE, // usdcPrice noConditions, // eligibleTokens (empty) address(0), // mintToken uint88(0), // mintTokenId @@ -179,41 +210,86 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { ); // First purchase - should be free - (uint256 ethPrice, uint256 currencyPrice) = - firstForFree.productPrice(slicerId, productIds[0], address(0), 1, buyer, ""); - assertEq(ethPrice, 0); - assertEq(currencyPrice, 0); + uint256 basePrice = _basePrice(slicerId, productIds[0], 1); + uint256 price = firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, basePrice, 1, buyer, ""); + assertEq(price, 0); // Second purchase - should be free - (ethPrice, currencyPrice) = firstForFree.productPrice(slicerId, productIds[0], address(0), 1, buyer, ""); - assertEq(ethPrice, 0); - assertEq(currencyPrice, 0); + price = firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, basePrice, 1, buyer, ""); + assertEq(price, 0); // Partial free purchase - (ethPrice, currencyPrice) = firstForFree.productPrice(slicerId, productIds[0], address(0), 2, buyer, ""); - assertEq(ethPrice, 0); - assertEq(currencyPrice, 0); + uint256 twoUnitBasePrice = _basePrice(slicerId, productIds[0], 2); + price = firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, twoUnitBasePrice, 2, buyer, ""); + assertEq(price, 0); // Purchase exceeding free units - (ethPrice, currencyPrice) = firstForFree.productPrice(slicerId, productIds[0], address(0), 3, buyer, ""); - assertEq(ethPrice, 0); - assertEq(currencyPrice, USDC_PRICE); // 1 paid unit + uint256 threeUnitBasePrice = _basePrice(slicerId, productIds[0], 3); + price = firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, threeUnitBasePrice, 3, buyer, ""); + assertEq(price, _basePrice(slicerId, productIds[0], 1)); // Purchase after using some free units (simulate 1 purchase made) - vm.prank(address(PRODUCTS_MODULE)); - firstForFree.onProductPurchase(slicerId, productIds[0], buyer, 1, "", ""); + vm.mockCall( + address(PRODUCTS_MODULE), + abi.encodeWithSelector(PRODUCTS_MODULE.getPurchases.selector, buyer, slicerId, productIds[0], variantId), + abi.encode(1, "") + ); + price = firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, twoUnitBasePrice, 2, buyer, ""); + assertEq(price, _basePrice(slicerId, productIds[0], 1)); - (ethPrice, currencyPrice) = firstForFree.productPrice(slicerId, productIds[0], address(0), 2, buyer, ""); - assertEq(ethPrice, 0); - assertEq(currencyPrice, USDC_PRICE); // 1 free, 1 paid + vm.mockCall( + address(PRODUCTS_MODULE), + abi.encodeWithSelector(PRODUCTS_MODULE.getPurchases.selector, buyer, slicerId, productIds[0], variantId), + abi.encode(2, "") + ); + price = firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, basePrice, 1, buyer, ""); + assertEq(price, basePrice); + } - // Purchase after using all free units (simulate 2 total purchases made) - vm.prank(address(PRODUCTS_MODULE)); - firstForFree.onProductPurchase(slicerId, productIds[0], buyer, 1, "", ""); + function testProductPrice_variantSpecificConfiguration() public { + TokenCondition[] memory noConditions = new TokenCondition[](0); - (ethPrice, currencyPrice) = firstForFree.productPrice(slicerId, productIds[0], address(0), 1, buyer, ""); - assertEq(ethPrice, 0); - assertEq(currencyPrice, USDC_PRICE); // All paid + vm.startPrank(productOwner); + firstForFree.configureProduct( + slicerId, productIds[0], variantId, abi.encode(noConditions, address(0), uint88(0), uint8(1)) + ); + firstForFree.configureProduct( + slicerId, productIds[0], variantIdAlt, abi.encode(noConditions, address(0), uint88(0), uint8(2)) + ); + vm.stopPrank(); + + uint256 basePriceVariant0 = _basePrice(slicerId, productIds[0], 1); + uint256 priceVariant0 = + firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, basePriceVariant0, 1, buyer, ""); + assertEq(priceVariant0, 0); + + vm.mockCall( + address(PRODUCTS_MODULE), + abi.encodeWithSelector(PRODUCTS_MODULE.getPurchases.selector, buyer, slicerId, productIds[0], variantId), + abi.encode(1, "") + ); + priceVariant0 = + firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, basePriceVariant0, 1, buyer, ""); + assertEq(priceVariant0, basePriceVariant0); + + uint256 basePriceVariantAlt = _basePriceForVariant(slicerId, productIds[0], variantIdAlt, 1); + uint256 priceVariantAlt = + firstForFree.productPrice(slicerId, productIds[0], variantIdAlt, ETH, basePriceVariantAlt, 1, buyer, ""); + assertEq(priceVariantAlt, 0); + + uint256 twoUnitBasePriceAlt = _basePriceForVariant(slicerId, productIds[0], variantIdAlt, 2); + priceVariantAlt = + firstForFree.productPrice(slicerId, productIds[0], variantIdAlt, ETH, twoUnitBasePriceAlt, 2, buyer, ""); + assertEq(priceVariantAlt, 0); + + vm.mockCall( + address(PRODUCTS_MODULE), + abi.encodeWithSelector(PRODUCTS_MODULE.getPurchases.selector, buyer, slicerId, productIds[0], variantIdAlt), + abi.encode(2, "") + ); + priceVariantAlt = + firstForFree.productPrice(slicerId, productIds[0], variantIdAlt, ETH, basePriceVariantAlt, 1, buyer, ""); + assertEq(priceVariantAlt, basePriceVariantAlt); } function testProductPrice_ERC721Condition() public { @@ -227,8 +303,8 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { firstForFree.configureProduct( slicerId, productIds[0], + variantId, abi.encode( - USDC_PRICE, // usdcPrice erc721Conditions, // eligibleTokens address(0), // mintToken uint88(0), // mintTokenId @@ -239,28 +315,25 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { vm.stopPrank(); // Buyer without required token - should pay full price - (uint256 ethPrice, uint256 currencyPrice) = - firstForFree.productPrice(slicerId, productIds[0], address(0), 1, buyer, ""); - assertEq(ethPrice, 0); - assertEq(currencyPrice, USDC_PRICE); + uint256 basePrice = _basePrice(slicerId, productIds[0], 1); + uint256 price = firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, basePrice, 1, buyer, ""); + assertEq(price, basePrice); // Give buyer the required ERC721 token mockERC721.mint(buyer); // Buyer with required token - should get free units - (ethPrice, currencyPrice) = firstForFree.productPrice(slicerId, productIds[0], address(0), 1, buyer, ""); - assertEq(ethPrice, 0); - assertEq(currencyPrice, 0); + price = firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, basePrice, 1, buyer, ""); + assertEq(price, 0); // Second free purchase - (ethPrice, currencyPrice) = firstForFree.productPrice(slicerId, productIds[0], address(0), 1, buyer, ""); - assertEq(ethPrice, 0); - assertEq(currencyPrice, 0); + price = firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, basePrice, 1, buyer, ""); + assertEq(price, 0); // Purchase exceeding free units - (ethPrice, currencyPrice) = firstForFree.productPrice(slicerId, productIds[0], address(0), 3, buyer, ""); - assertEq(ethPrice, 0); - assertEq(currencyPrice, USDC_PRICE); // 2 free, 1 paid + uint256 threeUnitBasePrice = _basePrice(slicerId, productIds[0], 3); + price = firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, threeUnitBasePrice, 3, buyer, ""); + assertEq(price, _basePrice(slicerId, productIds[0], 1)); // 2 free, 1 paid } function testProductPrice_ERC1155Condition() public { @@ -278,8 +351,8 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { firstForFree.configureProduct( slicerId, productIds[0], + variantId, abi.encode( - USDC_PRICE, // usdcPrice erc1155Conditions, // eligibleTokens address(0), // mintToken uint88(0), // mintTokenId @@ -291,24 +364,22 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { // Buyer without sufficient tokens - should pay full price mockERC1155.setBalance(buyer, 5, 5); // Less than required - (uint256 ethPrice, uint256 currencyPrice) = - firstForFree.productPrice(slicerId, productIds[0], address(0), 1, buyer, ""); - assertEq(ethPrice, 0); - assertEq(currencyPrice, USDC_PRICE); + uint256 basePrice = _basePrice(slicerId, productIds[0], 1); + uint256 price = firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, basePrice, 1, buyer, ""); + assertEq(price, basePrice); // Give buyer sufficient tokens mockERC1155.setBalance(buyer, 5, 15); // More than required // Buyer with sufficient tokens - should get free units - (ethPrice, currencyPrice) = firstForFree.productPrice(slicerId, productIds[0], address(0), 2, buyer, ""); - assertEq(ethPrice, 0); - assertEq(currencyPrice, 0); + uint256 twoUnitBasePrice = _basePrice(slicerId, productIds[0], 2); + price = firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, twoUnitBasePrice, 2, buyer, ""); + assertEq(price, 0); // Purchase exactly at minimum requirement mockERC1155.setBalance(buyer, 5, 10); // Exactly required amount - (ethPrice, currencyPrice) = firstForFree.productPrice(slicerId, productIds[0], address(0), 1, buyer, ""); - assertEq(ethPrice, 0); - assertEq(currencyPrice, 0); + price = firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, basePrice, 1, buyer, ""); + assertEq(price, 0); } function testProductPrice_MultipleConditions() public { @@ -328,8 +399,8 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { firstForFree.configureProduct( slicerId, productIds[0], + variantId, abi.encode( - USDC_PRICE, // usdcPrice multipleConditions, // eligibleTokens address(0), // mintToken uint88(0), // mintTokenId @@ -339,31 +410,27 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { vm.stopPrank(); + uint256 basePrice = _basePrice(slicerId, productIds[0], 1); + // Buyer meets first condition only mockERC721.mint(buyer); - (uint256 ethPrice, uint256 currencyPrice) = - firstForFree.productPrice(slicerId, productIds[0], address(0), 1, buyer, ""); - assertEq(ethPrice, 0); - assertEq(currencyPrice, 0); + uint256 price = firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, basePrice, 1, buyer, ""); + assertEq(price, 0); // Buyer meets second condition only mockERC1155.setBalance(buyer2, 3, 10); - (ethPrice, currencyPrice) = firstForFree.productPrice(slicerId, productIds[0], address(0), 1, buyer2, ""); - assertEq(ethPrice, 0); - assertEq(currencyPrice, 0); + price = firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, basePrice, 1, buyer2, ""); + assertEq(price, 0); // Buyer meets both conditions mockERC721.mint(buyer3); mockERC1155.setBalance(buyer3, 3, 10); - (ethPrice, currencyPrice) = firstForFree.productPrice(slicerId, productIds[0], address(0), 1, buyer3, ""); - assertEq(ethPrice, 0); - assertEq(currencyPrice, 0); + price = firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, basePrice, 1, buyer3, ""); + assertEq(price, 0); // Buyer meets neither condition - (ethPrice, currencyPrice) = - firstForFree.productPrice(slicerId, productIds[0], address(0), 1, address(0x999), ""); - assertEq(ethPrice, 0); - assertEq(currencyPrice, USDC_PRICE); + price = firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, basePrice, 1, address(0x999), ""); + assertEq(price, basePrice); } function testOnProductPurchase_WithMinting() public { @@ -373,8 +440,8 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { firstForFree.configureProduct( slicerId, productIds[0], + variantId, abi.encode( - USDC_PRICE, // usdcPrice noConditions, // eligibleTokens address(mockMintToken), // mintToken uint88(5), // mintTokenId @@ -384,33 +451,26 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { vm.stopPrank(); - // Test purchase tracking and minting - assertEq(firstForFree.purchases(buyer, slicerId), 0); + // Test minting assertEq(mockMintToken.balanceOf(buyer, 5), 0); vm.prank(address(PRODUCTS_MODULE)); - firstForFree.onProductPurchase(slicerId, productIds[0], buyer, 3, "", ""); + firstForFree.onProductPurchase(slicerId, productIds[0], variantId, buyer, 3, ""); - // Check purchase tracking - assertEq(firstForFree.purchases(buyer, slicerId), 3); // Check minting assertEq(mockMintToken.balanceOf(buyer, 5), 3); // Second purchase vm.prank(address(PRODUCTS_MODULE)); - firstForFree.onProductPurchase(slicerId, productIds[0], buyer, 2, "", ""); + firstForFree.onProductPurchase(slicerId, productIds[0], variantId, buyer, 2, ""); - assertEq(firstForFree.purchases(buyer, slicerId), 5); assertEq(mockMintToken.balanceOf(buyer, 5), 5); // Different buyer should have separate tracking vm.prank(address(PRODUCTS_MODULE)); - firstForFree.onProductPurchase(slicerId, productIds[0], buyer2, 1, "", ""); + firstForFree.onProductPurchase(slicerId, productIds[0], variantId, buyer2, 1, ""); - assertEq(firstForFree.purchases(buyer2, slicerId), 1); assertEq(mockMintToken.balanceOf(buyer2, 5), 1); - // Original buyer unchanged - assertEq(firstForFree.purchases(buyer, slicerId), 5); } function testOnProductPurchase_WithoutMinting() public { @@ -420,8 +480,8 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { firstForFree.configureProduct( slicerId, productIds[0], + variantId, abi.encode( - USDC_PRICE, // usdcPrice noConditions, // eligibleTokens address(0), // mintToken (no minting) uint88(0), // mintTokenId @@ -429,13 +489,10 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { ) ); - assertEq(firstForFree.purchases(buyer, slicerId), 0); - vm.prank(address(PRODUCTS_MODULE)); - firstForFree.onProductPurchase(slicerId, productIds[0], buyer, 2, "", ""); + firstForFree.onProductPurchase(slicerId, productIds[0], variantId, buyer, 2, ""); - // Only purchase tracking, no minting - assertEq(firstForFree.purchases(buyer, slicerId), 2); + // Check no minting assertEq(mockMintToken.balanceOf(buyer, 0), 0); } @@ -448,36 +505,37 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { firstForFree.configureProduct( 0, // slicerId 0 productIds[0], - abi.encode(USDC_PRICE, noConditions, address(0), uint88(0), uint8(2)) + variantId, + abi.encode(noConditions, address(0), uint88(0), uint8(2)) ); firstForFree.configureProduct( 1, // slicerId 1 productIds[0], - abi.encode(USDC_PRICE, noConditions, address(0), uint88(0), uint8(3)) + variantId, + abi.encode(noConditions, address(0), uint88(0), uint8(3)) ); vm.stopPrank(); - // Purchase on slicer 0 - vm.prank(address(PRODUCTS_MODULE)); - firstForFree.onProductPurchase(0, productIds[0], buyer, 2, "", ""); - - // Purchase on slicer 1 - vm.prank(address(PRODUCTS_MODULE)); - firstForFree.onProductPurchase(1, productIds[0], buyer, 1, "", ""); - - // Check separate tracking - assertEq(firstForFree.purchases(buyer, 0), 2); - assertEq(firstForFree.purchases(buyer, 1), 1); - // Verify pricing considers separate tracking - (uint256 ethPrice, uint256 currencyPrice) = - firstForFree.productPrice(0, productIds[0], address(0), 1, buyer, ""); - assertEq(currencyPrice, USDC_PRICE); // No free units left on slicer 0 - - (ethPrice, currencyPrice) = firstForFree.productPrice(1, productIds[0], address(0), 1, buyer, ""); - assertEq(currencyPrice, 0); // Still has free units on slicer 1 + uint256 basePriceSlicer0 = _basePrice(0, productIds[0], 1); + vm.mockCall( + address(PRODUCTS_MODULE), + abi.encodeWithSelector(PRODUCTS_MODULE.getPurchases.selector, buyer, 0, productIds[0], variantId), + abi.encode(2, "") + ); + uint256 price = firstForFree.productPrice(0, productIds[0], variantId, ETH, basePriceSlicer0, 1, buyer, ""); + assertEq(price, basePriceSlicer0); // No free units left on slicer 0 + + uint256 basePriceSlicer1 = _basePrice(1, productIds[0], 1); + vm.mockCall( + address(PRODUCTS_MODULE), + abi.encodeWithSelector(PRODUCTS_MODULE.getPurchases.selector, buyer, 1, productIds[0], variantId), + abi.encode(1, "") + ); + price = firstForFree.productPrice(1, productIds[0], variantId, ETH, basePriceSlicer1, 1, buyer, ""); + assertEq(price, 0); // Still has free units on slicer 1 } function testEdgeCases() public { @@ -487,8 +545,8 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { firstForFree.configureProduct( slicerId, productIds[0], + variantId, abi.encode( - USDC_PRICE, // usdcPrice noConditions, // eligibleTokens address(0), // mintToken uint88(0), // mintTokenId @@ -497,15 +555,13 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { ); // Zero free units - should always pay - (uint256 ethPrice, uint256 currencyPrice) = - firstForFree.productPrice(slicerId, productIds[0], address(0), 1, buyer, ""); - assertEq(ethPrice, 0); - assertEq(currencyPrice, USDC_PRICE); + uint256 basePrice = _basePrice(slicerId, productIds[0], 1); + uint256 price = firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, basePrice, 1, buyer, ""); + assertEq(price, basePrice); // Zero quantity - should return zero - (ethPrice, currencyPrice) = firstForFree.productPrice(slicerId, productIds[0], address(0), 0, buyer, ""); - assertEq(ethPrice, 0); - assertEq(currencyPrice, 0); + price = firstForFree.productPrice(slicerId, productIds[0], variantId, ETH, 0, 0, buyer, ""); + assertEq(price, 0); } function testConfigureProduct_UpdateExisting() public { @@ -519,8 +575,8 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { firstForFree.configureProduct( slicerId, productIds[0], + variantId, abi.encode( - USDC_PRICE, // usdcPrice initialConditions, // eligibleTokens address(mockMintToken), // mintToken uint88(1), // mintTokenId @@ -542,8 +598,8 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { firstForFree.configureProduct( slicerId, productIds[0], + variantId, abi.encode( - USDC_PRICE * 2, // usdcPrice (updated) newConditions, // eligibleTokens (updated) address(0), // mintToken (updated to none) uint88(0), // mintTokenId @@ -554,9 +610,8 @@ contract FirstForFreeTest is RegistryProductPriceActionTest { vm.stopPrank(); // Verify updated configuration - (uint256 updatedPrice, address updatedMintToken, uint88 updatedMintTokenId, uint8 updatedFreeUnits) = - firstForFree.usdcPrices(slicerId, productIds[0]); - assertEq(updatedPrice, USDC_PRICE * 2); + (address updatedMintToken, uint88 updatedMintTokenId, uint8 updatedFreeUnits) = + firstForFree.productParams(slicerId, productIds[0], variantId); assertEq(updatedMintToken, address(0)); assertEq(updatedMintTokenId, 0); assertEq(updatedFreeUnits, 5); diff --git a/test/utils/ProductActionTest.sol b/test/utils/ProductActionTest.sol index ef4f28e..5bb5bc5 100644 --- a/test/utils/ProductActionTest.sol +++ b/test/utils/ProductActionTest.sol @@ -11,7 +11,7 @@ abstract contract ProductActionTest is HookTest { function testRevert_onProductPurchase_NotPurchase() public { vm.expectRevert(abi.encodeWithSelector(ProductAction.NotPurchase.selector)); - IProductAction(hook).onProductPurchase(0, 0, address(0), 0, "", ""); + IProductAction(hook).onProductPurchase(0, 0, 0, address(0), 0, ""); } function testRevert_onProductPurchase_WrongSlicer() public { @@ -19,6 +19,6 @@ abstract contract ProductActionTest is HookTest { vm.expectRevert(abi.encodeWithSelector(ProductAction.WrongSlicer.selector)); vm.prank(address(PRODUCTS_MODULE)); - IProductAction(hook).onProductPurchase(unauthorizedSlicer, 0, address(0), 0, "", ""); + IProductAction(hook).onProductPurchase(unauthorizedSlicer, 0, 0, address(0), 0, ""); } } diff --git a/test/utils/HookRegistryTest.sol b/test/utils/ProductHookRegistryTest.sol similarity index 55% rename from test/utils/HookRegistryTest.sol rename to test/utils/ProductHookRegistryTest.sol index f381db1..b30a5c1 100644 --- a/test/utils/HookRegistryTest.sol +++ b/test/utils/ProductHookRegistryTest.sol @@ -2,22 +2,30 @@ pragma solidity ^0.8.30; import {HookTest} from "./HookTest.sol"; -import {IHookRegistry} from "@/utils/RegistryProductPrice.sol"; +import {IProductHookRegistry} from "@/utils/RegistryProductPrice.sol"; import {MockProductsModule} from "./mocks/MockProductsModule.sol"; import {SliceContext} from "@/utils/RegistryProductAction.sol"; -abstract contract HookRegistryTest is HookTest { - function testParamsSchema() public view { - string memory schema = IHookRegistry(hook).paramsSchema(); +bytes32 constant PRODUCT_MANAGER_ROLE = bytes32(uint256(0x04)); + +abstract contract ProductHookRegistryTest is HookTest { + function testConfigureProductSchema() public view { + string memory schema = IProductHookRegistry(hook).configureProductSchema(); assertTrue(bytes(schema).length > 0); } function testConfigureProduct_AccessControl() public { - vm.expectRevert(abi.encodeWithSelector(SliceContext.NotProductOwner.selector)); - IHookRegistry(hook).configureProduct(0, 0, ""); + vm.expectRevert( + abi.encodeWithSelector( + SliceContext.NotAuthorized.selector, + bytes32(uint256(1 << 1 | 1 << 3)) /* Role.ADMIN.getMask() | Role.PRODUCT_MANAGER.getMask() */ + ) + ); + + IProductHookRegistry(hook).configureProduct(0, 0, 0, ""); } - // TODO: verify paramsSchema effectively corresponds to the params + // TODO: verify configureProductSchema effectively corresponds to the params // Blocker: generate bytes params based on a generic string schema // function generateParamsFromSchema(string memory schema) public returns (bytes memory) { diff --git a/test/utils/ProductPriceActionTest.sol b/test/utils/ProductPriceActionTest.sol index cbf0476..9990e2a 100644 --- a/test/utils/ProductPriceActionTest.sol +++ b/test/utils/ProductPriceActionTest.sol @@ -12,7 +12,7 @@ abstract contract ProductPriceActionTest is HookTest { function testRevert_onProductPurchase_NotPurchase() public { vm.expectRevert(abi.encodeWithSelector(ProductAction.NotPurchase.selector)); - IProductAction(hook).onProductPurchase(0, 0, address(0), 0, "", ""); + IProductAction(hook).onProductPurchase(0, 0, 0, address(0), 0, ""); } function testRevert_onProductPurchase_WrongSlicer() public { @@ -20,6 +20,6 @@ abstract contract ProductPriceActionTest is HookTest { vm.expectRevert(abi.encodeWithSelector(ProductAction.WrongSlicer.selector)); vm.prank(address(PRODUCTS_MODULE)); - IProductAction(hook).onProductPurchase(unauthorizedSlicer, 0, address(0), 0, "", ""); + IProductAction(hook).onProductPurchase(unauthorizedSlicer, 0, 0, address(0), 0, ""); } } diff --git a/test/utils/RegistryProductActionTest.sol b/test/utils/RegistryProductActionTest.sol index 0205cbe..2adbbf3 100644 --- a/test/utils/RegistryProductActionTest.sol +++ b/test/utils/RegistryProductActionTest.sol @@ -1,17 +1,17 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.30; -import {HookRegistryTest} from "./HookRegistryTest.sol"; -import {RegistryProductAction, IHookRegistry, IProductAction} from "@/utils/RegistryProductAction.sol"; +import {ProductHookRegistryTest} from "./ProductHookRegistryTest.sol"; +import {RegistryProductAction, IProductHookRegistry, IProductAction} from "@/utils/RegistryProductAction.sol"; -abstract contract RegistryProductActionTest is HookRegistryTest { +abstract contract RegistryProductActionTest is ProductHookRegistryTest { function testSupportsInterface_RegistryProductAction() public view { assertTrue(IProductAction(hook).supportsInterface(type(IProductAction).interfaceId)); - assertTrue(IProductAction(hook).supportsInterface(type(IHookRegistry).interfaceId)); + assertTrue(IProductAction(hook).supportsInterface(type(IProductHookRegistry).interfaceId)); } function testRevert_onProductPurchase_NotPurchase() public { vm.expectRevert(abi.encodeWithSelector(RegistryProductAction.NotPurchase.selector)); - IProductAction(hook).onProductPurchase(0, 0, address(0), 0, "", ""); + IProductAction(hook).onProductPurchase(0, 0, 0, address(0), 0, ""); } } diff --git a/test/utils/RegistryProductPriceActionTest.sol b/test/utils/RegistryProductPriceActionTest.sol index 56b48dd..7b3cbd6 100644 --- a/test/utils/RegistryProductPriceActionTest.sol +++ b/test/utils/RegistryProductPriceActionTest.sol @@ -1,24 +1,24 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.30; -import {HookRegistryTest} from "./HookRegistryTest.sol"; +import {ProductHookRegistryTest} from "./ProductHookRegistryTest.sol"; import { RegistryProductPriceAction, RegistryProductAction, - IHookRegistry, + IProductHookRegistry, IProductAction, IProductPrice } from "@/utils/RegistryProductPriceAction.sol"; -abstract contract RegistryProductPriceActionTest is HookRegistryTest { +abstract contract RegistryProductPriceActionTest is ProductHookRegistryTest { function testSupportsInterface_RegistryProductPriceAction() public view { assertTrue(IProductAction(hook).supportsInterface(type(IProductAction).interfaceId)); assertTrue(IProductAction(hook).supportsInterface(type(IProductPrice).interfaceId)); - assertTrue(IProductAction(hook).supportsInterface(type(IHookRegistry).interfaceId)); + assertTrue(IProductAction(hook).supportsInterface(type(IProductHookRegistry).interfaceId)); } function testRevert_onProductPurchase_NotPurchase() public { vm.expectRevert(abi.encodeWithSelector(RegistryProductAction.NotPurchase.selector)); - IProductAction(hook).onProductPurchase(0, 0, address(0), 0, "", ""); + IProductAction(hook).onProductPurchase(0, 0, 0, address(0), 0, ""); } } diff --git a/test/utils/RegistryProductPriceTest.sol b/test/utils/RegistryProductPriceTest.sol index 6d236e6..1628687 100644 --- a/test/utils/RegistryProductPriceTest.sol +++ b/test/utils/RegistryProductPriceTest.sol @@ -1,12 +1,12 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.30; -import {HookRegistryTest} from "./HookRegistryTest.sol"; -import {RegistryProductPrice, IHookRegistry, IProductPrice} from "@/utils/RegistryProductPrice.sol"; +import {ProductHookRegistryTest} from "./ProductHookRegistryTest.sol"; +import {RegistryProductPrice, IProductHookRegistry, IProductPrice} from "@/utils/RegistryProductPrice.sol"; -abstract contract RegistryProductPriceTest is HookRegistryTest { +abstract contract RegistryProductPriceTest is ProductHookRegistryTest { function testSupportsInterface_RegistryProductPrice() public view { assertTrue(IProductPrice(hook).supportsInterface(type(IProductPrice).interfaceId)); - assertTrue(IProductPrice(hook).supportsInterface(type(IHookRegistry).interfaceId)); + assertTrue(IProductPrice(hook).supportsInterface(type(IProductHookRegistry).interfaceId)); } } diff --git a/test/utils/mocks/MockProductsModule.sol b/test/utils/mocks/MockProductsModule.sol index 0b8a4a9..2ea1328 100644 --- a/test/utils/mocks/MockProductsModule.sol +++ b/test/utils/mocks/MockProductsModule.sol @@ -2,26 +2,50 @@ pragma solidity ^0.8.30; // import {IProductsModule} from "@/utils/ProductAction.sol"; +import {Price} from "slice/types.sol"; import {Test} from "forge-std/Test.sol"; contract MockProductsModule is Test // , IProductsModule { + error NotAuthorized(bytes32 rolesMask); + + function checkRoles(uint256, bytes32, address account) public pure { + if (account != vm.addr(uint256(keccak256(abi.encodePacked("productOwner"))))) { + revert NotAuthorized(bytes32(uint256(1 << 1 | 1 << 3))); + } + } + function isProductOwner(uint256, uint256, address account) external pure returns (bool isAllowed) { isAllowed = account == vm.addr(uint256(keccak256(abi.encodePacked("productOwner")))); } - function basePrice(uint256, uint256, address, uint256) + function basePrice(uint256, uint256, uint256, address currency, uint256 quantity) external pure - returns (uint256 ethPrice, uint256 currencyPrice) + returns (uint256 amount) { - ethPrice = 1e16; - currencyPrice = 100e18; + if (currency == address(0)) { + amount = 1e16 * quantity; + } else { + // USDC + amount = 100e6 * quantity; + } } - function availableUnits(uint256, uint256) external pure returns (uint256 units, bool isInfinite) { + function stockUnits(uint256, uint256, uint256) external pure returns (uint256 units, bool isInfinite) { units = 6392; isInfinite = false; } + + function getPurchases(address, uint256, uint256, uint256) + external + pure + returns (uint256 purchasedUnits, bytes memory purchaseData) + { + purchasedUnits = 0; + purchaseData = ""; + } + + function productExists(uint256 slicerId, uint256 productId) external view {} }