diff --git a/.gitmodules b/.gitmodules index 1e99bb3..03a1465 100644 --- a/.gitmodules +++ b/.gitmodules @@ -17,3 +17,6 @@ path = lib/predeploy-contracts url = https://github.com/AcalaNetwork/predeploy-contracts branch = v4.3.5 +[submodule "lib/Euphrates"] + path = lib/Euphrates + url = https://github.com/AcalaNetwork/Euphrates diff --git a/lib/Euphrates b/lib/Euphrates new file mode 160000 index 0000000..92aea79 --- /dev/null +++ b/lib/Euphrates @@ -0,0 +1 @@ +Subproject commit 92aea79067ac08e5e6696ad37a47781c1a894aa6 diff --git a/remappings.txt b/remappings.txt index ee1dd11..3a2e461 100644 --- a/remappings.txt +++ b/remappings.txt @@ -4,3 +4,4 @@ solmate/=lib/solmate/src/ @openzeppelin/=lib/openzeppelin-contracts/ wormhole/=lib/wormhole/ethereum/contracts/ @acala-network/=lib/predeploy-contracts/ +euphrates/=lib/Euphrates/src/ \ No newline at end of file diff --git a/scripts/deploy-euphrates-factory.ts b/scripts/deploy-euphrates-factory.ts new file mode 100644 index 0000000..81acbd4 --- /dev/null +++ b/scripts/deploy-euphrates-factory.ts @@ -0,0 +1,20 @@ +import { ethers, run } from 'hardhat'; + +async function main() { + const Factory = await ethers.getContractFactory('EuphratesFactory'); + const factory = await Factory.deploy(); + await factory.deployed(); + + console.log(`euphrates factory address: ${factory.address}`); + console.log('remember to publish it!'); + + await run('verify:verify', { + address: factory.address, + constructorArguments: [], + }); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/src/EuphratesFactory.sol b/src/EuphratesFactory.sol new file mode 100644 index 0000000..f58c7c4 --- /dev/null +++ b/src/EuphratesFactory.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import { ERC20 } from "solmate/tokens/ERC20.sol"; +import { FeeRegistry } from "./FeeRegistry.sol"; +import { EuphratesRouter, EuphratesInstructions } from "./EuphratesRouter.sol"; + +contract EuphratesFactory { + function deployEuphratesRouter(FeeRegistry fees, EuphratesInstructions memory inst, address euphratesAddress) + public + returns (EuphratesRouter) + { + // no need to use salt as we want to keep the router address the same for the same fees &instructions + bytes32 salt; + + EuphratesRouter router; + try new EuphratesRouter{salt: salt}(fees, inst, euphratesAddress) returns (EuphratesRouter router_) { + router = router_; + } catch { + router = EuphratesRouter( + address( + uint160( + uint256( + keccak256( + abi.encodePacked( + bytes1(0xff), + address(this), + salt, + keccak256( + abi.encodePacked(type(EuphratesRouter).creationCode, abi.encode(fees, inst)) + ) + ) + ) + ) + ) + ) + ); + } + + return router; + } + + function deployEuphratesRouterAndRoute( + FeeRegistry fees, + EuphratesInstructions memory inst, + address euphratesAddress, + ERC20 token + ) public { + EuphratesRouter router = deployEuphratesRouter(fees, inst, euphratesAddress); + router.route(token, msg.sender); + } + + function deployEuphratesRouterAndRouteNoFee( + FeeRegistry fees, + EuphratesInstructions memory inst, + address euphratesAddress, + ERC20 token + ) public { + EuphratesRouter router = deployEuphratesRouter(fees, inst, euphratesAddress); + router.routeNoFee(token); + } +} diff --git a/src/EuphratesRouter.sol b/src/EuphratesRouter.sol new file mode 100644 index 0000000..4c31ea8 --- /dev/null +++ b/src/EuphratesRouter.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import { ERC20 } from "solmate/tokens/ERC20.sol"; +import { SafeTransferLib } from "solmate/utils/SafeTransferLib.sol"; +import { IStakingTo } from "euphrates/IStaking.sol"; +import { BaseRouter } from "./BaseRouter.sol"; +import { FeeRegistry } from "./FeeRegistry.sol"; + +struct EuphratesInstructions { + uint256 poolId; + address recipient; +} + +contract EuphratesRouter is BaseRouter { + using SafeTransferLib for ERC20; + + address private _euphratesAddress; + EuphratesInstructions private _instructions; + + constructor(FeeRegistry fees, EuphratesInstructions memory instructions, address euphratesAddress) + BaseRouter(fees) + { + _instructions = instructions; + _euphratesAddress = euphratesAddress; + } + + function routeImpl(ERC20 token) internal override { + if (address(token) == address(IStakingTo(_euphratesAddress).shareTypes(_instructions.poolId))) { + bool approved = token.approve(_euphratesAddress, token.balanceOf(address(this))); + require(approved, "EuphratesRouter: approve failed"); + + // This may fail due to the configurations of Euphrates. + // That means user is doing something wrong and will revert. + IStakingTo(_euphratesAddress).stakeTo( + _instructions.poolId, token.balanceOf(address(this)), _instructions.recipient + ); + } else { + // received token is not share token, transfer it to recipient to avoid it stuck in this contract + token.safeTransfer(_instructions.recipient, token.balanceOf(address(this))); + } + } +} diff --git a/test/EuphratesRouter.t.sol b/test/EuphratesRouter.t.sol new file mode 100644 index 0000000..9d99878 --- /dev/null +++ b/test/EuphratesRouter.t.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; + +import "../src/EuphratesRouter.sol"; +import "../src/FeeRegistry.sol"; +import "./MockToken.sol"; +import "./MockEuphrates.sol"; + +contract EuphratesRouterTest is Test { + FeeRegistry public fees; + MockToken public token1; + MockToken public token2; + MockToken public token3; + MockEuphrates public euphrates; + address public alice = address(0x01010101010101010101); + address public bob = address(0x02020202020202020202); + address public charlie = address(0x03030303030303030303); + + function setUp() public { + token1 = new MockToken("Token1", "TK1"); + token2 = new MockToken("Token2", "TK2"); + token3 = new MockToken("token3", "TK3"); + + Fee[] memory feeArray = new Fee[](2); + feeArray[0] = Fee(address(token1), 1 ether); + feeArray[1] = Fee(address(token2), 2 ether); + + fees = new FeeRegistry(feeArray); + + euphrates = new MockEuphrates(); + //vm.etch(EUPHRATES, address(euphrates).code); + euphrates.addPool(IERC20(address(token1))); + euphrates.addPool(IERC20(address(token2))); + } + + function testRouteWithFee() public { + EuphratesInstructions memory inst = EuphratesInstructions(0, alice); + EuphratesRouter router = new EuphratesRouter(fees, inst, address(euphrates)); + + token1.transfer(address(router), 5 ether); + + vm.prank(bob); + router.route(token1, bob); + + assertEq(token1.balanceOf(address(router)), 0); + assertEq(euphrates.shares(0, alice), 4 ether); // (amount - fee) + assertEq(token1.balanceOf(bob), 1 ether); // fee + } + + function testRouteWithFeeWithOtherRecipient() public { + EuphratesInstructions memory inst = EuphratesInstructions(0, alice); + EuphratesRouter router = new EuphratesRouter(fees, inst, address(euphrates)); + + token1.transfer(address(router), 5 ether); + + vm.prank(bob); + router.route(token1, charlie); + + assertEq(token1.balanceOf(address(router)), 0); + assertEq(euphrates.shares(0, alice), 4 ether); // (amount - fee) + assertEq(token1.balanceOf(charlie), 1 ether); // fee + } + + function testRouteWithoutFee() public { + EuphratesInstructions memory inst = EuphratesInstructions(0, alice); + EuphratesRouter router = new EuphratesRouter(fees, inst, address(euphrates)); + + token1.transfer(address(router), 5 ether); + + vm.prank(bob); + router.routeNoFee(token1); + + assertEq(token1.balanceOf(address(router)), 0); + assertEq(euphrates.shares(0, alice), 5 ether); + assertEq(token1.balanceOf(bob), 0); + } + + function testRouteForNotMatchedToken() public { + EuphratesInstructions memory inst = EuphratesInstructions(0, alice); + EuphratesRouter router = new EuphratesRouter(fees, inst, address(euphrates)); + + token2.transfer(address(router), 5 ether); + + vm.prank(bob); + router.route(token2, bob); + + assertEq(token2.balanceOf(address(router)), 0); + assertEq(euphrates.shares(0, alice), 0); + assertEq(token2.balanceOf(alice), 3 ether); // (amount - fee) + assertEq(token2.balanceOf(bob), 2 ether); // fee + } +} diff --git a/test/MockEuphrates.sol b/test/MockEuphrates.sol new file mode 100644 index 0000000..f11a3f7 --- /dev/null +++ b/test/MockEuphrates.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import "euphrates/IStaking.sol"; +import "euphrates/Staking.sol"; +import "./MockToken.sol"; + +contract MockEuphrates is Staking, IStakingTo { + using SafeMath for uint256; + using SafeERC20 for IERC20; + + function stakeTo(uint256 poolId, uint256 amount, address receiver) public override returns (bool) { + require(amount > 0, "cannot stake 0"); + IERC20 shareType = IERC20(address(shareTypes(poolId))); + require(address(shareType) != address(0), "invalid pool"); + + _totalShares[poolId] = _totalShares[poolId].add(amount); + _shares[poolId][receiver] = _shares[poolId][receiver].add(amount); + + shareType.safeTransferFrom(msg.sender, address(this), amount); + + emit Stake(receiver, poolId, amount); + + return true; + } +}