Skip to content

Commit ffa288d

Browse files
authored
Merge pull request #7 from slice-so/slice/internal
Sync slice/internal → master
2 parents 262331b + c6a5727 commit ffa288d

26 files changed

+1805
-97
lines changed

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2022 Slice
3+
Copyright (c) 2025 Slice
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

README.md

Lines changed: 74 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Slice Hooks
22

3-
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.
3+
Smart contracts for creating custom pricing strategies and onchain actions for [Slice](https://slice.so) products.
4+
5+
Hooks enable dynamic pricing, purchase restrictions, rewards, integration with external protocols and other custom behaviors when products are bought.
46

57
## Repository Structure
68

@@ -17,12 +19,50 @@ src/
1719

1820
## Core Concepts
1921

20-
Slice hooks are built around three main interfaces:
22+
Hooks are built around three main interfaces:
2123

2224
- **[`IOnchainAction`](./src/interfaces/IOnchainAction.sol)**: Execute custom logic during purchases (eligibility checks, rewards, etc.)
2325
- **[`IPricingStrategy`](./src/interfaces/IPricingStrategy.sol)**: Calculate dynamic prices for products
2426
- **[`IHookRegistry`](./src/interfaces/IHookRegistry.sol)**: Enable reusable hooks across multiple products with frontend integration
2527

28+
Hooks can be:
29+
30+
- **Product-specific**: Custom smart contracts tailored for individual products. These are integrated using the `custom` onchain action or pricing strategy in Slice.
31+
- **Registry hooks**: Reusable contracts designed to support multiple products. Registries enable automatic integration with Slice clients.
32+
33+
See [Hook types](#hook-types) for more details.
34+
35+
## Product Purchase Lifecycle
36+
37+
Here's how hooks integrate into the product purchase flow:
38+
39+
```
40+
Checkout
41+
42+
43+
┌─────────────────────┐
44+
│ Price Fetching │ ← `productPrice` called here
45+
│ (before purchase) │ (IPricingStrategy)
46+
└─────────────────────┘
47+
48+
49+
┌─────────────────────┐
50+
│ Purchase Execution │ ← `onProductPurchase` called here
51+
│ (during purchase) │ (IOnchainAction)
52+
└─────────────────────┘
53+
54+
55+
┌─────────────────────┐
56+
│ Purchase Complete │
57+
└─────────────────────┘
58+
```
59+
60+
**Pricing Strategies** are called during the price fetching phase to calculate price based on buyer and custom logic
61+
62+
**Onchain Actions** are executed during the purchase transaction to:
63+
- Validate purchase eligibility
64+
- Execute custom logic (gating, minting, rewards, etc.)
65+
2666
## Hook Types
2767

2868
### Registry Hooks (Reusable)
@@ -41,17 +81,19 @@ Tailored implementations for individual products:
4181

4282
## Base Contracts
4383

84+
The base contracts in `src/utils` are designed to be inherited, providing essential building blocks for developing custom Slice hooks efficiently.
85+
4486
### Registry (Reusable):
4587

4688
- **`RegistryOnchainAction`**: Base for reusable onchain actions
4789
- **`RegistryPricingStrategy`**: Base for reusable pricing strategies
48-
- **`RegistryPricingStrategyAction`**: Base for combined pricing + action hooks
90+
- **`RegistryPricingStrategyAction`**: Base for reusable pricing + action hooks
4991

5092
### Product-Specific
5193

52-
- **`OnchainAction`**: Base for simple onchain actions
53-
- **`PricingStrategy`**: Base for simple pricing strategies
54-
- **`PricingStrategyAction`**: Base for combined hooks
94+
- **`OnchainAction`**: Base for product-specific onchain actions
95+
- **`PricingStrategy`**: Base for product-specific pricing strategies
96+
- **`PricingStrategyAction`**: Base for product-specific pricing + action hooks
5597

5698
## Quick Start
5799

@@ -70,21 +112,37 @@ forge build # Build
70112

71113
Requires [Foundry](https://book.getfoundry.sh/getting-started/installation).
72114

73-
## Integration
115+
### Deployment
74116

75-
Registry hooks automatically integrate with Slice frontends through the `IHookRegistry` interface.
117+
To deploy hooks, use the deployment script:
76118

77-
Product-specific can be attached via the `custom` pricing strategy / onchain action, by passing the deployment address.
119+
```bash
120+
./script/deploy.sh
121+
```
78122

79-
## Testing
123+
The script will present you with a list of available contracts to deploy. Select the contract you want to deploy and follow the prompts.
80124

81-
## Contributing
125+
### Testing
82126

127+
When writing tests for your hooks, inherit from the appropriate base test contract:
128+
129+
- **`RegistryOnchainActionTest`**: For testing `RegistryOnchainAction` contracts
130+
- **`RegistryPricingStrategyTest`**: For testing `RegistryPricingStrategy` contracts
131+
- **`RegistryPricingStrategyActionTest`**: For testing `RegistryPricingStrategyAction` contracts
132+
- **`OnchainActionTest`**: For testing `OnchainAction` contracts
133+
- **`PricingStrategyTest`**: For testing `PricingStrategy` contracts
134+
- **`PricingStrategyActionTest`**: For testing `PricingStrategyAction` contracts
135+
136+
Inheriting the appropriate test contract for your hook allows you to focus your tests solely on your custom hook logic.
137+
138+
## Contributing
83139

84-
<!-- TODO:
85-
- update openzeppelin dependency to latest (after core is upgraded to latest)
140+
To contribute a new hook to this repository:
86141

87-
- add testing and contributing guidelines this readme
88-
- finalize tests
142+
1. **Choose the appropriate hook type** based on your needs (registry vs product-specific)
143+
2. **Implement your hook** following the existing patterns in the codebase
144+
3. **Write comprehensive tests** using the appropriate test base contract
145+
4. **Add documentation** explaining your hook's purpose and usage
146+
5. **Submit a pull request** against this repository
89147

90-
-->
148+
Make sure your contribution follows the existing code style and includes proper documentation.

foundry.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ remappings = [
1010
"slice/=dependencies/slice-0.0.4/",
1111
"@openzeppelin-4.8.0/=dependencies/@openzeppelin-contracts-4.8.0/",
1212
"@erc721a/=dependencies/erc721a-4.3.0/contracts/",
13+
"@murky/=dependencies/murky-0.1.0/src/",
1314
"forge-std/=dependencies/forge-std-1.9.7/src/",
1415
"@test/=test/",
1516
"@/=src/"
@@ -47,4 +48,5 @@ slice = "0.0.4"
4748
forge-std = "1.9.7"
4849
"@openzeppelin-contracts" = "4.8.0"
4950
erc721a = "4.3.0"
51+
murky = "0.1.0"
5052

script/ScriptUtils.sol

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -285,12 +285,7 @@ abstract contract SetUpContractsList is Script {
285285
if (isTopLevel) {
286286
string memory folderName = _getLastPathSegment(file.path);
287287
// Only include specific top-level folders
288-
if (
289-
keccak256(bytes(folderName)) != keccak256(bytes("internal"))
290-
&& keccak256(bytes(folderName)) != keccak256(bytes("actions"))
291-
&& keccak256(bytes(folderName)) != keccak256(bytes("pricingStrategies"))
292-
&& keccak256(bytes(folderName)) != keccak256(bytes("pricingStrategyActions"))
293-
) {
288+
if (keccak256(bytes(folderName)) != keccak256(bytes("hooks"))) {
294289
continue;
295290
}
296291
}
@@ -416,25 +411,78 @@ abstract contract SetUpContractsList is Script {
416411
break;
417412
}
418413
}
419-
// Find the first folder after src (or after root if no src)
420-
uint256 start = foundSrc ? srcIndex : 0;
421-
// skip leading slashes
422-
while (start < pathBytes.length && pathBytes[start] == 0x2f) {
423-
start++;
424-
}
425-
uint256 end = start;
426-
while (end < pathBytes.length && pathBytes[end] != 0x2f) {
427-
end++;
428-
}
429-
if (end > start) {
430-
bytes memory firstFolderBytes = new bytes(end - start);
431-
for (uint256 i = 0; i < end - start; i++) {
432-
firstFolderBytes[i] = pathBytes[start + i];
414+
415+
// For hooks subdirectories, use the subdirectory name as the category
416+
if (foundSrc) {
417+
// Look for "hooks/" after src
418+
uint256 hooksStart = srcIndex;
419+
while (hooksStart < pathBytes.length && pathBytes[hooksStart] == 0x2f) {
420+
hooksStart++;
421+
}
422+
423+
// Check if path starts with "hooks/"
424+
bytes memory hooksBytes = bytes("hooks");
425+
bool isHooksPath = true;
426+
if (hooksStart + hooksBytes.length < pathBytes.length) {
427+
for (uint256 i = 0; i < hooksBytes.length; i++) {
428+
if (pathBytes[hooksStart + i] != hooksBytes[i]) {
429+
isHooksPath = false;
430+
break;
431+
}
432+
}
433+
// Check for trailing slash after "hooks"
434+
if (isHooksPath && pathBytes[hooksStart + hooksBytes.length] != 0x2f) {
435+
isHooksPath = false;
436+
}
437+
} else {
438+
isHooksPath = false;
439+
}
440+
441+
if (isHooksPath) {
442+
// Find the subdirectory after "hooks/"
443+
uint256 subStart = hooksStart + hooksBytes.length + 1; // +1 for the slash
444+
while (subStart < pathBytes.length && pathBytes[subStart] == 0x2f) {
445+
subStart++;
446+
}
447+
uint256 subEnd = subStart;
448+
while (subEnd < pathBytes.length && pathBytes[subEnd] != 0x2f) {
449+
subEnd++;
450+
}
451+
452+
if (subEnd > subStart) {
453+
bytes memory subFolderBytes = new bytes(subEnd - subStart);
454+
for (uint256 i = 0; i < subEnd - subStart; i++) {
455+
subFolderBytes[i] = pathBytes[subStart + i];
456+
}
457+
firstFolderName = string(subFolderBytes);
458+
} else {
459+
firstFolderName = "hooks";
460+
}
461+
} else {
462+
// Find the first folder after src (or after root if no src)
463+
uint256 start = foundSrc ? srcIndex : 0;
464+
// skip leading slashes
465+
while (start < pathBytes.length && pathBytes[start] == 0x2f) {
466+
start++;
467+
}
468+
uint256 end = start;
469+
while (end < pathBytes.length && pathBytes[end] != 0x2f) {
470+
end++;
471+
}
472+
if (end > start) {
473+
bytes memory firstFolderBytes = new bytes(end - start);
474+
for (uint256 i = 0; i < end - start; i++) {
475+
firstFolderBytes[i] = pathBytes[start + i];
476+
}
477+
firstFolderName = string(firstFolderBytes);
478+
} else {
479+
firstFolderName = CONTRACT_PATH;
480+
}
433481
}
434-
firstFolderName = string(firstFolderBytes);
435482
} else {
436483
firstFolderName = CONTRACT_PATH;
437484
}
485+
438486
// Now get the last folder as before
439487
for (uint256 i = 0; i < pathBytes.length; i++) {
440488
if (pathBytes[i] == "/") {

soldeer.lock

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ url = "https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_7_28-04-2025_15:
1919
checksum = "8d9e0a885fa8ee6429a4d344aeb6799119f6a94c7c4fe6f188df79b0dce294ba"
2020
integrity = "9e60fdba82bc374df80db7f2951faff6467b9091873004a3d314cf0c084b3c7d"
2121

22+
[[dependencies]]
23+
name = "murky"
24+
version = "0.1.0"
25+
url = "https://soldeer-revisions.s3.amazonaws.com/murky/0_1_0_27-02-2025_09:52:15_murky.zip"
26+
checksum = "44716641e084b50af27de35f0676706c7cd42b22b39a12f7136fda4156023a15"
27+
integrity = "a41bd6903adfe80291f7b20c0317368e517db10c302e82aa7dc53776f17811cd"
28+
2229
[[dependencies]]
2330
name = "slice"
2431
version = "0.0.4"

src/hooks/actions/ERC20Mint/ERC20Mint.sol

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ contract ERC20Mint is RegistryOnchainAction {
2222
ERRORS
2323
//////////////////////////////////////////////////////////////*/
2424

25-
error MaxSupplyExceeded();
2625
error InvalidTokensPerUnit();
2726

2827
/*//////////////////////////////////////////////////////////////
@@ -83,8 +82,8 @@ contract ERC20Mint is RegistryOnchainAction {
8382
(bool success,) =
8483
address(tokenData_.token).call(abi.encodeWithSelector(tokenData_.token.mint.selector, buyer, tokensToMint));
8584

86-
if (tokenData_.revertOnMaxSupplyReached) {
87-
if (!success) revert MaxSupplyExceeded();
85+
if (success) {
86+
// Do nothing, just silence the warning
8887
}
8988
}
9089

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.20;
3+
4+
import {RegistryOnchainActionTest} from "@test/utils/RegistryOnchainActionTest.sol";
5+
import {Allowlisted} from "@/hooks/actions/Allowlisted/Allowlisted.sol";
6+
import {Merkle} from "@murky/Merkle.sol";
7+
8+
uint256 constant slicerId = 0;
9+
uint256 constant productId = 1;
10+
11+
contract AllowlistedTest is RegistryOnchainActionTest {
12+
Allowlisted allowlisted;
13+
Merkle m;
14+
bytes32[] data;
15+
16+
function setUp() public {
17+
allowlisted = new Allowlisted(PRODUCTS_MODULE);
18+
_setHook(address(allowlisted));
19+
20+
m = new Merkle();
21+
data = new bytes32[](4);
22+
data[0] = bytes32(keccak256(abi.encodePacked(buyer)));
23+
data[1] = bytes32(keccak256(abi.encodePacked(address(1))));
24+
data[2] = bytes32(keccak256(abi.encodePacked(address(2))));
25+
data[3] = bytes32(keccak256(abi.encodePacked(address(3))));
26+
}
27+
28+
function testConfigureProduct() public {
29+
bytes32 root = m.getRoot(data);
30+
31+
vm.prank(productOwner);
32+
allowlisted.configureProduct(slicerId, productId, abi.encode(root));
33+
34+
assertTrue(allowlisted.merkleRoots(slicerId, productId) == root);
35+
}
36+
37+
function testIsPurchaseAllowed() public {
38+
bytes32 root = m.getRoot(data);
39+
40+
vm.prank(productOwner);
41+
allowlisted.configureProduct(slicerId, productId, abi.encode(root));
42+
43+
bytes32[] memory wrongProof = m.getProof(data, 1);
44+
assertFalse(allowlisted.isPurchaseAllowed(slicerId, productId, buyer, 0, "", abi.encode(wrongProof)));
45+
46+
bytes32[] memory proof = m.getProof(data, 0);
47+
assertTrue(allowlisted.isPurchaseAllowed(slicerId, productId, buyer, 0, "", abi.encode(proof)));
48+
}
49+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.20;
3+
4+
import {RegistryOnchainActionTest} from "@test/utils/RegistryOnchainActionTest.sol";
5+
import {ERC20Gated, ERC20Gate} from "@/hooks/actions/ERC20Gated/ERC20Gated.sol";
6+
import {IERC20, MockERC20} from "@test/utils/mocks/MockERC20.sol";
7+
8+
uint256 constant slicerId = 0;
9+
uint256 constant productId = 1;
10+
11+
contract ERC20GatedTest is RegistryOnchainActionTest {
12+
ERC20Gated erc20Gated;
13+
MockERC20 token = new MockERC20();
14+
15+
function setUp() public {
16+
erc20Gated = new ERC20Gated(PRODUCTS_MODULE);
17+
_setHook(address(erc20Gated));
18+
}
19+
20+
function testConfigureProduct() public {
21+
ERC20Gate[] memory gates = new ERC20Gate[](1);
22+
gates[0] = ERC20Gate(token, 100);
23+
24+
vm.prank(productOwner);
25+
erc20Gated.configureProduct(slicerId, productId, abi.encode(gates));
26+
27+
(IERC20 tokenAddr, uint256 amount) = erc20Gated.tokenGates(slicerId, productId, 0);
28+
assertTrue(address(tokenAddr) == address(token));
29+
assertTrue(amount == 100);
30+
}
31+
32+
function testIsPurchaseAllowed() public {
33+
ERC20Gate[] memory gates = new ERC20Gate[](1);
34+
gates[0] = ERC20Gate(token, 100);
35+
36+
vm.prank(productOwner);
37+
erc20Gated.configureProduct(slicerId, productId, abi.encode(gates));
38+
39+
assertFalse(erc20Gated.isPurchaseAllowed(slicerId, productId, buyer, 0, "", ""));
40+
41+
token.mint(buyer, 100);
42+
assertTrue(erc20Gated.isPurchaseAllowed(slicerId, productId, buyer, 0, "", ""));
43+
}
44+
}

0 commit comments

Comments
 (0)