diff --git a/solidity/README.md b/solidity/README.md index 651236aa9..177801d40 100644 --- a/solidity/README.md +++ b/solidity/README.md @@ -24,6 +24,22 @@ To run the test execute: $ pnpm test ``` +### Integration testing + +To run the integration tests follow these steps: + +- Run the Hardhat Node locally forking Mainnet at block `19680873`: + +``` +$ npx hardhat node --no-deploy --fork https://eth-mainnet.g.alchemy.com/v2/ --fork-block-number 19680873 +``` + +- Once the local node is running you can execute the integration tests: + +``` +$ pnpm test:integration +``` + ### Deploying We deploy our contracts with diff --git a/solidity/deploy/00_resolve_mezo_portal.ts b/solidity/deploy/00_resolve_mezo_portal.ts index 412c468d6..15569002a 100644 --- a/solidity/deploy/00_resolve_mezo_portal.ts +++ b/solidity/deploy/00_resolve_mezo_portal.ts @@ -1,8 +1,5 @@ import type { DeployFunction } from "hardhat-deploy/types" -import type { - HardhatNetworkConfig, - HardhatRuntimeEnvironment, -} from "hardhat/types" +import type { HardhatRuntimeEnvironment } from "hardhat/types" import { isNonZeroAddress } from "../helpers/address" import { waitConfirmationsNumber } from "../helpers/deployment" @@ -15,10 +12,7 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { if (mezoPortal && isNonZeroAddress(mezoPortal.address)) { log(`using MezoPortal contract at ${mezoPortal.address}`) - } else if ( - (hre.network.config as HardhatNetworkConfig)?.forking?.enabled && - hre.network.name !== "hardhat" - ) { + } else if (hre.network.name !== "hardhat") { throw new Error("deployed MezoPortal contract not found") } else { log("deploying Mezo Portal contract stub") diff --git a/solidity/deploy/00_resolve_tbtc_bridge.ts b/solidity/deploy/00_resolve_tbtc_bridge.ts index 4150b696a..98e60c54e 100644 --- a/solidity/deploy/00_resolve_tbtc_bridge.ts +++ b/solidity/deploy/00_resolve_tbtc_bridge.ts @@ -1,8 +1,5 @@ import type { DeployFunction } from "hardhat-deploy/types" -import type { - HardhatNetworkConfig, - HardhatRuntimeEnvironment, -} from "hardhat/types" +import type { HardhatRuntimeEnvironment } from "hardhat/types" import { isNonZeroAddress } from "../helpers/address" import { waitConfirmationsNumber } from "../helpers/deployment" @@ -15,7 +12,7 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { if (bridge && isNonZeroAddress(bridge.address)) { log(`using Bridge contract at ${bridge.address}`) - } else if ((hre.network.config as HardhatNetworkConfig)?.forking?.enabled) { + } else if (hre.network.name !== "hardhat") { throw new Error("deployed Bridge contract not found") } else { log("deploying Bridge contract stub") diff --git a/solidity/deploy/00_resolve_tbtc_token.ts b/solidity/deploy/00_resolve_tbtc_token.ts index d1ddf731e..e710fd02a 100644 --- a/solidity/deploy/00_resolve_tbtc_token.ts +++ b/solidity/deploy/00_resolve_tbtc_token.ts @@ -1,8 +1,5 @@ import type { DeployFunction } from "hardhat-deploy/types" -import type { - HardhatNetworkConfig, - HardhatRuntimeEnvironment, -} from "hardhat/types" +import type { HardhatRuntimeEnvironment } from "hardhat/types" import { isNonZeroAddress } from "../helpers/address" import { waitConfirmationsNumber } from "../helpers/deployment" @@ -15,10 +12,7 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { if (tbtc && isNonZeroAddress(tbtc.address)) { log(`using TBTC contract at ${tbtc.address}`) - } else if ( - !hre.network.tags.allowStubs || - (hre.network.config as HardhatNetworkConfig)?.forking?.enabled - ) { + } else if (hre.network.name !== "hardhat") { throw new Error("deployed TBTC contract not found") } else { log("deploying TBTC contract stub") diff --git a/solidity/deploy/00_resolve_tbtc_vault.ts b/solidity/deploy/00_resolve_tbtc_vault.ts index 27034347f..c8a0f5517 100644 --- a/solidity/deploy/00_resolve_tbtc_vault.ts +++ b/solidity/deploy/00_resolve_tbtc_vault.ts @@ -1,8 +1,5 @@ import type { DeployFunction } from "hardhat-deploy/types" -import type { - HardhatNetworkConfig, - HardhatRuntimeEnvironment, -} from "hardhat/types" +import type { HardhatRuntimeEnvironment } from "hardhat/types" import { isNonZeroAddress } from "../helpers/address" import { waitConfirmationsNumber } from "../helpers/deployment" @@ -15,10 +12,7 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { if (tbtcVault && isNonZeroAddress(tbtcVault.address)) { log(`using TBTCVault contract at ${tbtcVault.address}`) - } else if ( - !hre.network.tags.allowStubs || - (hre.network.config as HardhatNetworkConfig)?.forking?.enabled - ) { + } else if (hre.network.name !== "hardhat") { throw new Error("deployed TBTCVault contract not found") } else { log("deploying TBTCVault contract stub") diff --git a/solidity/deploy/02_deploy_mezo_allocator.ts b/solidity/deploy/02_deploy_mezo_allocator.ts index 694fad100..2a1bbeb86 100644 --- a/solidity/deploy/02_deploy_mezo_allocator.ts +++ b/solidity/deploy/02_deploy_mezo_allocator.ts @@ -7,8 +7,8 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { const { governance } = await getNamedAccounts() const { deployer } = await helpers.signers.getNamedSigners() - const tbtc = await deployments.get("TBTC") const stbtc = await deployments.get("stBTC") + const tbtc = await deployments.get("TBTC") const mezoPortal = await deployments.get("MezoPortal") const [, deployment] = await helpers.upgrades.deployProxy("MezoAllocator", { diff --git a/solidity/deploy/03_deploy_bitcoin_depositor.ts b/solidity/deploy/03_deploy_bitcoin_depositor.ts index 7de26abcb..af280c9f5 100644 --- a/solidity/deploy/03_deploy_bitcoin_depositor.ts +++ b/solidity/deploy/03_deploy_bitcoin_depositor.ts @@ -7,9 +7,9 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { const { governance } = await getNamedAccounts() const { deployer } = await helpers.signers.getNamedSigners() + const tbtc = await deployments.get("TBTC") const bridge = await deployments.get("Bridge") const tbtcVault = await deployments.get("TBTCVault") - const tbtc = await deployments.get("TBTC") const stbtc = await deployments.get("stBTC") const [, deployment] = await helpers.upgrades.deployProxy( diff --git a/solidity/hardhat.config.ts b/solidity/hardhat.config.ts index 4db8ae721..ac6a1492e 100644 --- a/solidity/hardhat.config.ts +++ b/solidity/hardhat.config.ts @@ -26,8 +26,8 @@ const config: HardhatUserConfig = { }, networks: { - hardhat: { - tags: ["allowStubs"], + integration: { + url: "http://localhost:8545", }, sepolia: { url: process.env.CHAIN_API_URL || "", @@ -51,6 +51,7 @@ const config: HardhatUserConfig = { deployments: { sepolia: ["./external/sepolia"], mainnet: ["./external/mainnet"], + integration: ["./external/mainnet"], }, }, diff --git a/solidity/package.json b/solidity/package.json index 6f867cbaf..8cb5969e1 100644 --- a/solidity/package.json +++ b/solidity/package.json @@ -26,7 +26,8 @@ "lint:sol:fix": "solhint 'contracts/**/*.sol' --fix && prettier --write 'contracts/**/*.sol'", "lint:config": "prettier --check '**/*.@(json)'", "lint:config:fix": "prettier --write '**/*.@(json)'", - "test": "hardhat test" + "test": "hardhat test ./test/*.test.ts", + "test:integration": "hardhat test --deploy-fixture ./test/integration/*.test.ts --network integration" }, "devDependencies": { "@keep-network/hardhat-helpers": "^0.7.1", diff --git a/solidity/test/helpers/context.ts b/solidity/test/helpers/context.ts index b4d864fcb..ea8bb6920 100644 --- a/solidity/test/helpers/context.ts +++ b/solidity/test/helpers/context.ts @@ -6,7 +6,7 @@ import type { BridgeStub, TBTCVaultStub, MezoAllocator, - MezoPortalStub, + IMezoPortal, BitcoinDepositor, BitcoinRedeemer, TestTBTC, @@ -28,7 +28,7 @@ export async function deployment() { const mezoAllocator: MezoAllocator = await getDeployedContract("MezoAllocator") - const mezoPortal: MezoPortalStub = await getDeployedContract("MezoPortal") + const mezoPortal: IMezoPortal = await getDeployedContract("MezoPortal") return { tbtc, diff --git a/solidity/test/helpers/contract.ts b/solidity/test/helpers/contract.ts index 9233529d4..5ff70126b 100644 --- a/solidity/test/helpers/contract.ts +++ b/solidity/test/helpers/contract.ts @@ -14,7 +14,6 @@ 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 getUnnamedSigners() diff --git a/solidity/test/integration/MezoAllocator.test.ts b/solidity/test/integration/MezoAllocator.test.ts new file mode 100644 index 000000000..758704827 --- /dev/null +++ b/solidity/test/integration/MezoAllocator.test.ts @@ -0,0 +1,366 @@ +import { helpers, ethers } from "hardhat" +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers" +import { expect } from "chai" +import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers" + +import { ContractTransactionResponse } from "ethers" +import { beforeAfterSnapshotWrapper, deployment } from "../helpers" + +import { + StBTC as stBTC, + TestERC20, + MezoAllocator, + IMezoPortal, +} from "../../typechain" + +import { to1e18 } from "../utils" + +const { getNamedSigners, getUnnamedSigners } = helpers.signers +const { impersonateAccount } = helpers.account + +async function fixture() { + const { tbtc, stbtc, mezoAllocator, mezoPortal } = await deployment() + const { governance, maintainer } = await getNamedSigners() + const [depositor, thirdParty] = await getUnnamedSigners() + + return { + governance, + thirdParty, + depositor, + maintainer, + tbtc, + stbtc, + mezoAllocator, + mezoPortal, + } +} + +describe("MezoAllocator", () => { + // This is a random mainnet address of the whale account that holds 100 tBTC + // tokens that can be used for testing purposes after impersonation. + const whaleAddress = "0x84eA3907b9206427F45c7b2614925a2B86D12611" + let tbtc: TestERC20 + let stbtc: stBTC + let mezoAllocator: MezoAllocator + let mezoPortal: IMezoPortal + + let thirdParty: HardhatEthersSigner + let depositor: HardhatEthersSigner + let maintainer: HardhatEthersSigner + let governance: HardhatEthersSigner + let tbtcHolder: HardhatEthersSigner + + before(async () => { + ;({ + thirdParty, + depositor, + maintainer, + governance, + tbtc, + stbtc, + mezoAllocator, + mezoPortal, + } = await loadFixture(fixture)) + + await impersonateAccount(whaleAddress) + tbtcHolder = await ethers.getSigner(whaleAddress) + }) + + describe("allocate", () => { + beforeAfterSnapshotWrapper() + + context("when a caller is not a maintainer", () => { + it("should revert", async () => { + await expect( + mezoAllocator.connect(thirdParty).allocate(), + ).to.be.revertedWithCustomError(mezoAllocator, "CallerNotMaintainer") + }) + }) + + context("when the caller is maintainer", () => { + context("when a first deposit is made", () => { + let tx: ContractTransactionResponse + + before(async () => { + await tbtc + .connect(tbtcHolder) + .transfer(await stbtc.getAddress(), to1e18(6)) + tx = await mezoAllocator.connect(maintainer).allocate() + }) + + it("should deposit and transfer tBTC to Mezo Portal", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, + [await mezoPortal.getAddress()], + [to1e18(6)], + ) + }) + + it("should not store any tBTC in Mezo Allocator", async () => { + expect( + await tbtc.balanceOf(await mezoAllocator.getAddress()), + ).to.equal(0) + }) + + it("should increment the deposit id", async () => { + const actualDepositId = await mezoAllocator.depositId() + // As of a forked block 19680873, the deposit id was 2272 before the new + // allocation. The deposit id should be incremented by 1. + expect(actualDepositId).to.equal(2273) + }) + + it("should increase tracked deposit balance amount", async () => { + const depositBalance = await mezoAllocator.depositBalance() + expect(depositBalance).to.equal(to1e18(6)) + }) + + it("should emit DepositAllocated event", async () => { + await expect(tx) + .to.emit(mezoAllocator, "DepositAllocated") + .withArgs(0, 2273, to1e18(6), to1e18(6)) + }) + }) + + context("when a second deposit is made", () => { + let tx: ContractTransactionResponse + + before(async () => { + await tbtc + .connect(tbtcHolder) + .transfer(await stbtc.getAddress(), to1e18(5)) + + tx = await mezoAllocator.connect(maintainer).allocate() + }) + + it("should increment the deposit id", async () => { + const actualDepositId = await mezoAllocator.depositId() + expect(actualDepositId).to.equal(2274) + }) + + it("should emit DepositAllocated event", async () => { + await expect(tx) + .to.emit(mezoAllocator, "DepositAllocated") + .withArgs(2273, 2274, to1e18(5), to1e18(11)) + }) + + it("should deposit and transfer tBTC to Mezo Portal", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, + [await mezoPortal.getAddress()], + [to1e18(5)], + ) + }) + + it("should increase tracked deposit balance amount", async () => { + const depositBalance = await mezoAllocator.depositBalance() + expect(depositBalance).to.equal(to1e18(11)) + }) + + it("should not store any tBTC in Mezo Allocator", async () => { + expect( + await tbtc.balanceOf(await mezoAllocator.getAddress()), + ).to.equal(0) + }) + + it("should not store any tBTC in stBTC", async () => { + expect(await tbtc.balanceOf(await stbtc.getAddress())).to.equal(0) + }) + }) + }) + }) + + describe("withdraw", () => { + beforeAfterSnapshotWrapper() + + context("when a caller is not stBTC", () => { + it("should revert", async () => { + await expect( + mezoAllocator.connect(thirdParty).withdraw(1n), + ).to.be.revertedWithCustomError(mezoAllocator, "CallerNotStbtc") + }) + }) + + context("when the caller is stBTC contract", () => { + context("when there is no deposit", () => { + beforeAfterSnapshotWrapper() + + it("should revert", async () => { + // It is reverted because deposit id is 0 and there is no deposit + // with id 0 in Mezo Portal for Acre. + await expect( + stbtc.withdraw(1n, depositor, depositor), + ).to.be.revertedWithCustomError(mezoPortal, "DepositNotFound") + }) + }) + + context("when there is a deposit", () => { + beforeAfterSnapshotWrapper() + + let tx: ContractTransactionResponse + + before(async () => { + await tbtc.connect(tbtcHolder).transfer(depositor.address, to1e18(11)) + await tbtc.approve(await stbtc.getAddress(), to1e18(5)) + await stbtc.connect(depositor).deposit(to1e18(5), depositor) + await mezoAllocator.connect(maintainer).allocate() + }) + + context("when the deposit is not fully withdrawn", () => { + beforeAfterSnapshotWrapper() + + before(async () => { + tx = await stbtc.withdraw(to1e18(2), depositor, depositor) + }) + + it("should transfer 2 tBTC back to a depositor", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, + [depositor.address], + [to1e18(2)], + ) + }) + + it("should emit DepositWithdrawn event", async () => { + await expect(tx) + .to.emit(mezoAllocator, "DepositWithdrawn") + .withArgs(2273, to1e18(2)) + }) + + it("should decrease tracked deposit balance amount", async () => { + const depositBalance = await mezoAllocator.depositBalance() + expect(depositBalance).to.equal(to1e18(3)) + }) + + it("should decrease Mezo Portal balance", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, + [await mezoPortal.getAddress()], + [-to1e18(2)], + ) + }) + }) + + context("when the deposit is fully withdrawn", () => { + beforeAfterSnapshotWrapper() + + before(async () => { + tx = await stbtc.withdraw(to1e18(5), depositor, depositor) + }) + + it("should transfer 5 tBTC back to a depositor", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, + [depositor.address], + [to1e18(5)], + ) + }) + + it("should emit DepositWithdrawn event", async () => { + await expect(tx) + .to.emit(mezoAllocator, "DepositWithdrawn") + .withArgs(2273, to1e18(5)) + }) + + it("should decrease tracked deposit balance amount to zero", async () => { + const depositBalance = await mezoAllocator.depositBalance() + expect(depositBalance).to.equal(0) + }) + + it("should decrease Mezo Portal balance", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, + [await mezoPortal.getAddress()], + [-to1e18(5)], + ) + }) + }) + + context("when withdrawing more than was deposited", () => { + beforeAfterSnapshotWrapper() + + it("should revert", async () => { + await expect(stbtc.withdraw(to1e18(6), depositor, depositor)) + .to.be.revertedWithCustomError( + mezoPortal, + "InsufficientDepositAmount", + ) + .withArgs(to1e18(6), to1e18(5)) + }) + }) + }) + }) + }) + + describe("totalAssets", () => { + beforeAfterSnapshotWrapper() + + context("when there is no deposit", () => { + it("should return 0", async () => { + const totalAssets = await mezoAllocator.totalAssets() + expect(totalAssets).to.equal(0) + }) + }) + + context("when there is a deposit", () => { + before(async () => { + await tbtc + .connect(tbtcHolder) + .transfer(await stbtc.getAddress(), to1e18(5)) + await mezoAllocator.connect(maintainer).allocate() + }) + + it("should return the total assets value", async () => { + const totalAssets = await mezoAllocator.totalAssets() + expect(totalAssets).to.equal(to1e18(5)) + }) + }) + }) + + describe("releaseDeposit", () => { + beforeAfterSnapshotWrapper() + + context("when a caller is not governance", () => { + it("should revert", async () => { + await expect( + mezoAllocator.connect(thirdParty).releaseDeposit(), + ).to.be.revertedWithCustomError( + mezoAllocator, + "OwnableUnauthorizedAccount", + ) + }) + }) + + context("when the caller is governance", () => { + context("when there is a deposit", () => { + let tx: ContractTransactionResponse + + before(async () => { + await tbtc + .connect(tbtcHolder) + .transfer(await stbtc.getAddress(), to1e18(5)) + await mezoAllocator.connect(maintainer).allocate() + tx = await mezoAllocator.connect(governance).releaseDeposit() + }) + + it("should emit DepositReleased event", async () => { + await expect(tx) + .to.emit(mezoAllocator, "DepositReleased") + .withArgs(2273, to1e18(5)) + }) + + it("should decrease tracked deposit balance amount to zero", async () => { + const depositBalance = await mezoAllocator.depositBalance() + expect(depositBalance).to.equal(0) + }) + + it("should decrease Mezo Portal balance", async () => { + await expect(tx).to.changeTokenBalances( + tbtc, + [mezoPortal, stbtc], + [-to1e18(5), to1e18(5)], + ) + }) + }) + }) + }) +})