Skip to content
Open
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
64 changes: 64 additions & 0 deletions script/06_DeployBatchDistributeHook.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "forge-std/Script.sol";
import {BaseScript} from "./BaseScript.sol";

import {BatchDistributeHook} from "../src/pool-cl/batch-distribute/BatchDistributeHook.sol";
import {SpraayAddresses} from "../src/pool-cl/batch-distribute/SpraayAddresses.sol";
import {ICLPoolManager} from "infinity-core/src/pool-cl/interfaces/ICLPoolManager.sol";
import {Create3Factory} from "pancake-create3-factory/src/Create3Factory.sol";

/**
* Supported configs: bsc-mainnet (chainId 56), base-mainnet (chainId 8453)
*
* Step 1: Deploy
* SCRIPT_CONFIG=bsc-mainnet HOOK_OWNER=<owner> HOOK_OPERATOR=<operator> \
* forge script script/06_DeployBatchDistributeHook.s.sol:DeployBatchDistributeHookScript -vvv \
* --rpc-url $RPC_URL \
* --broadcast \
* --slow
*
* Step 2: Verify
* forge verify-contract <address> BatchDistributeHook --watch \
* --chain <chain_id> --constructor-args $(cast abi-encode "constructor(address,address,address,address)" \
* "<clPoolManager>" "<spraay>" "<owner>" "<operator>")
*/
contract DeployBatchDistributeHookScript is BaseScript {
function getDeploymentSalt() public pure override returns (bytes32) {
return keccak256("INFINITY-HOOKS/BatchDistributeHook/1.0.0");
}

function run() public {
Create3Factory factory = Create3Factory(getAddressFromConfig("create3Factory"));

address clPoolManager = getAddressFromConfig("clPoolManager");
emit log_named_address("CLPoolManager", clPoolManager);

address spraay = getAddressFromConfig("spraay");
require(spraay == SpraayAddresses.forChain(block.chainid), "Spraay address does not match chain");
emit log_named_address("Spraay", spraay);

address owner = vm.envAddress("HOOK_OWNER");
address operator = vm.envOr("HOOK_OPERATOR", address(0));
emit log_named_address("Owner", owner);
emit log_named_address("Operator", operator);

uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);

bytes memory creationCode = abi.encodePacked(
type(BatchDistributeHook).creationCode, abi.encode(ICLPoolManager(clPoolManager), spraay, owner, operator)
);
address hookAddr;
// PancakeSwap's Create3Factory only accepts whitelisted deployers; anyone else deploys directly
if (factory.isUserWhitelisted(vm.addr(deployerPrivateKey))) {
hookAddr = factory.deploy(getDeploymentSalt(), creationCode, keccak256(creationCode), 0, new bytes(0), 0);
} else {
hookAddr = address(new BatchDistributeHook(ICLPoolManager(clPoolManager), spraay, owner, operator));
}
emit log_named_address("BatchDistributeHook", hookAddr);

vm.stopBroadcast();
}
}
6 changes: 6 additions & 0 deletions script/config/base-mainnet.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"create3Factory": "0x38Ab3f2CE00973A51d3A2A04d634C9bcbf20e4e1",
"vault": "0x238a358808379702088667322f80aC48bAd5e6c4",
"clPoolManager": "0xa0FfB9c1CE1Fe56963B0321B32E7A0302114058b",
"spraay": "0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC"
}
6 changes: 6 additions & 0 deletions script/config/bsc-mainnet.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"create3Factory": "0x38Ab3f2CE00973A51d3A2A04d634C9bcbf20e4e1",
"vault": "0x238a358808379702088667322f80aC48bAd5e6c4",
"clPoolManager": "0xa0FfB9c1CE1Fe56963B0321B32E7A0302114058b",
"spraay": "0x3093a2951FB77b3beDfB8BA20De645F7413432C1"
}
269 changes: 269 additions & 0 deletions src/pool-cl/batch-distribute/BatchDistributeHook.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {PoolKey} from "infinity-core/src/types/PoolKey.sol";
import {PoolId, PoolIdLibrary} from "infinity-core/src/types/PoolId.sol";
import {ICLPoolManager} from "infinity-core/src/pool-cl/interfaces/ICLPoolManager.sol";
import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";
import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol";
import {Ownable2Step} from "openzeppelin-contracts/contracts/access/Ownable2Step.sol";
import {ReentrancyGuard} from "openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol";

import {CLBaseHook} from "../CLBaseHook.sol";
import {ISpraay} from "./interfaces/ISpraay.sol";

