diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 958dcb5..0158cd2 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.18; import { PaymentCombiner } from "../src/payments/PaymentCombiner.sol"; import { PaymentsFactory } from "../src/payments/PaymentsFactory.sol"; +import { WithdrawOnlyToFactory } from "../src/payments/WithdrawOnlyToFactory.sol"; import { ERC1155ItemsFactory } from "../src/tokens/ERC1155/presets/items/ERC1155ItemsFactory.sol"; import { ERC1155PackFactory } from "../src/tokens/ERC1155/presets/pack/ERC1155PackFactory.sol"; @@ -82,6 +83,9 @@ contract Deploy is SingletonDeployer { "PaymentsFactory", abi.encodePacked(type(PaymentsFactory).creationCode, abi.encode(factoryOwner)), salt, pk ); _deployIfNotAlready("PaymentCombiner", abi.encodePacked(type(PaymentCombiner).creationCode), salt, pk); + _deployIfNotAlready( + "WithdrawOnlyToFactory", abi.encodePacked(type(WithdrawOnlyToFactory).creationCode), salt, pk + ); address clawbackMetadata = _deployIfNotAlready("ClawbackMetadata", abi.encodePacked(type(ClawbackMetadata).creationCode), salt, pk); _deployIfNotAlready( diff --git a/src/payments/WithdrawOnlyTo.sol b/src/payments/WithdrawOnlyTo.sol new file mode 100644 index 0000000..7614241 --- /dev/null +++ b/src/payments/WithdrawOnlyTo.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.19; + +import { IWithdrawControlled } from "../tokens/common/IWithdrawControlled.sol"; + +error AlreadyInitialized(); + +contract WithdrawOnlyTo { + + address public withdrawTo; + + constructor( + address to + ) { + withdrawTo = to; + } + + function initialize( + address to + ) external { + if (withdrawTo != address(0)) { + revert AlreadyInitialized(); + } + withdrawTo = to; + } + + function withdrawERC20(address from, address token, uint256 value) external { + IWithdrawControlled(from).withdrawERC20(token, withdrawTo, value); + } + + function withdrawETH(address from, uint256 value) external { + IWithdrawControlled(from).withdrawETH(withdrawTo, value); + } + +} diff --git a/src/payments/WithdrawOnlyToFactory.sol b/src/payments/WithdrawOnlyToFactory.sol new file mode 100644 index 0000000..313eda1 --- /dev/null +++ b/src/payments/WithdrawOnlyToFactory.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.19; + +import { WithdrawOnlyTo } from "./WithdrawOnlyTo.sol"; + +import { Clones } from "openzeppelin-contracts/contracts/proxy/Clones.sol"; + +contract WithdrawOnlyToFactory { + + using Clones for address; + + event WithdrawOnlyToDeployed(address proxyAddr); + + address public immutable implementation; + + constructor() { + implementation = address(new WithdrawOnlyTo(address(0))); + } + + function deploy( + address withdrawTo + ) external returns (address proxyAddr) { + proxyAddr = implementation.cloneDeterministic(keccak256(abi.encode(withdrawTo))); + WithdrawOnlyTo(payable(proxyAddr)).initialize(withdrawTo); + emit WithdrawOnlyToDeployed(proxyAddr); + return proxyAddr; + } + + function determineAddress( + address withdrawTo + ) external view returns (address proxyAddr) { + return implementation.predictDeterministicAddress(keccak256(abi.encode(withdrawTo))); + } + +} diff --git a/test/tokens/common/WithdrawControlled.t.sol b/test/tokens/common/WithdrawControlled.t.sol new file mode 100644 index 0000000..8fcccaf --- /dev/null +++ b/test/tokens/common/WithdrawControlled.t.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.19; + +import { TestHelper } from "../../TestHelper.sol"; + +import { IWithdrawControlled, WithdrawControlled } from "src/tokens/common/WithdrawControlled.sol"; + +import { ERC20Mock } from "../../_mocks/ERC20Mock.sol"; +import { IERC165 } from "openzeppelin-contracts/contracts/utils/introspection/IERC165.sol"; +import { WithdrawOnlyTo, WithdrawOnlyToFactory } from "src/payments/WithdrawOnlyToFactory.sol"; + +contract WithdrawControlledTest is TestHelper { + + bytes32 internal constant WITHDRAW_ROLE = keccak256("WITHDRAW_ROLE"); + + WithdrawControlledFixture private _withdrawControlled; + WithdrawOnlyToFactory private _withdrawOnlyToFactory; + address private _owner; + ERC20Mock private _token; + + function setUp() public { + _owner = makeAddr("owner"); + _withdrawControlled = new WithdrawControlledFixture(_owner); + _withdrawOnlyToFactory = new WithdrawOnlyToFactory(); + _token = new ERC20Mock(_owner); + } + + function testSupportsInterface() public view { + assertTrue(_withdrawControlled.supportsInterface(type(IERC165).interfaceId)); + assertTrue(_withdrawControlled.supportsInterface(type(IWithdrawControlled).interfaceId)); + } + + function testWithdrawERC20(uint256 amount, address withdrawTo) public { + vm.assume(withdrawTo != address(_withdrawControlled)); + assumeSafeAddress(withdrawTo); + _token.mint(address(_withdrawControlled), amount); + + vm.prank(_owner); + _withdrawControlled.withdrawERC20(address(_token), withdrawTo, amount); + + assertEq(_token.balanceOf(withdrawTo), amount); + assertEq(_token.balanceOf(address(_withdrawControlled)), 0); + } + + function testWithdrawETH(uint256 amount, address withdrawTo) public { + assumePayable(withdrawTo); + vm.assume(withdrawTo.balance == 0); + vm.assume(withdrawTo != address(_withdrawControlled)); + vm.deal(address(_withdrawControlled), amount); + + vm.prank(_owner); + _withdrawControlled.withdrawETH(withdrawTo, amount); + + assertEq(address(_withdrawControlled).balance, 0); + assertEq(withdrawTo.balance, amount); + } + + function testWithdrawERC20WithOnlyTo(uint256 amount, address withdrawTo) public { + vm.assume(withdrawTo != address(_withdrawControlled)); + assumeSafeAddress(withdrawTo); + _token.mint(address(_withdrawControlled), amount); + + address onlyTo = _withdrawOnlyToFactory.deploy(withdrawTo); + vm.prank(_owner); + _withdrawControlled.grantRole(WITHDRAW_ROLE, onlyTo); + + WithdrawOnlyTo(onlyTo).withdrawERC20(address(_withdrawControlled), address(_token), amount); + + assertEq(_token.balanceOf(withdrawTo), amount); + assertEq(_token.balanceOf(address(_withdrawControlled)), 0); + } + + function testWithdrawETHWithOnlyTo(uint256 amount, address withdrawTo) public { + vm.assume(withdrawTo != address(_withdrawControlled)); + vm.assume(withdrawTo.balance == 0); + assumeSafeAddress(withdrawTo); + vm.deal(address(_withdrawControlled), amount); + + address onlyTo = _withdrawOnlyToFactory.deploy(withdrawTo); + vm.prank(_owner); + _withdrawControlled.grantRole(WITHDRAW_ROLE, onlyTo); + + WithdrawOnlyTo(onlyTo).withdrawETH(address(_withdrawControlled), amount); + + assertEq(address(_withdrawControlled).balance, 0); + assertEq(withdrawTo.balance, amount); + } + +} + +contract WithdrawControlledFixture is WithdrawControlled { + + constructor( + address owner + ) { + _grantRole(DEFAULT_ADMIN_ROLE, owner); + _grantRole(WITHDRAW_ROLE, owner); + } + +}