Commit f7f6d76f authored by Maurelian's avatar Maurelian Committed by GitHub

bmon: Detect at warning and danger level balances (#4571)

* bmon: Detect at warning and danger level balances

* bmon: Simplify by reducing to a single threshold

* bmon: Set amounts in eth rather than wei

* bmon: Add ops genie alert creation

* bmon: Add ops genie heartbeat

* bmon: Prep for goerli
parent d3308176
export L1_RPC_URL= export L1_RPC_URL=
export OPS_GENIE_KEY=
export OPS_GENIE_TEAM=
export OPS_GENIE_HEARTBEAT_NAME=
# Threshold values are denominated in ETH.
export SEQUENCER_ADDRESS= export SEQUENCER_ADDRESS=
export SEQUENCER_WARNING_THRESHOLD= export SEQUENCER_DANGER_THRESHOLD=10
export SEQUENCER_DANGER_THRESHOLD=
export PROPOSER_ADDRESS= export PROPOSER_ADDRESS=
export PROPOSER_WARNING_THRESHOLD= export PROPOSER_DANGER_THRESHOLD=10
export PROPOSER_DANGER_THRESHOLD=
...@@ -2,23 +2,22 @@ ...@@ -2,23 +2,22 @@
## Description ## Description
A forta agent which detects when a specified account balance is below 0.5 ETH A forta agent which detects when a specified account balance is below the
specified threshold.
## Installing and building
`yarn && yarn build`
## Running ## Running
1. Copy `.env.example` into `.env` and set the values as desired. 1. Copy `.env.example` into `.env` and set the appropriate values.
2. Copy `forta.config.example.json` into `forta.config.json`, and set the RPC endpoint (yes, this is
duplicated in the .env file).
2. `yarn run start:prod` 2. `yarn run start:prod`
## Alerts ## Alerts
- `OPTIMISM-BALANCE-WARNING-[ACCOUNT_NAME]`
- `ACCOUNT_NAME` is either `SEQUENCER` or `PROPOSER`
- Fired when the specified account balance is below the configured WARNING threshold
- Severity is always set to "info"
- Type is always set to "info"
- Metadata "balance" field contains amount of wei in account
- `OPTIMISM-BALANCE-DANGER-[ACCOUNT_NAME]` - `OPTIMISM-BALANCE-DANGER-[ACCOUNT_NAME]`
- Fired when the specified account balance is below the configured DANGER threshold - Fired when the specified account balance is below the configured DANGER threshold
- Severity is always set to "high" - Severity is always set to "high"
......
...@@ -9,12 +9,15 @@ ...@@ -9,12 +9,15 @@
"type": "git", "type": "git",
"url": "https://github.com/ethereum-optimism/optimism.git" "url": "https://github.com/ethereum-optimism/optimism.git"
}, },
"chainIds": [
5
],
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"clean": "rimraf dist/ ./tsconfig.tsbuildinfo", "clean": "rimraf dist/ ./tsconfig.tsbuildinfo",
"start": "yarn run start:dev", "start": "yarn run start:dev",
"start:dev": "nodemon --watch src --watch forta.config.json -e js,ts,json --exec 'yarn run build && forta-agent run'", "start:dev": "nodemon --watch src --watch forta.config.json -e js,ts,json --exec 'yarn run build && forta-agent run'",
"start:prod": "forta-agent run --prod", "start:prod": "forta-agent run",
"tx": "yarn run build && forta-agent run --tx", "tx": "yarn run build && forta-agent run --tx",
"block": "yarn run build && forta-agent run --block", "block": "yarn run build && forta-agent run --block",
"range": "yarn run build && forta-agent run --range", "range": "yarn run build && forta-agent run --range",
...@@ -26,6 +29,7 @@ ...@@ -26,6 +29,7 @@
}, },
"dependencies": { "dependencies": {
"ethers": "^5.7.2", "ethers": "^5.7.2",
"node-fetch": "^2.6.1",
"forta-agent": "^0.1.1" "forta-agent": "^0.1.1"
}, },
"devDependencies": { "devDependencies": {
......
#!/bin/bash #!/bin/bash
export SEQUENCER_ADDRESS=0xabba export SEQUENCER_ADDRESS=0xabba
export SEQUENCER_WARNING_THRESHOLD=1000 export SEQUENCER_DANGER_THRESHOLD=100 # 100 eth
export SEQUENCER_DANGER_THRESHOLD=100
export PROPOSER_ADDRESS=0xacdc export PROPOSER_ADDRESS=0xacdc
export PROPOSER_WARNING_THRESHOLD=2000 export PROPOSER_DANGER_THRESHOLD=200 # 200 eth
export PROPOSER_DANGER_THRESHOLD=200
yarn ts-mocha src/*.spec.ts yarn ts-mocha src/*.spec.ts
import { HandleBlock, createBlockEvent } from 'forta-agent' import {
Finding,
FindingSeverity,
FindingType,
HandleBlock,
createBlockEvent,
} from 'forta-agent'
import { BigNumber, utils } from 'ethers' import { BigNumber, utils } from 'ethers'
import { expect } from 'chai' import { expect } from 'chai'
import agent from './agent' import agent, { accounts } from './agent'
import { describeFinding } from './utils'
describe('minimum balance agent', async () => { describe('minimum balance agent', () => {
let handleBlock: HandleBlock let handleBlock: HandleBlock
let mockEthersProvider let mockEthersProvider
const blockEvent = createBlockEvent({ const blockEvent = createBlockEvent({
...@@ -19,7 +26,18 @@ describe('minimum balance agent', async () => { ...@@ -19,7 +26,18 @@ describe('minimum balance agent', async () => {
return { return {
getBalance: async (addr: string): Promise<BigNumber> => { getBalance: async (addr: string): Promise<BigNumber> => {
if (addr === '0xabba') { if (addr === '0xabba') {
return utils.parseEther('1001') return utils.parseEther('101')
}
if (addr === '0xacdc') {
return utils.parseEther('2001')
}
},
} as any
case 'danger':
return {
getBalance: async (addr: string): Promise<BigNumber> => {
if (addr === '0xabba') {
return utils.parseEther('99') // below danger threshold
} }
if (addr === '0xacdc') { if (addr === '0xacdc') {
return utils.parseEther('2001') return utils.parseEther('2001')
...@@ -35,13 +53,40 @@ describe('minimum balance agent', async () => { ...@@ -35,13 +53,40 @@ describe('minimum balance agent', async () => {
handleBlock = agent.provideHandleBlock(mockEthersProvider) handleBlock = agent.provideHandleBlock(mockEthersProvider)
}) })
describe('handleBlock', async () => { describe('handleBlock', () => {
it('returns empty findings if balance is above threshold', async () => { it('returns empty findings if balance is above threshold', async () => {
mockEthersProvider = mockEthersProviderByCase('safe') mockEthersProvider = mockEthersProviderByCase('safe')
handleBlock = agent.provideHandleBlock(mockEthersProvider) handleBlock = agent.provideHandleBlock(mockEthersProvider)
const findings = await handleBlock(blockEvent) const findings = await handleBlock(blockEvent)
expect(findings).to.deep.equal([]) expect(findings).to.deep.equal([])
}) })
it('returns high severity finding if balance is below danger threshold', async () => {
mockEthersProvider = mockEthersProviderByCase('danger')
handleBlock = agent.provideHandleBlock(mockEthersProvider)
const balance = await mockEthersProvider.getBalance('0xabba')
const findings = await handleBlock(blockEvent)
// Take the second alert in the list, as the first is a warning
expect(findings).to.deep.equal([
Finding.fromObject({
name: 'Minimum Account Balance',
description: describeFinding(
accounts[0].address,
balance,
accounts[0].thresholds.danger
),
alertId: 'OPTIMISM-BALANCE-DANGER-Sequencer',
severity: FindingSeverity.High,
type: FindingType.Info,
metadata: {
balance: balance.toString(),
},
}),
])
})
}) })
}) })
import { BlockEvent, Finding, HandleBlock } from 'forta-agent' import {
import { BigNumber, providers } from 'ethers' BlockEvent,
Finding,
HandleBlock,
FindingSeverity,
FindingType,
} from 'forta-agent'
import { BigNumber, providers, utils } from 'ethers'
import { createAlert, heartBeat, describeFinding } from './utils'
type AccountAlert = { type AccountAlert = {
name: string name: string
address: string address: string
thresholds: { thresholds: {
warning: BigNumber
danger: BigNumber danger: BigNumber
} }
} }
...@@ -15,16 +22,14 @@ export const accounts: AccountAlert[] = [ ...@@ -15,16 +22,14 @@ export const accounts: AccountAlert[] = [
name: 'Sequencer', name: 'Sequencer',
address: process.env.SEQUENCER_ADDRESS, address: process.env.SEQUENCER_ADDRESS,
thresholds: { thresholds: {
warning: BigNumber.from(process.env.SEQUENCER_WARNING_THRESHOLD), danger: utils.parseEther(process.env.SEQUENCER_DANGER_THRESHOLD),
danger: BigNumber.from(process.env.SEQUENCER_DANGER_THRESHOLD),
}, },
}, },
{ {
name: 'Proposer', name: 'Proposer',
address: process.env.PROPOSER_ADDRESS, address: process.env.PROPOSER_ADDRESS,
thresholds: { thresholds: {
warning: BigNumber.from(process.env.PROPOSER_WARNING_THRESHOLD), danger: utils.parseEther(process.env.PROPOSER_DANGER_THRESHOLD),
danger: BigNumber.from(process.env.PROPOSER_DANGER_THRESHOLD),
}, },
}, },
] ]
...@@ -37,21 +42,46 @@ const provideHandleBlock = ( ...@@ -37,21 +42,46 @@ const provideHandleBlock = (
const findings: Finding[] = [] const findings: Finding[] = []
// iterate over accounts with the index // iterate over accounts with the index
for (const [idx, account] of accounts.entries()) { for (const [, account] of accounts.entries()) {
const accountBalance = BigNumber.from( const accountBalance = BigNumber.from(
( (
await provider.getBalance(account.address, blockEvent.blockNumber) await provider.getBalance(account.address, blockEvent.blockNumber)
).toString() ).toString()
) )
if (accountBalance.gte(account.thresholds.warning)) {
// todo: add to the findings array when balances are below the threshold if (accountBalance.lte(account.thresholds.danger)) {
// return if this is the last account const alertId = `OPTIMISM-BALANCE-DANGER-${account.name}`
if (idx === accounts.length - 1) { const description = describeFinding(
return findings account.address,
accountBalance,
account.thresholds.danger
)
// If an alert is already open with the same alertId, this will have no effect.
// Alerts must be disabled manually in opsgenie. We don't provide a method here
// for closing when the balance is above the threshold again.
if (process.env.OPS_GENIE_KEY !== undefined) {
await createAlert({ alias: alertId, message: description })
} }
// Add to the findings array. This will only be meaningful when running on
// public forta nodes.
findings.push(
Finding.fromObject({
name: 'Minimum Account Balance',
description,
alertId,
severity: FindingSeverity.High,
type: FindingType.Info,
metadata: {
balance: accountBalance.toString(),
},
})
)
} }
} }
// Let ops-genie know that we're still alive.
await heartBeat()
return findings return findings
} }
} }
......
// import 'ethers'
import { ethers } from 'ethers'
import fetch from 'node-fetch'
// new function to log an account an it's balance
export const describeFinding = (
account: string,
actual: ethers.BigNumber,
threshold: ethers.BigNumber
) => {
return `Balance of account ${account} is (${ethers.utils.formatEther(
actual
)} eth) below threshold (${ethers.utils.formatEther(threshold)} eth)`
}
// Create an alert in ops-genie. The alias will be used an unique identifier for the alert.
// There can only be one open alert per alias. If this is called with an alias which already
// has an alert, it will not be reopened.
export const createAlert = async (alertOpts: {
alias: string
message: string
}) => {
const response = await fetch('https://api.opsgenie.com/v2/alerts', {
method: 'post',
body: JSON.stringify({
message: alertOpts.message,
alias: alertOpts.alias,
responders: [{ id: process.env.OPS_GENIE_TEAM, type: 'team' }],
tags: ['Bedrock-Beta', 'Balance-Low'],
priority: 'P2',
}),
headers: {
'Content-type': 'application/json',
Authorization: `GenieKey ${process.env.OPS_GENIE_KEY}`,
},
})
if (!response.ok) {
console.log(`Error creating alert: ${JSON.stringify(response.body)}`)
}
}
// Send this with every block. If Ops Genie doesn't get this ping for 10 minutes,
// it will trigger a P3 alert.
export const heartBeat = async () => {
const response = await fetch(
`https://api.opsgenie.com/v2/heartbeats/${process.env.OPS_GENIE_HEARTBEAT_NAME}/ping`,
{
method: 'get',
headers: {
Authorization: `GenieKey ${process.env.OPS_GENIE_KEY}`,
},
}
)
if (!response.ok) {
console.log(`Error creating alert: ${JSON.stringify(response.body)}`)
}
}
{ {
"extends": "../../tsconfig.json", "extends": "../../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./dist" "outDir": "./dist",
"rootDir": "./src"
}, },
"include": [ "include": [
"src/**/*" "src/**/*"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment