From 27e45caeefbf8f48713a496008b93c5f080c2203 Mon Sep 17 00:00:00 2001 From: jj_ranalli Date: Tue, 15 Jul 2025 04:08:32 +0200 Subject: [PATCH 1/4] added action tests --- foundry.toml | 2 + soldeer.lock | 7 ++ test/actions/Allowlisted/Allowlisted.t.sol | 49 +++++++++ test/actions/ERC20Gated/ERC20Gated.t.sol | 44 ++++++++ test/actions/NFTGated/NFTGated.t.sol | 102 ++++++++++++++++++ .../NFTDiscount.sol | 36 +++---- .../pricingStrategies/VRGDA/LinearVRGDA.t.sol | 4 +- .../VRGDA/LogisticVRGDA.t.sol | 4 +- .../correctness/LinearVRGDACorrectness.t.sol | 2 +- test/utils/HookRegistryTest.sol | 22 +++- test/utils/HookTest.sol | 33 ++---- test/utils/OnchainActionTest.sol | 15 ++- test/utils/PricingStrategyActionTest.sol | 17 ++- test/utils/RegistryOnchainActionTest.sol | 7 +- .../RegistryPricingStrategyActionTest.sol | 12 ++- .../mocks/MockERC1155.sol | 0 test/utils/mocks/MockERC20.sol | 12 +++ .../mocks/MockERC721.sol | 0 test/utils/mocks/MockProductsModule.sol | 18 ++++ 19 files changed, 330 insertions(+), 56 deletions(-) create mode 100644 test/actions/Allowlisted/Allowlisted.t.sol create mode 100644 test/actions/ERC20Gated/ERC20Gated.t.sol create mode 100644 test/actions/NFTGated/NFTGated.t.sol rename test/pricingStrategies/{NFTDiscount => TieredDiscount}/NFTDiscount.sol (95%) rename test/{pricingStrategies/NFTDiscount => utils}/mocks/MockERC1155.sol (100%) create mode 100644 test/utils/mocks/MockERC20.sol rename test/{pricingStrategies/NFTDiscount => utils}/mocks/MockERC721.sol (100%) create mode 100644 test/utils/mocks/MockProductsModule.sol diff --git a/foundry.toml b/foundry.toml index 531376e..0eeb8bc 100644 --- a/foundry.toml +++ b/foundry.toml @@ -10,6 +10,7 @@ remappings = [ "slice/=dependencies/slice-0.0.4/", "@openzeppelin-4.8.0/=dependencies/@openzeppelin-contracts-4.8.0/", "@erc721a/=dependencies/erc721a-4.3.0/contracts/", + "@murky/=dependencies/murky-0.1.0/src/", "forge-std/=dependencies/forge-std-1.9.7/src/", "@test/=test/", "@/=src/" @@ -47,4 +48,5 @@ slice = "0.0.4" forge-std = "1.9.7" "@openzeppelin-contracts" = "4.8.0" erc721a = "4.3.0" +murky = "0.1.0" diff --git a/soldeer.lock b/soldeer.lock index 2b1717e..3f903d8 100644 --- a/soldeer.lock +++ b/soldeer.lock @@ -19,6 +19,13 @@ url = "https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_7_28-04-2025_15: checksum = "8d9e0a885fa8ee6429a4d344aeb6799119f6a94c7c4fe6f188df79b0dce294ba" integrity = "9e60fdba82bc374df80db7f2951faff6467b9091873004a3d314cf0c084b3c7d" +[[dependencies]] +name = "murky" +version = "0.1.0" +url = "https://soldeer-revisions.s3.amazonaws.com/murky/0_1_0_27-02-2025_09:52:15_murky.zip" +checksum = "44716641e084b50af27de35f0676706c7cd42b22b39a12f7136fda4156023a15" +integrity = "a41bd6903adfe80291f7b20c0317368e517db10c302e82aa7dc53776f17811cd" + [[dependencies]] name = "slice" version = "0.0.4" diff --git a/test/actions/Allowlisted/Allowlisted.t.sol b/test/actions/Allowlisted/Allowlisted.t.sol new file mode 100644 index 0000000..2678432 --- /dev/null +++ b/test/actions/Allowlisted/Allowlisted.t.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {RegistryOnchainActionTest} from "@test/utils/RegistryOnchainActionTest.sol"; +import {Allowlisted} from "@/hooks/actions/Allowlisted/Allowlisted.sol"; +import {Merkle} from "@murky/Merkle.sol"; + +uint256 constant slicerId = 0; +uint256 constant productId = 1; + +contract AllowlistedTest is RegistryOnchainActionTest { + Allowlisted allowlisted; + Merkle m; + bytes32[] data; + + function setUp() public { + allowlisted = new Allowlisted(PRODUCTS_MODULE); + _setHook(address(allowlisted)); + + m = new Merkle(); + data = new bytes32[](4); + data[0] = bytes32(keccak256(abi.encodePacked(buyer))); + data[1] = bytes32(keccak256(abi.encodePacked(address(1)))); + data[2] = bytes32(keccak256(abi.encodePacked(address(2)))); + data[3] = bytes32(keccak256(abi.encodePacked(address(3)))); + } + + function testConfigureProduct() public { + bytes32 root = m.getRoot(data); + + vm.prank(productOwner); + allowlisted.configureProduct(slicerId, productId, abi.encode(root)); + + assertTrue(allowlisted.merkleRoots(slicerId, productId) == root); + } + + function testIsPurchaseAllowed() public { + bytes32 root = m.getRoot(data); + + vm.prank(productOwner); + allowlisted.configureProduct(slicerId, productId, abi.encode(root)); + + bytes32[] memory wrongProof = m.getProof(data, 1); + assertFalse(allowlisted.isPurchaseAllowed(slicerId, productId, buyer, 0, "", abi.encode(wrongProof))); + + bytes32[] memory proof = m.getProof(data, 0); + assertTrue(allowlisted.isPurchaseAllowed(slicerId, productId, buyer, 0, "", abi.encode(proof))); + } +} diff --git a/test/actions/ERC20Gated/ERC20Gated.t.sol b/test/actions/ERC20Gated/ERC20Gated.t.sol new file mode 100644 index 0000000..6746e4e --- /dev/null +++ b/test/actions/ERC20Gated/ERC20Gated.t.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {RegistryOnchainActionTest} from "@test/utils/RegistryOnchainActionTest.sol"; +import {ERC20Gated, ERC20Gate} from "@/hooks/actions/ERC20Gated/ERC20Gated.sol"; +import {IERC20, MockERC20} from "@test/utils/mocks/MockERC20.sol"; + +uint256 constant slicerId = 0; +uint256 constant productId = 1; + +contract ERC20GatedTest is RegistryOnchainActionTest { + ERC20Gated erc20Gated; + MockERC20 token = new MockERC20(); + + function setUp() public { + erc20Gated = new ERC20Gated(PRODUCTS_MODULE); + _setHook(address(erc20Gated)); + } + + function testConfigureProduct() public { + ERC20Gate[] memory gates = new ERC20Gate[](1); + gates[0] = ERC20Gate(token, 100); + + vm.prank(productOwner); + erc20Gated.configureProduct(slicerId, productId, abi.encode(gates)); + + (IERC20 tokenAddr, uint256 amount) = erc20Gated.tokenGates(slicerId, productId, 0); + assertTrue(address(tokenAddr) == address(token)); + assertTrue(amount == 100); + } + + function testIsPurchaseAllowed() public { + ERC20Gate[] memory gates = new ERC20Gate[](1); + gates[0] = ERC20Gate(token, 100); + + vm.prank(productOwner); + erc20Gated.configureProduct(slicerId, productId, abi.encode(gates)); + + assertFalse(erc20Gated.isPurchaseAllowed(slicerId, productId, buyer, 0, "", "")); + + token.mint(buyer, 100); + assertTrue(erc20Gated.isPurchaseAllowed(slicerId, productId, buyer, 0, "", "")); + } +} diff --git a/test/actions/NFTGated/NFTGated.t.sol b/test/actions/NFTGated/NFTGated.t.sol new file mode 100644 index 0000000..5f3cfbd --- /dev/null +++ b/test/actions/NFTGated/NFTGated.t.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {RegistryOnchainActionTest} from "@test/utils/RegistryOnchainActionTest.sol"; +import {NFTGated, NFTGates, NFTGate, TokenType} from "@/hooks/actions/NFTGated/NFTGated.sol"; +import {MockERC721} from "@test/utils/mocks/MockERC721.sol"; +import {MockERC1155} from "@test/utils/mocks/MockERC1155.sol"; + +import {console2} from "forge-std/console2.sol"; + +uint256 constant slicerId = 0; + +contract NFTGatedTest is RegistryOnchainActionTest { + NFTGated nftGated; + MockERC721 nft721 = new MockERC721(); + MockERC1155 nft1155 = new MockERC1155(); + + uint256[] productIds = [1, 2, 3, 4]; + + function setUp() public { + nftGated = new NFTGated(PRODUCTS_MODULE); + _setHook(address(nftGated)); + } + + function testConfigureProduct() public { + NFTGates[] memory nftGates = generateNFTGates(); + + 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); + } + vm.stopPrank(); + } + + function testIsPurchaseAllowed() public { + NFTGates[] memory nftGates = generateNFTGates(); + + vm.startPrank(productOwner); + for (uint256 i = 0; i < productIds.length; i++) { + nftGated.configureProduct(slicerId, productIds[i], abi.encode(nftGates[i])); + } + vm.stopPrank(); + + // Mint both nfts to buyer, and only one of each to buyer2 and buyer3 + nft721.mint(buyer); + nft1155.mint(buyer); + nft721.mint(buyer2); + 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, "", "")); + + // buyer 2 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, "", "")); + + // buyer 3 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, "", "")); + } + + /*////////////////////////////////////////////////////////////// + INTERNAL + //////////////////////////////////////////////////////////////*/ + + function generateNFTGates() public view returns (NFTGates[] memory nftGates) { + nftGates = new NFTGates[](4); + + NFTGate memory gate721 = NFTGate(address(nft721), TokenType.ERC721, 1, 1); + NFTGate memory gate1155 = NFTGate(address(nft1155), TokenType.ERC1155, 1, 1); + + // Only 721 is required + NFTGate[] memory gates1 = new NFTGate[](1); + gates1[0] = gate721; + nftGates[0] = NFTGates(gates1, 1); + + // Only 1155 is required + NFTGate[] memory gates2 = new NFTGate[](1); + gates2[0] = gate1155; + nftGates[1] = NFTGates(gates2, 1); + + // Either 721 or 1155 are required + NFTGate[] memory gates3 = new NFTGate[](2); + gates3[0] = gate721; + gates3[1] = gate1155; + nftGates[2] = NFTGates(gates3, 1); + + // Both 721 and 1155 are required + NFTGate[] memory gates4 = new NFTGate[](2); + gates4[0] = gate721; + gates4[1] = gate1155; + nftGates[3] = NFTGates(gates4, 2); + } +} diff --git a/test/pricingStrategies/NFTDiscount/NFTDiscount.sol b/test/pricingStrategies/TieredDiscount/NFTDiscount.sol similarity index 95% rename from test/pricingStrategies/NFTDiscount/NFTDiscount.sol rename to test/pricingStrategies/TieredDiscount/NFTDiscount.sol index 3aff126..be445e8 100644 --- a/test/pricingStrategies/NFTDiscount/NFTDiscount.sol +++ b/test/pricingStrategies/TieredDiscount/NFTDiscount.sol @@ -12,15 +12,13 @@ import { CurrencyParams, NFTType } from "@/hooks/pricing/TieredDiscount/NFTDiscount/NFTDiscount.sol"; -import {MockERC721} from "./mocks/MockERC721.sol"; -import {MockERC1155} from "./mocks/MockERC1155.sol"; +import {MockERC721} from "@test/utils/mocks/MockERC721.sol"; +import {MockERC1155} from "@test/utils/mocks/MockERC1155.sol"; address constant ETH = address(0); address constant USDC = address(1); uint256 constant slicerId = 0; uint256 constant productId = 1; -address constant owner = address(0); -address constant buyer = address(10); uint80 constant fixedDiscountOne = 100; uint80 constant fixedDiscountTwo = 200; uint80 constant percentDiscount = 1000; // 10% @@ -53,15 +51,11 @@ contract NFTDiscountTest is RegistryPricingStrategyTest { CurrencyParams[] memory currenciesParams = new CurrencyParams[](1); currenciesParams[0] = CurrencyParams(ETH, basePrice, false, DiscountType.Absolute, discounts); - vm.prank(owner); + vm.prank(productOwner); erc721GatedDiscount.configureProduct(slicerId, productId, abi.encode(currenciesParams)); } - function testDeploy() public view { - assertTrue(address(erc721GatedDiscount) != address(0)); - } - - function testSetProductPrice__ETH() public { + function testConfigureProduct__ETH() public { DiscountParams[] memory discounts = new DiscountParams[](1); /// set product price with additional custom inputs @@ -76,7 +70,7 @@ contract NFTDiscountTest is RegistryPricingStrategyTest { CurrencyParams[] memory currenciesParams = new CurrencyParams[](1); currenciesParams[0] = CurrencyParams(ETH, basePrice, false, DiscountType.Absolute, discounts); - vm.prank(owner); + vm.prank(productOwner); erc721GatedDiscount.configureProduct(slicerId, productId, abi.encode(currenciesParams)); /// check product price @@ -87,7 +81,7 @@ contract NFTDiscountTest is RegistryPricingStrategyTest { assertTrue(currencyPrice == 0); } - function testSetProductPrice__ERC20() public { + function testConfigureProduct__ERC20() public { DiscountParams[] memory discounts = new DiscountParams[](1); /// set product price with additional custom inputs @@ -102,7 +96,7 @@ contract NFTDiscountTest is RegistryPricingStrategyTest { CurrencyParams[] memory currenciesParams = new CurrencyParams[](1); currenciesParams[0] = CurrencyParams(USDC, basePrice, false, DiscountType.Absolute, discounts); - vm.prank(owner); + vm.prank(productOwner); erc721GatedDiscount.configureProduct(slicerId, productId, abi.encode(currenciesParams)); /// check product price @@ -113,7 +107,7 @@ contract NFTDiscountTest is RegistryPricingStrategyTest { assertTrue(ethPrice == 0); } - function testSetProductPrice__ERC1155() public { + function testConfigureProduct__ERC1155() public { DiscountParams[] memory discounts = new DiscountParams[](1); /// set product price with additional custom inputs @@ -128,7 +122,7 @@ contract NFTDiscountTest is RegistryPricingStrategyTest { CurrencyParams[] memory currenciesParams = new CurrencyParams[](1); currenciesParams[0] = CurrencyParams(USDC, basePrice, false, DiscountType.Absolute, discounts); - vm.prank(owner); + vm.prank(productOwner); erc721GatedDiscount.configureProduct(slicerId, productId, abi.encode(currenciesParams)); /// check product price @@ -146,7 +140,7 @@ contract NFTDiscountTest is RegistryPricingStrategyTest { assertTrue(ethPrice == 0); } - function testSetProductPrice__MultipleCurrencies() public { + function testConfigureProduct__MultipleCurrencies() public { DiscountParams[] memory discountsOne = new DiscountParams[](1); DiscountParams[] memory discountsTwo = new DiscountParams[](1); CurrencyParams[] memory currenciesParams = new CurrencyParams[](2); @@ -173,7 +167,7 @@ contract NFTDiscountTest is RegistryPricingStrategyTest { currenciesParams[1] = CurrencyParams(USDC, basePrice, false, DiscountType.Absolute, discountsTwo); - vm.prank(owner); + vm.prank(productOwner); erc721GatedDiscount.configureProduct(slicerId, productId, abi.encode(currenciesParams)); /// check product price for ETH @@ -299,7 +293,7 @@ contract NFTDiscountTest is RegistryPricingStrategyTest { /// set product price with percentage discount currenciesParams[0] = CurrencyParams(ETH, basePrice, false, DiscountType.Relative, discounts); - vm.prank(owner); + vm.prank(productOwner); erc721GatedDiscount.configureProduct(slicerId, productId, abi.encode(currenciesParams)); /// check product price @@ -325,7 +319,7 @@ contract NFTDiscountTest is RegistryPricingStrategyTest { /// set product price with percentage discount currenciesParams[0] = CurrencyParams(ETH, basePrice, false, DiscountType.Relative, discounts); - vm.prank(owner); + vm.prank(productOwner); erc721GatedDiscount.configureProduct(slicerId, productId, abi.encode(currenciesParams)); // buy multiple products @@ -339,7 +333,7 @@ contract NFTDiscountTest is RegistryPricingStrategyTest { assertTrue(currencyPrice == 0); } - function testSetProductPrice__Edit_Add() public { + function testConfigureProduct__Edit_Add() public { DiscountParams[] memory discounts = new DiscountParams[](1); discounts[0] = DiscountParams({ @@ -391,7 +385,7 @@ contract NFTDiscountTest is RegistryPricingStrategyTest { assertTrue(secondCurrencyPrice == 0); } - function testSetProductPrice__Edit_Remove() public { + function testConfigureProduct__Edit_Remove() public { DiscountParams[] memory discounts = new DiscountParams[](2); // mint NFT 2 diff --git a/test/pricingStrategies/VRGDA/LinearVRGDA.t.sol b/test/pricingStrategies/VRGDA/LinearVRGDA.t.sol index 1409aa9..fb2a617 100644 --- a/test/pricingStrategies/VRGDA/LinearVRGDA.t.sol +++ b/test/pricingStrategies/VRGDA/LinearVRGDA.t.sol @@ -31,7 +31,7 @@ contract LinearVRGDATest is RegistryPricingStrategyTest { bytes memory params = abi.encode(linearParams, priceDecayPercent); - vm.startPrank(address(0)); + vm.startPrank(productOwner); vrgda.configureProduct(slicerId, productId, params); vm.stopPrank(); } @@ -127,7 +127,7 @@ contract LinearVRGDATest is RegistryPricingStrategyTest { bytes memory params = abi.encode(linearParams, priceDecayPercent); - vm.startPrank(address(0)); + vm.startPrank(productOwner); vrgda.configureProduct(slicerId, productId_, params); vm.stopPrank(); diff --git a/test/pricingStrategies/VRGDA/LogisticVRGDA.t.sol b/test/pricingStrategies/VRGDA/LogisticVRGDA.t.sol index c040522..8846949 100644 --- a/test/pricingStrategies/VRGDA/LogisticVRGDA.t.sol +++ b/test/pricingStrategies/VRGDA/LogisticVRGDA.t.sol @@ -33,7 +33,7 @@ contract LogisticVRGDATest is RegistryPricingStrategyTest { bytes memory params = abi.encode(logisticParams, priceDecayPercent); - vm.startPrank(address(0)); + vm.startPrank(productOwner); vrgda.configureProduct(slicerId, productId, params); vm.stopPrank(); } @@ -167,7 +167,7 @@ contract LogisticVRGDATest is RegistryPricingStrategyTest { bytes memory params = abi.encode(logisticParams, priceDecayPercent); - vm.startPrank(address(0)); + vm.startPrank(productOwner); vrgda.configureProduct(slicerId, productIdTest, params); vm.stopPrank(); diff --git a/test/pricingStrategies/VRGDA/correctness/LinearVRGDACorrectness.t.sol b/test/pricingStrategies/VRGDA/correctness/LinearVRGDACorrectness.t.sol index 110f367..2dee0bd 100644 --- a/test/pricingStrategies/VRGDA/correctness/LinearVRGDACorrectness.t.sol +++ b/test/pricingStrategies/VRGDA/correctness/LinearVRGDACorrectness.t.sol @@ -29,7 +29,7 @@ contract LinearVRGDACorrectnessTest is RegistryPricingStrategyTest { LinearVRGDAParams[] memory linearParams = new LinearVRGDAParams[](1); linearParams[0] = LinearVRGDAParams(address(0), targetPriceConstant, min, perTimeUnit); - vm.prank(address(0)); + vm.prank(productOwner); bytes memory params = abi.encode(linearParams, priceDecayPercent); vrgda.configureProduct(slicerId, productId, params); } diff --git a/test/utils/HookRegistryTest.sol b/test/utils/HookRegistryTest.sol index d0a9f58..3c8013b 100644 --- a/test/utils/HookRegistryTest.sol +++ b/test/utils/HookRegistryTest.sol @@ -3,11 +3,31 @@ pragma solidity ^0.8.20; import {HookTest} from "./HookTest.sol"; import {IHookRegistry} from "@/utils/RegistryPricingStrategy.sol"; +import {MockProductsModule} from "./mocks/MockProductsModule.sol"; +import {SliceContext} from "@/utils/RegistryOnchainAction.sol"; abstract contract HookRegistryTest is HookTest { - // TODO: function testParamsSchema() public view { string memory schema = IHookRegistry(hook).paramsSchema(); assertTrue(bytes(schema).length > 0); } + + function testConfigureProduct_AccessControl() public { + vm.expectRevert(abi.encodeWithSelector(SliceContext.NotProductOwner.selector)); + IHookRegistry(hook).configureProduct(0, 0, ""); + } + + // TODO: verify paramsSchema effectively corresponds to the params + + // Blocker: generate bytes params based on a generic string schema + // function generateParamsFromSchema(string memory schema) public returns (bytes memory) { + // string[] memory params = vm.split(schema, ","); + + // // example schema: "(address currency,int128 targetPrice,uint128 min,int256 perTimeUnit)[] linearParams,int256 priceDecayPercent"; + + // for (uint256 i = 0; i < params.length; i++) { + // string[] memory keyValue = vm.split(params[i], " "); + // // ... + // } + // } } diff --git a/test/utils/HookTest.sol b/test/utils/HookTest.sol index e9b25e5..3021ab5 100644 --- a/test/utils/HookTest.sol +++ b/test/utils/HookTest.sol @@ -3,11 +3,16 @@ pragma solidity ^0.8.20; import {Test} from "forge-std/Test.sol"; import {IProductsModule} from "@/utils/OnchainAction.sol"; +import {MockProductsModule} from "./mocks/MockProductsModule.sol"; abstract contract HookTest is Test { - MockProductsModule public mockProductsModule = new MockProductsModule(); - IProductsModule public PRODUCTS_MODULE = IProductsModule(address(mockProductsModule)); + IProductsModule public PRODUCTS_MODULE = IProductsModule(address(new MockProductsModule())); + address public productOwner = makeAddr("productOwner"); + address public buyer = makeAddr("buyer"); + address public buyer2 = makeAddr("buyer2"); + address public buyer3 = makeAddr("buyer3"); + address public buyer4 = makeAddr("buyer4"); address public hook; function _setHook(address _hookAddress) internal { @@ -16,28 +21,6 @@ abstract contract HookTest is Test { function testSetup_HookInitialized() public view { assertTrue(hook != address(0), "Hook address is not set with `_setHook`"); - } - - // TODO: parse the schema and generate the params - // function generateParamsFromSchema(string memory schema) public pure returns (bytes memory) { - // string[] memory params = vm.split(schema, ","); - - // example schema: "(address currency,int128 targetPrice,uint128 min,int256 perTimeUnit)[] linearParams,int256 priceDecayPercent"; - - // for (uint256 i = 0; i < params.length; i++) { - // string[] memory keyValue = vm.split(params[i], " "); - // ... - // } - // } -} - -contract MockProductsModule { - function isProductOwner(uint256, uint256, address account) external pure returns (bool isAllowed) { - isAllowed = account == address(0); - } - - function availableUnits(uint256, uint256) external pure returns (uint256 units, bool isInfinite) { - units = 6392; - isInfinite = false; + assertTrue(hook.code.length > 0, "Hook code is not deployed"); } } diff --git a/test/utils/OnchainActionTest.sol b/test/utils/OnchainActionTest.sol index f6acea7..b5df674 100644 --- a/test/utils/OnchainActionTest.sol +++ b/test/utils/OnchainActionTest.sol @@ -2,10 +2,23 @@ pragma solidity ^0.8.20; import {HookTest} from "./HookTest.sol"; -import {IOnchainAction} from "@/utils/OnchainAction.sol"; +import {OnchainAction, IOnchainAction} from "@/utils/OnchainAction.sol"; abstract contract OnchainActionTest is HookTest { function testSupportsInterface_OnchainAction() public view { assertTrue(IOnchainAction(hook).supportsInterface(type(IOnchainAction).interfaceId)); } + + function testRevert_onProductPurchase_NotPurchase() public { + vm.expectRevert(abi.encodeWithSelector(OnchainAction.NotPurchase.selector)); + IOnchainAction(hook).onProductPurchase(0, 0, address(0), 0, "", ""); + } + + function testRevert_onProductPurchase_WrongSlicer() public { + uint256 unauthorizedSlicer = OnchainAction(hook).ALLOWED_SLICER_ID() + 1; + + vm.expectRevert(abi.encodeWithSelector(OnchainAction.WrongSlicer.selector)); + vm.prank(address(PRODUCTS_MODULE)); + IOnchainAction(hook).onProductPurchase(unauthorizedSlicer, 0, address(0), 0, "", ""); + } } diff --git a/test/utils/PricingStrategyActionTest.sol b/test/utils/PricingStrategyActionTest.sol index bc93872..67f3a04 100644 --- a/test/utils/PricingStrategyActionTest.sol +++ b/test/utils/PricingStrategyActionTest.sol @@ -2,11 +2,26 @@ pragma solidity ^0.8.20; import {HookTest} from "./HookTest.sol"; -import {PricingStrategyAction, IOnchainAction, IPricingStrategy} from "@/utils/PricingStrategyAction.sol"; +import { + OnchainAction, PricingStrategyAction, IOnchainAction, IPricingStrategy +} from "@/utils/PricingStrategyAction.sol"; abstract contract PricingStrategyActionTest is HookTest { function testSupportsInterface_PricingStrategyAction() public view { assertTrue(PricingStrategyAction(hook).supportsInterface(type(IOnchainAction).interfaceId)); assertTrue(PricingStrategyAction(hook).supportsInterface(type(IPricingStrategy).interfaceId)); } + + function testRevert_onProductPurchase_NotPurchase() public { + vm.expectRevert(abi.encodeWithSelector(OnchainAction.NotPurchase.selector)); + IOnchainAction(hook).onProductPurchase(0, 0, address(0), 0, "", ""); + } + + function testRevert_onProductPurchase_WrongSlicer() public { + uint256 unauthorizedSlicer = OnchainAction(hook).ALLOWED_SLICER_ID() + 1; + + vm.expectRevert(abi.encodeWithSelector(OnchainAction.WrongSlicer.selector)); + vm.prank(address(PRODUCTS_MODULE)); + IOnchainAction(hook).onProductPurchase(unauthorizedSlicer, 0, address(0), 0, "", ""); + } } diff --git a/test/utils/RegistryOnchainActionTest.sol b/test/utils/RegistryOnchainActionTest.sol index 36911eb..67988aa 100644 --- a/test/utils/RegistryOnchainActionTest.sol +++ b/test/utils/RegistryOnchainActionTest.sol @@ -2,11 +2,16 @@ pragma solidity ^0.8.20; import {HookRegistryTest} from "./HookRegistryTest.sol"; -import {IHookRegistry, IOnchainAction} from "@/utils/RegistryOnchainAction.sol"; +import {RegistryOnchainAction, IHookRegistry, IOnchainAction} from "@/utils/RegistryOnchainAction.sol"; abstract contract RegistryOnchainActionTest is HookRegistryTest { function testSupportsInterface_RegistryOnchainAction() public view { assertTrue(IOnchainAction(hook).supportsInterface(type(IOnchainAction).interfaceId)); assertTrue(IOnchainAction(hook).supportsInterface(type(IHookRegistry).interfaceId)); } + + function testRevert_onProductPurchase_NotPurchase() public { + vm.expectRevert(abi.encodeWithSelector(RegistryOnchainAction.NotPurchase.selector)); + IOnchainAction(hook).onProductPurchase(0, 0, address(0), 0, "", ""); + } } diff --git a/test/utils/RegistryPricingStrategyActionTest.sol b/test/utils/RegistryPricingStrategyActionTest.sol index 6f46da3..adcbe06 100644 --- a/test/utils/RegistryPricingStrategyActionTest.sol +++ b/test/utils/RegistryPricingStrategyActionTest.sol @@ -2,7 +2,12 @@ pragma solidity ^0.8.20; import {HookRegistryTest} from "./HookRegistryTest.sol"; -import {IHookRegistry, IOnchainAction, IPricingStrategy} from "@/utils/RegistryPricingStrategyAction.sol"; +import { + RegistryOnchainAction, + IHookRegistry, + IOnchainAction, + IPricingStrategy +} from "@/utils/RegistryPricingStrategyAction.sol"; abstract contract RegistryPricingStrategyActionTest is HookRegistryTest { function testSupportsInterface_RegistryPricingStrategyAction() public view { @@ -10,4 +15,9 @@ abstract contract RegistryPricingStrategyActionTest is HookRegistryTest { assertTrue(IOnchainAction(hook).supportsInterface(type(IPricingStrategy).interfaceId)); assertTrue(IOnchainAction(hook).supportsInterface(type(IHookRegistry).interfaceId)); } + + function testRevert_onProductPurchase_NotPurchase() public { + vm.expectRevert(abi.encodeWithSelector(RegistryOnchainAction.NotPurchase.selector)); + IOnchainAction(hook).onProductPurchase(0, 0, address(0), 0, "", ""); + } } diff --git a/test/pricingStrategies/NFTDiscount/mocks/MockERC1155.sol b/test/utils/mocks/MockERC1155.sol similarity index 100% rename from test/pricingStrategies/NFTDiscount/mocks/MockERC1155.sol rename to test/utils/mocks/MockERC1155.sol diff --git a/test/utils/mocks/MockERC20.sol b/test/utils/mocks/MockERC20.sol new file mode 100644 index 0000000..6f53c15 --- /dev/null +++ b/test/utils/mocks/MockERC20.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ERC20, IERC20} from "@openzeppelin-4.8.0/token/ERC20/ERC20.sol"; + +contract MockERC20 is ERC20 { + constructor() ERC20("name", "symbol") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} diff --git a/test/pricingStrategies/NFTDiscount/mocks/MockERC721.sol b/test/utils/mocks/MockERC721.sol similarity index 100% rename from test/pricingStrategies/NFTDiscount/mocks/MockERC721.sol rename to test/utils/mocks/MockERC721.sol diff --git a/test/utils/mocks/MockProductsModule.sol b/test/utils/mocks/MockProductsModule.sol new file mode 100644 index 0000000..335f87a --- /dev/null +++ b/test/utils/mocks/MockProductsModule.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +// import {IProductsModule} from "@/utils/OnchainAction.sol"; +import {Test} from "forge-std/Test.sol"; + +contract MockProductsModule is + Test // , IProductsModule +{ + function isProductOwner(uint256, uint256, address account) external pure returns (bool isAllowed) { + isAllowed = account == vm.addr(uint256(keccak256(abi.encodePacked("productOwner")))); + } + + function availableUnits(uint256, uint256) external pure returns (uint256 units, bool isInfinite) { + units = 6392; + isInfinite = false; + } +} From e6235bfd99cda8fe53ad03eaa900fc0ff4625cda Mon Sep 17 00:00:00 2001 From: jj_ranalli Date: Wed, 16 Jul 2025 00:37:38 +0200 Subject: [PATCH 2/4] finalize tests --- src/hooks/actions/ERC20Mint/ERC20Mint.sol | 5 +- test/actions/ERC20Mint/ERC20Mint.t.sol | 353 +++++++++++ test/actions/ERC721AMint/ERC721Mint.t.sol | 412 +++++++++++++ test/actions/NFTGated/NFTGated.t.sol | 4 +- .../FirstForFree/FirstForFree.t.sol | 564 ++++++++++++++++++ 5 files changed, 1333 insertions(+), 5 deletions(-) create mode 100644 test/actions/ERC20Mint/ERC20Mint.t.sol create mode 100644 test/actions/ERC721AMint/ERC721Mint.t.sol create mode 100644 test/pricingActions/FirstForFree/FirstForFree.t.sol diff --git a/src/hooks/actions/ERC20Mint/ERC20Mint.sol b/src/hooks/actions/ERC20Mint/ERC20Mint.sol index e8398ed..cf17e0c 100644 --- a/src/hooks/actions/ERC20Mint/ERC20Mint.sol +++ b/src/hooks/actions/ERC20Mint/ERC20Mint.sol @@ -22,7 +22,6 @@ contract ERC20Mint is RegistryOnchainAction { ERRORS //////////////////////////////////////////////////////////////*/ - error MaxSupplyExceeded(); error InvalidTokensPerUnit(); /*////////////////////////////////////////////////////////////// @@ -83,8 +82,8 @@ contract ERC20Mint is RegistryOnchainAction { (bool success,) = address(tokenData_.token).call(abi.encodeWithSelector(tokenData_.token.mint.selector, buyer, tokensToMint)); - if (tokenData_.revertOnMaxSupplyReached) { - if (!success) revert MaxSupplyExceeded(); + if (success) { + // Do nothing, just silence the warning } } diff --git a/test/actions/ERC20Mint/ERC20Mint.t.sol b/test/actions/ERC20Mint/ERC20Mint.t.sol new file mode 100644 index 0000000..4d66433 --- /dev/null +++ b/test/actions/ERC20Mint/ERC20Mint.t.sol @@ -0,0 +1,353 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {RegistryOnchainAction, RegistryOnchainActionTest} from "@test/utils/RegistryOnchainActionTest.sol"; +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"; + +uint256 constant slicerId = 0; + +contract ERC20MintTest is RegistryOnchainActionTest { + ERC20Mint erc20Mint; + + uint256[] productIds = [1, 2, 3, 4]; + + function setUp() public { + erc20Mint = new ERC20Mint(PRODUCTS_MODULE); + _setHook(address(erc20Mint)); + } + + function testConfigureProduct() public { + vm.startPrank(productOwner); + + // Configure product 1: Standard token with max supply + erc20Mint.configureProduct( + slicerId, + productIds[0], + abi.encode( + "Test Token 1", // name + "TT1", // symbol + 1000, // premintAmount + productOwner, // premintReceiver + true, // revertOnMaxSupplyReached + 10000, // maxSupply + 100 // tokensPerUnit + ) + ); + + // Configure product 2: Token without max supply limit + erc20Mint.configureProduct( + slicerId, + productIds[1], + abi.encode( + "Test Token 2", // name + "TT2", // symbol + 0, // premintAmount (no premint) + address(0), // premintReceiver + false, // revertOnMaxSupplyReached + 0, // maxSupply (unlimited) + 50 // tokensPerUnit + ) + ); + + // Configure product 3: Token with revert on max supply + erc20Mint.configureProduct( + slicerId, + productIds[2], + abi.encode( + "Test Token 3", // name + "TT3", // symbol + 500, // premintAmount + buyer, // premintReceiver + true, // revertOnMaxSupplyReached + 1000, // maxSupply + 1 // tokensPerUnit + ) + ); + + vm.stopPrank(); + + // Verify tokenData is set correctly + (ERC20Mint_BaseToken token1, bool revertOnMaxSupply1, uint256 tokensPerUnit1) = + erc20Mint.tokenData(slicerId, productIds[0]); + assertEq(revertOnMaxSupply1, true); + assertEq(tokensPerUnit1, 100); + assertEq(token1.name(), "Test Token 1"); + assertEq(token1.symbol(), "TT1"); + assertEq(token1.maxSupply(), 10000); + assertEq(token1.totalSupply(), 1000); // premint amount + assertEq(token1.balanceOf(productOwner), 1000); + + (ERC20Mint_BaseToken token2, bool revertOnMaxSupply2, uint256 tokensPerUnit2) = + erc20Mint.tokenData(slicerId, productIds[1]); + assertEq(revertOnMaxSupply2, false); + assertEq(tokensPerUnit2, 50); + assertEq(token2.name(), "Test Token 2"); + assertEq(token2.symbol(), "TT2"); + assertEq(token2.maxSupply(), type(uint256).max); + assertEq(token2.totalSupply(), 0); // no premint + + (ERC20Mint_BaseToken token3, bool revertOnMaxSupply3, uint256 tokensPerUnit3) = + erc20Mint.tokenData(slicerId, productIds[2]); + assertEq(revertOnMaxSupply3, true); + assertEq(tokensPerUnit3, 1); + assertEq(token3.totalSupply(), 500); // premint amount + assertEq(token3.balanceOf(buyer), 500); + } + + function testConfigureProduct_UpdateExistingToken() public { + vm.startPrank(productOwner); + + // First configuration + erc20Mint.configureProduct( + slicerId, + productIds[0], + abi.encode( + "Test Token", // name + "TT", // symbol + 100, // premintAmount + productOwner, // premintReceiver + true, // revertOnMaxSupplyReached + 1000, // maxSupply + 10 // tokensPerUnit + ) + ); + + (ERC20Mint_BaseToken token1,,) = erc20Mint.tokenData(slicerId, productIds[0]); + address tokenAddress = address(token1); + + // Second configuration - should update existing token + erc20Mint.configureProduct( + slicerId, + productIds[0], + abi.encode( + "Updated Token", // name (ignored for existing token) + "UT", // symbol (ignored for existing token) + 0, // premintAmount + address(0), // premintReceiver + false, // revertOnMaxSupplyReached + 2000, // maxSupply (updated) + 20 // tokensPerUnit (updated) + ) + ); + + (ERC20Mint_BaseToken token2, bool revertOnMaxSupply2, uint256 tokensPerUnit2) = + erc20Mint.tokenData(slicerId, productIds[0]); + + // Token address should be the same + assertEq(address(token2), tokenAddress); + // Config should be updated + assertEq(revertOnMaxSupply2, false); + assertEq(tokensPerUnit2, 20); + assertEq(token2.maxSupply(), 2000); + // Original token properties remain + assertEq(token2.name(), "Test Token"); + assertEq(token2.symbol(), "TT"); + + vm.stopPrank(); + } + + function testRevert_configureProduct_InvalidTokensPerUnit() public { + vm.startPrank(productOwner); + + vm.expectRevert(ERC20Mint.InvalidTokensPerUnit.selector); + erc20Mint.configureProduct( + slicerId, + productIds[0], + abi.encode( + "Test Token", // name + "TT", // symbol + 0, // premintAmount + address(0), // premintReceiver + false, // revertOnMaxSupplyReached + 1000, // maxSupply + 0 // tokensPerUnit (invalid) + ) + ); + + vm.stopPrank(); + } + + function testIsPurchaseAllowed() public { + vm.startPrank(productOwner); + + // Configure product with max supply and revert enabled + erc20Mint.configureProduct( + slicerId, + productIds[0], + abi.encode( + "Test Token", // name + "TT", // symbol + 800, // premintAmount + productOwner, // premintReceiver + true, // revertOnMaxSupplyReached + 1000, // maxSupply + 10 // tokensPerUnit + ) + ); + + // Configure product without max supply limit + erc20Mint.configureProduct( + slicerId, + productIds[1], + abi.encode( + "Test Token 2", // name + "TT2", // symbol + 0, // premintAmount + address(0), // premintReceiver + false, // revertOnMaxSupplyReached + 0, // maxSupply (unlimited) + 50 // tokensPerUnit + ) + ); + + vm.stopPrank(); + + // Test product 1 (with max supply limit) + // 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) + + // 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, "", "")); + } + + function testOnProductPurchase() public { + vm.startPrank(productOwner); + + // Configure products with different settings + erc20Mint.configureProduct( + slicerId, + productIds[0], + abi.encode( + "Test Token 1", // name + "TT1", // symbol + 0, // premintAmount + address(0), // premintReceiver + true, // revertOnMaxSupplyReached + 1000, // maxSupply + 100 // tokensPerUnit + ) + ); + + erc20Mint.configureProduct( + slicerId, + productIds[1], + abi.encode( + "Test Token 2", // name + "TT2", // symbol + 0, // premintAmount + address(0), // premintReceiver + false, // revertOnMaxSupplyReached + 0, // maxSupply (unlimited) + 50 // tokensPerUnit + ) + ); + + vm.stopPrank(); + + (ERC20Mint_BaseToken token1,,) = erc20Mint.tokenData(slicerId, productIds[0]); + (ERC20Mint_BaseToken token2,,) = erc20Mint.tokenData(slicerId, productIds[1]); + + // 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, "", ""); + + assertEq(token1.balanceOf(buyer), initialBalance1 + 300); // 3 * 100 + assertEq(token1.totalSupply(), initialSupply1 + 300); + + // Test minting for product 2 + uint256 initialBalance2 = token2.balanceOf(buyer2); + uint256 initialSupply2 = token2.totalSupply(); + + vm.prank(address(PRODUCTS_MODULE)); + erc20Mint.onProductPurchase(slicerId, productIds[1], 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, "", ""); + assertEq(token1.balanceOf(buyer3), 200); // 2 * 100 + assertEq(token1.totalSupply(), initialSupply1 + 500); // 300 + 200 + } + + function testOnProductPurchase_NoRevertOnMaxSupply() public { + vm.startPrank(productOwner); + + // Configure product with max supply but revert disabled + erc20Mint.configureProduct( + slicerId, + productIds[0], + abi.encode( + "Test Token", // name + "TT", // symbol + 990, // premintAmount (close to max) + productOwner, // premintReceiver + false, // revertOnMaxSupplyReached (disabled) + 1000, // maxSupply + 10 // tokensPerUnit + ) + ); + + vm.stopPrank(); + + // This should succeed but not mint tokens (exceeds max supply) + (ERC20Mint_BaseToken token,,) = erc20Mint.tokenData(slicerId, productIds[0]); + uint256 initialBalance = token.balanceOf(buyer); + uint256 initialSupply = token.totalSupply(); + + vm.prank(address(PRODUCTS_MODULE)); + erc20Mint.onProductPurchase(slicerId, productIds[0], buyer, 2, "", ""); + + // Balance and supply should remain unchanged (mint failed silently) + assertEq(token.balanceOf(buyer), initialBalance); + assertEq(token.totalSupply(), initialSupply); + } + + function testRevert_onProductPurchase_MaxSupplyReached() public { + vm.startPrank(productOwner); + + // Configure product with small max supply and revert enabled + erc20Mint.configureProduct( + slicerId, + productIds[0], + abi.encode( + "Test Token", // name + "TT", // symbol + 950, // premintAmount (close to max) + productOwner, // premintReceiver + true, // revertOnMaxSupplyReached + 1000, // maxSupply + 10 // tokensPerUnit + ) + ); + + vm.stopPrank(); + + // This should succeed (50 tokens available, need 50) + vm.prank(address(PRODUCTS_MODULE)); + erc20Mint.onProductPurchase(slicerId, productIds[0], buyer, 5, "", ""); + + (ERC20Mint_BaseToken token,,) = erc20Mint.tokenData(slicerId, productIds[0]); + assertEq(token.totalSupply(), 1000); // at max supply + + // This should revert (no tokens available) + vm.expectRevert(RegistryOnchainAction.NotAllowed.selector); + vm.prank(address(PRODUCTS_MODULE)); + erc20Mint.onProductPurchase(slicerId, productIds[0], buyer, 1, "", ""); + } +} diff --git a/test/actions/ERC721AMint/ERC721Mint.t.sol b/test/actions/ERC721AMint/ERC721Mint.t.sol new file mode 100644 index 0000000..f76a30c --- /dev/null +++ b/test/actions/ERC721AMint/ERC721Mint.t.sol @@ -0,0 +1,412 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {RegistryOnchainAction, RegistryOnchainActionTest} from "@test/utils/RegistryOnchainActionTest.sol"; +import {ERC721Mint} from "@/hooks/actions/ERC721AMint/ERC721Mint.sol"; +import {ERC721Data} from "@/hooks/actions/ERC721AMint/types/ERC721Data.sol"; +import {ERC721Mint_BaseToken, MAX_ROYALTY} from "@/hooks/actions/ERC721AMint/utils/ERC721Mint_BaseToken.sol"; + +import {console2} from "forge-std/console2.sol"; + +uint256 constant slicerId = 0; + +contract ERC721MintTest is RegistryOnchainActionTest { + ERC721Mint erc721Mint; + + uint256[] productIds = [1, 2, 3, 4]; + + function setUp() public { + erc721Mint = new ERC721Mint(PRODUCTS_MODULE); + _setHook(address(erc721Mint)); + } + + function testConfigureProduct() public { + vm.startPrank(productOwner); + + // Configure product 1: Standard NFT with max supply and royalties + erc721Mint.configureProduct( + slicerId, + productIds[0], + abi.encode( + "Test NFT 1", // name + "TNT1", // symbol + productOwner, // royaltyReceiver + 500, // royaltyFraction (5%) + "https://api.example.com/metadata/", // baseURI + "https://api.example.com/fallback.json", // tokenURI + true, // revertOnMaxSupplyReached + 1000 // maxSupply + ) + ); + + // Configure product 2: NFT without max supply limit + erc721Mint.configureProduct( + slicerId, + productIds[1], + abi.encode( + "Test NFT 2", // name + "TNT2", // symbol + address(0), // royaltyReceiver (no royalties) + 0, // royaltyFraction + "", // baseURI (empty) + "https://api.example.com/single.json", // tokenURI + false, // revertOnMaxSupplyReached + 0 // maxSupply (unlimited) + ) + ); + + // Configure product 3: NFT with revert on max supply + erc721Mint.configureProduct( + slicerId, + productIds[2], + abi.encode( + "Test NFT 3", // name + "TNT3", // symbol + buyer, // royaltyReceiver + 1000, // royaltyFraction (10%) + "ipfs://QmHash/", // baseURI + "", // tokenURI (empty) + true, // revertOnMaxSupplyReached + 100 // maxSupply + ) + ); + + vm.stopPrank(); + + // Verify tokenData is set correctly + (ERC721Mint_BaseToken token1, bool revertOnMaxSupply1) = erc721Mint.tokenData(slicerId, productIds[0]); + assertEq(revertOnMaxSupply1, true); + assertEq(token1.name(), "Test NFT 1"); + assertEq(token1.symbol(), "TNT1"); + assertEq(token1.maxSupply(), 1000); + assertEq(token1.totalSupply(), 0); + assertEq(token1.royaltyReceiver(), productOwner); + assertEq(token1.royaltyFraction(), 500); + 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]); + assertEq(revertOnMaxSupply2, false); + assertEq(token2.name(), "Test NFT 2"); + assertEq(token2.symbol(), "TNT2"); + assertEq(token2.maxSupply(), type(uint256).max); + assertEq(token2.totalSupply(), 0); + assertEq(token2.royaltyReceiver(), address(0)); + assertEq(token2.royaltyFraction(), 0); + assertEq(token2.baseURI_(), ""); + assertEq(token2.tokenURI_(), "https://api.example.com/single.json"); + + (ERC721Mint_BaseToken token3, bool revertOnMaxSupply3) = erc721Mint.tokenData(slicerId, productIds[2]); + assertEq(revertOnMaxSupply3, true); + assertEq(token3.maxSupply(), 100); + assertEq(token3.royaltyReceiver(), buyer); + assertEq(token3.royaltyFraction(), 1000); + assertEq(token3.baseURI_(), "ipfs://QmHash/"); + assertEq(token3.tokenURI_(), ""); + } + + function testConfigureProduct_UpdateExistingToken() public { + vm.startPrank(productOwner); + + // First configuration + erc721Mint.configureProduct( + slicerId, + productIds[0], + abi.encode( + "Test NFT", // name + "TNT", // symbol + productOwner, // royaltyReceiver + 250, // royaltyFraction (2.5%) + "https://api.v1.com/", // baseURI + "https://fallback.v1.json", // tokenURI + true, // revertOnMaxSupplyReached + 500 // maxSupply + ) + ); + + (ERC721Mint_BaseToken token1,) = erc721Mint.tokenData(slicerId, productIds[0]); + address tokenAddress = address(token1); + + // Second configuration - should update existing token + erc721Mint.configureProduct( + slicerId, + productIds[0], + abi.encode( + "Updated NFT", // name (ignored for existing token) + "UNT", // symbol (ignored for existing token) + buyer, // royaltyReceiver (updated) + 750, // royaltyFraction (updated to 7.5%) + "https://api.v2.com/", // baseURI (updated) + "https://fallback.v2.json", // tokenURI (updated) + false, // revertOnMaxSupplyReached (updated) + 1000 // maxSupply (updated) + ) + ); + + (ERC721Mint_BaseToken token2, bool revertOnMaxSupply2) = erc721Mint.tokenData(slicerId, productIds[0]); + + // Token address should be the same + assertEq(address(token2), tokenAddress); + // Config should be updated + assertEq(revertOnMaxSupply2, false); + assertEq(token2.maxSupply(), 1000); + assertEq(token2.royaltyReceiver(), buyer); + assertEq(token2.royaltyFraction(), 750); + assertEq(token2.baseURI_(), "https://api.v2.com/"); + assertEq(token2.tokenURI_(), "https://fallback.v2.json"); + // Original token properties remain + assertEq(token2.name(), "Test NFT"); + assertEq(token2.symbol(), "TNT"); + + vm.stopPrank(); + } + + function testRevert_configureProduct_InvalidRoyaltyFraction() public { + vm.prank(productOwner); + vm.expectRevert(ERC721Mint.InvalidRoyaltyFraction.selector); + erc721Mint.configureProduct( + slicerId, + productIds[0], + abi.encode( + "Test NFT", // name + "TNT", // symbol + productOwner, // royaltyReceiver + MAX_ROYALTY + 1, // royaltyFraction (invalid - exceeds max) + "https://api.example.com/", // baseURI + "", // tokenURI + false, // revertOnMaxSupplyReached + 1000 // maxSupply + ) + ); + } + + function testOnProductPurchase() public { + vm.startPrank(productOwner); + + // Configure products with different settings + erc721Mint.configureProduct( + slicerId, + productIds[0], + abi.encode( + "Test NFT 1", // name + "TNT1", // symbol + productOwner, // royaltyReceiver + 500, // royaltyFraction + "https://api.example.com/", // baseURI + "", // tokenURI + true, // revertOnMaxSupplyReached + 1000 // maxSupply + ) + ); + + erc721Mint.configureProduct( + slicerId, + productIds[1], + abi.encode( + "Test NFT 2", // name + "TNT2", // symbol + address(0), // royaltyReceiver + 0, // royaltyFraction + "", // baseURI + "https://single.json", // tokenURI + false, // revertOnMaxSupplyReached + 0 // maxSupply (unlimited) + ) + ); + + vm.stopPrank(); + + (ERC721Mint_BaseToken token1,) = erc721Mint.tokenData(slicerId, productIds[0]); + (ERC721Mint_BaseToken token2,) = erc721Mint.tokenData(slicerId, productIds[1]); + + // 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, "", ""); + + assertEq(token1.balanceOf(buyer), initialBalance1 + 3); + assertEq(token1.totalSupply(), initialSupply1 + 3); + + // Test minting for product 2 + uint256 initialBalance2 = token2.balanceOf(buyer2); + uint256 initialSupply2 = token2.totalSupply(); + + vm.prank(address(PRODUCTS_MODULE)); + erc721Mint.onProductPurchase(slicerId, productIds[1], 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, "", ""); + assertEq(token1.balanceOf(buyer3), 2); + assertEq(token1.totalSupply(), initialSupply1 + 5); // 3 + 2 + } + + function testOnProductPurchase_NoRevertOnMaxSupply() public { + vm.prank(productOwner); + + // Configure product with max supply but revert disabled + erc721Mint.configureProduct( + slicerId, + productIds[0], + abi.encode( + "Test NFT", // name + "TNT", // symbol + productOwner, // royaltyReceiver + 0, // royaltyFraction + "", // baseURI + "", // tokenURI + false, // revertOnMaxSupplyReached (disabled) + 5 // maxSupply (small for testing) + ) + ); + + (ERC721Mint_BaseToken token,) = erc721Mint.tokenData(slicerId, productIds[0]); + + // First purchase - should succeed + vm.prank(address(PRODUCTS_MODULE)); + erc721Mint.onProductPurchase(slicerId, productIds[0], buyer, 3, "", ""); + assertEq(token.totalSupply(), 3); + + // Second purchase - should succeed + vm.prank(address(PRODUCTS_MODULE)); + erc721Mint.onProductPurchase(slicerId, productIds[0], buyer2, 2, "", ""); + assertEq(token.totalSupply(), 5); + + // Third purchase - exceeds max supply but should not revert (mint will fail silently) + uint256 balanceBefore = token.balanceOf(buyer3); + uint256 supplyBefore = token.totalSupply(); + + vm.prank(address(PRODUCTS_MODULE)); + erc721Mint.onProductPurchase(slicerId, productIds[0], buyer3, 2, "", ""); + + // Balance and supply should remain unchanged (mint failed silently) + assertEq(token.balanceOf(buyer3), balanceBefore); + assertEq(token.totalSupply(), supplyBefore); + } + + function testRevert_onProductPurchase_MaxSupplyReached() public { + vm.prank(productOwner); + + // Configure product with small max supply and revert enabled + erc721Mint.configureProduct( + slicerId, + productIds[0], + 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.onProductPurchase(slicerId, productIds[0], buyer, 3, "", ""); + + (ERC721Mint_BaseToken token,) = erc721Mint.tokenData(slicerId, productIds[0]); + assertEq(token.totalSupply(), 3); + + // Second purchase - should succeed (exactly at max supply) + vm.prank(address(PRODUCTS_MODULE)); + erc721Mint.onProductPurchase(slicerId, productIds[0], 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, "", ""); + } + + function testTokenURI() public { + vm.startPrank(productOwner); + + // Configure product with baseURI + erc721Mint.configureProduct( + slicerId, + productIds[0], + abi.encode( + "Test NFT", // name + "TNT", // symbol + address(0), // royaltyReceiver + 0, // royaltyFraction + "https://api.example.com/metadata/", // baseURI + "https://fallback.json", // tokenURI + false, // revertOnMaxSupplyReached + 1000 // maxSupply + ) + ); + + // Configure product with only tokenURI (no baseURI) + erc721Mint.configureProduct( + slicerId, + productIds[1], + abi.encode( + "Test NFT 2", // name + "TNT2", // symbol + address(0), // royaltyReceiver + 0, // royaltyFraction + "", // baseURI (empty) + "https://single-token.json", // tokenURI + false, // revertOnMaxSupplyReached + 1000 // maxSupply + ) + ); + + vm.stopPrank(); + + (ERC721Mint_BaseToken token1,) = erc721Mint.tokenData(slicerId, productIds[0]); + (ERC721Mint_BaseToken token2,) = erc721Mint.tokenData(slicerId, productIds[1]); + + // Mint some tokens + vm.prank(address(PRODUCTS_MODULE)); + erc721Mint.onProductPurchase(slicerId, productIds[0], buyer, 2, "", ""); + + vm.prank(address(PRODUCTS_MODULE)); + erc721Mint.onProductPurchase(slicerId, productIds[1], buyer, 1, "", ""); + + // Test token URIs for product 1 (with baseURI) + assertEq(token1.tokenURI(0), "https://api.example.com/metadata/0"); + assertEq(token1.tokenURI(1), "https://api.example.com/metadata/1"); + + // Test token URI for product 2 (fallback tokenURI) + assertEq(token2.tokenURI(0), "https://single-token.json"); + } + + function testRoyaltyInfo() public { + vm.prank(productOwner); + erc721Mint.configureProduct( + slicerId, + productIds[0], + abi.encode( + "Test NFT", // name + "TNT", // symbol + productOwner, // royaltyReceiver + 500, // royaltyFraction (5%) + "", // baseURI + "", // tokenURI + false, // revertOnMaxSupplyReached + 1000 // maxSupply + ) + ); + + (ERC721Mint_BaseToken token,) = erc721Mint.tokenData(slicerId, productIds[0]); + + // Test royalty calculation + (address receiver, uint256 royaltyAmount) = token.royaltyInfo(0, 1000); + assertEq(receiver, productOwner); + assertEq(royaltyAmount, 50); // 5% of 1000 + + (receiver, royaltyAmount) = token.royaltyInfo(0, 2000); + assertEq(receiver, productOwner); + assertEq(royaltyAmount, 100); // 5% of 2000 + } +} diff --git a/test/actions/NFTGated/NFTGated.t.sol b/test/actions/NFTGated/NFTGated.t.sol index 5f3cfbd..2a769af 100644 --- a/test/actions/NFTGated/NFTGated.t.sol +++ b/test/actions/NFTGated/NFTGated.t.sol @@ -54,13 +54,13 @@ contract NFTGatedTest is RegistryOnchainActionTest { assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[2], buyer, 0, "", "")); assertTrue(nftGated.isPurchaseAllowed(slicerId, productIds[3], buyer, 0, "", "")); - // buyer 2 should be able to purchase all products except product 2 and 4 + // 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, "", "")); - // buyer 3 should be able to purchase all products except product 1 and 4 + // 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, "", "")); diff --git a/test/pricingActions/FirstForFree/FirstForFree.t.sol b/test/pricingActions/FirstForFree/FirstForFree.t.sol new file mode 100644 index 0000000..bf6436e --- /dev/null +++ b/test/pricingActions/FirstForFree/FirstForFree.t.sol @@ -0,0 +1,564 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {RegistryPricingStrategyActionTest} from "@test/utils/RegistryPricingStrategyActionTest.sol"; +import {FirstForFree} from "@/hooks/pricingActions/FirstForFree/FirstForFree.sol"; +import {ProductParams} from "@/hooks/pricingActions/FirstForFree/types/ProductParams.sol"; +import {TokenCondition, TokenType} from "@/hooks/pricingActions/FirstForFree/types/TokenCondition.sol"; +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; + + function mintTo(address to, uint256 tokenId, string calldata, uint256 amount) external { + balanceOf[to][tokenId] += amount; + mintedAmounts[to] += amount; + } + + function setBalance(address owner, uint256 tokenId, uint256 amount) external { + balanceOf[owner][tokenId] = amount; + } +} + +contract FirstForFreeTest is RegistryPricingStrategyActionTest { + FirstForFree firstForFree; + MockERC721 mockERC721; + MockERC1155Token mockERC1155; + MockERC1155Token mockMintToken; + + uint256[] productIds = [1, 2, 3, 4]; + uint256 constant USDC_PRICE = 1000000; // 1 USDC (6 decimals) + + function setUp() public { + firstForFree = new FirstForFree(PRODUCTS_MODULE); + _setHook(address(firstForFree)); + + mockERC721 = new MockERC721(); + mockERC1155 = new MockERC1155Token(); + mockMintToken = new MockERC1155Token(); + } + + function testConfigureProduct() public { + vm.startPrank(productOwner); + + // Configure product 1: Basic free units without token conditions + TokenCondition[] memory noConditions = new TokenCondition[](0); + firstForFree.configureProduct( + slicerId, + productIds[0], + abi.encode( + USDC_PRICE, // usdcPrice + noConditions, // eligibleTokens (empty) + address(mockMintToken), // mintToken + uint88(1), // mintTokenId + uint8(3) // freeUnits + ) + ); + + // Configure product 2: With ERC721 token condition + TokenCondition[] memory erc721Conditions = new TokenCondition[](1); + erc721Conditions[0] = TokenCondition({ + tokenAddress: address(mockERC721), + tokenType: TokenType.ERC721, + tokenId: 0, // Not used for ERC721 + minQuantity: 1 + }); + + firstForFree.configureProduct( + slicerId, + productIds[1], + abi.encode( + USDC_PRICE * 2, // usdcPrice + erc721Conditions, // eligibleTokens + address(0), // mintToken (no minting) + uint88(0), // mintTokenId + uint8(2) // freeUnits + ) + ); + + // Configure product 3: With ERC1155 token condition + TokenCondition[] memory erc1155Conditions = new TokenCondition[](1); + erc1155Conditions[0] = TokenCondition({ + tokenAddress: address(mockERC1155), + tokenType: TokenType.ERC1155, + tokenId: 5, + minQuantity: 10 + }); + + firstForFree.configureProduct( + slicerId, + productIds[2], + abi.encode( + USDC_PRICE / 2, // usdcPrice + erc1155Conditions, // eligibleTokens + address(mockMintToken), // mintToken + uint88(2), // mintTokenId + uint8(1) // freeUnits + ) + ); + + // Configure product 4: Multiple token conditions + TokenCondition[] memory multipleConditions = new TokenCondition[](2); + multipleConditions[0] = + TokenCondition({tokenAddress: address(mockERC721), tokenType: TokenType.ERC721, tokenId: 0, minQuantity: 1}); + multipleConditions[1] = TokenCondition({ + tokenAddress: address(mockERC1155), + tokenType: TokenType.ERC1155, + tokenId: 3, + minQuantity: 5 + }); + + firstForFree.configureProduct( + slicerId, + productIds[3], + abi.encode( + USDC_PRICE * 3, // usdcPrice + multipleConditions, // eligibleTokens + address(mockMintToken), // mintToken + uint88(3), // mintTokenId + uint8(5) // freeUnits + ) + ); + + vm.stopPrank(); + + // Verify product 1 configuration + (uint256 usdcPrice1, address mintToken1, uint88 mintTokenId1, uint8 freeUnits1) = + firstForFree.usdcPrices(slicerId, productIds[0]); + assertEq(usdcPrice1, USDC_PRICE); + 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); + 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); + 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); + assertEq(mintToken4, address(mockMintToken)); + assertEq(mintTokenId4, 3); + assertEq(freeUnits4, 5); + } + + function testProductPrice_NoConditions() public { + vm.prank(productOwner); + + // Configure product with no token conditions + TokenCondition[] memory noConditions = new TokenCondition[](0); + firstForFree.configureProduct( + slicerId, + productIds[0], + abi.encode( + USDC_PRICE, // usdcPrice + noConditions, // eligibleTokens (empty) + address(0), // mintToken + uint88(0), // mintTokenId + uint8(2) // freeUnits + ) + ); + + // First purchase - should be free + (uint256 ethPrice, uint256 currencyPrice) = + firstForFree.productPrice(slicerId, productIds[0], address(0), 1, buyer, ""); + assertEq(ethPrice, 0); + assertEq(currencyPrice, 0); + + // Second purchase - should be free + (ethPrice, currencyPrice) = firstForFree.productPrice(slicerId, productIds[0], address(0), 1, buyer, ""); + assertEq(ethPrice, 0); + assertEq(currencyPrice, 0); + + // Partial free purchase + (ethPrice, currencyPrice) = firstForFree.productPrice(slicerId, productIds[0], address(0), 2, buyer, ""); + assertEq(ethPrice, 0); + assertEq(currencyPrice, 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 + + // Purchase after using some free units (simulate 1 purchase made) + vm.prank(address(PRODUCTS_MODULE)); + firstForFree.onProductPurchase(slicerId, productIds[0], buyer, 1, "", ""); + + (ethPrice, currencyPrice) = firstForFree.productPrice(slicerId, productIds[0], address(0), 2, buyer, ""); + assertEq(ethPrice, 0); + assertEq(currencyPrice, USDC_PRICE); // 1 free, 1 paid + + // Purchase after using all free units (simulate 2 total purchases made) + vm.prank(address(PRODUCTS_MODULE)); + firstForFree.onProductPurchase(slicerId, productIds[0], buyer, 1, "", ""); + + (ethPrice, currencyPrice) = firstForFree.productPrice(slicerId, productIds[0], address(0), 1, buyer, ""); + assertEq(ethPrice, 0); + assertEq(currencyPrice, USDC_PRICE); // All paid + } + + function testProductPrice_ERC721Condition() public { + vm.startPrank(productOwner); + + // Configure product with ERC721 condition + TokenCondition[] memory erc721Conditions = new TokenCondition[](1); + erc721Conditions[0] = + TokenCondition({tokenAddress: address(mockERC721), tokenType: TokenType.ERC721, tokenId: 0, minQuantity: 1}); + + firstForFree.configureProduct( + slicerId, + productIds[0], + abi.encode( + USDC_PRICE, // usdcPrice + erc721Conditions, // eligibleTokens + address(0), // mintToken + uint88(0), // mintTokenId + uint8(2) // freeUnits + ) + ); + + 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); + + // 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); + + // Second free purchase + (ethPrice, currencyPrice) = firstForFree.productPrice(slicerId, productIds[0], address(0), 1, buyer, ""); + assertEq(ethPrice, 0); + assertEq(currencyPrice, 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 + } + + function testProductPrice_ERC1155Condition() public { + vm.startPrank(productOwner); + + // Configure product with ERC1155 condition + TokenCondition[] memory erc1155Conditions = new TokenCondition[](1); + erc1155Conditions[0] = TokenCondition({ + tokenAddress: address(mockERC1155), + tokenType: TokenType.ERC1155, + tokenId: 5, + minQuantity: 10 + }); + + firstForFree.configureProduct( + slicerId, + productIds[0], + abi.encode( + USDC_PRICE, // usdcPrice + erc1155Conditions, // eligibleTokens + address(0), // mintToken + uint88(0), // mintTokenId + uint8(3) // freeUnits + ) + ); + + vm.stopPrank(); + + // 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); + + // 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); + + // 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); + } + + function testProductPrice_MultipleConditions() public { + vm.startPrank(productOwner); + + // Configure product with multiple conditions (OR logic) + TokenCondition[] memory multipleConditions = new TokenCondition[](2); + multipleConditions[0] = + TokenCondition({tokenAddress: address(mockERC721), tokenType: TokenType.ERC721, tokenId: 0, minQuantity: 1}); + multipleConditions[1] = TokenCondition({ + tokenAddress: address(mockERC1155), + tokenType: TokenType.ERC1155, + tokenId: 3, + minQuantity: 5 + }); + + firstForFree.configureProduct( + slicerId, + productIds[0], + abi.encode( + USDC_PRICE, // usdcPrice + multipleConditions, // eligibleTokens + address(0), // mintToken + uint88(0), // mintTokenId + uint8(2) // freeUnits + ) + ); + + vm.stopPrank(); + + // 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); + + // 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); + + // 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); + + // Buyer meets neither condition + (ethPrice, currencyPrice) = + firstForFree.productPrice(slicerId, productIds[0], address(0), 1, address(0x999), ""); + assertEq(ethPrice, 0); + assertEq(currencyPrice, USDC_PRICE); + } + + function testOnProductPurchase_WithMinting() public { + vm.startPrank(productOwner); + + TokenCondition[] memory noConditions = new TokenCondition[](0); + firstForFree.configureProduct( + slicerId, + productIds[0], + abi.encode( + USDC_PRICE, // usdcPrice + noConditions, // eligibleTokens + address(mockMintToken), // mintToken + uint88(5), // mintTokenId + uint8(2) // freeUnits + ) + ); + + vm.stopPrank(); + + // Test purchase tracking and minting + assertEq(firstForFree.purchases(buyer, slicerId), 0); + assertEq(mockMintToken.balanceOf(buyer, 5), 0); + + vm.prank(address(PRODUCTS_MODULE)); + firstForFree.onProductPurchase(slicerId, productIds[0], 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, "", ""); + + 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, "", ""); + + 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 { + vm.prank(productOwner); + + TokenCondition[] memory noConditions = new TokenCondition[](0); + firstForFree.configureProduct( + slicerId, + productIds[0], + abi.encode( + USDC_PRICE, // usdcPrice + noConditions, // eligibleTokens + address(0), // mintToken (no minting) + uint88(0), // mintTokenId + uint8(1) // freeUnits + ) + ); + + assertEq(firstForFree.purchases(buyer, slicerId), 0); + + vm.prank(address(PRODUCTS_MODULE)); + firstForFree.onProductPurchase(slicerId, productIds[0], buyer, 2, "", ""); + + // Only purchase tracking, no minting + assertEq(firstForFree.purchases(buyer, slicerId), 2); + assertEq(mockMintToken.balanceOf(buyer, 0), 0); + } + + function testPurchaseTracking_DifferentSlicers() public { + vm.startPrank(productOwner); + + TokenCondition[] memory noConditions = new TokenCondition[](0); + + // Configure same product on different slicers + firstForFree.configureProduct( + 0, // slicerId 0 + productIds[0], + abi.encode(USDC_PRICE, 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)) + ); + + 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 + } + + function testEdgeCases() public { + vm.prank(productOwner); + + TokenCondition[] memory noConditions = new TokenCondition[](0); + firstForFree.configureProduct( + slicerId, + productIds[0], + abi.encode( + USDC_PRICE, // usdcPrice + noConditions, // eligibleTokens + address(0), // mintToken + uint88(0), // mintTokenId + uint8(0) // freeUnits = 0 + ) + ); + + // 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); + + // Zero quantity - should return zero + (ethPrice, currencyPrice) = firstForFree.productPrice(slicerId, productIds[0], address(0), 0, buyer, ""); + assertEq(ethPrice, 0); + assertEq(currencyPrice, 0); + } + + function testConfigureProduct_UpdateExisting() public { + vm.startPrank(productOwner); + + // Initial configuration + TokenCondition[] memory initialConditions = new TokenCondition[](1); + initialConditions[0] = + TokenCondition({tokenAddress: address(mockERC721), tokenType: TokenType.ERC721, tokenId: 0, minQuantity: 1}); + + firstForFree.configureProduct( + slicerId, + productIds[0], + abi.encode( + USDC_PRICE, // usdcPrice + initialConditions, // eligibleTokens + address(mockMintToken), // mintToken + uint88(1), // mintTokenId + uint8(2) // freeUnits + ) + ); + + // Update configuration + TokenCondition[] memory newConditions = new TokenCondition[](2); + newConditions[0] = TokenCondition({ + tokenAddress: address(mockERC1155), + tokenType: TokenType.ERC1155, + tokenId: 5, + minQuantity: 10 + }); + newConditions[1] = + TokenCondition({tokenAddress: address(mockERC721), tokenType: TokenType.ERC721, tokenId: 0, minQuantity: 2}); + + firstForFree.configureProduct( + slicerId, + productIds[0], + abi.encode( + USDC_PRICE * 2, // usdcPrice (updated) + newConditions, // eligibleTokens (updated) + address(0), // mintToken (updated to none) + uint88(0), // mintTokenId + uint8(5) // freeUnits (updated) + ) + ); + + vm.stopPrank(); + + // Verify updated configuration + (uint256 updatedPrice, address updatedMintToken, uint88 updatedMintTokenId, uint8 updatedFreeUnits) = + firstForFree.usdcPrices(slicerId, productIds[0]); + assertEq(updatedPrice, USDC_PRICE * 2); + assertEq(updatedMintToken, address(0)); + assertEq(updatedMintTokenId, 0); + assertEq(updatedFreeUnits, 5); + } +} From 8a89fe1ab26b5b0cad33890ba80f5428bec688df Mon Sep 17 00:00:00 2001 From: jj_ranalli Date: Wed, 16 Jul 2025 00:57:56 +0200 Subject: [PATCH 3/4] fix script logic --- script/ScriptUtils.sol | 90 ++++++++++++++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 21 deletions(-) diff --git a/script/ScriptUtils.sol b/script/ScriptUtils.sol index 9dba9e3..d604d0c 100644 --- a/script/ScriptUtils.sol +++ b/script/ScriptUtils.sol @@ -285,12 +285,7 @@ abstract contract SetUpContractsList is Script { if (isTopLevel) { string memory folderName = _getLastPathSegment(file.path); // Only include specific top-level folders - if ( - keccak256(bytes(folderName)) != keccak256(bytes("internal")) - && keccak256(bytes(folderName)) != keccak256(bytes("actions")) - && keccak256(bytes(folderName)) != keccak256(bytes("pricingStrategies")) - && keccak256(bytes(folderName)) != keccak256(bytes("pricingStrategyActions")) - ) { + if (keccak256(bytes(folderName)) != keccak256(bytes("hooks"))) { continue; } } @@ -416,25 +411,78 @@ abstract contract SetUpContractsList is Script { break; } } - // Find the first folder after src (or after root if no src) - uint256 start = foundSrc ? srcIndex : 0; - // skip leading slashes - while (start < pathBytes.length && pathBytes[start] == 0x2f) { - start++; - } - uint256 end = start; - while (end < pathBytes.length && pathBytes[end] != 0x2f) { - end++; - } - if (end > start) { - bytes memory firstFolderBytes = new bytes(end - start); - for (uint256 i = 0; i < end - start; i++) { - firstFolderBytes[i] = pathBytes[start + i]; + + // For hooks subdirectories, use the subdirectory name as the category + if (foundSrc) { + // Look for "hooks/" after src + uint256 hooksStart = srcIndex; + while (hooksStart < pathBytes.length && pathBytes[hooksStart] == 0x2f) { + hooksStart++; + } + + // Check if path starts with "hooks/" + bytes memory hooksBytes = bytes("hooks"); + bool isHooksPath = true; + if (hooksStart + hooksBytes.length < pathBytes.length) { + for (uint256 i = 0; i < hooksBytes.length; i++) { + if (pathBytes[hooksStart + i] != hooksBytes[i]) { + isHooksPath = false; + break; + } + } + // Check for trailing slash after "hooks" + if (isHooksPath && pathBytes[hooksStart + hooksBytes.length] != 0x2f) { + isHooksPath = false; + } + } else { + isHooksPath = false; + } + + if (isHooksPath) { + // Find the subdirectory after "hooks/" + uint256 subStart = hooksStart + hooksBytes.length + 1; // +1 for the slash + while (subStart < pathBytes.length && pathBytes[subStart] == 0x2f) { + subStart++; + } + uint256 subEnd = subStart; + while (subEnd < pathBytes.length && pathBytes[subEnd] != 0x2f) { + subEnd++; + } + + if (subEnd > subStart) { + bytes memory subFolderBytes = new bytes(subEnd - subStart); + for (uint256 i = 0; i < subEnd - subStart; i++) { + subFolderBytes[i] = pathBytes[subStart + i]; + } + firstFolderName = string(subFolderBytes); + } else { + firstFolderName = "hooks"; + } + } else { + // Find the first folder after src (or after root if no src) + uint256 start = foundSrc ? srcIndex : 0; + // skip leading slashes + while (start < pathBytes.length && pathBytes[start] == 0x2f) { + start++; + } + uint256 end = start; + while (end < pathBytes.length && pathBytes[end] != 0x2f) { + end++; + } + if (end > start) { + bytes memory firstFolderBytes = new bytes(end - start); + for (uint256 i = 0; i < end - start; i++) { + firstFolderBytes[i] = pathBytes[start + i]; + } + firstFolderName = string(firstFolderBytes); + } else { + firstFolderName = CONTRACT_PATH; + } } - firstFolderName = string(firstFolderBytes); } else { firstFolderName = CONTRACT_PATH; } + // Now get the last folder as before for (uint256 i = 0; i < pathBytes.length; i++) { if (pathBytes[i] == "/") { From c6a5727b817b43cae442edc2cbf6d66c9278108b Mon Sep 17 00:00:00 2001 From: jj_ranalli Date: Wed, 16 Jul 2025 18:32:58 +0200 Subject: [PATCH 4/4] readme --- LICENSE | 2 +- README.md | 90 +++++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/LICENSE b/LICENSE index 100de61..4bd79c7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Slice +Copyright (c) 2025 Slice Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 5b8e76d..28632ee 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Slice Hooks -Smart contracts for creating custom pricing strategies and onchain actions for [Slice](https://slice.so) products. Hooks enable dynamic pricing, purchase restrictions, rewards, and other custom behaviors when products are bought. +Smart contracts for creating custom pricing strategies and onchain actions for [Slice](https://slice.so) products. + +Hooks enable dynamic pricing, purchase restrictions, rewards, integration with external protocols and other custom behaviors when products are bought. ## Repository Structure @@ -17,12 +19,50 @@ src/ ## Core Concepts -Slice hooks are built around three main interfaces: +Hooks are built around three main interfaces: - **[`IOnchainAction`](./src/interfaces/IOnchainAction.sol)**: Execute custom logic during purchases (eligibility checks, rewards, etc.) - **[`IPricingStrategy`](./src/interfaces/IPricingStrategy.sol)**: Calculate dynamic prices for products - **[`IHookRegistry`](./src/interfaces/IHookRegistry.sol)**: Enable reusable hooks across multiple products with frontend integration +Hooks can be: + +- **Product-specific**: Custom smart contracts tailored for individual products. These are integrated using the `custom` onchain action or pricing strategy in Slice. +- **Registry hooks**: Reusable contracts designed to support multiple products. Registries enable automatic integration with Slice clients. + +See [Hook types](#hook-types) for more details. + +## Product Purchase Lifecycle + +Here's how hooks integrate into the product purchase flow: + +``` + Checkout + │ + ▼ +┌─────────────────────┐ +│ Price Fetching │ ← `productPrice` called here +│ (before purchase) │ (IPricingStrategy) +└─────────────────────┘ + │ + ▼ +┌─────────────────────┐ +│ Purchase Execution │ ← `onProductPurchase` called here +│ (during purchase) │ (IOnchainAction) +└─────────────────────┘ + │ + ▼ +┌─────────────────────┐ +│ Purchase Complete │ +└─────────────────────┘ +``` + +**Pricing Strategies** are called during the price fetching phase to calculate price based on buyer and custom logic + +**Onchain Actions** are executed during the purchase transaction to: +- Validate purchase eligibility +- Execute custom logic (gating, minting, rewards, etc.) + ## Hook Types ### Registry Hooks (Reusable) @@ -41,17 +81,19 @@ Tailored implementations for individual products: ## Base Contracts +The base contracts in `src/utils` are designed to be inherited, providing essential building blocks for developing custom Slice hooks efficiently. + ### Registry (Reusable): - **`RegistryOnchainAction`**: Base for reusable onchain actions - **`RegistryPricingStrategy`**: Base for reusable pricing strategies -- **`RegistryPricingStrategyAction`**: Base for combined pricing + action hooks +- **`RegistryPricingStrategyAction`**: Base for reusable pricing + action hooks ### Product-Specific -- **`OnchainAction`**: Base for simple onchain actions -- **`PricingStrategy`**: Base for simple pricing strategies -- **`PricingStrategyAction`**: Base for combined hooks +- **`OnchainAction`**: Base for product-specific onchain actions +- **`PricingStrategy`**: Base for product-specific pricing strategies +- **`PricingStrategyAction`**: Base for product-specific pricing + action hooks ## Quick Start @@ -70,21 +112,37 @@ forge build # Build Requires [Foundry](https://book.getfoundry.sh/getting-started/installation). -## Integration +### Deployment -Registry hooks automatically integrate with Slice frontends through the `IHookRegistry` interface. +To deploy hooks, use the deployment script: -Product-specific can be attached via the `custom` pricing strategy / onchain action, by passing the deployment address. +```bash +./script/deploy.sh +``` -## Testing +The script will present you with a list of available contracts to deploy. Select the contract you want to deploy and follow the prompts. -## Contributing +### Testing +When writing tests for your hooks, inherit from the appropriate base test contract: + +- **`RegistryOnchainActionTest`**: For testing `RegistryOnchainAction` contracts +- **`RegistryPricingStrategyTest`**: For testing `RegistryPricingStrategy` contracts +- **`RegistryPricingStrategyActionTest`**: For testing `RegistryPricingStrategyAction` contracts +- **`OnchainActionTest`**: For testing `OnchainAction` contracts +- **`PricingStrategyTest`**: For testing `PricingStrategy` contracts +- **`PricingStrategyActionTest`**: For testing `PricingStrategyAction` contracts + +Inheriting the appropriate test contract for your hook allows you to focus your tests solely on your custom hook logic. + +## Contributing - \ No newline at end of file +Make sure your contribution follows the existing code style and includes proper documentation. \ No newline at end of file