Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -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
Expand Down
90 changes: 74 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)
Expand All @@ -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

Expand All @@ -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

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

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

-->
Make sure your contribution follows the existing code style and includes proper documentation.
2 changes: 2 additions & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down Expand Up @@ -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"

90 changes: 69 additions & 21 deletions script/ScriptUtils.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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] == "/") {
Expand Down
7 changes: 7 additions & 0 deletions soldeer.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 2 additions & 3 deletions src/hooks/actions/ERC20Mint/ERC20Mint.sol
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ contract ERC20Mint is RegistryOnchainAction {
ERRORS
//////////////////////////////////////////////////////////////*/

error MaxSupplyExceeded();
error InvalidTokensPerUnit();

/*//////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -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
}
}

Expand Down
49 changes: 49 additions & 0 deletions test/actions/Allowlisted/Allowlisted.t.sol
Original file line number Diff line number Diff line change
@@ -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)));
}
}
44 changes: 44 additions & 0 deletions test/actions/ERC20Gated/ERC20Gated.t.sol
Original file line number Diff line number Diff line change
@@ -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, "", ""));
}
}
Loading