diff --git a/core/contracts/Acre.sol b/core/contracts/Acre.sol index 83e10b835..2f2edbf6b 100644 --- a/core/contracts/Acre.sol +++ b/core/contracts/Acre.sol @@ -2,7 +2,9 @@ pragma solidity ^0.8.21; import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; +import "./Dispatcher.sol"; /// @title Acre /// @notice This contract implements the ERC-4626 tokenized vault standard. By @@ -16,19 +18,41 @@ import "@openzeppelin/contracts/access/Ownable.sol"; /// burning of shares (stBTC), which are represented as standard ERC20 /// tokens, providing a seamless exchange with tBTC tokens. contract Acre is ERC4626, Ownable { - // Minimum amount for a single deposit operation. + using SafeERC20 for IERC20; + + /// Dispatcher contract that routes tBTC from Acre to a given vault and back. + Dispatcher public dispatcher; + /// Minimum amount for a single deposit operation. uint256 public minimumDepositAmount; - // Maximum total amount of tBTC token held by Acre. + /// Maximum total amount of tBTC token held by Acre. uint256 public maximumTotalAssets; + /// Emitted when a referral is used. + /// @param referral Used for referral program. + /// @param assets Amount of tBTC tokens staked. event StakeReferral(bytes32 indexed referral, uint256 assets); + + /// Emitted when deposit parameters are updated. + /// @param minimumDepositAmount New value of the minimum deposit amount. + /// @param maximumTotalAssets New value of the maximum total assets amount. event DepositParametersUpdated( uint256 minimumDepositAmount, uint256 maximumTotalAssets ); + /// Emitted when the dispatcher contract is updated. + /// @param oldDispatcher Address of the old dispatcher contract. + /// @param newDispatcher Address of the new dispatcher contract. + event DispatcherUpdated(address oldDispatcher, address newDispatcher); + + /// Reverts if the amount is less than the minimum deposit amount. + /// @param amount Amount to check. + /// @param min Minimum amount to check 'amount' against. error DepositAmountLessThanMin(uint256 amount, uint256 min); + /// Reverts if the address is zero. + error ZeroAddress(); + constructor( IERC20 tbtc ) ERC4626(tbtc) ERC20("Acre Staked Bitcoin", "stBTC") Ownable(msg.sender) { @@ -59,6 +83,34 @@ contract Acre is ERC4626, Ownable { ); } + // TODO: Implement a governed upgrade process that initiates an update and + // then finalizes it after a delay. + /// @notice Updates the dispatcher contract and gives it an unlimited + /// allowance to transfer staked tBTC. + /// @param newDispatcher Address of the new dispatcher contract. + function updateDispatcher(Dispatcher newDispatcher) external onlyOwner { + if (address(newDispatcher) == address(0)) { + revert ZeroAddress(); + } + + address oldDispatcher = address(dispatcher); + + emit DispatcherUpdated(oldDispatcher, address(newDispatcher)); + dispatcher = newDispatcher; + + // TODO: Once withdrawal/rebalancing is implemented, we need to revoke the + // approval of the vaults share tokens from the old dispatcher and approve + // a new dispatcher to manage the share tokens. + + if (oldDispatcher != address(0)) { + // Setting allowance to zero for the old dispatcher + IERC20(asset()).forceApprove(oldDispatcher, 0); + } + + // Setting allowance to max for the new dispatcher + IERC20(asset()).forceApprove(address(dispatcher), type(uint256).max); + } + /// @notice Mints shares to receiver by depositing exactly amount of /// tBTC tokens. /// @dev Takes into account a deposit parameter, minimum deposit amount, diff --git a/core/contracts/Dispatcher.sol b/core/contracts/Dispatcher.sol index 309da2ae8..cbdc0dd0f 100644 --- a/core/contracts/Dispatcher.sol +++ b/core/contracts/Dispatcher.sol @@ -2,37 +2,91 @@ pragma solidity ^0.8.21; import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import "./Router.sol"; +import "./Acre.sol"; /// @title Dispatcher -/// @notice Dispatcher is a contract that routes TBTC from stBTC (Acre) to -/// a given vault and back. Vaults supply yield strategies with TBTC that +/// @notice Dispatcher is a contract that routes tBTC from Acre (stBTC) to +/// yield vaults and back. Vaults supply yield strategies with tBTC that /// generate yield for Bitcoin holders. -contract Dispatcher is Ownable { - error VaultAlreadyAuthorized(); - error VaultUnauthorized(); +contract Dispatcher is Router, Ownable { + using SafeERC20 for IERC20; + /// Struct holds information about a vault. struct VaultInfo { bool authorized; } - /// @notice Authorized Yield Vaults that implement ERC4626 standard. These - /// vaults deposit assets to yield strategies, e.g. Uniswap V3 - /// WBTC/TBTC pool. Vault can be a part of Acre ecosystem or can be - /// implemented externally. As long as it complies with ERC4626 - /// standard and is authorized by the owner it can be plugged into - /// Acre. + /// The main Acre contract holding tBTC deposited by stakers. + Acre public immutable acre; + /// tBTC token contract. + IERC20 public immutable tbtc; + /// Address of the maintainer bot. + address public maintainer; + + /// Authorized Yield Vaults that implement ERC4626 standard. These + /// vaults deposit assets to yield strategies, e.g. Uniswap V3 + /// WBTC/TBTC pool. Vault can be a part of Acre ecosystem or can be + /// implemented externally. As long as it complies with ERC4626 + /// standard and is authorized by the owner it can be plugged into + /// Acre. address[] public vaults; + /// Mapping of vaults to their information. mapping(address => VaultInfo) public vaultsInfo; + /// Emitted when a vault is authorized. + /// @param vault Address of the vault. event VaultAuthorized(address indexed vault); + + /// Emitted when a vault is deauthorized. + /// @param vault Address of the vault. event VaultDeauthorized(address indexed vault); - constructor() Ownable(msg.sender) {} + /// Emitted when tBTC is routed to a vault. + /// @param vault Address of the vault. + /// @param amount Amount of tBTC. + /// @param sharesOut Amount of shares received by Acre. + event DepositAllocated( + address indexed vault, + uint256 amount, + uint256 sharesOut + ); + + /// Emitted when the maintainer address is updated. + /// @param maintainer Address of the new maintainer. + event MaintainerUpdated(address indexed maintainer); + + /// Reverts if the vault is already authorized. + error VaultAlreadyAuthorized(); + + /// Reverts if the vault is not authorized. + error VaultUnauthorized(); + + /// Reverts if the caller is not the maintainer. + error NotMaintainer(); + + /// Reverts if the address is zero. + error ZeroAddress(); + + /// Modifier that reverts if the caller is not the maintainer. + modifier onlyMaintainer() { + if (msg.sender != maintainer) { + revert NotMaintainer(); + } + _; + } + + constructor(Acre _acre, IERC20 _tbtc) Ownable(msg.sender) { + acre = _acre; + tbtc = _tbtc; + } /// @notice Adds a vault to the list of authorized vaults. /// @param vault Address of the vault to add. function authorizeVault(address vault) external onlyOwner { - if (vaultsInfo[vault].authorized) { + if (isVaultAuthorized(vault)) { revert VaultAlreadyAuthorized(); } @@ -45,7 +99,7 @@ contract Dispatcher is Ownable { /// @notice Removes a vault from the list of authorized vaults. /// @param vault Address of the vault to remove. function deauthorizeVault(address vault) external onlyOwner { - if (!vaultsInfo[vault].authorized) { + if (!isVaultAuthorized(vault)) { revert VaultUnauthorized(); } @@ -63,7 +117,58 @@ contract Dispatcher is Ownable { emit VaultDeauthorized(vault); } - function getVaults() external view returns (address[] memory) { + /// @notice Updates the maintainer address. + /// @param newMaintainer Address of the new maintainer. + function updateMaintainer(address newMaintainer) external onlyOwner { + if (newMaintainer == address(0)) { + revert ZeroAddress(); + } + + maintainer = newMaintainer; + + emit MaintainerUpdated(maintainer); + } + + /// TODO: make this function internal once the allocation distribution is + /// implemented + /// @notice Routes tBTC from Acre to a vault. Can be called by the maintainer + /// only. + /// @param vault Address of the vault to route the assets to. + /// @param amount Amount of tBTC to deposit. + /// @param minSharesOut Minimum amount of shares to receive by Acre. + function depositToVault( + address vault, + uint256 amount, + uint256 minSharesOut + ) public onlyMaintainer { + if (!isVaultAuthorized(vault)) { + revert VaultUnauthorized(); + } + + // slither-disable-next-line arbitrary-send-erc20 + tbtc.safeTransferFrom(address(acre), address(this), amount); + tbtc.forceApprove(address(vault), amount); + + uint256 sharesOut = deposit( + IERC4626(vault), + address(acre), + amount, + minSharesOut + ); + // slither-disable-next-line reentrancy-events + emit DepositAllocated(vault, amount, sharesOut); + } + + /// @notice Returns the list of authorized vaults. + function getVaults() public view returns (address[] memory) { return vaults; } + + /// @notice Returns true if the vault is authorized. + /// @param vault Address of the vault to check. + function isVaultAuthorized(address vault) public view returns (bool) { + return vaultsInfo[vault].authorized; + } + + /// TODO: implement redeem() / withdraw() functions } diff --git a/core/contracts/Router.sol b/core/contracts/Router.sol new file mode 100644 index 000000000..6d07a22a2 --- /dev/null +++ b/core/contracts/Router.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.21; + +import "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +/// @title Router +/// @notice Router is a contract that routes tBTC from stBTC (Acre) to +/// a given vault and back. Vaults supply yield strategies with tBTC that +/// generate yield for Bitcoin holders. +abstract contract Router { + /// Thrown when amount of shares received is below the min set by caller. + /// @param vault Address of the vault. + /// @param sharesOut Amount of shares received by Acre. + /// @param minSharesOut Minimum amount of shares expected to receive. + error MinSharesError( + address vault, + uint256 sharesOut, + uint256 minSharesOut + ); + + /// @notice Routes funds from stBTC (Acre) to a vault. The amount of tBTC to + /// Shares of deposited tBTC are minted to the stBTC contract. + /// @param vault Address of the vault to route the funds to. + /// @param receiver Address of the receiver of the shares. + /// @param amount Amount of tBTC to deposit. + /// @param minSharesOut Minimum amount of shares to receive. + function deposit( + IERC4626 vault, + address receiver, + uint256 amount, + uint256 minSharesOut + ) internal returns (uint256 sharesOut) { + if ((sharesOut = vault.deposit(amount, receiver)) < minSharesOut) { + revert MinSharesError(address(vault), sharesOut, minSharesOut); + } + } +} 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/00_resolve_testing_erc4626.ts b/core/deploy/00_resolve_testing_erc4626.ts new file mode 100644 index 000000000..b8e483897 --- /dev/null +++ b/core/deploy/00_resolve_testing_erc4626.ts @@ -0,0 +1,27 @@ +import type { HardhatRuntimeEnvironment } from "hardhat/types" +import type { DeployFunction } from "hardhat-deploy/types" + +const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { + const { getNamedAccounts, deployments } = hre + const { log } = deployments + const { deployer } = await getNamedAccounts() + const tBTC = await deployments.get("TBTC") + + log("deploying Mock ERC4626 Vault") + + await deployments.deploy("Vault", { + contract: "TestERC4626", + from: deployer, + args: [tBTC.address, "MockVault", "MV"], + log: true, + waitConfirmations: 1, + }) +} + +export default func + +func.tags = ["TestERC4626"] +func.dependencies = ["TBTC"] + +func.skip = async (hre: HardhatRuntimeEnvironment): Promise => + hre.network.name === "mainnet" diff --git a/core/deploy/02_deploy_acre_router.ts b/core/deploy/02_deploy_dispatcher.ts similarity index 80% rename from core/deploy/02_deploy_acre_router.ts rename to core/deploy/02_deploy_dispatcher.ts index bf99d4d73..3c7a84e5c 100644 --- a/core/deploy/02_deploy_acre_router.ts +++ b/core/deploy/02_deploy_dispatcher.ts @@ -5,9 +5,12 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { const { getNamedAccounts, deployments } = hre const { deployer } = await getNamedAccounts() + const tbtc = await deployments.get("TBTC") + const acre = await deployments.get("Acre") + await deployments.deploy("Dispatcher", { from: deployer, - args: [], + args: [acre.address, tbtc.address], log: true, waitConfirmations: 1, }) diff --git a/core/deploy/11_acre_update_dispatcher.ts b/core/deploy/11_acre_update_dispatcher.ts new file mode 100644 index 000000000..2f645027c --- /dev/null +++ b/core/deploy/11_acre_update_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 } = await getNamedAccounts() + + const dispatcher = await deployments.get("Dispatcher") + + await deployments.execute( + "Acre", + { from: deployer, log: true, waitConfirmations: 1 }, + "updateDispatcher", + dispatcher.address, + ) +} + +export default func + +func.tags = ["AcreUpdateDispatcher"] +func.dependencies = ["Acre", "Dispatcher"] diff --git a/core/deploy/12_dispatcher_update_maintainer.ts b/core/deploy/12_dispatcher_update_maintainer.ts new file mode 100644 index 000000000..8f616fac0 --- /dev/null +++ b/core/deploy/12_dispatcher_update_maintainer.ts @@ -0,0 +1,19 @@ +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, maintainer } = await getNamedAccounts() + + await deployments.execute( + "Dispatcher", + { from: deployer, log: true, waitConfirmations: 1 }, + "updateMaintainer", + maintainer, + ) +} + +export default func + +func.tags = ["DispatcherUpdateMaintainer"] +func.dependencies = ["Dispatcher"] diff --git a/core/hardhat.config.ts b/core/hardhat.config.ts index 264e9fcbd..3ca6f77bf 100644 --- a/core/hardhat.config.ts +++ b/core/hardhat.config.ts @@ -67,8 +67,13 @@ const config: HardhatUserConfig = { }, governance: { default: 2, - sepolia: 0, - mainnet: "", + sepolia: 0, // TODO: updated to the actual address once available + mainnet: "", // TODO: updated to the actual address once available + }, + maintainer: { + default: 3, + sepolia: 0, // TODO: updated to the actual address once available + mainnet: "", // TODO: updated to the actual address once available }, }, diff --git a/core/test/Acre.test.ts b/core/test/Acre.test.ts index 4a5b97620..fa6e5ddf1 100644 --- a/core/test/Acre.test.ts +++ b/core/test/Acre.test.ts @@ -13,34 +13,38 @@ import { import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers" import type { SnapshotRestorer } from "@nomicfoundation/hardhat-toolbox/network-helpers" import { deployment } from "./helpers/context" -import { getNamedSigner, getUnnamedSigner } from "./helpers/signer" +import { getUnnamedSigner, getNamedSigner } from "./helpers/signer" import { to1e18 } from "./utils" -import type { Acre, TestERC20 } from "../typechain" +import type { Acre, TestERC20, Dispatcher } from "../typechain" async function fixture() { - const { tbtc, acre } = await deployment() + const { tbtc, acre, dispatcher } = await deployment() + const { governance } = await getNamedSigner() - const [staker1, staker2] = await getUnnamedSigner() - const { governance: owner } = await getNamedSigner() + const [staker1, staker2, thirdParty] = await getUnnamedSigner() const amountToMint = to1e18(100000) tbtc.mint(staker1, amountToMint) tbtc.mint(staker2, amountToMint) - return { acre, tbtc, owner, staker1, staker2 } + return { acre, tbtc, staker1, staker2, dispatcher, governance, thirdParty } } describe("Acre", () => { let acre: Acre let tbtc: TestERC20 - let owner: HardhatEthersSigner + let dispatcher: Dispatcher + + let governance: HardhatEthersSigner let staker1: HardhatEthersSigner let staker2: HardhatEthersSigner + let thirdParty: HardhatEthersSigner before(async () => { - ;({ acre, tbtc, staker1, staker2, owner } = await loadFixture(fixture)) + ;({ acre, tbtc, staker1, staker2, dispatcher, governance, thirdParty } = + await loadFixture(fixture)) }) describe("stake", () => { @@ -370,7 +374,7 @@ describe("Acre", () => { ) }) - it("the staker A should be able to redeem more tokens than before", async () => { + it("the staker A should redeem more tokens than before", async () => { const shares = await acre.balanceOf(staker1.address) const availableAssetsToRedeem = await acre.previewRedeem(shares) @@ -382,7 +386,7 @@ describe("Acre", () => { expect(availableAssetsToRedeem).to.be.eq(expectedAssetsToRedeem) }) - it("the staker B should be able to redeem more tokens than before", async () => { + it("the staker B should redeem more tokens than before", async () => { const shares = await acre.balanceOf(staker2.address) const availableAssetsToRedeem = await acre.previewRedeem(shares) @@ -436,7 +440,7 @@ describe("Acre", () => { expect(shares).to.be.eq(sharesBefore + expectedSharesToMint) }) - it("should be able to redeem more tokens than before", async () => { + it("should redeem more tokens than before", async () => { const shares = await acre.balanceOf(staker1.address) const availableToRedeem = await acre.previewRedeem(shares) @@ -514,7 +518,7 @@ describe("Acre", () => { expect(await acre.maxDeposit(staker1)).to.eq(0) }) - it("should not be able to stake more tokens", async () => { + it("should not stake more tokens", async () => { await expect(acre.stake(amountToStake, staker1, referral)) .to.be.revertedWithCustomError( acre, @@ -646,13 +650,13 @@ describe("Acre", () => { await snapshot.restore() }) - context("when is called by owner", () => { + context("when is called by governance", () => { context("when all parameters are valid", () => { let tx: ContractTransactionResponse beforeEach(async () => { tx = await acre - .connect(owner) + .connect(governance) .updateDepositParameters( validMinimumDepositAmount, validMaximumTotalAssetsAmount, @@ -679,7 +683,7 @@ describe("Acre", () => { beforeEach(async () => { await acre - .connect(owner) + .connect(governance) .updateDepositParameters( newMinimumDepositAmount, validMaximumTotalAssetsAmount, @@ -698,7 +702,7 @@ describe("Acre", () => { beforeEach(async () => { await acre - .connect(owner) + .connect(governance) .updateDepositParameters( validMinimumDepositAmount, newMaximumTotalAssets, @@ -711,7 +715,7 @@ describe("Acre", () => { }) }) - context("when it is called by non-owner", () => { + context("when it is called by non-governance", () => { it("should revert", async () => { await expect( acre @@ -785,7 +789,7 @@ describe("Acre", () => { beforeEach(async () => { await acre - .connect(owner) + .connect(governance) .updateDepositParameters(minimumDepositAmount, maximum) }) @@ -816,6 +820,73 @@ describe("Acre", () => { }) }) + describe("updateDispatcher", () => { + let snapshot: SnapshotRestorer + + before(async () => { + snapshot = await takeSnapshot() + }) + + after(async () => { + await snapshot.restore() + }) + + context("when caller is not governance", () => { + it("should revert", async () => { + await expect(acre.connect(thirdParty).updateDispatcher(ZeroAddress)) + .to.be.revertedWithCustomError(acre, "OwnableUnauthorizedAccount") + .withArgs(thirdParty.address) + }) + }) + + context("when caller is governance", () => { + context("when a new dispatcher is zero address", () => { + it("should revert", async () => { + await expect( + acre.connect(governance).updateDispatcher(ZeroAddress), + ).to.be.revertedWithCustomError(acre, "ZeroAddress") + }) + }) + + context("when a new dispatcher is non-zero address", () => { + let newDispatcher: string + let acreAddress: string + let dispatcherAddress: string + let tx: ContractTransactionResponse + + before(async () => { + // Dispatcher is set by the deployment scripts. See deployment tests + // where initial parameters are checked. + dispatcherAddress = await dispatcher.getAddress() + newDispatcher = await ethers.Wallet.createRandom().getAddress() + acreAddress = await acre.getAddress() + + tx = await acre.connect(governance).updateDispatcher(newDispatcher) + }) + + it("should update the dispatcher", async () => { + expect(await acre.dispatcher()).to.be.equal(newDispatcher) + }) + + it("should reset approval amount for the old dispatcher", async () => { + const allowance = await tbtc.allowance(acreAddress, dispatcherAddress) + expect(allowance).to.be.equal(0) + }) + + it("should approve max amount for the new dispatcher", async () => { + const allowance = await tbtc.allowance(acreAddress, newDispatcher) + expect(allowance).to.be.equal(MaxUint256) + }) + + it("should emit DispatcherUpdated event", async () => { + await expect(tx) + .to.emit(acre, "DispatcherUpdated") + .withArgs(dispatcherAddress, newDispatcher) + }) + }) + }) + }) + describe("maxMint", () => { let maximumTotalAssets: bigint let minimumDepositAmount: bigint @@ -890,7 +961,7 @@ describe("Acre", () => { beforeEach(async () => { await acre - .connect(owner) + .connect(governance) .updateDepositParameters(minimumDepositAmount, maximum) }) diff --git a/core/test/Deployment.test.ts b/core/test/Deployment.test.ts new file mode 100644 index 000000000..362e2f43c --- /dev/null +++ b/core/test/Deployment.test.ts @@ -0,0 +1,61 @@ +import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers" +import { expect } from "chai" +import { MaxUint256 } from "ethers" + +import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers" +import { deployment } from "./helpers/context" +import { getNamedSigner } from "./helpers/signer" + +import type { Acre, Dispatcher, TestERC20 } from "../typechain" + +async function fixture() { + const { tbtc, acre, dispatcher } = await deployment() + const { governance, maintainer } = await getNamedSigner() + + return { acre, dispatcher, tbtc, governance, maintainer } +} + +describe("Deployment", () => { + let acre: Acre + let dispatcher: Dispatcher + let tbtc: TestERC20 + let maintainer: HardhatEthersSigner + + before(async () => { + ;({ acre, dispatcher, tbtc, maintainer } = await loadFixture(fixture)) + }) + + describe("Acre", () => { + describe("updateDispatcher", () => { + context("when a dispatcher has been set", () => { + it("should be set to a dispatcher address by the deployment script", async () => { + const actualDispatcher = await acre.dispatcher() + + expect(actualDispatcher).to.be.equal(await dispatcher.getAddress()) + }) + + it("should approve max amount for the dispatcher", async () => { + const actualDispatcher = await acre.dispatcher() + const allowance = await tbtc.allowance( + await acre.getAddress(), + actualDispatcher, + ) + + expect(allowance).to.be.equal(MaxUint256) + }) + }) + }) + }) + + describe("Dispatcher", () => { + describe("updateMaintainer", () => { + context("when a new maintainer has been set", () => { + it("should be set to a new maintainer address", async () => { + const actualMaintainer = await dispatcher.maintainer() + + expect(actualMaintainer).to.be.equal(await maintainer.getAddress()) + }) + }) + }) + }) +}) diff --git a/core/test/Dispatcher.test.ts b/core/test/Dispatcher.test.ts index 1fdecc5d9..435ee2808 100644 --- a/core/test/Dispatcher.test.ts +++ b/core/test/Dispatcher.test.ts @@ -6,31 +6,39 @@ import { takeSnapshot, loadFixture, } from "@nomicfoundation/hardhat-toolbox/network-helpers" -import type { Dispatcher } from "../typechain" +import { ZeroAddress } from "ethers" +import type { Dispatcher, TestERC4626, Acre, TestERC20 } from "../typechain" import { deployment } from "./helpers/context" import { getNamedSigner, getUnnamedSigner } from "./helpers/signer" +import { to1e18 } from "./utils" async function fixture() { - const { dispatcher } = await deployment() - const { governance } = await getNamedSigner() + const { tbtc, acre, dispatcher, vault } = await deployment() + const { governance, maintainer } = await getNamedSigner() const [thirdParty] = await getUnnamedSigner() - return { dispatcher, governance, thirdParty } + return { dispatcher, governance, thirdParty, maintainer, vault, tbtc, acre } } describe("Dispatcher", () => { let snapshot: SnapshotRestorer let dispatcher: Dispatcher + let vault: TestERC4626 + let tbtc: TestERC20 + let acre: Acre + let governance: HardhatEthersSigner let thirdParty: HardhatEthersSigner + let maintainer: HardhatEthersSigner let vaultAddress1: string let vaultAddress2: string let vaultAddress3: string let vaultAddress4: string before(async () => { - ;({ dispatcher, governance, thirdParty } = await loadFixture(fixture)) + ;({ dispatcher, governance, thirdParty, maintainer, vault, tbtc, acre } = + await loadFixture(fixture)) vaultAddress1 = await ethers.Wallet.createRandom().getAddress() vaultAddress2 = await ethers.Wallet.createRandom().getAddress() @@ -51,19 +59,25 @@ describe("Dispatcher", () => { it("should revert when adding a vault", async () => { await expect( dispatcher.connect(thirdParty).authorizeVault(vaultAddress1), - ).to.be.revertedWithCustomError( - dispatcher, - "OwnableUnauthorizedAccount", ) + .to.be.revertedWithCustomError( + dispatcher, + "OwnableUnauthorizedAccount", + ) + .withArgs(thirdParty.address) }) }) context("when caller is a governance account", () => { - it("should be able to authorize vaults", async () => { - await dispatcher.connect(governance).authorizeVault(vaultAddress1) + let tx: ContractTransactionResponse + + beforeEach(async () => { + tx = await dispatcher.connect(governance).authorizeVault(vaultAddress1) await dispatcher.connect(governance).authorizeVault(vaultAddress2) await dispatcher.connect(governance).authorizeVault(vaultAddress3) + }) + it("should authorize vaults", async () => { expect(await dispatcher.vaults(0)).to.equal(vaultAddress1) expect(await dispatcher.vaultsInfo(vaultAddress1)).to.be.equal(true) @@ -74,17 +88,14 @@ describe("Dispatcher", () => { expect(await dispatcher.vaultsInfo(vaultAddress3)).to.be.equal(true) }) - it("should not be able to authorize the same vault twice", async () => { - await dispatcher.connect(governance).authorizeVault(vaultAddress1) + it("should not authorize the same vault twice", async () => { await expect( dispatcher.connect(governance).authorizeVault(vaultAddress1), ).to.be.revertedWithCustomError(dispatcher, "VaultAlreadyAuthorized") }) it("should emit an event when adding a vault", async () => { - await expect( - dispatcher.connect(governance).authorizeVault(vaultAddress1), - ) + await expect(tx) .to.emit(dispatcher, "VaultAuthorized") .withArgs(vaultAddress1) }) @@ -102,15 +113,17 @@ describe("Dispatcher", () => { it("should revert when adding a vault", async () => { await expect( dispatcher.connect(thirdParty).deauthorizeVault(vaultAddress1), - ).to.be.revertedWithCustomError( - dispatcher, - "OwnableUnauthorizedAccount", ) + .to.be.revertedWithCustomError( + dispatcher, + "OwnableUnauthorizedAccount", + ) + .withArgs(thirdParty.address) }) }) context("when caller is a governance account", () => { - it("should be able to authorize vaults", async () => { + it("should deauthorize vaults", async () => { await dispatcher.connect(governance).deauthorizeVault(vaultAddress1) // Last vault replaced the first vault in the 'vaults' array @@ -130,7 +143,7 @@ describe("Dispatcher", () => { expect(await dispatcher.vaultsInfo(vaultAddress3)).to.be.equal(false) }) - it("should be able to deauthorize a vault and authorize it again", async () => { + it("should deauthorize a vault and authorize it again", async () => { await dispatcher.connect(governance).deauthorizeVault(vaultAddress1) expect(await dispatcher.vaultsInfo(vaultAddress1)).to.be.equal(false) @@ -138,7 +151,7 @@ describe("Dispatcher", () => { expect(await dispatcher.vaultsInfo(vaultAddress1)).to.be.equal(true) }) - it("should not be able to deauthorize a vault that is not authorized", async () => { + it("should not deauthorize a vault that is not authorized", async () => { await expect( dispatcher.connect(governance).deauthorizeVault(vaultAddress4), ).to.be.revertedWithCustomError(dispatcher, "VaultUnauthorized") @@ -153,4 +166,150 @@ describe("Dispatcher", () => { }) }) }) + + describe("depositToVault", () => { + const assetsToAllocate = to1e18(100) + const minSharesOut = to1e18(100) + + before(async () => { + await dispatcher.connect(governance).authorizeVault(vault.getAddress()) + await tbtc.mint(await acre.getAddress(), to1e18(100000)) + }) + + context("when caller is not maintainer", () => { + it("should revert when depositing to a vault", async () => { + await expect( + dispatcher + .connect(thirdParty) + .depositToVault( + await vault.getAddress(), + assetsToAllocate, + minSharesOut, + ), + ).to.be.revertedWithCustomError(dispatcher, "NotMaintainer") + }) + }) + + context("when caller is maintainer", () => { + context("when vault is not authorized", () => { + it("should revert", async () => { + const randomAddress = await ethers.Wallet.createRandom().getAddress() + await expect( + dispatcher + .connect(maintainer) + .depositToVault(randomAddress, assetsToAllocate, minSharesOut), + ).to.be.revertedWithCustomError(dispatcher, "VaultUnauthorized") + }) + }) + + context("when the vault is authorized", () => { + let vaultAddress: string + + before(async () => { + vaultAddress = await vault.getAddress() + }) + + context("when allocation is successful", () => { + let tx: ContractTransactionResponse + + before(async () => { + tx = await dispatcher + .connect(maintainer) + .depositToVault(vaultAddress, assetsToAllocate, minSharesOut) + }) + + it("should deposit tBTC to a vault", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, + [acre, vault], + [-assetsToAllocate, assetsToAllocate], + ) + }) + + it("should mint vault's shares for Acre contract", async () => { + await expect(tx).to.changeTokenBalances( + vault, + [acre], + [minSharesOut], + ) + }) + + it("should emit a DepositAllocated event", async () => { + await expect(tx) + .to.emit(dispatcher, "DepositAllocated") + .withArgs(vaultAddress, assetsToAllocate, minSharesOut) + }) + }) + + context( + "when the expected returned shares are less than the actual returned shares", + () => { + const sharesOut = assetsToAllocate + const minShares = to1e18(101) + + it("should emit a MinSharesError event", async () => { + await expect( + dispatcher + .connect(maintainer) + .depositToVault(vaultAddress, assetsToAllocate, minShares), + ) + .to.be.revertedWithCustomError(dispatcher, "MinSharesError") + .withArgs(vaultAddress, sharesOut, minShares) + }) + }, + ) + }) + }) + }) + + describe("updateMaintainer", () => { + let newMaintainer: string + + before(async () => { + newMaintainer = await ethers.Wallet.createRandom().getAddress() + }) + + context("when caller is not an owner", () => { + it("should revert", async () => { + await expect( + dispatcher.connect(thirdParty).updateMaintainer(newMaintainer), + ) + .to.be.revertedWithCustomError( + dispatcher, + "OwnableUnauthorizedAccount", + ) + .withArgs(thirdParty.address) + }) + }) + + context("when caller is an owner", () => { + context("when maintainer is a zero address", () => { + it("should revert", async () => { + await expect( + dispatcher.connect(governance).updateMaintainer(ZeroAddress), + ).to.be.revertedWithCustomError(dispatcher, "ZeroAddress") + }) + }) + + context("when maintainer is not a zero address", () => { + let tx: ContractTransactionResponse + + before(async () => { + tx = await dispatcher + .connect(governance) + .updateMaintainer(newMaintainer) + }) + + it("should update the maintainer", async () => { + expect(await dispatcher.maintainer()).to.be.equal(newMaintainer) + }) + + it("should emit an event when updating the maintainer", async () => { + await expect(tx) + .to.emit(dispatcher, "MaintainerUpdated") + .withArgs(newMaintainer) + }) + }) + }) + }) }) diff --git a/core/test/helpers/context.ts b/core/test/helpers/context.ts index 34875e43d..239242a39 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, Dispatcher, TestERC20 } from "../../typechain" +import type { Acre, Dispatcher, TestERC20, TestERC4626 } 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 dispatcher: Dispatcher = await getDeployedContract("Dispatcher") + const vault: TestERC4626 = await getDeployedContract("Vault") - return { tbtc, acre, dispatcher } + return { tbtc, acre, dispatcher, vault } }