/// @notice BatchDistributeHook lets the operators of pools that use this hook push-distribute
/// tokens (rewards, incentives, airdrops, revenue shares) to up to 200 recipients in a single
/// transaction, by routing the transfer through Spraay's on-chain batch contract.
///
/// PancakeSwap's existing reward flows are pull-based (each LP / staker claims). This hook
/// complements them with a push model: the operator decides the recipient list off-chain and
/// delivers everything in one call, with no claim step for the recipients.
///
/// Funding model: the hook is non-custodial. Each distribution pulls exactly
/// `amount + Spraay fee` from the calling operator (ERC-20 via transferFrom, native via msg.value),
/// approves Spraay for exactly that amount and calls it in the same transaction. Excess native
/// currency is refunded to the operator.
///
/// Hook callbacks: `afterInitialize` registers pools that use this hook, so every distribution is
/// attributable to a real Infinity pool; `afterDonate` emits an event so indexers can see pull-side
/// (donate) and push-side (batch distribute) incentives for the same pool.
///
/// @dev Fee-on-transfer and rebasing tokens are not supported: Spraay pulls the exact gross amount.
contract BatchDistributeHook is CLBaseHook, Ownable2Step, ReentrancyGuard {
using PoolIdLibrary for PoolKey;
using SafeERC20 for IERC20;

/// @notice Spraay enforces the same limit on-chain; checked here to fail early with a clear error
uint256 public constant MAX_RECIPIENTS = 200;

/// @notice Address of Spraay's batch distribution contract on this chain
ISpraay public spraay;

/// @notice Accounts allowed to trigger distributions
mapping(address => bool) public isOperator;

/// @notice Pools initialized with this hook
mapping(PoolId => bool) public isRegisteredPool;

event SpraayContractUpdated(address indexed oldSpraay, address indexed newSpraay);
event OperatorUpdated(address indexed operator, bool allowed);
event PoolRegistered(PoolId indexed poolId);
event PoolDonation(PoolId indexed poolId, address indexed sender, uint256 amount0, uint256 amount1);
event BatchDistributed(
PoolId indexed poolId,
address indexed operator,
address indexed token,
uint256 recipientCount,
uint256 totalAmount,
uint256 feeAmount
);

error NotOperator();
error ZeroAddress();
error PoolNotRegistered();
error NoRecipients();
error TooManyRecipients(uint256 count, uint256 max);
error LengthMismatch();
error InsufficientNativeAmount(uint256 required, uint256 provided);
error UnexpectedNativeAmount();
error NativeTransferFailed();

modifier onlyOperator() {
if (!isOperator[msg.sender]) revert NotOperator();
_;
}

/// @param _poolManager The CLPoolManager on this chain
/// @param _spraay Spraay's batch contract on this chain
/// @param _owner Admin account; passed explicitly because deployment goes through Create3Factory
/// @param _operator Initial distribution operator (may be address(0) to set later)
constructor(ICLPoolManager _poolManager, address _spraay, address _owner, address _operator)
CLBaseHook(_poolManager)
Ownable(_owner)
{
if (_spraay == address(0)) revert ZeroAddress();
spraay = ISpraay(_spraay);
emit SpraayContractUpdated(address(0), _spraay);

if (_operator != address(0)) {
isOperator[_operator] = true;
emit OperatorUpdated(_operator, true);
}
}

function getHooksRegistrationBitmap() external pure override returns (uint16) {
return _hooksRegistrationBitmapFrom(
Permissions({
beforeInitialize: false,
afterInitialize: true,
beforeAddLiquidity: false,
afterAddLiquidity: false,
beforeRemoveLiquidity: false,
afterRemoveLiquidity: false,
beforeSwap: false,
afterSwap: false,
beforeDonate: false,
afterDonate: true,
beforeSwapReturnDelta: false,
afterSwapReturnDelta: false,
afterAddLiquidityReturnDelta: false,
afterRemoveLiquidityReturnDelta: false
})
);
}

// ============ Hook callbacks ============

function _afterInitialize(address, PoolKey calldata key, uint160, int24) internal override returns (bytes4) {
PoolId id = key.toId();
isRegisteredPool[id] = true;
emit PoolRegistered(id);
return this.afterInitialize.selector;
}

function _afterDonate(address sender, PoolKey calldata key, uint256 amount0, uint256 amount1, bytes calldata)
internal
override
returns (bytes4)
{
emit PoolDonation(key.toId(), sender, amount0, amount1);
return this.afterDonate.selector;
}

// ============ Distribution ============

/// @notice Push-distribute variable amounts to many recipients in one transaction
/// @param poolId Pool the distribution is attributed to (must use this hook)
/// @param token ERC-20 to distribute, or address(0) for the chain's native currency
/// @param recipients Recipient addresses (max 200)
/// @param amounts Amount for each recipient, excluding the Spraay fee
/// @dev ERC-20: operator must approve this hook for `quoteTotalCost(sum(amounts))`.
/// Native: msg.value must be at least `quoteTotalCost(sum(amounts))`; any excess is refunded.
function batchDistribute(
PoolId poolId,
address token,
address[] calldata recipients,
uint256[] calldata amounts
) external payable nonReentrant onlyOperator {
_checkPoolAndRecipients(poolId, recipients.length);
if (recipients.length != amounts.length) revert LengthMismatch();

ISpraay.Recipient[] memory batch = new ISpraay.Recipient[](recipients.length);
uint256 totalAmount = 0;
for (uint256 i; i < recipients.length; ++i) {
batch[i] = ISpraay.Recipient({recipient: payable(recipients[i]), amount: amounts[i]});
totalAmount += amounts[i];
}

ISpraay _spraay = spraay;
uint256 requiredAmount = _prepare(_spraay, token, totalAmount);
emit BatchDistributed(
poolId, msg.sender, token, recipients.length, totalAmount, requiredAmount - totalAmount
);

if (token == address(0)) {
_spraay.sprayETH{value: requiredAmount}(batch);
_refundExcess(requiredAmount);
} else {
_pullAndApprove(token, address(_spraay), requiredAmount);
_spraay.sprayToken(token, batch);
}
}

/// @notice Push-distribute the same amount to every recipient in one transaction
/// @param poolId Pool the distribution is attributed to (must use this hook)
/// @param token ERC-20 to distribute, or address(0) for the chain's native currency
/// @param recipients Recipient addresses (max 200)
/// @param amountEach Amount each recipient receives, excluding the Spraay fee
function batchDistributeEqual(
PoolId poolId,
address token,
address[] calldata recipients,
uint256 amountEach
) external payable nonReentrant onlyOperator {
_checkPoolAndRecipients(poolId, recipients.length);

address payable[] memory batch = new address payable[](recipients.length);
for (uint256 i; i < recipients.length; ++i) {
batch[i] = payable(recipients[i]);
}

ISpraay _spraay = spraay;
uint256 totalAmount = amountEach * recipients.length;
uint256 requiredAmount = _prepare(_spraay, token, totalAmount);
emit BatchDistributed(
poolId, msg.sender, token, recipients.length, totalAmount, requiredAmount - totalAmount
);

if (token == address(0)) {
_spraay.sprayEqual{value: requiredAmount}(address(0), batch, amountEach);
_refundExcess(requiredAmount);
} else {
_pullAndApprove(token, address(_spraay), requiredAmount);
_spraay.sprayEqual(token, batch, amountEach);
}
}

/// @notice Amount (distribution + Spraay fee) an operator must provide to distribute `totalAmount`
function quoteTotalCost(uint256 totalAmount) external view returns (uint256) {
return spraay.calculateTotalCost(totalAmount);
}

// ============ Admin ============

/// @notice Point the hook at a different Spraay deployment (e.g. after a Spraay upgrade)
function setSpraayContract(address newSpraay) external onlyOwner {
if (newSpraay == address(0)) revert ZeroAddress();
emit SpraayContractUpdated(address(spraay), newSpraay);
spraay = ISpraay(newSpraay);
}

function setOperator(address operator, bool allowed) external onlyOwner {
if (operator == address(0)) revert ZeroAddress();
isOperator[operator] = allowed;
emit OperatorUpdated(operator, allowed);
}

/// @notice Recover ERC-20s sent to the hook by mistake. The hook never holds funds between calls.
function rescueTokens(address token, address to, uint256 amount) external onlyOwner {
if (to == address(0)) revert ZeroAddress();
IERC20(token).safeTransfer(to, amount);
}

// ============ Internal ============

function _checkPoolAndRecipients(PoolId poolId, uint256 count) internal view {
if (!isRegisteredPool[poolId]) revert PoolNotRegistered();
if (count == 0) revert NoRecipients();
if (count > MAX_RECIPIENTS) revert TooManyRecipients(count, MAX_RECIPIENTS);
}

/// @dev Quotes the gross amount (distribution + Spraay fee) and validates msg.value against it
function _prepare(ISpraay _spraay, address token, uint256 totalAmount)
internal
view
returns (uint256 requiredAmount)
{
requiredAmount = _spraay.calculateTotalCost(totalAmount);
if (token == address(0)) {
if (msg.value < requiredAmount) revert InsufficientNativeAmount(requiredAmount, msg.value);
} else if (msg.value != 0) {
revert UnexpectedNativeAmount();
}
}

function _refundExcess(uint256 requiredAmount) internal {
uint256 excess = msg.value - requiredAmount;
if (excess > 0) {
(bool success,) = msg.sender.call{value: excess}("");
if (!success) revert NativeTransferFailed();
}
}

function _pullAndApprove(address token, address spender, uint256 amount) internal {
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
IERC20(token).forceApprove(spender, amount);
}
}
Loading