Skip to content

Commit

Permalink
Handling ERC4626 vaults in AcreRouter (#62)
Browse files Browse the repository at this point in the history
AcreRouter contract should manage ERC4626 vaults within the Acre
ecosystem. Owner of the contract should be able to add or remove vaults.

There's a `Vault` struct inside of the `AcreRouter` that holds only one
boolean `approved`. This struct will grow in the next PR(s) adding
things like "distribution percent" for each vault.
  • Loading branch information
nkuba authored Dec 13, 2023
2 parents 3a05a45 + e94c5fc commit 5cdfff9
Show file tree
Hide file tree
Showing 6 changed files with 274 additions and 3 deletions.
3 changes: 2 additions & 1 deletion core/.solhint.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"extends": "thesis",
"plugins": []
"plugins": [],
"rules": {}
}
69 changes: 69 additions & 0 deletions core/contracts/Dispatcher.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
22 changes: 22 additions & 0 deletions core/deploy/02_deploy_acre_router.ts
Original file line number Diff line number Diff line change
@@ -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"]
22 changes: 22 additions & 0 deletions core/deploy/22_transfer_ownership_acre_router.ts
Original file line number Diff line number Diff line change
@@ -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"]
156 changes: 156 additions & 0 deletions core/test/Dispatcher.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
})
5 changes: 3 additions & 2 deletions core/test/helpers/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { deployments } from "hardhat"

import { getDeployedContract } from "./contract"

import type { Acre, TestERC20 } from "../../typechain"
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 }
return { tbtc, acre, dispatcher }
}

0 comments on commit 5cdfff9

Please sign in to comment.