diff --git a/src/Giver.sol b/src/Giver.sol new file mode 100644 index 00000000..fe72b364 --- /dev/null +++ b/src/Giver.sol @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.20; + +import {AddressDriver, Drips, IERC20} from "./AddressDriver.sol"; +import {Managed} from "./Managed.sol"; +import {Clones} from "openzeppelin-contracts/proxy/Clones.sol"; +import {Address} from "openzeppelin-contracts/utils/Address.sol"; +import {SafeERC20} from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; + +/// @notice Each Drips account ID has a single `Giver` contract assigned to it, +/// and each `Giver` has a single account ID assigned. +/// Any ERC-20 tokens or native tokens sent to `Giver` will +/// eventually be `give`n to the account assigned to it. +/// This contract should never be called directly, it can only be called by its owner. +/// For most practical purposes the address of a `Giver` should be treated like an EOA address. +contract Giver { + /// @notice The owner of this contract, allowed to call it. + address public immutable owner = msg.sender; + + receive() external payable {} + + /// @notice Delegate call to another contract. This function is callable only by the owner. + /// @param target The address to delegate to. + /// @param data The calldata to use when delegating. + /// @return ret The data returned from the delegation. + function delegate(address target, bytes memory data) + public + payable + returns (bytes memory ret) + { + require(msg.sender == owner, "Caller is not the owner"); + return Address.functionDelegateCall(target, data, "Giver failed"); + } +} + +/// @notice This contract deploys and calls `Giver` contracts. +/// Each Drips account ID has a single `Giver` contract assigned to it, +/// and each `Giver` has a single account ID assigned. +/// Any ERC-20 tokens or native tokens sent to `Giver` will +/// eventually be `give`n to the account assigned to it. +contract GiversRegistry is Managed { + /// @notice The ERC-20 contract used to wrap the native tokens before `give`ing. + IERC20 public immutable nativeTokenWrapper; + /// @notice The driver to use to `give`. + AddressDriver public immutable addressDriver; + /// @notice The `Drips` contract used by `addressDriver`. + // slither-disable-next-line naming-convention + Drips internal immutable _drips; + /// @notice The maximum balance of each token that Drips can hold. + // slither-disable-next-line naming-convention + uint128 internal immutable _maxTotalBalance; + + /// @param addressDriver_ The driver to use to `give`. + constructor(AddressDriver addressDriver_) { + addressDriver = addressDriver_; + _drips = addressDriver.drips(); + _maxTotalBalance = _drips.MAX_TOTAL_BALANCE(); + + address nativeTokenWrapper_; + if (block.chainid == 1 /* Mainnet */ ) { + nativeTokenWrapper_ = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + } else if (block.chainid == 5 /* Goerli */ ) { + nativeTokenWrapper_ = 0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6; + } else if (block.chainid == 11155111 /* Sepolia */ ) { + nativeTokenWrapper_ = 0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14; + } else { + nativeTokenWrapper_ = address(bytes20("native token wrapper")); + } + nativeTokenWrapper = IERC20(nativeTokenWrapper_); + } + + /// @notice Initialize this instance of the contract. + function initialize() public { + if (!Address.isContract(_giverLogic(address(this)))) new Giver(); + } + + /// @notice Calculate the address of the `Giver` assigned to the account ID. + /// The `Giver` may not be deployed yet, but the tokens sent + /// to its address will be `give`n when `give` is called. + /// @param accountId The ID of the account to which the `Giver` is assigned. + /// @return giver_ The address of the `Giver`. + function giver(uint256 accountId) public view returns (address giver_) { + return _giver(accountId, address(this)); + } + + /// @notice Calculate the address of the `Giver` assigned to the account ID. + /// @param accountId The ID of the account to which the `Giver` is assigned. + /// @param deployer The address of the deployer of the `Giver` and its logic. + /// @return giver_ The address of the `Giver`. + function _giver(uint256 accountId, address deployer) internal pure returns (address giver_) { + return + Clones.predictDeterministicAddress(_giverLogic(deployer), bytes32(accountId), deployer); + } + + /// @notice Calculate the address of the logic that is cloned for each `Giver`. + /// @param deployer The address of the deployer of the `Giver` logic. + /// @param giverLogic The address of the `Giver` logic. + function _giverLogic(address deployer) internal pure returns (address giverLogic) { + // The address is calculated assuming that the logic is the first contract + // deployed by the instance of `GiversRegistry` using plain `CREATE`. + bytes32 hash = keccak256(abi.encodePacked(hex"D694", deployer, hex"01")); + return address(uint160(uint256(hash))); + } + + /// @notice `give` to the account all the tokens held by the `Giver` assigned to that account. + /// @param accountId The ID of the account to `give` tokens to. + /// @param erc20 The token to `give` to the account. + /// If it's the zero address, `Giver` wraps all the native tokens it holds using + /// `nativeTokenWrapper`, and then `give`s to the account all the wrapped tokens it holds. + /// @param amt The amount of tokens that were `give`n. + function give(uint256 accountId, IERC20 erc20) public whenNotPaused returns (uint256 amt) { + address giver_ = giver(accountId); + if (!Address.isContract(giver_)) { + // slither-disable-next-line unused-return + Clones.cloneDeterministic(_giverLogic(address(this)), bytes32(accountId)); + } + bytes memory delegateCalldata = abi.encodeCall(this.giveImpl, (accountId, erc20)); + bytes memory returned = Giver(payable(giver_)).delegate(implementation(), delegateCalldata); + return abi.decode(returned, (uint256)); + } + + /// @notice The delegation target for `Giver`. + /// Only executable by `Giver` delegation and if `Giver` is called by its deployer. + /// `give`s to the account all the tokens held by the `Giver` assigned to that account. + /// @param accountId The ID of the account to which tokens should be `give`n. + /// It must be the account assigned to the `Giver` on its deployment. + /// @param erc20 The token to `give` to the account. + /// If it's the zero address, wraps all the native tokens using + /// `nativeTokenWrapper`, and then `give`s to the account all the wrapped tokens. + /// @param amt The amount of tokens that were `give`n. + function giveImpl(uint256 accountId, IERC20 erc20) public returns (uint256 amt) { + // `address(this)` in this context should be the `Giver` clone contract. + require(address(this) == _giver(accountId, msg.sender), "Caller is not GiversRegistry"); + if (address(erc20) == address(0)) { + erc20 = nativeTokenWrapper; + // slither-disable-next-line unused-return + Address.functionCallWithValue( + address(erc20), "", address(this).balance, "Failed to wrap native tokens" + ); + } + (uint128 streamsBalance, uint128 splitsBalance) = _drips.balances(erc20); + uint256 maxAmt = _maxTotalBalance - streamsBalance - splitsBalance; + // The balance of the `Giver` clone contract. + amt = erc20.balanceOf(address(this)); + if (amt > maxAmt) amt = maxAmt; + // slither-disable-next-line incorrect-equality + if (amt == 0) return amt; + SafeERC20.forceApprove(erc20, address(addressDriver), amt); + addressDriver.give(accountId, erc20, uint128(amt)); + } +} diff --git a/test/Giver.t.sol b/test/Giver.t.sol new file mode 100644 index 00000000..d1857c3e --- /dev/null +++ b/test/Giver.t.sol @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {AddressDriver, Drips, IERC20, StreamReceiver} from "src/AddressDriver.sol"; +import {Address, Giver, GiversRegistry} from "src/Giver.sol"; +import {ManagedProxy} from "src/Managed.sol"; +import { + ERC20, + ERC20PresetFixedSupply +} from "openzeppelin-contracts/token/ERC20/presets/ERC20PresetFixedSupply.sol"; + +contract NativeTokenWrapper is ERC20("Native token wrapper", "NTW") { + receive() external payable { + _mint(msg.sender, msg.value); + } +} + +contract Logic { + function fun(uint256 arg) external payable returns (address, uint256, uint256) { + return (address(this), arg, msg.value); + } +} + +contract GiverTest is Test { + Giver internal giver = new Giver(); + address internal logic = address(new Logic()); + + function testDelegate() public { + uint256 arg = 1234; + uint256 value = 5678; + + bytes memory returned = giver.delegate{value: value}(logic, abi.encodeCall(Logic.fun, arg)); + + (address thisAddr, uint256 receivedArg, uint256 receivedValue) = + abi.decode(returned, (address, uint256, uint256)); + assertEq(thisAddr, address(giver), "Invalid delegation context"); + assertEq(receivedArg, arg, "Invalid argument"); + assertEq(receivedValue, value, "Invalid value"); + } + + function testDelegateRevertsForNonOwner() public { + vm.prank(address(1234)); + vm.expectRevert("Caller is not the owner"); + giver.delegate(logic, ""); + } + + function testTransferToGiver() public { + uint256 amt = 123; + payable(address(giver)).transfer(amt); + assertEq(address(giver).balance, amt, "Invalid balance"); + } +} + +contract GiversRegistryTest is Test { + Drips internal drips; + AddressDriver internal addressDriver; + IERC20 internal erc20; + IERC20 internal nativeTokenWrapper; + GiversRegistry internal giversRegistry; + address internal admin = address(1); + uint256 internal accountId; + address payable internal giver; + + function setUp() public { + Drips dripsLogic = new Drips(10); + drips = Drips(address(new ManagedProxy(dripsLogic, admin))); + drips.registerDriver(address(1)); + AddressDriver addressDriverLogic = + new AddressDriver(drips, address(0), drips.nextDriverId()); + addressDriver = AddressDriver(address(new ManagedProxy(addressDriverLogic, admin))); + drips.registerDriver(address(addressDriver)); + + GiversRegistry giversRegistryLogic = new GiversRegistry(addressDriver); + giversRegistry = GiversRegistry(address(new ManagedProxy(giversRegistryLogic, admin))); + giversRegistry.initialize(); + nativeTokenWrapper = giversRegistry.nativeTokenWrapper(); + vm.etch(address(nativeTokenWrapper), address(new NativeTokenWrapper()).code); + accountId = 1234; + giver = payable(giversRegistry.giver(accountId)); + emit log_named_address("GIVER", giver); + + erc20 = new ERC20PresetFixedSupply("test", "test", type(uint136).max, address(this)); + erc20.approve(address(addressDriver), type(uint256).max); + } + + function give(uint256 amt) internal { + give(amt, amt); + } + + function give(uint256 amt, uint256 expectedGiven) internal { + erc20.transfer(giver, amt); + uint256 balanceBefore = erc20.balanceOf(giver); + uint256 amtBefore = drips.splittable(accountId, erc20); + + giversRegistry.give(accountId, erc20); + + uint256 balanceAfter = erc20.balanceOf(giver); + uint256 amtAfter = drips.splittable(accountId, erc20); + assertEq(balanceAfter, balanceBefore - expectedGiven, "Invalid giver balance"); + assertEq(amtAfter, amtBefore + expectedGiven, "Invalid given amount"); + } + + function giveNative(uint256 amtNative, uint256 amtWrapped) internal { + Address.sendValue(giver, amtNative); + Address.sendValue(payable(address(nativeTokenWrapper)), amtWrapped); + nativeTokenWrapper.transfer(giver, amtWrapped); + + uint256 balanceBefore = giver.balance + nativeTokenWrapper.balanceOf(giver); + uint256 amtBefore = drips.splittable(accountId, nativeTokenWrapper); + + giversRegistry.give(accountId, IERC20(address(0))); + + uint256 balanceAfter = nativeTokenWrapper.balanceOf(giver); + uint256 amtAfter = drips.splittable(accountId, nativeTokenWrapper); + assertEq(giver.balance, 0, "Invalid giver native token balance"); + uint256 expectedGiven = amtNative + amtWrapped; + assertEq(balanceAfter, balanceBefore - expectedGiven, "Invalid giver balance"); + assertEq(amtAfter, amtBefore + expectedGiven, "Invalid given amount"); + } + + function testGive() public { + give(5); + } + + function testGiveZero() public { + give(0); + } + + function testGiveUsingDeployedGiver() public { + give(1); + give(5); + } + + function testGiveMaxBalance() public { + give(drips.MAX_TOTAL_BALANCE()); + give(1, 0); + } + + function testGiveOverMaxBalance() public { + erc20.approve(address(addressDriver), 15); + addressDriver.setStreams( + erc20, new StreamReceiver[](0), 10, new StreamReceiver[](0), 0, 0, address(this) + ); + addressDriver.give(0, erc20, 5); + give(drips.MAX_TOTAL_BALANCE(), drips.MAX_TOTAL_BALANCE() - 15); + } + + function testGiveNative() public { + giveNative(10, 0); + } + + function testGiveWrapped() public { + giveNative(0, 5); + } + + function testGiveNativeAndWrapped() public { + giveNative(10, 5); + } + + function testGiveZeroWrapped() public { + giveNative(0, 0); + } + + function testGiveCanBePaused() public { + vm.prank(admin); + giversRegistry.pause(); + vm.expectRevert("Contract paused"); + giversRegistry.give(accountId, erc20); + } + + function testGiveImplReverts() public { + vm.expectRevert("Caller is not GiversRegistry"); + giversRegistry.giveImpl(accountId, erc20); + } +}