From bd0559a611f04f2849e46537e3edec71d8a04537 Mon Sep 17 00:00:00 2001 From: Jakub Nowakowski Date: Fri, 8 Dec 2023 10:14:12 +0100 Subject: [PATCH] WIP Dispatcher and funds routing This is WIP code to check how dispatching to vaults could work --- core/contracts/Acre.sol | 20 +++- core/contracts/Dispatcher.sol | 34 ++++++ core/contracts/Router.sol | 67 +++++++++++ core/contracts/test/TestERC4626.sol | 12 ++ core/deploy/02_deploy_dispatcher.ts | 24 ++++ core/deploy/21_transfer_ownership_acre.ts | 2 - .../22_transfer_ownership_dispatcher.ts | 21 ++++ core/test/Dispatcher.test.ts | 109 ++++++++++++++++++ core/test/helpers/context.ts | 5 +- 9 files changed, 288 insertions(+), 6 deletions(-) create mode 100644 core/contracts/Dispatcher.sol create mode 100644 core/contracts/Router.sol create mode 100644 core/contracts/test/TestERC4626.sol create mode 100644 core/deploy/02_deploy_dispatcher.ts create mode 100644 core/deploy/22_transfer_ownership_dispatcher.ts create mode 100644 core/test/Dispatcher.test.ts diff --git a/core/contracts/Acre.sol b/core/contracts/Acre.sol index 0e1cf640f..d4b229295 100644 --- a/core/contracts/Acre.sol +++ b/core/contracts/Acre.sol @@ -2,6 +2,9 @@ pragma solidity ^0.8.21; import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +import "./Dispatcher.sol"; /// @title Acre /// @notice This contract implements the ERC-4626 tokenized vault standard. By @@ -14,12 +17,15 @@ import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; /// of yield-bearing vaults. This contract facilitates the minting and /// burning of shares (stBTC), which are represented as standard ERC20 /// tokens, providing a seamless exchange with tBTC tokens. -contract Acre is ERC4626 { +contract Acre is ERC4626, Ownable { event StakeReferral(bytes32 indexed referral, uint256 assets); + Dispatcher public dispatcher; + constructor( IERC20 tbtc - ) ERC4626(tbtc) ERC20("Acre Staked Bitcoin", "stBTC") {} + ) ERC4626(tbtc) ERC20("Acre Staked Bitcoin", "stBTC") Ownable(msg.sender) {} + /// @notice Stakes a given amount of tBTC token and mints shares to a /// receiver. @@ -43,4 +49,14 @@ contract Acre is ERC4626 { return shares; } + + function upgradeDispatcher(Dispatcher _newDispatcher) public onlyOwner { + if (address(dispatcher) != address(0)) { + IERC20(asset()).approve(address(dispatcher), 0); + } + + dispatcher = _newDispatcher; + + IERC20(asset()).approve(address(dispatcher), type(uint256).max); + } } diff --git a/core/contracts/Dispatcher.sol b/core/contracts/Dispatcher.sol new file mode 100644 index 000000000..cb24deb2d --- /dev/null +++ b/core/contracts/Dispatcher.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.21; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import "./Router.sol"; + +contract Dispatcher is Router, Ownable { + using SafeERC20 for IERC20; + + Acre acre; + + constructor(Acre _acre) Ownable(msg.sender) { + acre = _acre; + } + + function assetsHolder() public virtual override returns (address){ + return address(acre); + } + + function sharesHolder() public virtual override returns (address){ + return address(this); + } + + function migrateShares(IERC4626[] calldata _vaults) public onlyOwner { + address newDispatcher = address(acre.dispatcher()); + + for (uint i=0; i<_vaults.length; i++) { + _vaults[i].transfer(newDispatcher, _vaults[i].balanceOf(address(this))); + } + } +} diff --git a/core/contracts/Router.sol b/core/contracts/Router.sol new file mode 100644 index 000000000..6c7923c5a --- /dev/null +++ b/core/contracts/Router.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.21; + +import "@openzeppelin/contracts/interfaces/IERC20.sol"; +import "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "./Acre.sol"; + + +// TODO: Consider deploying ERC4626RouterBase from the ERC4626 Alliance. +// TODO: Think about adding reentrancy guard +// TODO: Add ACL + +abstract contract Router { + using SafeERC20 for IERC20; + + /// @notice thrown when amount of assets received is below the min set by caller + error MinAmountError(); + + /// @notice thrown when amount of shares received is below the min set by caller + error MinSharesError(); + + /// @notice thrown when amount of assets received is above the max set by caller + error MaxAmountError(); + + /// @notice thrown when amount of shares received is above the max set by caller + error MaxSharesError(); + + + function assetsHolder() public virtual returns (address); + function sharesHolder() public virtual returns (address); + + function deposit( + IERC4626 vault, + uint256 amount, + uint256 minSharesOut + ) public returns (uint256 sharesOut) { + IERC20(vault.asset()).safeTransferFrom(assetsHolder(), address(this), amount); + + IERC20(vault.asset()).approve(address(vault), amount); + + if ((sharesOut = vault.deposit(amount, sharesHolder())) < minSharesOut) { + revert MinSharesError(); + } + } + + + function withdraw( + IERC4626 vault, + uint256 amount, + uint256 maxSharesOut + ) public returns (uint256 sharesOut) { + if ((sharesOut = vault.withdraw(amount, assetsHolder(), sharesHolder())) > maxSharesOut) { + revert MaxSharesError(); + } + } + + function redeem( + IERC4626 vault, + uint256 shares, + uint256 minAmountOut + ) public returns (uint256 amountOut) { + if ((amountOut = vault.redeem(shares, assetsHolder(), sharesHolder())) < minAmountOut) { + revert MinAmountError(); + } + } +} diff --git a/core/contracts/test/TestERC4626.sol b/core/contracts/test/TestERC4626.sol new file mode 100644 index 000000000..acf09928e --- /dev/null +++ b/core/contracts/test/TestERC4626.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.21; + +import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; + +contract TestERC4626 is ERC4626 { + constructor( + IERC20 asset, + string memory tokenName, + string memory tokenSymbol + ) ERC4626(asset) ERC20(tokenName, tokenSymbol) {} +} diff --git a/core/deploy/02_deploy_dispatcher.ts b/core/deploy/02_deploy_dispatcher.ts new file mode 100644 index 000000000..b27730f62 --- /dev/null +++ b/core/deploy/02_deploy_dispatcher.ts @@ -0,0 +1,24 @@ +import type { HardhatRuntimeEnvironment } from "hardhat/types" +import type { DeployFunction } from "hardhat-deploy/types" + +const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { + const { getNamedAccounts, deployments } = hre + const { deployer } = await getNamedAccounts() + + const acre = await deployments.get("Acre") + + await deployments.deploy("Dispatcher", { + from: deployer, + args: [acre.address], + log: true, + waitConfirmations: 1, + }) + + // TODO: Add Etherscan verification + // TODO: Add Tenderly verification +} + +export default func + +func.tags = ["Dispatcher"] +func.dependencies = ["Acre"] diff --git a/core/deploy/21_transfer_ownership_acre.ts b/core/deploy/21_transfer_ownership_acre.ts index c62708641..09a875a1d 100644 --- a/core/deploy/21_transfer_ownership_acre.ts +++ b/core/deploy/21_transfer_ownership_acre.ts @@ -19,5 +19,3 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { export default func func.tags = ["TransferOwnershipAcre"] -// TODO: Enable once Acre extends Ownable -func.skip = async () => true diff --git a/core/deploy/22_transfer_ownership_dispatcher.ts b/core/deploy/22_transfer_ownership_dispatcher.ts new file mode 100644 index 000000000..77103ddb7 --- /dev/null +++ b/core/deploy/22_transfer_ownership_dispatcher.ts @@ -0,0 +1,21 @@ +import type { HardhatRuntimeEnvironment } from "hardhat/types" +import type { DeployFunction } from "hardhat-deploy/types" + +const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { + const { getNamedAccounts, deployments } = hre + const { deployer, governance } = await getNamedAccounts() + const { log } = deployments + + log(`transferring ownership of AcreRouter contract to ${governance}`) + + await deployments.execute( + "Dispatcher", + { from: deployer, log: true, waitConfirmations: 1 }, + "transferOwnership", + governance, + ) +} + +export default func + +func.tags = ["TransferOwnershipDispatcher"] diff --git a/core/test/Dispatcher.test.ts b/core/test/Dispatcher.test.ts new file mode 100644 index 000000000..77955cb1c --- /dev/null +++ b/core/test/Dispatcher.test.ts @@ -0,0 +1,109 @@ +import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers" +import { expect } from "chai" + +import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers" + +import { ethers } from "hardhat" +import { deployment } from "./helpers/context" +import { getNamedSigner, getUnnamedSigner } from "./helpers/signer" + +import { to1e18 } from "./utils" + +import type { Acre, Dispatcher, TestERC4626, TestERC20 } from "../typechain" + +async function fixture() { + const { tbtc, acre, dispatcher } = await deployment() + + const { governance } = await getNamedSigner() + + const [staker1] = await getUnnamedSigner() + + await acre + .connect(governance) + .upgradeDispatcher(await dispatcher.getAddress()) + + const vault: TestERC4626 = await ethers.deployContract("TestERC4626", [ + await tbtc.getAddress(), + "Test Vault Token", + "vToken", + ]) + await vault.waitForDeployment() + + return { acre, tbtc, dispatcher, vault, staker1 } +} + +describe("Dispatcher", () => { + const staker1Amount = to1e18(1000) + + let acre: Acre + let tbtc: TestERC20 + let dispatcher: Dispatcher + let vault: TestERC4626 + + let staker1: HardhatEthersSigner + + before(async () => { + ;({ acre, tbtc, dispatcher, vault, staker1 } = await loadFixture(fixture)) + }) + + it("test deposit and withdraw", async () => { + // Mint tBTC for staker. + await tbtc.mint(staker1.address, staker1Amount) + + // Stake tBTC in Acre. + await tbtc.approve(await acre.getAddress(), staker1Amount) + await acre.connect(staker1).deposit(staker1Amount, staker1.address) + + expect(await tbtc.balanceOf(await acre.getAddress())).to.be.equal( + staker1Amount, + ) + + const vaultDepositAmount = to1e18(500) + const expectedSharesDeposit = vaultDepositAmount + + await dispatcher.deposit( + await vault.getAddress(), + vaultDepositAmount, + expectedSharesDeposit, + ) + + expect(await tbtc.balanceOf(await acre.getAddress())).to.be.equal( + staker1Amount - vaultDepositAmount, + ) + expect(await tbtc.balanceOf(await dispatcher.getAddress())).to.be.equal(0) + expect(await tbtc.balanceOf(await vault.getAddress())).to.be.equal( + vaultDepositAmount, + ) + + expect(await vault.balanceOf(await acre.getAddress())).to.be.equal(0) + expect(await vault.balanceOf(await dispatcher.getAddress())).to.be.equal( + expectedSharesDeposit, + ) + + // // Simulate Vault generating yield. + // const yieldAmount = to1e18(200) + // await tbtc.mint(await vault.getAddress(), yieldAmount) + + // Partial withdrawal. + const amountToWithdraw1 = to1e18(300) + const expectedSharesWithdraw = to1e18(300) + await dispatcher.withdraw( + await vault.getAddress(), + amountToWithdraw1, + expectedSharesWithdraw, + ) + + expect(await vault.balanceOf(await acre.getAddress())).to.be.equal(0) + expect(await vault.balanceOf(await dispatcher.getAddress())).to.be.equal( + expectedSharesDeposit - expectedSharesWithdraw, + ) + + expect(await tbtc.balanceOf(await acre.getAddress())).to.be.equal( + staker1Amount - vaultDepositAmount + amountToWithdraw1, + ) + expect(await tbtc.balanceOf(await dispatcher.getAddress())).to.be.equal(0) + expect(await tbtc.balanceOf(await vault.getAddress())).to.be.equal( + vaultDepositAmount - amountToWithdraw1, + ) + }) +}) diff --git a/core/test/helpers/context.ts b/core/test/helpers/context.ts index ec4c56cbd..d043238f7 100644 --- a/core/test/helpers/context.ts +++ b/core/test/helpers/context.ts @@ -2,7 +2,7 @@ import { deployments } from "hardhat" import { getDeployedContract } from "./contract" -import type { Acre, AcreRouter, TestERC20 } from "../../typechain" +import type { Acre, AcreRouter, Dispatcher, TestERC20 } from "../../typechain" // eslint-disable-next-line import/prefer-default-export export async function deployment() { @@ -11,6 +11,7 @@ export async function deployment() { const tbtc: TestERC20 = await getDeployedContract("TBTC") const acre: Acre = await getDeployedContract("Acre") const acreRouter: AcreRouter = await getDeployedContract("AcreRouter") + const dispatcher: Dispatcher = await getDeployedContract("Dispatcher") - return { tbtc, acre, acreRouter } + return { tbtc, acre, acreRouter, dispatcher } }