From 8ed13ad176e5fc602ad2b5e4b57f486538fcab6d Mon Sep 17 00:00:00 2001 From: Isaac Date: Mon, 16 Mar 2026 10:46:23 -0400 Subject: [PATCH] Helper contract for distributing an ERC20 --- .../src/staking/TokenDistributor.sol | 37 +++++ .../test/staking/TokenDistributorTest.t.sol | 142 ++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 synd-contracts/src/staking/TokenDistributor.sol create mode 100644 synd-contracts/test/staking/TokenDistributorTest.t.sol diff --git a/synd-contracts/src/staking/TokenDistributor.sol b/synd-contracts/src/staking/TokenDistributor.sol new file mode 100644 index 00000000..efafa13e --- /dev/null +++ b/synd-contracts/src/staking/TokenDistributor.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract TokenDistributor is Ownable, Pausable { + IERC20 public immutable token; + + mapping(address sender => bool allowed) public isAllowedSender; + + modifier onlyAllowedSenders() { + require(isAllowedSender[msg.sender], "Must be an allowed sender"); + _; + } + + constructor(address _token, address _admin) Ownable(_admin) { + token = IERC20(_token); + } + + function updateSender(address sender, bool isAllowed) external onlyOwner { + isAllowedSender[sender] = isAllowed; + } + + function transfer(address account, uint256 amount) external whenNotPaused onlyAllowedSenders returns (bool) { + return IERC20(token).transfer(account, amount); + } + + function pause() external onlyOwner { + _pause(); + } + + function unpause() external onlyOwner { + _unpause(); + } +} diff --git a/synd-contracts/test/staking/TokenDistributorTest.t.sol b/synd-contracts/test/staking/TokenDistributorTest.t.sol new file mode 100644 index 00000000..acb6d9d5 --- /dev/null +++ b/synd-contracts/test/staking/TokenDistributorTest.t.sol @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {TokenDistributor} from "src/staking/TokenDistributor.sol"; +import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; + +contract TokenDistributorTest is Test { + TokenDistributor public distributor; + ERC20Mock public token; + + address admin = makeAddr("admin"); + address sender = makeAddr("sender"); + address recipient = makeAddr("recipient"); + + function setUp() public { + token = new ERC20Mock(); + distributor = new TokenDistributor(address(token), admin); + + // Fund the distributor contract with tokens + token.mint(address(distributor), 1_000_000 ether); + + // Allow the sender + vm.prank(admin); + distributor.updateSender(sender, true); + } + + // --- Constructor --- + + function test_constructor_setsToken() public view { + assertEq(address(distributor.token()), address(token)); + } + + function test_constructor_setsOwner() public view { + assertEq(distributor.owner(), admin); + } + + // --- updateSender --- + + function test_updateSender_allowsSender() public { + address newSender = makeAddr("newSender"); + + vm.prank(admin); + distributor.updateSender(newSender, true); + + assertTrue(distributor.isAllowedSender(newSender)); + } + + function test_updateSender_disallowsSender() public { + vm.prank(admin); + distributor.updateSender(sender, false); + + assertFalse(distributor.isAllowedSender(sender)); + } + + function test_updateSender_revertsIfNotOwner() public { + vm.prank(sender); + vm.expectRevert(); + distributor.updateSender(sender, true); + } + + // --- transfer --- + + function test_transfer_sendsTokens() public { + vm.prank(sender); + bool success = distributor.transfer(recipient, 100 ether); + + assertTrue(success); + assertEq(token.balanceOf(recipient), 100 ether); + } + + function test_transfer_revertsIfNotAllowedSender() public { + address notAllowed = makeAddr("notAllowed"); + + vm.prank(notAllowed); + vm.expectRevert("Must be an allowed sender"); + distributor.transfer(recipient, 100 ether); + } + + function test_transfer_revertsWhenPaused() public { + vm.prank(admin); + distributor.pause(); + + vm.prank(sender); + vm.expectRevert(); + distributor.transfer(recipient, 100 ether); + } + + function test_transfer_revertsIfInsufficientBalance() public { + vm.prank(sender); + vm.expectRevert(); + distributor.transfer(recipient, 2_000_000 ether); + } + + // --- pause / unpause --- + + function test_pause_pausesContract() public { + vm.prank(admin); + distributor.pause(); + + assertTrue(distributor.paused()); + } + + function test_pause_revertsIfNotOwner() public { + vm.prank(sender); + vm.expectRevert(); + distributor.pause(); + } + + function test_unpause_unpausesContract() public { + vm.prank(admin); + distributor.pause(); + + vm.prank(admin); + distributor.unpause(); + + assertFalse(distributor.paused()); + } + + function test_unpause_revertsIfNotOwner() public { + vm.prank(admin); + distributor.pause(); + + vm.prank(sender); + vm.expectRevert(); + distributor.unpause(); + } + + function test_transfer_worksAfterUnpause() public { + vm.prank(admin); + distributor.pause(); + + vm.prank(admin); + distributor.unpause(); + + vm.prank(sender); + bool success = distributor.transfer(recipient, 50 ether); + + assertTrue(success); + assertEq(token.balanceOf(recipient), 50 ether); + } +}