diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..dcd0be8 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,47 @@ +# .github/workflows/release.yml +name: goreleaser + +on: + push: + # run only against tags + tags: ["v*"] + + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: self-hosted + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + # More assembly might be required: Docker logins, GPG, etc. + # It all depends on your needs. + - name: build + run: make build + - name: golangci-lint + run: make lint + - name: format + run: | + make format + if [ -z "$(git status --untracked-files=no --porcelain)" ]; then + echo "All files formatted" + else + echo "Running format is required" + exit 1 + fi + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + distribution: goreleaser + version: latest + args: release -f .goreleaser.yml --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..20e3cf0 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,14 @@ +# .goreleaser.yml +project_name: fault_detector +builds: + - env: [CGO_ENABLED=0] + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm64 + id: "fault_detector" + dir: . + main: ./cmd/ \ No newline at end of file diff --git a/Makefile b/Makefile index ae3d980..69dc752 100644 --- a/Makefile +++ b/Makefile @@ -2,26 +2,30 @@ PKGS=$(shell go list ./... | grep -v "/vendor/") -.PHONY: test - APP_NAME = faultdetector -GREEN = \033[0;32m -BLUE = \033[0;34m +GREEN = \033[1;32m +BLUE = \033[1;34m COLOR_END = \033[0;39m -build: +build: # Builds the application and create a binary at ./bin/ @echo "$(BLUE)» Building fault detector application binary... $(COLOR_END)" - @CGO_ENABLED=0 go build -a -v -o bin/$(APP_NAME) ./cmd/ + @CGO_ENABLED=0 go build -a -o bin/$(APP_NAME) ./cmd/... @echo "$(GREEN) Binary successfully built$(COLOR_END)" -run-app: +install: # Installs faultdetector cmd and creates executable at $GOPATH/bin/ + @echo "$(BLUE)» Installing fault detector command... $(COLOR_END)" + @CGO_ENABLED=0 go install ./cmd/$(APP_NAME) + @echo "$(GREEN) $(APP_NAME) successfully installed$(COLOR_END)" + +run-app: # Runs the application, use `make run-app config={PATH_TO_CONFIG_FILE}` to provide custom config ifdef config @./bin/${APP_NAME} --config $(config) else @./bin/${APP_NAME} endif -test: +.PHONY: test +test: # Runs tests @echo "Test packages" @go test -race -shuffle=on -coverprofile=coverage.out -cover $(PKGS) @@ -31,29 +35,37 @@ test.coverage: test test.coverage.html: test go tool cover -html=coverage.out -lint: +lint: # Runs golangci-lint on the repo @go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest golangci-lint run -format: +format: # Runs gofmt on the repo gofmt -s -w . -godocs: +godocs: # Runs godoc and serves via endpoint @go install golang.org/x/tools/cmd/godoc@latest @echo "open http://localhost:6060/pkg/github.com/LiskHQ/op-fault-detector" godoc -http=:6060 .PHONY: docker-build -docker-build: +docker-build: # Builds docker image @echo "$(BLUE) Building docker image...$(COLOR_END)" @docker build -t $(APP_NAME) . .PHONY: docker-run -docker-run: +docker-run: # Runs docker image, use `make docker-run config={PATH_TO_CONFIG_FILE}` to provide custom config and to provide slack access token use `make docker-run slack_access_token={ACCESS_TOKEN}` ifdef config @echo "$(BLUE) Running docker image...$(COLOR_END)" +ifdef slack_access_token + @docker run -p 8080:8080 -v $(config):/home/onchain/faultdetector/config.yaml -t -e SLACK_ACCESS_TOKEN_KEY=$(slack_access_token) $(APP_NAME) +else @docker run -p 8080:8080 -v $(config):/home/onchain/faultdetector/config.yaml -t $(APP_NAME) +endif else @echo "$(BLUE) Running docker image...$(COLOR_END)" @docker run -p 8080:8080 $(APP_NAME) endif + +.PHONY: help +help: # Show help for each of the Makefile recipes + @grep -E '^[a-zA-Z0-9 -]+:.*#' Makefile | sort | while read -r l; do printf "$(GREEN)$$(echo $$l | cut -f 1 -d':')$(COLOR_END):$$(echo $$l | cut -f 2- -d'#')\n"; done diff --git a/README.md b/README.md index 7d5b8f1..99ab342 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,177 @@ # op-fault-detector -Fault detector is a service to detect mismatch between a local view of the Optimism network and L2 output proposals published to Ethereum. +[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](http://www.apache.org/licenses/LICENSE-2.0) +![GitHub repo size](https://img.shields.io/github/repo-size/liskhq/op-fault-detector) +![GitHub issues](https://img.shields.io/github/issues-raw/liskhq/op-fault-detector) +![GitHub closed issues](https://img.shields.io/github/issues-closed-raw/liskhq/op-fault-detector) +[![PR CI](https://github.com/LiskHQ/op-fault-detector/actions/workflows/pr.yaml/badge.svg?branch=main&event=merge_group)](https://github.com/LiskHQ/op-fault-detector/actions/workflows/pr.yaml) + +Fault detector is a service that identifies mismatches between a local view of the Optimism or superchain network and L2 output proposals published to Ethereum. Here is the reference to the original implementation of the [fault monitoring](https://github.com/ethereum-optimism/optimism/blob/v1.5.0/packages/chain-mon/src/fault-mon/README.md) service from [Optimism](https://www.optimism.io/). + +## How it works + +The state root of the block is published to the [L2OutputOracle](https://github.com/ethereum-optimism/optimism/blob/39b7262cc3ffd78cd314341b8512b2683c1d9af7/packages/contracts-bedrock/contracts/L1/L2OutputOracle.sol) contract on Ethereum. The `L2OutputOracle` is inferred from the portal contract. + +In the application, we take the state root of the given block as reported by an Optimism node, compute `outputRoot` from it and compare it with the `outputRoot` as published to `L2OutputOracle` contract on Ethereum. + +## Installation + +``` +git clone https://github.com/liskhq/op-fault-detector +make install +``` + +## Running Fault Detector + +Copy `config.yaml` file at the root path and use any name with `.yaml` extension or edit existing `config.yaml` file to set configuration for the application. + +To run with default config, + +```sh +faultdetector +``` + +To run with custom config file, + +```sh +faultdetector --config /PATH/TO/YOUR/CUSTOM/CONFIG +``` + +### To build and run from source code + +#### Build + +``` +make build +``` + +#### Run + +``` +make run-app +``` +if want to provide custom config file, for example, `my-config.yaml`, run, + +``` +make run-app config=/path/to/my-config.yaml +``` + +View all available commands by running `make help` and view the commands with options as below. + +```sh +build: Builds the application and create a binary at ./bin/ + +install: Installs faultdetector cmd and creates executable at $GOPATH/bin/ + +docker-build: Builds docker image + +docker-run: Runs docker image, use `make docker-run config={PATH_TO_CONFIG_FILE}` to provide custom config and to provide slack access token use `make docker-run slack_access_token={ACCESS_TOKEN}` + +format: Runs gofmt on the repo + +godocs: Runs godoc and serves via endpoint + +help: Show help for each of the Makefile recipes + +lint: Runs golangci-lint on the repo + +run-app: Runs the application, use `make run-app config={PATH_TO_CONFIG_FILE}` to provide custom config + +test: Runs tests +``` + +## Config + +The configuration file is used to configure the application. Currently, the default configuration is found under `./config.yaml`. To provide custom config, edit the `./config.yaml` or create own and provide it while running the application `make run-app config={PATH_TO_CUSTOM_CONFIG_FILE}`. + +**Config paths:** + +Config file can be placed in any of the paths below and will be read without giving `--config` flag, +- `.` +- `..` +- `$HOME/.op-fault-detector` + +Below is the default config file, + +```yaml +# General system configurations +system: + log_level: "info" + +# API related configurations +api: + server: + host: "127.0.0.1" + port: 8080 + base_path: "/api" + register_versions: + - v1 + +# Faultdetector configurations +fault_detector: + l1_rpc_endpoint: "https://rpc.notadegen.com/eth" + l2_rpc_endpoint: "https://mainnet.optimism.io/" + start_batch_index: -1 + l2_output_oracle_contract_address: "0x0000000000000000000000000000000000000000" + +``` +### System Config +- `system.log_level`: Set log level of the application, by default `info` and available options are `warn`, `debug`, `error` and `fatal` + +### API Config +- `api.server.host`: Host of application +- `api.server.port`: Port of application +- `api.base_path`: Base path for the API +- `register_versions`: Versions for APIs + +### Fault Detector Config + +- `fault_detector.l1_rpc_endpoint`: RPC endpoint for L1 chain. +- `fault_detector.l2_rpc_endpoint`: RPC endpoint for L2 chain. +- `fault_detector.start_batch_index`: Provide batch_index to start from. If not provided, it will pick default `-1` and then application will find the first unfinalized batch index that has not yet passed the fault proof window. +- `fault_detector.l2_output_oracle_contract_address`: Deployed `L2OutputOracle` contract address used to retrieve necessary info for output verification. Only provided for the chains other than Optimism and Lisk Superchain. + +## API and Metrics + +### API +- Status API exposed via `{api.server.host}:{api.server.port}/api/v1/status` +- Metrics is exposed at `{api.server.host}:{api.server.port}/metrics` +- `{api.server.host}` in `config.yaml` defaults to `127.0.0.1` +- `{api.server.port}` in `config.yaml` defaults to `8080` + +### Metrics + +```sh +- fault_detector_highest_output_index prometheus.Gauge Highest known output index +- fault_detector_is_state_mismatch prometheus.Gauge 0 if state is ok, 1 if state is mismatched +- fault_detector_api_connection_failure prometheus.Gauge Number of API RPC calls failed for L1 and L2 nodes +``` + +## Notification Service + +When the state root for the proposed batch index on `L2OutputOracle` doesn't match the local view, user can also get notifications on [Slack](https://slack.com/). +This is an optional feature that can be enabled by following steps, +- Set configuration as below, + +```yaml +notification: + enable: true + slack: + channel_id: "YOUR_CHANNEL_ID" +``` + +`channel_id` can be found on Slack, reference [Locate your Slack URL or ID](https://slack.com/intl/en-gb/help/articles/221769328-Locate-your-Slack-URL-or-ID). + +- Set environment variable `SLACK_ACCESS_TOKEN_KEY`, + +```sh +export SLACK_ACCESS_TOKEN_KEY="{SLACK_ACCESS_TOKEN}" +``` + +[Access token](https://api.slack.com/authentication/token-types) for Slack can be found in Slack application, reference [How to quickly get and use a Slack API token](https://api.slack.com/tutorials/tracks/getting-a-token). + +- Run app `faultdetector` or `faultdetector --config /PATH/TO/CUSTOM/CONFIG/FILE` + +To run notification service with docker. + +- Run `make docker-run config={/PATH/TO/CUSTOM/CONFIG/FILE} slack_access_token={ACCESS_TOKEN}` \ No newline at end of file diff --git a/cmd/main.go b/cmd/faultdetector/main.go similarity index 100% rename from cmd/main.go rename to cmd/faultdetector/main.go diff --git a/config.yaml b/config.yaml index 52459d0..a5812a6 100644 --- a/config.yaml +++ b/config.yaml @@ -21,6 +21,6 @@ fault_detector: # Notification service related configurations notification: - enable: true + enable: false slack: channel_id: "" diff --git a/pkg/config/config.go b/pkg/config/config.go index 958be46..6e8aee1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -86,7 +86,11 @@ func (c *Config) Validate() error { sysConfigError := c.System.Validate() apiConfigError := c.Api.Validate() fdConfigError := c.FaultDetectorConfig.Validate() - notificationConfigError := c.Notification.Validate() + // Validate notification config only when it is enabled + var notificationConfigError error + if c.Notification.Enable { + notificationConfigError = c.Notification.Validate() + } validationErrors = multierr.Combine(sysConfigError, apiConfigError, fdConfigError, notificationConfigError) diff --git a/pkg/faultdetector/faultdetector.go b/pkg/faultdetector/faultdetector.go index 889389f..ebdfb50 100644 --- a/pkg/faultdetector/faultdetector.go +++ b/pkg/faultdetector/faultdetector.go @@ -252,7 +252,7 @@ func (fd *FaultDetector) checkFault() error { if fd.notification != nil { notificationMessage := fmt.Sprintf("*Fault detected*, state root does not match:\noutputIndex: %d\nExpectedStateRoot: %s\nCalculatedStateRoot: %s\nFinalizationTime: %s", fd.currentOutputIndex, expectedOutputRoot, calculatedOutputRoot, finalizationTime) if err := fd.notification.Notify(notificationMessage); err != nil { - fd.logger.Errorf("Error while sending notification, error: %w", err) + fd.logger.Errorf("Error while sending notification, %w", err) } } diff --git a/pkg/utils/notification/channel/slack.go b/pkg/utils/notification/channel/slack.go index dfc0b0a..f65a9fb 100644 --- a/pkg/utils/notification/channel/slack.go +++ b/pkg/utils/notification/channel/slack.go @@ -4,6 +4,9 @@ import ( "context" "fmt" "os" + "strconv" + "strings" + "time" "github.com/LiskHQ/op-fault-detector/pkg/config" "github.com/LiskHQ/op-fault-detector/pkg/log" @@ -42,12 +45,19 @@ func (s *Slack) Notify(msg string) error { s.ChannelID, slack.MsgOptionText(msg, false), ) - if err != nil { s.logger.Errorf("Failed to send notification to the channel %s, error: %w", s.ChannelID, err) return err } - s.logger.Infof("Message successfully sent to the channel %s at %s", s.ChannelID, timestamp) + parts := strings.Split(timestamp, ".") + + timeInMS, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return err + } + localTime := time.UnixMilli(timeInMS * int64(time.Microsecond)).Local() + + s.logger.Infof("Message successfully sent to the channel %s at %s", s.ChannelID, localTime.String()) return nil } diff --git a/pkg/utils/notification/notification.go b/pkg/utils/notification/notification.go index 9f99d01..316f117 100644 --- a/pkg/utils/notification/notification.go +++ b/pkg/utils/notification/notification.go @@ -6,6 +6,7 @@ import ( "github.com/LiskHQ/op-fault-detector/pkg/config" "github.com/LiskHQ/op-fault-detector/pkg/log" slack "github.com/LiskHQ/op-fault-detector/pkg/utils/notification/channel" + "go.uber.org/multierr" ) // Notification holds information on all the supported channels require to communicate with the channel API. @@ -29,15 +30,15 @@ func NewNotification(ctx context.Context, logger log.Logger, notificationConfig return newNotification, nil } -// Notify sends a message to the available channels. -func (n *Notification) Notify(msg string) *[]error { - var errors []error +// Notify sends a message to the available channels and returns combined error from different channels if any. +func (n *Notification) Notify(msg string) error { + var combinedError error if n.slack != nil { if err := n.slack.Notify(msg); err != nil { - errors = append(errors, err) + combinedError = multierr.Append(combinedError, err) } } - return &errors + return combinedError }