diff --git a/core/.solhint.json b/core/.solhint.json index f54af0b9a..7afe6405c 100644 --- a/core/.solhint.json +++ b/core/.solhint.json @@ -1,4 +1,5 @@ { "extends": "thesis", - "plugins": [] + "plugins": [], + "rules": {} } diff --git a/core/contracts/Dispatcher.sol b/core/contracts/Dispatcher.sol new file mode 100644 index 000000000..309da2ae8 --- /dev/null +++ b/core/contracts/Dispatcher.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.21; + +import "@openzeppelin/contracts/access/Ownable.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 +/// generate yield for Bitcoin holders. +contract Dispatcher is Ownable { + error VaultAlreadyAuthorized(); + error VaultUnauthorized(); + + 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. + address[] public vaults; + mapping(address => VaultInfo) public vaultsInfo; + + event VaultAuthorized(address indexed vault); + event VaultDeauthorized(address indexed vault); + + constructor() Ownable(msg.sender) {} + + /// @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) { + revert VaultAlreadyAuthorized(); + } + + vaults.push(vault); + vaultsInfo[vault].authorized = true; + + emit VaultAuthorized(vault); + } + + /// @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) { + revert VaultUnauthorized(); + } + + vaultsInfo[vault].authorized = false; + + for (uint256 i = 0; i < vaults.length; i++) { + if (vaults[i] == vault) { + vaults[i] = vaults[vaults.length - 1]; + // slither-disable-next-line costly-loop + vaults.pop(); + break; + } + } + + emit VaultDeauthorized(vault); + } + + function getVaults() external view returns (address[] memory) { + return vaults; + } +} diff --git a/core/deploy/02_deploy_acre_router.ts b/core/deploy/02_deploy_acre_router.ts new file mode 100644 index 000000000..bf99d4d73 --- /dev/null +++ b/core/deploy/02_deploy_acre_router.ts @@ -0,0 +1,22 @@ +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() + + await deployments.deploy("Dispatcher", { + from: deployer, + args: [], + 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/22_transfer_ownership_acre_router.ts b/core/deploy/22_transfer_ownership_acre_router.ts new file mode 100644 index 000000000..686d378c4 --- /dev/null +++ b/core/deploy/22_transfer_ownership_acre_router.ts @@ -0,0 +1,22 @@ +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 Dispatcher contract to ${governance}`) + + await deployments.execute( + "Dispatcher", + { from: deployer, log: true, waitConfirmations: 1 }, + "transferOwnership", + governance, + ) +} + +export default func + +func.tags = ["TransferOwnershipAcreRouter"] +func.dependencies = ["Dispatcher"] diff --git a/core/scripts/fetch_external_artifacts.sh b/core/scripts/fetch_external_artifacts.sh index 5c043568e..c75c98122 100755 --- a/core/scripts/fetch_external_artifacts.sh +++ b/core/scripts/fetch_external_artifacts.sh @@ -1,7 +1,10 @@ #! /bin/bash set -eou pipefail -ROOT_DIR="$(realpath "$(dirname $0)/../")" +ROOT_DIR=$( + cd "$(dirname $0)/../" + pwd -P +) TMP_DIR=${ROOT_DIR}/tmp/external-artifacts EXTERNAL_ARTIFACTS_DIR=${ROOT_DIR}/external diff --git a/core/test/Acre.test.ts b/core/test/Acre.test.ts index 74e764ca0..4a5960e3e 100644 --- a/core/test/Acre.test.ts +++ b/core/test/Acre.test.ts @@ -1,23 +1,23 @@ import { - SnapshotRestorer, - loadFixture, takeSnapshot, + loadFixture, } from "@nomicfoundation/hardhat-toolbox/network-helpers" -import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers" -import { ethers } from "hardhat" import { expect } from "chai" import { ContractTransactionResponse, ZeroAddress } from "ethers" -import type { TestERC20, Acre } from "../typechain" + +import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers" +import type { SnapshotRestorer } from "@nomicfoundation/hardhat-toolbox/network-helpers" +import { deployment } from "./helpers/context" +import { getUnnamedSigner } from "./helpers/signer" + import { to1e18 } from "./utils" -async function acreFixture() { - const [staker1, staker2] = await ethers.getSigners() +import type { Acre, TestERC20 } from "../typechain" - const TestERC20 = await ethers.getContractFactory("TestERC20") - const tbtc = await TestERC20.deploy() +async function fixture() { + const { tbtc, acre } = await deployment() - const Acre = await ethers.getContractFactory("Acre") - const acre = await Acre.deploy(await tbtc.getAddress()) + const [staker1, staker2] = await getUnnamedSigner() const amountToMint = to1e18(100000) tbtc.mint(staker1, amountToMint) @@ -33,7 +33,7 @@ describe("Acre", () => { let staker2: HardhatEthersSigner before(async () => { - ;({ acre, tbtc, staker1, staker2 } = await loadFixture(acreFixture)) + ;({ acre, tbtc, staker1, staker2 } = await loadFixture(fixture)) }) describe("stake", () => { diff --git a/core/test/Dispatcher.test.ts b/core/test/Dispatcher.test.ts new file mode 100644 index 000000000..1fdecc5d9 --- /dev/null +++ b/core/test/Dispatcher.test.ts @@ -0,0 +1,156 @@ +import { ethers } from "hardhat" +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers" +import { expect } from "chai" +import { + SnapshotRestorer, + takeSnapshot, + loadFixture, +} from "@nomicfoundation/hardhat-toolbox/network-helpers" +import type { Dispatcher } from "../typechain" +import { deployment } from "./helpers/context" +import { getNamedSigner, getUnnamedSigner } from "./helpers/signer" + +async function fixture() { + const { dispatcher } = await deployment() + const { governance } = await getNamedSigner() + const [thirdParty] = await getUnnamedSigner() + + return { dispatcher, governance, thirdParty } +} + +describe("Dispatcher", () => { + let snapshot: SnapshotRestorer + + let dispatcher: Dispatcher + let governance: HardhatEthersSigner + let thirdParty: HardhatEthersSigner + let vaultAddress1: string + let vaultAddress2: string + let vaultAddress3: string + let vaultAddress4: string + + before(async () => { + ;({ dispatcher, governance, thirdParty } = await loadFixture(fixture)) + + vaultAddress1 = await ethers.Wallet.createRandom().getAddress() + vaultAddress2 = await ethers.Wallet.createRandom().getAddress() + vaultAddress3 = await ethers.Wallet.createRandom().getAddress() + vaultAddress4 = await ethers.Wallet.createRandom().getAddress() + }) + + beforeEach(async () => { + snapshot = await takeSnapshot() + }) + + afterEach(async () => { + await snapshot.restore() + }) + + describe("authorizeVault", () => { + context("when caller is not a governance account", () => { + it("should revert when adding a vault", async () => { + await expect( + dispatcher.connect(thirdParty).authorizeVault(vaultAddress1), + ).to.be.revertedWithCustomError( + dispatcher, + "OwnableUnauthorizedAccount", + ) + }) + }) + + context("when caller is a governance account", () => { + it("should be able to authorize vaults", async () => { + await dispatcher.connect(governance).authorizeVault(vaultAddress1) + await dispatcher.connect(governance).authorizeVault(vaultAddress2) + await dispatcher.connect(governance).authorizeVault(vaultAddress3) + + expect(await dispatcher.vaults(0)).to.equal(vaultAddress1) + expect(await dispatcher.vaultsInfo(vaultAddress1)).to.be.equal(true) + + expect(await dispatcher.vaults(1)).to.equal(vaultAddress2) + expect(await dispatcher.vaultsInfo(vaultAddress2)).to.be.equal(true) + + expect(await dispatcher.vaults(2)).to.equal(vaultAddress3) + 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) + 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), + ) + .to.emit(dispatcher, "VaultAuthorized") + .withArgs(vaultAddress1) + }) + }) + }) + + describe("deauthorizeVault", () => { + beforeEach(async () => { + await dispatcher.connect(governance).authorizeVault(vaultAddress1) + await dispatcher.connect(governance).authorizeVault(vaultAddress2) + await dispatcher.connect(governance).authorizeVault(vaultAddress3) + }) + + context("when caller is not a governance account", () => { + it("should revert when adding a vault", async () => { + await expect( + dispatcher.connect(thirdParty).deauthorizeVault(vaultAddress1), + ).to.be.revertedWithCustomError( + dispatcher, + "OwnableUnauthorizedAccount", + ) + }) + }) + + context("when caller is a governance account", () => { + it("should be able to authorize vaults", async () => { + await dispatcher.connect(governance).deauthorizeVault(vaultAddress1) + + // Last vault replaced the first vault in the 'vaults' array + expect(await dispatcher.vaults(0)).to.equal(vaultAddress3) + expect(await dispatcher.vaultsInfo(vaultAddress1)).to.be.equal(false) + expect((await dispatcher.getVaults()).length).to.equal(2) + + await dispatcher.connect(governance).deauthorizeVault(vaultAddress2) + + // Last vault (vaultAddress2) was removed from the 'vaults' array + expect(await dispatcher.vaults(0)).to.equal(vaultAddress3) + expect((await dispatcher.getVaults()).length).to.equal(1) + expect(await dispatcher.vaultsInfo(vaultAddress2)).to.be.equal(false) + + await dispatcher.connect(governance).deauthorizeVault(vaultAddress3) + expect((await dispatcher.getVaults()).length).to.equal(0) + expect(await dispatcher.vaultsInfo(vaultAddress3)).to.be.equal(false) + }) + + it("should be able to deauthorize a vault and authorize it again", async () => { + await dispatcher.connect(governance).deauthorizeVault(vaultAddress1) + expect(await dispatcher.vaultsInfo(vaultAddress1)).to.be.equal(false) + + await dispatcher.connect(governance).authorizeVault(vaultAddress1) + expect(await dispatcher.vaultsInfo(vaultAddress1)).to.be.equal(true) + }) + + it("should not be able to deauthorize a vault that is not authorized", async () => { + await expect( + dispatcher.connect(governance).deauthorizeVault(vaultAddress4), + ).to.be.revertedWithCustomError(dispatcher, "VaultUnauthorized") + }) + + it("should emit an event when removing a vault", async () => { + await expect( + dispatcher.connect(governance).deauthorizeVault(vaultAddress1), + ) + .to.emit(dispatcher, "VaultDeauthorized") + .withArgs(vaultAddress1) + }) + }) + }) +}) diff --git a/core/test/helpers/context.ts b/core/test/helpers/context.ts new file mode 100644 index 000000000..34875e43d --- /dev/null +++ b/core/test/helpers/context.ts @@ -0,0 +1,16 @@ +import { deployments } from "hardhat" + +import { getDeployedContract } from "./contract" + +import type { Acre, Dispatcher, TestERC20 } from "../../typechain" + +// eslint-disable-next-line import/prefer-default-export +export async function deployment() { + await deployments.fixture() + + const tbtc: TestERC20 = await getDeployedContract("TBTC") + const acre: Acre = await getDeployedContract("Acre") + const dispatcher: Dispatcher = await getDeployedContract("Dispatcher") + + return { tbtc, acre, dispatcher } +} diff --git a/core/test/helpers/contract.ts b/core/test/helpers/contract.ts new file mode 100644 index 000000000..88a83812a --- /dev/null +++ b/core/test/helpers/contract.ts @@ -0,0 +1,22 @@ +import { ethers } from "ethers" +import { deployments } from "hardhat" + +import type { BaseContract } from "ethers" +import { getUnnamedSigner } from "./signer" + +/** + * Get instance of a contract from Hardhat Deployments. + * @param deploymentName Name of the contract deployment. + * @returns Deployed Ethers contract instance. + */ +// eslint-disable-next-line import/prefer-default-export +export async function getDeployedContract( + deploymentName: string, +): Promise { + const { address, abi } = await deployments.get(deploymentName) + + // Use default unnamed signer from index 0 to initialize the contract runner. + const [defaultSigner] = await getUnnamedSigner() + + return new ethers.BaseContract(address, abi, defaultSigner) as T +} diff --git a/core/test/helpers/index.ts b/core/test/helpers/index.ts new file mode 100644 index 000000000..27ddcb0b9 --- /dev/null +++ b/core/test/helpers/index.ts @@ -0,0 +1,3 @@ +export * from "./context" +export * from "./contract" +export * from "./signer" diff --git a/core/test/helpers/signer.ts b/core/test/helpers/signer.ts new file mode 100644 index 000000000..0ae57f35e --- /dev/null +++ b/core/test/helpers/signer.ts @@ -0,0 +1,31 @@ +import { ethers, getNamedAccounts, getUnnamedAccounts } from "hardhat" + +import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers" + +/** + * Get named Hardhat Ethers Signers. + * @returns Map of named Hardhat Ethers Signers. + */ +export async function getNamedSigner(): Promise<{ + [name: string]: HardhatEthersSigner +}> { + const namedSigners: { [name: string]: HardhatEthersSigner } = {} + + await Promise.all( + Object.entries(await getNamedAccounts()).map(async ([name, address]) => { + namedSigners[name] = await ethers.getSigner(address) + }), + ) + + return namedSigners +} + +/** + * Get unnamed Hardhat Ethers Signers. + * @returns Array of unnamed Hardhat Ethers Signers. + */ +export async function getUnnamedSigner(): Promise { + const accounts = await getUnnamedAccounts() + + return Promise.all(accounts.map(ethers.getSigner)) +}