@hyperlane-xyz/core
Version:
Core solidity contracts for Hyperlane
664 lines (608 loc) • 23.8 kB
text/typescript
import { ethers } from 'hardhat'
import { assert, expect } from 'chai'
import type { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'
import { EthBalanceMonitorExposed } from '../../../typechain/EthBalanceMonitorExposed'
import { ReceiveReverter } from '../../../typechain/ReceiveReverter'
import { ReceiveEmitter } from '../../../typechain/ReceiveEmitter'
import { ReceiveFallbackEmitter } from '../../../typechain/ReceiveFallbackEmitter'
import { BigNumber } from 'ethers'
import * as h from '../../test-helpers/helpers'
const OWNABLE_ERR = 'Only callable by owner'
const INVALID_WATCHLIST_ERR = `InvalidWatchList`
const PAUSED_ERR = 'Pausable: paused'
const ONLY_KEEPER_ERR = `OnlyKeeperRegistry`
const zeroEth = ethers.utils.parseEther('0')
const oneEth = ethers.utils.parseEther('1')
const twoEth = ethers.utils.parseEther('2')
const threeEth = ethers.utils.parseEther('3')
const fiveEth = ethers.utils.parseEther('5')
const sixEth = ethers.utils.parseEther('6')
const tenEth = ethers.utils.parseEther('10')
const watchAddress1 = ethers.Wallet.createRandom().address
const watchAddress2 = ethers.Wallet.createRandom().address
const watchAddress3 = ethers.Wallet.createRandom().address
const watchAddress4 = ethers.Wallet.createRandom().address
let watchAddress5: string
let watchAddress6: string
async function assertWatchlistBalances(
balance1: number,
balance2: number,
balance3: number,
balance4: number,
balance5: number,
balance6: number,
) {
const toEth = (n: number) => ethers.utils.parseUnits(n.toString(), 'ether')
await h.assertBalance(watchAddress1, toEth(balance1), 'address 1')
await h.assertBalance(watchAddress2, toEth(balance2), 'address 2')
await h.assertBalance(watchAddress3, toEth(balance3), 'address 3')
await h.assertBalance(watchAddress4, toEth(balance4), 'address 4')
await h.assertBalance(watchAddress5, toEth(balance5), 'address 5')
await h.assertBalance(watchAddress6, toEth(balance6), 'address 6')
}
let bm: EthBalanceMonitorExposed
let receiveReverter: ReceiveReverter
let receiveEmitter: ReceiveEmitter
let receiveFallbackEmitter: ReceiveFallbackEmitter
let owner: SignerWithAddress
let stranger: SignerWithAddress
let keeperRegistry: SignerWithAddress
describe('EthBalanceMonitor', () => {
beforeEach(async () => {
const accounts = await ethers.getSigners()
owner = accounts[0]
stranger = accounts[1]
keeperRegistry = accounts[2]
watchAddress5 = accounts[3].address
watchAddress6 = accounts[4].address
const bmFactory = await ethers.getContractFactory(
'EthBalanceMonitorExposed',
owner,
)
const rrFactory = await ethers.getContractFactory('ReceiveReverter', owner)
const reFactory = await ethers.getContractFactory('ReceiveEmitter', owner)
const rfeFactory = await ethers.getContractFactory(
'ReceiveFallbackEmitter',
owner,
)
bm = await bmFactory.deploy(keeperRegistry.address, 0)
receiveReverter = await rrFactory.deploy()
receiveEmitter = await reFactory.deploy()
receiveFallbackEmitter = await rfeFactory.deploy()
await Promise.all([
bm.deployed(),
receiveReverter.deployed(),
receiveEmitter.deployed(),
receiveFallbackEmitter.deployed(),
])
})
afterEach(async () => {
await h.reset()
})
describe('receive()', () => {
it('Should allow anyone to add funds', async () => {
await owner.sendTransaction({
to: bm.address,
value: oneEth,
})
await stranger.sendTransaction({
to: bm.address,
value: oneEth,
})
})
it('Should emit an event', async () => {
await owner.sendTransaction({
to: bm.address,
value: oneEth,
})
const tx = stranger.sendTransaction({
to: bm.address,
value: oneEth,
})
await expect(tx)
.to.emit(bm, 'FundsAdded')
.withArgs(oneEth, twoEth, stranger.address)
})
})
describe('withdraw()', () => {
beforeEach(async () => {
const tx = await owner.sendTransaction({
to: bm.address,
value: oneEth,
})
await tx.wait()
})
it('Should allow the owner to withdraw', async () => {
const beforeBalance = await owner.getBalance()
const tx = await bm.connect(owner).withdraw(oneEth, owner.address)
await tx.wait()
const afterBalance = await owner.getBalance()
assert.isTrue(
afterBalance.gt(beforeBalance),
'balance did not increase after withdraw',
)
})
it('Should emit an event', async () => {
const tx = await bm.connect(owner).withdraw(oneEth, owner.address)
await expect(tx)
.to.emit(bm, 'FundsWithdrawn')
.withArgs(oneEth, owner.address)
})
it('Should allow the owner to withdraw to anyone', async () => {
const beforeBalance = await stranger.getBalance()
const tx = await bm.connect(owner).withdraw(oneEth, stranger.address)
await tx.wait()
const afterBalance = await stranger.getBalance()
assert.isTrue(
beforeBalance.add(oneEth).eq(afterBalance),
'balance did not increase after withdraw',
)
})
it('Should not allow strangers to withdraw', async () => {
const tx = bm.connect(stranger).withdraw(oneEth, owner.address)
await expect(tx).to.be.revertedWith(OWNABLE_ERR)
})
})
describe('pause() / unpause()', () => {
it('Should allow owner to pause / unpause', async () => {
const pauseTx = await bm.connect(owner).pause()
await pauseTx.wait()
const unpauseTx = await bm.connect(owner).unpause()
await unpauseTx.wait()
})
it('Should not allow strangers to pause / unpause', async () => {
const pauseTxStranger = bm.connect(stranger).pause()
await expect(pauseTxStranger).to.be.revertedWith(OWNABLE_ERR)
const pauseTxOwner = await bm.connect(owner).pause()
await pauseTxOwner.wait()
const unpauseTxStranger = bm.connect(stranger).unpause()
await expect(unpauseTxStranger).to.be.revertedWith(OWNABLE_ERR)
})
})
describe('setWatchList() / getWatchList() / getAccountInfo()', () => {
it('Should allow owner to set the watchlist', async () => {
// should start unactive
assert.isFalse((await bm.getAccountInfo(watchAddress1)).isActive)
// add first watchlist
let setTx = await bm
.connect(owner)
.setWatchList([watchAddress1], [oneEth], [twoEth])
await setTx.wait()
let watchList = await bm.getWatchList()
assert.deepEqual(watchList, [watchAddress1])
const accountInfo = await bm.getAccountInfo(watchAddress1)
assert.isTrue(accountInfo.isActive)
expect(accountInfo.minBalanceWei).to.equal(oneEth)
expect(accountInfo.topUpAmountWei).to.equal(twoEth)
// add more to watchlist
setTx = await bm
.connect(owner)
.setWatchList(
[watchAddress1, watchAddress2, watchAddress3],
[oneEth, twoEth, threeEth],
[oneEth, twoEth, threeEth],
)
await setTx.wait()
watchList = await bm.getWatchList()
assert.deepEqual(watchList, [watchAddress1, watchAddress2, watchAddress3])
let accountInfo1 = await bm.getAccountInfo(watchAddress1)
let accountInfo2 = await bm.getAccountInfo(watchAddress2)
let accountInfo3 = await bm.getAccountInfo(watchAddress3)
expect(accountInfo1.isActive).to.be.true
expect(accountInfo1.minBalanceWei).to.equal(oneEth)
expect(accountInfo1.topUpAmountWei).to.equal(oneEth)
expect(accountInfo2.isActive).to.be.true
expect(accountInfo2.minBalanceWei).to.equal(twoEth)
expect(accountInfo2.topUpAmountWei).to.equal(twoEth)
expect(accountInfo3.isActive).to.be.true
expect(accountInfo3.minBalanceWei).to.equal(threeEth)
expect(accountInfo3.topUpAmountWei).to.equal(threeEth)
// remove some from watchlist
setTx = await bm
.connect(owner)
.setWatchList(
[watchAddress3, watchAddress1],
[threeEth, oneEth],
[threeEth, oneEth],
)
await setTx.wait()
watchList = await bm.getWatchList()
assert.deepEqual(watchList, [watchAddress3, watchAddress1])
accountInfo1 = await bm.getAccountInfo(watchAddress1)
accountInfo2 = await bm.getAccountInfo(watchAddress2)
accountInfo3 = await bm.getAccountInfo(watchAddress3)
expect(accountInfo1.isActive).to.be.true
expect(accountInfo2.isActive).to.be.false
expect(accountInfo3.isActive).to.be.true
})
it('Should not allow duplicates in the watchlist', async () => {
const errMsg = `DuplicateAddress`
const setTx = bm
.connect(owner)
.setWatchList(
[watchAddress1, watchAddress2, watchAddress1],
[oneEth, twoEth, threeEth],
[oneEth, twoEth, threeEth],
)
await expect(setTx)
.to.be.revertedWithCustomError(bm, errMsg)
.withArgs(watchAddress1)
})
it('Should not allow strangers to set the watchlist', async () => {
const setTxStranger = bm
.connect(stranger)
.setWatchList([watchAddress1], [oneEth], [twoEth])
await expect(setTxStranger).to.be.revertedWith(OWNABLE_ERR)
})
it('Should revert if the list lengths differ', async () => {
let tx = bm.connect(owner).setWatchList([watchAddress1], [], [twoEth])
await expect(tx).to.be.revertedWithCustomError(bm, INVALID_WATCHLIST_ERR)
tx = bm.connect(owner).setWatchList([watchAddress1], [oneEth], [])
await expect(tx).to.be.revertedWithCustomError(bm, INVALID_WATCHLIST_ERR)
tx = bm.connect(owner).setWatchList([], [oneEth], [twoEth])
await expect(tx).to.be.revertedWithCustomError(bm, INVALID_WATCHLIST_ERR)
})
it('Should revert if any of the addresses are empty', async () => {
let tx = bm
.connect(owner)
.setWatchList(
[watchAddress1, ethers.constants.AddressZero],
[oneEth, oneEth],
[twoEth, twoEth],
)
await expect(tx).to.be.revertedWithCustomError(bm, INVALID_WATCHLIST_ERR)
})
it('Should revert if any of the top up amounts are 0', async () => {
const tx = bm
.connect(owner)
.setWatchList(
[watchAddress1, watchAddress2],
[oneEth, oneEth],
[twoEth, zeroEth],
)
await expect(tx).to.be.revertedWithCustomError(bm, INVALID_WATCHLIST_ERR)
})
})
describe('getKeeperRegistryAddress() / setKeeperRegistryAddress()', () => {
const newAddress = ethers.Wallet.createRandom().address
it('Should initialize with the registry address provided to the constructor', async () => {
const address = await bm.getKeeperRegistryAddress()
assert.equal(address, keeperRegistry.address)
})
it('Should allow the owner to set the registry address', async () => {
const setTx = await bm.connect(owner).setKeeperRegistryAddress(newAddress)
await setTx.wait()
const address = await bm.getKeeperRegistryAddress()
assert.equal(address, newAddress)
})
it('Should not allow strangers to set the registry address', async () => {
const setTx = bm.connect(stranger).setKeeperRegistryAddress(newAddress)
await expect(setTx).to.be.revertedWith(OWNABLE_ERR)
})
it('Should emit an event', async () => {
const setTx = await bm.connect(owner).setKeeperRegistryAddress(newAddress)
await expect(setTx)
.to.emit(bm, 'KeeperRegistryAddressUpdated')
.withArgs(keeperRegistry.address, newAddress)
})
})
describe('getMinWaitPeriodSeconds / setMinWaitPeriodSeconds()', () => {
const newWaitPeriod = BigNumber.from(1)
it('Should initialize with the wait period provided to the constructor', async () => {
const minWaitPeriod = await bm.getMinWaitPeriodSeconds()
expect(minWaitPeriod).to.equal(0)
})
it('Should allow owner to set the wait period', async () => {
const setTx = await bm
.connect(owner)
.setMinWaitPeriodSeconds(newWaitPeriod)
await setTx.wait()
const minWaitPeriod = await bm.getMinWaitPeriodSeconds()
expect(minWaitPeriod).to.equal(newWaitPeriod)
})
it('Should not allow strangers to set the wait period', async () => {
const setTx = bm.connect(stranger).setMinWaitPeriodSeconds(newWaitPeriod)
await expect(setTx).to.be.revertedWith(OWNABLE_ERR)
})
it('Should emit an event', async () => {
const setTx = await bm
.connect(owner)
.setMinWaitPeriodSeconds(newWaitPeriod)
await expect(setTx)
.to.emit(bm, 'MinWaitPeriodUpdated')
.withArgs(0, newWaitPeriod)
})
})
describe('checkUpkeep() / getUnderfundedAddresses()', () => {
beforeEach(async () => {
const setTx = await bm.connect(owner).setWatchList(
[
watchAddress1, // needs funds
watchAddress5, // funded
watchAddress2, // needs funds
watchAddress6, // funded
watchAddress3, // needs funds
],
new Array(5).fill(oneEth),
new Array(5).fill(twoEth),
)
await setTx.wait()
})
it('Should return list of address that are underfunded', async () => {
const fundTx = await owner.sendTransaction({
to: bm.address,
value: sixEth, // needs 6 total
})
await fundTx.wait()
const [should, payload] = await bm.checkUpkeep('0x')
assert.isTrue(should)
let [addresses] = ethers.utils.defaultAbiCoder.decode(
['address[]'],
payload,
)
assert.deepEqual(addresses, [watchAddress1, watchAddress2, watchAddress3])
// checkUpkeep payload should match getUnderfundedAddresses()
addresses = await bm.getUnderfundedAddresses()
assert.deepEqual(addresses, [watchAddress1, watchAddress2, watchAddress3])
})
it('Should return some results even if contract cannot fund all eligible targets', async () => {
const fundTx = await owner.sendTransaction({
to: bm.address,
value: fiveEth, // needs 6 total
})
await fundTx.wait()
const [should, payload] = await bm.checkUpkeep('0x')
assert.isTrue(should)
const [addresses] = ethers.utils.defaultAbiCoder.decode(
['address[]'],
payload,
)
assert.deepEqual(addresses, [watchAddress1, watchAddress2])
})
it('Should omit addresses that have been funded recently', async () => {
const setWaitPdTx = await bm.setMinWaitPeriodSeconds(3600) // 1 hour
const fundTx = await owner.sendTransaction({
to: bm.address,
value: sixEth,
})
await Promise.all([setWaitPdTx.wait(), fundTx.wait()])
const block = await ethers.provider.getBlock('latest')
const setTopUpTx = await bm.setLastTopUpXXXTestOnly(
watchAddress2,
block.timestamp - 100,
)
await setTopUpTx.wait()
const [should, payload] = await bm.checkUpkeep('0x')
assert.isTrue(should)
const [addresses] = ethers.utils.defaultAbiCoder.decode(
['address[]'],
payload,
)
assert.deepEqual(addresses, [watchAddress1, watchAddress3])
})
it('Should revert when paused', async () => {
const tx = await bm.connect(owner).pause()
await tx.wait()
const ethCall = bm.checkUpkeep('0x')
await expect(ethCall).to.be.revertedWith(PAUSED_ERR)
})
})
describe('performUpkeep()', () => {
let validPayload: string
let invalidPayload: string
beforeEach(async () => {
validPayload = ethers.utils.defaultAbiCoder.encode(
['address[]'],
[[watchAddress1, watchAddress2, watchAddress3]],
)
invalidPayload = ethers.utils.defaultAbiCoder.encode(
['address[]'],
[[watchAddress1, watchAddress2, watchAddress4, watchAddress5]],
)
const setTx = await bm.connect(owner).setWatchList(
[
watchAddress1, // needs funds
watchAddress5, // funded
watchAddress2, // needs funds
watchAddress6, // funded
watchAddress3, // needs funds
// watchAddress4 - omitted
],
new Array(5).fill(oneEth),
new Array(5).fill(twoEth),
)
await setTx.wait()
})
it('Should revert when paused', async () => {
const pauseTx = await bm.connect(owner).pause()
await pauseTx.wait()
const performTx = bm.connect(keeperRegistry).performUpkeep(validPayload)
await expect(performTx).to.be.revertedWith(PAUSED_ERR)
})
context('when partially funded', () => {
it('Should fund as many addresses as possible', async () => {
const fundTx = await owner.sendTransaction({
to: bm.address,
value: fiveEth, // only enough eth to fund 2 addresses
})
await fundTx.wait()
await assertWatchlistBalances(0, 0, 0, 0, 10_000, 10_000)
const performTx = await bm
.connect(keeperRegistry)
.performUpkeep(validPayload)
await assertWatchlistBalances(2, 2, 0, 0, 10_000, 10_000)
await expect(performTx)
.to.emit(bm, 'TopUpSucceeded')
.withArgs(watchAddress1)
await expect(performTx)
.to.emit(bm, 'TopUpSucceeded')
.withArgs(watchAddress2)
})
})
context('when fully funded', () => {
beforeEach(async () => {
const fundTx = await owner.sendTransaction({
to: bm.address,
value: tenEth,
})
await fundTx.wait()
})
it('Should fund the appropriate addresses', async () => {
await assertWatchlistBalances(0, 0, 0, 0, 10_000, 10_000)
const performTx = await bm
.connect(keeperRegistry)
.performUpkeep(validPayload, { gasLimit: 2_500_000 })
await performTx.wait()
await assertWatchlistBalances(2, 2, 2, 0, 10_000, 10_000)
})
it('Should only fund active, underfunded addresses', async () => {
await assertWatchlistBalances(0, 0, 0, 0, 10_000, 10_000)
const performTx = await bm
.connect(keeperRegistry)
.performUpkeep(invalidPayload, { gasLimit: 2_500_000 })
await performTx.wait()
await assertWatchlistBalances(2, 2, 0, 0, 10_000, 10_000)
})
it('Should continue funding addresses even if one reverts', async () => {
await assertWatchlistBalances(0, 0, 0, 0, 10_000, 10_000)
const addresses = [
watchAddress1,
receiveReverter.address,
watchAddress2,
]
const setTx = await bm
.connect(owner)
.setWatchList(
addresses,
new Array(3).fill(oneEth),
new Array(3).fill(twoEth),
)
await setTx.wait()
const payload = ethers.utils.defaultAbiCoder.encode(
['address[]'],
[addresses],
)
const performTx = await bm
.connect(keeperRegistry)
.performUpkeep(payload, { gasLimit: 2_500_000 })
await performTx.wait()
await assertWatchlistBalances(2, 2, 0, 0, 10_000, 10_000)
await h.assertBalance(receiveReverter.address, 0)
await expect(performTx)
.to.emit(bm, 'TopUpSucceeded')
.withArgs(watchAddress1)
await expect(performTx)
.to.emit(bm, 'TopUpSucceeded')
.withArgs(watchAddress2)
await expect(performTx)
.to.emit(bm, 'TopUpFailed')
.withArgs(receiveReverter.address)
})
it('Should not fund addresses that have been funded recently', async () => {
const setWaitPdTx = await bm.setMinWaitPeriodSeconds(3600) // 1 hour
await setWaitPdTx.wait()
const block = await ethers.provider.getBlock('latest')
const setTopUpTx = await bm.setLastTopUpXXXTestOnly(
watchAddress2,
block.timestamp - 100,
)
await setTopUpTx.wait()
await assertWatchlistBalances(0, 0, 0, 0, 10_000, 10_000)
const performTx = await bm
.connect(keeperRegistry)
.performUpkeep(validPayload, { gasLimit: 2_500_000 })
await performTx.wait()
await assertWatchlistBalances(2, 0, 2, 0, 10_000, 10_000)
})
it('Should only be callable by the keeper registry contract', async () => {
let performTx = bm.connect(owner).performUpkeep(validPayload)
await expect(performTx).to.be.revertedWithCustomError(
bm,
ONLY_KEEPER_ERR,
)
performTx = bm.connect(stranger).performUpkeep(validPayload)
await expect(performTx).to.be.revertedWithCustomError(
bm,
ONLY_KEEPER_ERR,
)
})
it('Should protect against running out of gas', async () => {
await assertWatchlistBalances(0, 0, 0, 0, 10_000, 10_000)
const performTx = await bm
.connect(keeperRegistry)
.performUpkeep(validPayload, { gasLimit: 130_000 }) // too little for all 3 transfers
await performTx.wait()
const balance1 = await ethers.provider.getBalance(watchAddress1)
const balance2 = await ethers.provider.getBalance(watchAddress2)
const balance3 = await ethers.provider.getBalance(watchAddress3)
const balances = [balance1, balance2, balance3].map((n) => n.toString())
expect(balances)
.to.include(twoEth.toString()) // expect at least 1 transfer
.to.include(zeroEth.toString()) // expect at least 1 out of funds
})
it('Should provide enough gas to support receive and fallback functions', async () => {
const addresses = [
receiveEmitter.address,
receiveFallbackEmitter.address,
]
const payload = ethers.utils.defaultAbiCoder.encode(
['address[]'],
[addresses],
)
const setTx = await bm
.connect(owner)
.setWatchList(
addresses,
new Array(2).fill(oneEth),
new Array(2).fill(twoEth),
)
await setTx.wait()
const reBalanceBefore = await ethers.provider.getBalance(
receiveEmitter.address,
)
const rfeBalanceBefore = await ethers.provider.getBalance(
receiveFallbackEmitter.address,
)
const performTx = await bm
.connect(keeperRegistry)
.performUpkeep(payload, { gasLimit: 2_500_000 })
await h.assertBalance(
receiveEmitter.address,
reBalanceBefore.add(twoEth),
)
await h.assertBalance(
receiveFallbackEmitter.address,
rfeBalanceBefore.add(twoEth),
)
await expect(performTx)
.to.emit(bm, 'TopUpSucceeded')
.withArgs(receiveEmitter.address)
await expect(performTx)
.to.emit(bm, 'TopUpSucceeded')
.withArgs(receiveFallbackEmitter.address)
})
})
})
describe('topUp()', () => {
context('when not paused', () => {
it('Should be callable by anyone', async () => {
const users = [owner, keeperRegistry, stranger]
for (let idx = 0; idx < users.length; idx++) {
const user = users[idx]
await bm.connect(user).topUp([])
}
})
})
context('when paused', () => {
it('Should be callable by no one', async () => {
await bm.connect(owner).pause()
const users = [owner, keeperRegistry, stranger]
for (let idx = 0; idx < users.length; idx++) {
const user = users[idx]
const tx = bm.connect(user).topUp([])
await expect(tx).to.be.revertedWith(PAUSED_ERR)
}
})
})
})
})