From de14d2ea830f11ae3824ec570529755206bb0b49 Mon Sep 17 00:00:00 2001 From: plagtech Date: Mon, 12 Oct 2026 13:40:54 -0700 Subject: [PATCH] feat: add BatchDistributeHook for Spraay push-model batch distribution Adds a PancakeSwap Infinity hook that integrates Spraay Protocol's on-chain batch payment contract, enabling pool operators to push-distribute tokens (ERC-20 and native BNB/ETH) to up to 200 recipients in a single transaction. Supports BNB Chain and Base. - src/pool-cl/batch-distribute/BatchDistributeHook.sol - Deploy scripts and chain configs for BSC and Base mainnet - 26 unit tests + 2 live fork tests (all passing) - No existing files modified --- script/06_DeployBatchDistributeHook.s.sol | 64 +++ script/config/base-mainnet.json | 6 + script/config/bsc-mainnet.json | 6 + .../batch-distribute/BatchDistributeHook.sol | 269 +++++++++++++ src/pool-cl/batch-distribute/README.md | 118 ++++++ .../batch-distribute/SpraayAddresses.sol | 18 + .../batch-distribute/interfaces/ISpraay.sol | 32 ++ test/pool-cl/BatchDistributeHook.fork.t.sol | 105 +++++ test/pool-cl/BatchDistributeHook.t.sol | 380 ++++++++++++++++++ test/pool-cl/helpers/MockSpraay.sol | 269 +++++++++++++ 10 files changed, 1267 insertions(+) create mode 100644 script/06_DeployBatchDistributeHook.s.sol create mode 100644 script/config/base-mainnet.json create mode 100644 script/config/bsc-mainnet.json create mode 100644 src/pool-cl/batch-distribute/BatchDistributeHook.sol create mode 100644 src/pool-cl/batch-distribute/README.md create mode 100644 src/pool-cl/batch-distribute/SpraayAddresses.sol create mode 100644 src/pool-cl/batch-distribute/interfaces/ISpraay.sol create mode 100644 test/pool-cl/BatchDistributeHook.fork.t.sol create mode 100644 test/pool-cl/BatchDistributeHook.t.sol create mode 100644 test/pool-cl/helpers/MockSpraay.sol diff --git a/script/06_DeployBatchDistributeHook.s.sol b/script/06_DeployBatchDistributeHook.s.sol new file mode 100644 index 0000000..c3e63f9 --- /dev/null +++ b/script/06_DeployBatchDistributeHook.s.sol @@ -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= HOOK_OPERATOR= \ + * forge script script/06_DeployBatchDistributeHook.s.sol:DeployBatchDistributeHookScript -vvv \ + * --rpc-url $RPC_URL \ + * --broadcast \ + * --slow + * + * Step 2: Verify + * forge verify-contract
BatchDistributeHook --watch \ + * --chain --constructor-args $(cast abi-encode "constructor(address,address,address,address)" \ + * "" "" "" "") + */ +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(); + } +} diff --git a/script/config/base-mainnet.json b/script/config/base-mainnet.json new file mode 100644 index 0000000..b5cdb78 --- /dev/null +++ b/script/config/base-mainnet.json @@ -0,0 +1,6 @@ +{ + "create3Factory": "0x38Ab3f2CE00973A51d3A2A04d634C9bcbf20e4e1", + "vault": "0x238a358808379702088667322f80aC48bAd5e6c4", + "clPoolManager": "0xa0FfB9c1CE1Fe56963B0321B32E7A0302114058b", + "spraay": "0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC" +} diff --git a/script/config/bsc-mainnet.json b/script/config/bsc-mainnet.json new file mode 100644 index 0000000..df2adf1 --- /dev/null +++ b/script/config/bsc-mainnet.json @@ -0,0 +1,6 @@ +{ + "create3Factory": "0x38Ab3f2CE00973A51d3A2A04d634C9bcbf20e4e1", + "vault": "0x238a358808379702088667322f80aC48bAd5e6c4", + "clPoolManager": "0xa0FfB9c1CE1Fe56963B0321B32E7A0302114058b", + "spraay": "0x3093a2951FB77b3beDfB8BA20De645F7413432C1" +} diff --git a/src/pool-cl/batch-distribute/BatchDistributeHook.sol b/src/pool-cl/batch-distribute/BatchDistributeHook.sol new file mode 100644 index 0000000..ad7db03 --- /dev/null +++ b/src/pool-cl/batch-distribute/BatchDistributeHook.sol @@ -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); + } +} diff --git a/src/pool-cl/batch-distribute/README.md b/src/pool-cl/batch-distribute/README.md new file mode 100644 index 0000000..b3f2e95 --- /dev/null +++ b/src/pool-cl/batch-distribute/README.md @@ -0,0 +1,118 @@ +# BatchDistributeHook + +A PancakeSwap Infinity CL hook that lets pool operators **push** tokens to up to 200 recipients in a single transaction by routing transfers through [Spraay](https://spraay.app)'s on-chain batch contract. + +## Push vs. pull distribution + +PancakeSwap's existing reward flows are **pull-based**. Farms, CAKE emissions, veCAKE rewards and `donate()` all credit value to a position, and each LP or staker has to claim it themselves. This scales well for large, open sets of participants, but it has drawbacks for smaller, targeted programs: + +- Every recipient pays gas to claim, so small rewards often go unclaimed. +- Recipients who are not LPs (contributors, market makers, community members, partner treasuries) cannot be paid through the pool at all. +- Every program needs a claim UI and someone to tell recipients to use it. + +`BatchDistributeHook` adds a **push** model next to these flows. The pool operator works out the recipient list off-chain (for example from a liquidity snapshot, volume, or a contributor list) and delivers everything in one call. Recipients do nothing and pay no gas. Each distribution is tied on-chain to the Infinity pool it belongs to. + +| | Pull (farms / `donate`) | Push (`BatchDistributeHook`) | +| ------------------- | ------------------------------ | --------------------------------------- | +| Who pays gas | Each recipient, on claim | The operator, once per batch | +| Eligible recipients | Positions in the pool | Any address | +| Allocation logic | On-chain (pro-rata, in range) | Off-chain, decided by the operator | +| Recipient action | Claim | None | +| Best for | Open, continuous incentives | Targeted payouts, airdrops, revenue shares | + +The two models work together. A pool can keep its farm or donations running while the operator uses this hook for one-off or targeted payouts. + +## How it works + +``` +operator ──batchDistribute(poolId, token, recipients, amounts)──▶ BatchDistributeHook + │ 1. check operator, pool, ≤200 recipients + │ 2. quote amount + Spraay fee + │ 3. pull exactly that from operator + │ 4. approve Spraay for exactly that + ▼ + Spraay.sprayToken / sprayETH / sprayEqual + │ + ┌──────────┬──────────┼──────────┬──────────┐ + ▼ ▼ ▼ ▼ ▼ + recipient₁ recipient₂ ... recipient₂₀₀ fee recipient +``` + +- **Non-custodial.** The hook holds no funds between calls. Each call pulls exactly `amount + fee` from the operator, approves Spraay for exactly that amount, and Spraay uses all of it. Excess native currency is refunded in the same transaction. +- **Pool-attributed.** `afterInitialize` registers every pool created with this hook, and distributions must reference a registered `PoolId`. `afterDonate` emits `PoolDonation`, so indexers can see pull-side and push-side incentives for the same pool. +- **No effect on swaps or liquidity.** Only `afterInitialize` and `afterDonate` are registered, so swaps and liquidity changes in the pool cost no extra gas. + +### Hook permissions + +| Callback | Enabled | Purpose | +| ----------------- | ------- | ----------------------------------------- | +| `afterInitialize` | ✅ | Register the pool for distributions | +| `afterDonate` | ✅ | Emit `PoolDonation` for indexing | +| everything else | ❌ | | + +## Interface + +```solidity +// token == address(0) distributes the native currency (BNB / ETH) +function batchDistribute(PoolId poolId, address token, address[] calldata recipients, uint256[] calldata amounts) + external payable; // → Spraay.sprayToken / sprayETH + +function batchDistributeEqual(PoolId poolId, address token, address[] calldata recipients, uint256 amountEach) + external payable; // → Spraay.sprayEqual + +function quoteTotalCost(uint256 totalAmount) external view returns (uint256); // amount + Spraay fee + +// owner only +function setSpraayContract(address newSpraay) external; +function setOperator(address operator, bool allowed) external; +function rescueTokens(address token, address to, uint256 amount) external; +``` + +**Roles:** + +- **Owner** (`Ownable2Step`) manages operators, can point the hook at a new Spraay deployment, and can recover tokens sent to the hook by mistake. The owner is not automatically an operator. +- **Operators** trigger distributions and fund them. + +**Funding a distribution:** + +- **ERC-20:** `approve(hook, quoteTotalCost(sum(amounts)))`, then call `batchDistribute`. +- **Native:** send `msg.value >= quoteTotalCost(sum(amounts))`. The hook refunds any excess. + +## Spraay fee + +Spraay charges `feeBps` (currently **30 bps = 0.3%**) **on top of** the distributed amount. Recipients receive exactly the amounts specified, and the operator pays `total + total * feeBps / 10_000`. The hook reads the fee from Spraay at call time through `calculateTotalCost`, so a fee change on Spraay's side needs no change to the hook. Spraay caps `feeBps` at 500. + +## Deployments + +| Chain | Spraay (`SprayContract`) | CLPoolManager | +| --------- | -------------------------------------------- | -------------------------------------------- | +| BNB Chain | `0x3093a2951FB77b3beDfB8BA20De645F7413432C1` | `0xa0FfB9c1CE1Fe56963B0321B32E7A0302114058b` | +| Base | `0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC` | `0xa0FfB9c1CE1Fe56963B0321B32E7A0302114058b` | + +Both Spraay contracts are verified on Sourcify (exact match) and have identical source. The addresses are also available in Solidity via `SpraayAddresses.sol` and in `script/config/{bsc,base}-mainnet.json`. + +```sh +SCRIPT_CONFIG=bsc-mainnet HOOK_OWNER= HOOK_OPERATOR= PRIVATE_KEY= \ +forge script script/06_DeployBatchDistributeHook.s.sol:DeployBatchDistributeHookScript \ + --rpc-url $RPC_URL --broadcast --slow +``` + +The script deploys through PancakeSwap's Create3Factory when the deployer is whitelisted on it. Otherwise it deploys directly. + +## Tests + +```sh +forge test --match-contract BatchDistributeHookTest + +# against the live Spraay + Infinity deployments +RPC_BSC_MAINNET= RPC_BASE_MAINNET= forge test --match-contract BatchDistributeHookForkTest +``` + +The unit tests use `test/pool-cl/helpers/MockSpraay.sol`, a copy of the verified Spraay source in which only the import paths and the contract name are changed. + +## Limitations + +- Fee-on-transfer and rebasing tokens are not supported, because Spraay pulls the exact gross amount. +- Each call is limited to 200 recipients (Spraay's `MAX_RECIPIENTS`). Larger lists must be split across several calls. +- In a native distribution, a recipient contract that rejects the payment makes the whole batch revert. Spraay does not skip failed recipients. +- Allocation is trusted to the operator. The hook enforces who can distribute, not who should receive. diff --git a/src/pool-cl/batch-distribute/SpraayAddresses.sol b/src/pool-cl/batch-distribute/SpraayAddresses.sol new file mode 100644 index 0000000..1fe6e15 --- /dev/null +++ b/src/pool-cl/batch-distribute/SpraayAddresses.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +/// @notice Spraay batch contract deployments (SprayContract, verified on Sourcify) +library SpraayAddresses { + /// @dev BNB Chain mainnet, chainId 56 + address internal constant BSC_MAINNET = 0x3093a2951FB77b3beDfB8BA20De645F7413432C1; + /// @dev Base mainnet, chainId 8453 + address internal constant BASE_MAINNET = 0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC; + + error UnsupportedChain(uint256 chainId); + + function forChain(uint256 chainId) internal pure returns (address) { + if (chainId == 56) return BSC_MAINNET; + if (chainId == 8453) return BASE_MAINNET; + revert UnsupportedChain(chainId); + } +} diff --git a/src/pool-cl/batch-distribute/interfaces/ISpraay.sol b/src/pool-cl/batch-distribute/interfaces/ISpraay.sol new file mode 100644 index 0000000..6b6f3f6 --- /dev/null +++ b/src/pool-cl/batch-distribute/interfaces/ISpraay.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +/// @notice Minimal interface for Spraay's batch distribution contract (SprayContract) +/// @dev Verified source (exact match on Sourcify): +/// - BNB Chain: 0x3093a2951FB77b3beDfB8BA20De645F7413432C1 +/// - Base: 0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC +/// The protocol fee (`feeBps`, 30 bps at time of writing) is charged on top of the distributed amount: +/// the caller must provide `totalAmount + totalAmount * feeBps / 10_000`. +interface ISpraay { + struct Recipient { + address payable recipient; + uint256 amount; + } + + /// @notice Batch send native currency with per-recipient amounts. Excess msg.value is refunded to the caller. + function sprayETH(Recipient[] calldata recipients) external payable; + + /// @notice Batch send an ERC-20 with per-recipient amounts. Pulls `totalAmount + fee` from the caller. + function sprayToken(address token, Recipient[] calldata recipients) external; + + /// @notice Batch send the same amount to every recipient. `token == address(0)` means native currency. + function sprayEqual(address token, address payable[] calldata recipients, uint256 amountPerRecipient) + external + payable; + + function calculateTotalCost(uint256 totalAmount) external view returns (uint256); + + function feeBps() external view returns (uint256); + + function MAX_RECIPIENTS() external view returns (uint256); +} diff --git a/test/pool-cl/BatchDistributeHook.fork.t.sol b/test/pool-cl/BatchDistributeHook.fork.t.sol new file mode 100644 index 0000000..7bb6218 --- /dev/null +++ b/test/pool-cl/BatchDistributeHook.fork.t.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; + +import {ICLPoolManager} from "infinity-core/src/pool-cl/interfaces/ICLPoolManager.sol"; +import {Currency} from "infinity-core/src/types/Currency.sol"; +import {PoolKey} from "infinity-core/src/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "infinity-core/src/types/PoolId.sol"; +import {CLPoolParametersHelper} from "infinity-core/src/pool-cl/libraries/CLPoolParametersHelper.sol"; +import {Constants} from "infinity-core/test/pool-cl/helpers/Constants.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; + +import {BatchDistributeHook} from "../../src/pool-cl/batch-distribute/BatchDistributeHook.sol"; +import {ISpraay} from "../../src/pool-cl/batch-distribute/interfaces/ISpraay.sol"; +import {SpraayAddresses} from "../../src/pool-cl/batch-distribute/SpraayAddresses.sol"; + +/// @notice Runs the hook against the live Spraay and PancakeSwap Infinity deployments. +/// Skipped unless RPC_BSC_MAINNET / RPC_BASE_MAINNET are set, e.g. +/// RPC_BSC_MAINNET=https://bsc-dataseed.binance.org RPC_BASE_MAINNET=https://mainnet.base.org \ +/// forge test --match-contract BatchDistributeHookForkTest +contract BatchDistributeHookForkTest is Test { + using PoolIdLibrary for PoolKey; + using CLPoolParametersHelper for bytes32; + + /// @dev PancakeSwap Infinity CLPoolManager, same address on BNB Chain and Base + address constant CL_POOL_MANAGER = 0xa0FfB9c1CE1Fe56963B0321B32E7A0302114058b; + + address owner = makeAddr("owner"); + address operator = makeAddr("operator"); + + function test_Fork_BSC() public { + _runFork("RPC_BSC_MAINNET", SpraayAddresses.BSC_MAINNET); + } + + function test_Fork_Base() public { + _runFork("RPC_BASE_MAINNET", SpraayAddresses.BASE_MAINNET); + } + + function _runFork(string memory rpcEnv, address spraayAddr) internal { + string memory rpc = vm.envOr(rpcEnv, string("")); + if (bytes(rpc).length == 0) { + vm.skip(true); + return; + } + vm.createSelectFork(rpc); + + ISpraay spraay = ISpraay(spraayAddr); + uint256 feeBps = spraay.feeBps(); + assertEq(spraay.MAX_RECIPIENTS(), 200); + + BatchDistributeHook hook = + new BatchDistributeHook(ICLPoolManager(CL_POOL_MANAGER), spraayAddr, owner, operator); + + // a fresh pool on the live CLPoolManager that uses the hook + MockERC20 tokenA = new MockERC20("A", "A", 18); + MockERC20 tokenB = new MockERC20("B", "B", 18); + (address t0, address t1) = + address(tokenA) < address(tokenB) ? (address(tokenA), address(tokenB)) : (address(tokenB), address(tokenA)); + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(t0), + currency1: Currency.wrap(t1), + hooks: hook, + poolManager: ICLPoolManager(CL_POOL_MANAGER), + fee: 3000, + parameters: bytes32(uint256(hook.getHooksRegistrationBitmap())).setTickSpacing(60) + }); + ICLPoolManager(CL_POOL_MANAGER).initialize(key, Constants.SQRT_RATIO_1_1); + PoolId id = key.toId(); + assertTrue(hook.isRegisteredPool(id)); + + address[] memory recipients = new address[](3); + uint256[] memory amounts = new uint256[](3); + for (uint256 i; i < 3; ++i) { + recipients[i] = makeAddr(string.concat("forkRecipient", vm.toString(i))); + amounts[i] = (i + 1) * 1 ether; + } + uint256 total = 6 ether; + uint256 required = total + total * feeBps / 10_000; + assertEq(hook.quoteTotalCost(total), required); + + // ERC-20 via sprayToken + MockERC20 reward = new MockERC20("R", "R", 18); + reward.mint(operator, 1_000 ether); + vm.startPrank(operator); + reward.approve(address(hook), type(uint256).max); + hook.batchDistribute(id, address(reward), recipients, amounts); + // ERC-20 via sprayEqual + hook.batchDistributeEqual(id, address(reward), recipients, 1 ether); + vm.stopPrank(); + + assertEq(reward.balanceOf(recipients[0]), 2 ether); + assertEq(reward.balanceOf(recipients[2]), 4 ether); + assertEq(1_000 ether - reward.balanceOf(operator), required + 3 ether + 3 ether * feeBps / 10_000); + assertEq(reward.balanceOf(address(hook)), 0); + + // native via sprayETH, with excess refunded + vm.deal(operator, 100 ether); + vm.prank(operator); + hook.batchDistribute{value: required + 1 ether}(id, address(0), recipients, amounts); + assertEq(recipients[1].balance, 2 ether); + assertEq(operator.balance, 100 ether - required); + assertEq(address(hook).balance, 0); + } +} diff --git a/test/pool-cl/BatchDistributeHook.t.sol b/test/pool-cl/BatchDistributeHook.t.sol new file mode 100644 index 0000000..2c89452 --- /dev/null +++ b/test/pool-cl/BatchDistributeHook.t.sol @@ -0,0 +1,380 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; + +import {ICLPoolManager} from "infinity-core/src/pool-cl/interfaces/ICLPoolManager.sol"; +import {IVault} from "infinity-core/src/interfaces/IVault.sol"; +import {Currency} from "infinity-core/src/types/Currency.sol"; +import {PoolKey} from "infinity-core/src/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "infinity-core/src/types/PoolId.sol"; +import {CLPoolParametersHelper} from "infinity-core/src/pool-cl/libraries/CLPoolParametersHelper.sol"; +import {SortTokens} from "infinity-core/test/helpers/SortTokens.sol"; +import {Deployers} from "infinity-core/test/pool-cl/helpers/Deployers.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; +import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; + +import {BatchDistributeHook} from "../../src/pool-cl/batch-distribute/BatchDistributeHook.sol"; +import {MockSpraay} from "./helpers/MockSpraay.sol"; + +contract BatchDistributeHookTest is Test, Deployers { + using PoolIdLibrary for PoolKey; + using CLPoolParametersHelper for bytes32; + + uint256 constant FEE_BPS = 30; + + IVault vault; + ICLPoolManager poolManager; + BatchDistributeHook hook; + MockSpraay spraay; + + MockERC20 token0; + MockERC20 token1; + MockERC20 rewardToken; + PoolKey key; + PoolId id; + + address owner = makeAddr("owner"); + address operator = makeAddr("operator"); + address feeRecipient = makeAddr("spraayFeeRecipient"); + address stranger = makeAddr("stranger"); + + function setUp() public { + MockERC20[] memory tokens = deployTokens(3, type(uint256).max); + (Currency currency0, Currency currency1) = SortTokens.sort(tokens[0], tokens[1]); + token0 = MockERC20(Currency.unwrap(currency0)); + token1 = MockERC20(Currency.unwrap(currency1)); + rewardToken = tokens[2]; + + (vault, poolManager) = createFreshManager(); + spraay = new MockSpraay(feeRecipient, FEE_BPS); + hook = new BatchDistributeHook(poolManager, address(spraay), owner, operator); + + key = PoolKey({ + currency0: currency0, + currency1: currency1, + hooks: hook, + poolManager: poolManager, + fee: 3000, + parameters: bytes32(uint256(hook.getHooksRegistrationBitmap())).setTickSpacing(60) + }); + id = key.toId(); + + poolManager.initialize(key, SQRT_RATIO_1_1); + + rewardToken.transfer(operator, 1_000_000 ether); + vm.deal(operator, 1_000_000 ether); + vm.prank(operator); + rewardToken.approve(address(hook), type(uint256).max); + } + + // ============ Setup / hook callbacks ============ + + function test_Constructor() public view { + assertEq(address(hook.spraay()), address(spraay)); + assertEq(hook.owner(), owner); + assertTrue(hook.isOperator(operator)); + assertEq(hook.MAX_RECIPIENTS(), 200); + } + + function test_AfterInitialize_RegistersPool() public view { + assertTrue(hook.isRegisteredPool(id)); + } + + function test_AfterDonate_EmitsEvent() public { + vm.expectEmit(true, true, false, true, address(hook)); + emit BatchDistributeHook.PoolDonation(id, address(this), 1, 2); + vm.prank(address(poolManager)); + hook.afterDonate(address(this), key, 1, 2, ""); + } + + function test_HookCallbacks_OnlyPoolManager() public { + vm.expectRevert(); + hook.afterDonate(address(this), key, 1, 2, ""); + } + + // ============ sprayToken path ============ + + function test_BatchDistribute_ERC20() public { + (address[] memory recipients, uint256[] memory amounts, uint256 total) = _batch(5, 1 ether); + uint256 fee = total * FEE_BPS / 10_000; + uint256 operatorBefore = rewardToken.balanceOf(operator); + + vm.expectEmit(true, true, true, true, address(hook)); + emit BatchDistributeHook.BatchDistributed(id, operator, address(rewardToken), 5, total, fee); + vm.prank(operator); + hook.batchDistribute(id, address(rewardToken), recipients, amounts); + + for (uint256 i; i < recipients.length; ++i) { + assertEq(rewardToken.balanceOf(recipients[i]), amounts[i]); + } + assertEq(rewardToken.balanceOf(feeRecipient), fee); + assertEq(operatorBefore - rewardToken.balanceOf(operator), total + fee); + // non-custodial: nothing left behind, no dangling approval + assertEq(rewardToken.balanceOf(address(hook)), 0); + assertEq(rewardToken.balanceOf(address(spraay)), 0); + assertEq(rewardToken.allowance(address(hook), address(spraay)), 0); + } + + function test_BatchDistribute_ERC20_RevertIfNativeSent() public { + (address[] memory recipients, uint256[] memory amounts,) = _batch(2, 1 ether); + vm.prank(operator); + vm.expectRevert(BatchDistributeHook.UnexpectedNativeAmount.selector); + hook.batchDistribute{value: 1}(id, address(rewardToken), recipients, amounts); + } + + function test_QuoteTotalCost() public view { + assertEq(hook.quoteTotalCost(10_000), 10_030); + } + + // ============ sprayETH path ============ + + function test_BatchDistribute_Native() public { + (address[] memory recipients, uint256[] memory amounts, uint256 total) = _batch(4, 0.5 ether); + uint256 fee = total * FEE_BPS / 10_000; + uint256 operatorBefore = operator.balance; + + vm.expectEmit(true, true, true, true, address(hook)); + emit BatchDistributeHook.BatchDistributed(id, operator, address(0), 4, total, fee); + vm.prank(operator); + hook.batchDistribute{value: total + fee}(id, address(0), recipients, amounts); + + for (uint256 i; i < recipients.length; ++i) { + assertEq(recipients[i].balance, amounts[i]); + } + assertEq(feeRecipient.balance, fee); + assertEq(operatorBefore - operator.balance, total + fee); + assertEq(address(hook).balance, 0); + } + + function test_BatchDistribute_Native_RefundsExcess() public { + (address[] memory recipients, uint256[] memory amounts, uint256 total) = _batch(3, 1 ether); + uint256 fee = total * FEE_BPS / 10_000; + uint256 operatorBefore = operator.balance; + + vm.prank(operator); + hook.batchDistribute{value: total + fee + 7 ether}(id, address(0), recipients, amounts); + + assertEq(operatorBefore - operator.balance, total + fee); + assertEq(address(hook).balance, 0); + assertEq(address(spraay).balance, 0); + } + + function test_BatchDistribute_Native_RevertIfInsufficient() public { + (address[] memory recipients, uint256[] memory amounts, uint256 total) = _batch(3, 1 ether); + uint256 required = total + total * FEE_BPS / 10_000; + + vm.prank(operator); + vm.expectRevert( + abi.encodeWithSelector(BatchDistributeHook.InsufficientNativeAmount.selector, required, total) + ); + hook.batchDistribute{value: total}(id, address(0), recipients, amounts); + } + + // ============ sprayEqual path ============ + + function test_BatchDistributeEqual_ERC20() public { + (address[] memory recipients,,) = _batch(10, 0); + uint256 amountEach = 3 ether; + uint256 total = amountEach * 10; + uint256 fee = total * FEE_BPS / 10_000; + uint256 operatorBefore = rewardToken.balanceOf(operator); + + vm.expectEmit(true, true, true, true, address(hook)); + emit BatchDistributeHook.BatchDistributed(id, operator, address(rewardToken), 10, total, fee); + vm.prank(operator); + hook.batchDistributeEqual(id, address(rewardToken), recipients, amountEach); + + for (uint256 i; i < recipients.length; ++i) { + assertEq(rewardToken.balanceOf(recipients[i]), amountEach); + } + assertEq(rewardToken.balanceOf(feeRecipient), fee); + assertEq(operatorBefore - rewardToken.balanceOf(operator), total + fee); + assertEq(rewardToken.balanceOf(address(hook)), 0); + assertEq(rewardToken.allowance(address(hook), address(spraay)), 0); + } + + function test_BatchDistributeEqual_Native() public { + (address[] memory recipients,,) = _batch(6, 0); + uint256 amountEach = 0.25 ether; + uint256 total = amountEach * 6; + uint256 fee = total * FEE_BPS / 10_000; + uint256 operatorBefore = operator.balance; + + vm.prank(operator); + hook.batchDistributeEqual{value: total + fee + 1 ether}(id, address(0), recipients, amountEach); + + for (uint256 i; i < recipients.length; ++i) { + assertEq(recipients[i].balance, amountEach); + } + assertEq(feeRecipient.balance, fee); + assertEq(operatorBefore - operator.balance, total + fee); + assertEq(address(hook).balance, 0); + } + + // ============ Access control ============ + + function test_RevertIfNotOperator() public { + (address[] memory recipients, uint256[] memory amounts, uint256 total) = _batch(2, 1 ether); + rewardToken.transfer(stranger, 100 ether); + vm.deal(stranger, 100 ether); + + vm.startPrank(stranger); + rewardToken.approve(address(hook), type(uint256).max); + + vm.expectRevert(BatchDistributeHook.NotOperator.selector); + hook.batchDistribute(id, address(rewardToken), recipients, amounts); + + vm.expectRevert(BatchDistributeHook.NotOperator.selector); + hook.batchDistribute{value: total * 2}(id, address(0), recipients, amounts); + + vm.expectRevert(BatchDistributeHook.NotOperator.selector); + hook.batchDistributeEqual(id, address(rewardToken), recipients, 1 ether); + vm.stopPrank(); + } + + function test_OwnerIsNotImplicitlyOperator() public { + (address[] memory recipients, uint256[] memory amounts,) = _batch(2, 1 ether); + vm.prank(owner); + vm.expectRevert(BatchDistributeHook.NotOperator.selector); + hook.batchDistribute(id, address(rewardToken), recipients, amounts); + } + + function test_SetOperator() public { + vm.prank(owner); + hook.setOperator(stranger, true); + assertTrue(hook.isOperator(stranger)); + + vm.prank(owner); + hook.setOperator(operator, false); + assertFalse(hook.isOperator(operator)); + + (address[] memory recipients, uint256[] memory amounts,) = _batch(2, 1 ether); + vm.prank(operator); + vm.expectRevert(BatchDistributeHook.NotOperator.selector); + hook.batchDistribute(id, address(rewardToken), recipients, amounts); + } + + function test_SetOperator_RevertIfNotOwner() public { + vm.prank(operator); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, operator)); + hook.setOperator(stranger, true); + } + + function test_SetSpraayContract() public { + MockSpraay newSpraay = new MockSpraay(feeRecipient, 50); + + vm.expectEmit(true, true, false, false, address(hook)); + emit BatchDistributeHook.SpraayContractUpdated(address(spraay), address(newSpraay)); + vm.prank(owner); + hook.setSpraayContract(address(newSpraay)); + assertEq(address(hook.spraay()), address(newSpraay)); + + // distributions now route through the new contract and its fee + (address[] memory recipients, uint256[] memory amounts, uint256 total) = _batch(2, 1 ether); + vm.prank(operator); + hook.batchDistribute(id, address(rewardToken), recipients, amounts); + assertEq(rewardToken.balanceOf(feeRecipient), total * 50 / 10_000); + } + + function test_SetSpraayContract_RevertIfNotOwner() public { + vm.prank(operator); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, operator)); + hook.setSpraayContract(address(1)); + } + + function test_SetSpraayContract_RevertIfZero() public { + vm.prank(owner); + vm.expectRevert(BatchDistributeHook.ZeroAddress.selector); + hook.setSpraayContract(address(0)); + } + + function test_RescueTokens() public { + rewardToken.transfer(address(hook), 5 ether); + vm.prank(owner); + hook.rescueTokens(address(rewardToken), owner, 5 ether); + assertEq(rewardToken.balanceOf(owner), 5 ether); + + vm.prank(operator); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, operator)); + hook.rescueTokens(address(rewardToken), operator, 0); + } + + // ============ Recipient limits / validation ============ + + function test_MaxRecipients_200Succeeds() public { + (address[] memory recipients, uint256[] memory amounts,) = _batch(200, 1 ether); + vm.prank(operator); + hook.batchDistribute(id, address(rewardToken), recipients, amounts); + assertEq(rewardToken.balanceOf(recipients[199]), 200 ether); + + vm.prank(operator); + hook.batchDistributeEqual(id, address(rewardToken), recipients, 1 ether); + assertEq(rewardToken.balanceOf(recipients[199]), 201 ether); + } + + function test_MaxRecipients_201Reverts() public { + (address[] memory recipients, uint256[] memory amounts, uint256 total) = _batch(201, 1 ether); + bytes memory err = abi.encodeWithSelector(BatchDistributeHook.TooManyRecipients.selector, 201, 200); + + vm.startPrank(operator); + vm.expectRevert(err); + hook.batchDistribute(id, address(rewardToken), recipients, amounts); + + vm.expectRevert(err); + hook.batchDistribute{value: total * 2}(id, address(0), recipients, amounts); + + vm.expectRevert(err); + hook.batchDistributeEqual(id, address(rewardToken), recipients, 1 ether); + vm.stopPrank(); + } + + function test_RevertIfNoRecipients() public { + vm.prank(operator); + vm.expectRevert(BatchDistributeHook.NoRecipients.selector); + hook.batchDistribute(id, address(rewardToken), new address[](0), new uint256[](0)); + } + + function test_RevertIfLengthMismatch() public { + (address[] memory recipients,,) = _batch(3, 1 ether); + vm.prank(operator); + vm.expectRevert(BatchDistributeHook.LengthMismatch.selector); + hook.batchDistribute(id, address(rewardToken), recipients, new uint256[](2)); + } + + function test_RevertIfPoolNotRegistered() public { + (address[] memory recipients, uint256[] memory amounts,) = _batch(2, 1 ether); + vm.prank(operator); + vm.expectRevert(BatchDistributeHook.PoolNotRegistered.selector); + hook.batchDistribute(PoolId.wrap(bytes32(uint256(1))), address(rewardToken), recipients, amounts); + } + + function testFuzz_BatchDistribute_ERC20(uint8 count, uint96 amountEach) public { + count = uint8(bound(count, 1, 200)); + amountEach = uint96(bound(amountEach, 1, 10 ether)); + (address[] memory recipients, uint256[] memory amounts, uint256 total) = _batch(count, amountEach); + uint256 operatorBefore = rewardToken.balanceOf(operator); + + vm.prank(operator); + hook.batchDistribute(id, address(rewardToken), recipients, amounts); + + assertEq(operatorBefore - rewardToken.balanceOf(operator), total + total * FEE_BPS / 10_000); + assertEq(rewardToken.balanceOf(address(hook)), 0); + } + + // ============ Helpers ============ + + /// @dev Builds `count` fresh EOA recipients; amounts vary per recipient (amountEach * (i + 1)) + function _batch(uint256 count, uint256 amountEach) + internal + returns (address[] memory recipients, uint256[] memory amounts, uint256 total) + { + recipients = new address[](count); + amounts = new uint256[](count); + for (uint256 i; i < count; ++i) { + recipients[i] = makeAddr(string.concat("recipient", vm.toString(i))); + amounts[i] = amountEach * (i + 1); + total += amounts[i]; + } + } +} diff --git a/test/pool-cl/helpers/MockSpraay.sol b/test/pool-cl/helpers/MockSpraay.sol new file mode 100644 index 0000000..af2fe98 --- /dev/null +++ b/test/pool-cl/helpers/MockSpraay.sol @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import "openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol"; +import "openzeppelin-contracts/contracts/utils/Pausable.sol"; +import "openzeppelin-contracts/contracts/access/Ownable.sol"; + +/** + * @dev Test copy of Spraay's SprayContract, verified source (Sourcify exact match) at + * BNB 0x3093a2951FB77b3beDfB8BA20De645F7413432C1 and Base 0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC. + * Only the import paths and contract name differ. + * @title SprayContract + * @notice Enables batch token transfers to multiple recipients in a single transaction + * @dev Supports both native ETH and ERC20 tokens with a 0.3% protocol fee + * @custom:website https://spraay.app + * @custom:author @lostpoet + */ +contract MockSpraay is ReentrancyGuard, Pausable, Ownable { + using SafeERC20 for IERC20; + + // ============ Events ============ + + event SprayETHExecuted( + address indexed sender, + uint256 totalAmount, + uint256 recipientCount, + uint256 feeAmount, + uint256 timestamp + ); + + event SprayTokenExecuted( + address indexed sender, + address indexed token, + uint256 totalAmount, + uint256 recipientCount, + uint256 feeAmount, + uint256 timestamp + ); + + event FeeUpdated(uint256 oldFeeBps, uint256 newFeeBps); + event FeeRecipientUpdated(address oldRecipient, address newRecipient); + event EmergencyWithdraw(address indexed token, uint256 amount); + + // ============ State Variables ============ + + uint256 public feeBps; // Fee in basis points (30 = 0.3%) + address public feeRecipient; // Address that receives protocol fees + + uint256 public constant MAX_FEE_BPS = 500; // 5% max fee cap + uint256 public constant MAX_RECIPIENTS = 200; // Safety limit per transaction + + // ============ Structs ============ + + struct Recipient { + address payable recipient; + uint256 amount; + } + + // ============ Constructor ============ + + constructor(address _feeRecipient, uint256 _feeBps) Ownable(msg.sender) { + require(_feeRecipient != address(0), "Invalid fee recipient"); + require(_feeBps <= MAX_FEE_BPS, "Fee too high"); + + feeRecipient = _feeRecipient; + feeBps = _feeBps; + } + + // ============ Core Functions ============ + + /** + * @notice Spray native ETH to multiple recipients with variable amounts + * @param recipients Array of Recipient structs (address + amount) + * @dev msg.value must cover totalAmount + fee. Excess is refunded. + */ + function sprayETH(Recipient[] calldata recipients) + external + payable + nonReentrant + whenNotPaused + { + require(recipients.length > 0, "No recipients"); + require(recipients.length <= MAX_RECIPIENTS, "Too many recipients"); + + uint256 totalAmount = 0; + for (uint256 i = 0; i < recipients.length; i++) { + require(recipients[i].recipient != address(0), "Invalid recipient"); + require(recipients[i].amount > 0, "Invalid amount"); + totalAmount += recipients[i].amount; + } + + uint256 feeAmount = (totalAmount * feeBps) / 10000; + uint256 requiredAmount = totalAmount + feeAmount; + require(msg.value >= requiredAmount, "Insufficient ETH"); + + // Distribute to recipients + for (uint256 i = 0; i < recipients.length; i++) { + (bool success, ) = recipients[i].recipient.call{value: recipients[i].amount}(""); + require(success, "ETH transfer failed"); + } + + // Transfer fee + if (feeAmount > 0) { + (bool feeSuccess, ) = payable(feeRecipient).call{value: feeAmount}(""); + require(feeSuccess, "Fee transfer failed"); + } + + // Refund excess + uint256 excess = msg.value - requiredAmount; + if (excess > 0) { + (bool refundSuccess, ) = payable(msg.sender).call{value: excess}(""); + require(refundSuccess, "Refund failed"); + } + + emit SprayETHExecuted(msg.sender, totalAmount, recipients.length, feeAmount, block.timestamp); + } + + /** + * @notice Spray ERC-20 tokens to multiple recipients with variable amounts + * @param token Address of the ERC-20 token + * @param recipients Array of Recipient structs (address + amount) + * @dev Caller must approve this contract for totalAmount + fee beforehand + */ + function sprayToken(address token, Recipient[] calldata recipients) + external + nonReentrant + whenNotPaused + { + require(token != address(0), "Invalid token"); + require(recipients.length > 0, "No recipients"); + require(recipients.length <= MAX_RECIPIENTS, "Too many recipients"); + + IERC20 tokenContract = IERC20(token); + + uint256 totalAmount = 0; + for (uint256 i = 0; i < recipients.length; i++) { + require(recipients[i].recipient != address(0), "Invalid recipient"); + require(recipients[i].amount > 0, "Invalid amount"); + totalAmount += recipients[i].amount; + } + + uint256 feeAmount = (totalAmount * feeBps) / 10000; + uint256 requiredAmount = totalAmount + feeAmount; + + // Pull total from sender + tokenContract.safeTransferFrom(msg.sender, address(this), requiredAmount); + + // Distribute to recipients + for (uint256 i = 0; i < recipients.length; i++) { + tokenContract.safeTransfer(recipients[i].recipient, recipients[i].amount); + } + + // Transfer fee + if (feeAmount > 0) { + tokenContract.safeTransfer(feeRecipient, feeAmount); + } + + emit SprayTokenExecuted(msg.sender, token, totalAmount, recipients.length, feeAmount, block.timestamp); + } + + /** + * @notice Gas-efficient spray when all recipients get the same amount + * @param token Address of token (address(0) for ETH) + * @param recipients Array of recipient addresses + * @param amountPerRecipient Amount to send to each recipient + */ + function sprayEqual( + address token, + address payable[] calldata recipients, + uint256 amountPerRecipient + ) external payable nonReentrant whenNotPaused { + require(recipients.length > 0, "No recipients"); + require(recipients.length <= MAX_RECIPIENTS, "Too many recipients"); + require(amountPerRecipient > 0, "Invalid amount"); + + uint256 totalAmount = amountPerRecipient * recipients.length; + uint256 feeAmount = (totalAmount * feeBps) / 10000; + uint256 requiredAmount = totalAmount + feeAmount; + + if (token == address(0)) { + // ETH equal spray + require(msg.value >= requiredAmount, "Insufficient ETH"); + + for (uint256 i = 0; i < recipients.length; i++) { + require(recipients[i] != address(0), "Invalid recipient"); + (bool success, ) = recipients[i].call{value: amountPerRecipient}(""); + require(success, "ETH transfer failed"); + } + + if (feeAmount > 0) { + (bool feeSuccess, ) = payable(feeRecipient).call{value: feeAmount}(""); + require(feeSuccess, "Fee transfer failed"); + } + + uint256 excess = msg.value - requiredAmount; + if (excess > 0) { + (bool refundSuccess, ) = payable(msg.sender).call{value: excess}(""); + require(refundSuccess, "Refund failed"); + } + + emit SprayETHExecuted(msg.sender, totalAmount, recipients.length, feeAmount, block.timestamp); + } else { + // ERC20 equal spray + IERC20 tokenContract = IERC20(token); + tokenContract.safeTransferFrom(msg.sender, address(this), requiredAmount); + + for (uint256 i = 0; i < recipients.length; i++) { + require(recipients[i] != address(0), "Invalid recipient"); + tokenContract.safeTransfer(recipients[i], amountPerRecipient); + } + + if (feeAmount > 0) { + tokenContract.safeTransfer(feeRecipient, feeAmount); + } + + emit SprayTokenExecuted(msg.sender, token, totalAmount, recipients.length, feeAmount, block.timestamp); + } + } + + // ============ Admin Functions ============ + + function updateFee(uint256 newFeeBps) external onlyOwner { + require(newFeeBps <= MAX_FEE_BPS, "Fee too high"); + uint256 oldFee = feeBps; + feeBps = newFeeBps; + emit FeeUpdated(oldFee, newFeeBps); + } + + function updateFeeRecipient(address newFeeRecipient) external onlyOwner { + require(newFeeRecipient != address(0), "Invalid address"); + address oldRecipient = feeRecipient; + feeRecipient = newFeeRecipient; + emit FeeRecipientUpdated(oldRecipient, newFeeRecipient); + } + + function pause() external onlyOwner { + _pause(); + } + + function unpause() external onlyOwner { + _unpause(); + } + + function emergencyWithdraw(address token, uint256 amount) external onlyOwner { + if (token == address(0)) { + (bool success, ) = payable(owner()).call{value: amount}(""); + require(success, "ETH withdrawal failed"); + } else { + IERC20(token).safeTransfer(owner(), amount); + } + emit EmergencyWithdraw(token, amount); + } + + // ============ View Functions ============ + + function calculateTotalCost(uint256 totalAmount) external view returns (uint256) { + uint256 feeAmount = (totalAmount * feeBps) / 10000; + return totalAmount + feeAmount; + } + + function calculateFee(uint256 amount) external view returns (uint256) { + return (amount * feeBps) / 10000; + } + + // Accept ETH + receive() external payable {} +}