Commit e60d24bf authored by OptimismBot's avatar OptimismBot Committed by GitHub

Merge pull request #7312 from epociask/indexer.client

[Indexer] Client Implementation & Bug Fixes
parents 78e0b0eb 9c9ff44d
......@@ -9,4 +9,4 @@
"juanblanco.solidity",
"golang.go",
],
}
}
\ No newline at end of file
docker-compose.dev.yml
.env
/indexer
api-ts/yarn.lock
api-ts/package-lock.json
\ No newline at end of file
......@@ -54,7 +54,11 @@ The indexer service is responsible for polling and processing real-time batches
* Process and persist new bridge events
* Synchronize L1 proven/finalized withdrawals with their L2 initialization counterparts
#### API
The indexer service runs a lightweight health server adjacently to the main service. The health server exposes a single endpoint `/healthz` that can be used to check the health of the indexer service. The health assessment doesn't check dependency health (ie. database) but rather checks the health of the indexer service itself.
### Database
The indexer service currently supports a Postgres database for storing L1/L2 OP Stack chain data. The most up-to-date database schemas can be found in the `./migrations` directory.
**NOTE:** The indexer service implementation currently does not natively support database migration. Because of this a database must be manually updated to ensure forward compatibility with the latest indexer service implementation.
\ No newline at end of file
## Metrics
The indexer services exposes a set of Prometheus metrics that can be used to monitor the health of the service. The metrics are exposed via the `/metrics` endpoint on the health server.
\ No newline at end of file
// Code generated by tygo. DO NOT EDIT.
//////////
// source: deposits.go
// source: models.go
/**
* DepositItem ... Deposit item model for API responses
*/
export interface DepositItem {
guid: string;
from: string;
......@@ -15,29 +18,23 @@ export interface DepositItem {
l1TokenAddress: string;
l2TokenAddress: string;
}
/**
* DepositResponse ... Data model for API JSON response
*/
export interface DepositResponse {
cursor: string;
hasNextPage: boolean;
items: DepositItem[];
}
//////////
// source: routes.go
export interface Routes {
Logger: any /* log.Logger */;
BridgeTransfersView: any /* database.BridgeTransfersView */;
Router?: any /* chi.Mux */;
}
//////////
// source: withdrawals.go
/**
* WithdrawalItem ... Data model for API JSON response
*/
export interface WithdrawalItem {
guid: string;
from: string;
to: string;
transactionHash: string;
messageHash: string;
timestamp: number /* uint64 */;
l2BlockHash: string;
amount: string;
......@@ -46,6 +43,9 @@ export interface WithdrawalItem {
l1TokenAddress: string;
l2TokenAddress: string;
}
/**
* WithdrawalResponse ... Data model for API JSON response
*/
export interface WithdrawalResponse {
cursor: string;
hasNextPage: boolean;
......
......@@ -17,7 +17,7 @@
],
"scripts": {
"clean": "rm -rf generated.ts indexer.cjs indexer.js",
"generate": "npm run clean && tygo generate && mv ../api/routes/index.ts generated.ts && tsup",
"generate": "npm run clean && tygo generate && mv ../api/models/index.ts generated.ts && tsup",
"test": "vitest"
},
"keywords": [
......
packages:
- path: "github.com/ethereum-optimism/optimism/indexer/api/routes"
- path: "github.com/ethereum-optimism/optimism/indexer/api/models"
......@@ -3,6 +3,7 @@ package api
import (
"context"
"fmt"
"net"
"net/http"
"runtime/debug"
"sync"
......@@ -10,7 +11,6 @@ import (
"github.com/ethereum-optimism/optimism/indexer/api/routes"
"github.com/ethereum-optimism/optimism/indexer/config"
"github.com/ethereum-optimism/optimism/indexer/database"
"github.com/ethereum-optimism/optimism/op-service/httputil"
"github.com/ethereum-optimism/optimism/op-service/metrics"
"github.com/ethereum/go-ethereum/log"
"github.com/go-chi/chi/v5"
......@@ -20,45 +20,63 @@ import (
const ethereumAddressRegex = `^0x[a-fA-F0-9]{40}$`
type Api struct {
// Api ... Indexer API struct
// TODO : Structured error responses
type API struct {
log log.Logger
Router *chi.Mux
router *chi.Mux
serverConfig config.ServerConfig
metricsConfig config.ServerConfig
metricsRegistry *prometheus.Registry
}
const (
MetricsNamespace = "op_indexer"
MetricsNamespace = "op_indexer_api"
addressParam = "{address:%s}"
// Endpoint paths
// NOTE - This can be further broken out over time as new version iterations
// are implemented
HealthPath = "/healthz"
DepositsPath = "/api/v0/deposits/"
WithdrawalsPath = "/api/v0/withdrawals/"
)
// chiMetricsMiddleware ... Injects a metrics recorder into request processing middleware
func chiMetricsMiddleware(rec metrics.HTTPRecorder) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return metrics.NewHTTPRecordingMiddleware(rec, next)
}
}
func NewApi(logger log.Logger, bv database.BridgeTransfersView, serverConfig config.ServerConfig, metricsConfig config.ServerConfig) *Api {
// NewApi ... Construct a new api instance
func NewApi(logger log.Logger, bv database.BridgeTransfersView, serverConfig config.ServerConfig, metricsConfig config.ServerConfig) *API {
// (1) Initialize dependencies
apiRouter := chi.NewRouter()
h := routes.NewRoutes(logger, bv, apiRouter)
mr := metrics.NewRegistry()
promRecorder := metrics.NewPromHTTPRecorder(mr, MetricsNamespace)
// (2) Inject routing middleware
apiRouter.Use(chiMetricsMiddleware(promRecorder))
apiRouter.Use(middleware.Recoverer)
apiRouter.Use(middleware.Heartbeat("/healthz"))
apiRouter.Use(middleware.Heartbeat(HealthPath))
apiRouter.Get(fmt.Sprintf("/api/v0/deposits/{address:%s}", ethereumAddressRegex), h.L1DepositsHandler)
apiRouter.Get(fmt.Sprintf("/api/v0/withdrawals/{address:%s}", ethereumAddressRegex), h.L2WithdrawalsHandler)
// (3) Set GET routes
apiRouter.Get(fmt.Sprintf(DepositsPath+addressParam, ethereumAddressRegex), h.L1DepositsHandler)
apiRouter.Get(fmt.Sprintf(WithdrawalsPath+addressParam, ethereumAddressRegex), h.L2WithdrawalsHandler)
return &Api{log: logger, Router: apiRouter, metricsRegistry: mr, serverConfig: serverConfig, metricsConfig: metricsConfig}
return &API{log: logger, router: apiRouter, metricsRegistry: mr, serverConfig: serverConfig, metricsConfig: metricsConfig}
}
func (a *Api) Start(ctx context.Context) error {
// Run ... Runs the API server routines
func (a *API) Run(ctx context.Context) error {
var wg sync.WaitGroup
errCh := make(chan error, 2)
// (1) Construct an inner function that will start a goroutine
// and handle any panics that occur on a shared error channel
processCtx, processCancel := context.WithCancel(ctx)
runProcess := func(start func(ctx context.Context) error) {
wg.Add(1)
......@@ -78,9 +96,11 @@ func (a *Api) Start(ctx context.Context) error {
}()
}
// (2) Start the API and metrics servers
runProcess(a.startServer)
runProcess(a.startMetricsServer)
// (3) Wait for all processes to complete
wg.Wait()
err := <-errCh
......@@ -93,19 +113,42 @@ func (a *Api) Start(ctx context.Context) error {
return err
}
func (a *Api) startServer(ctx context.Context) error {
// Port ... Returns the the port that server is listening on
func (a *API) Port() int {
return a.serverConfig.Port
}
// startServer ... Starts the API server
func (a *API) startServer(ctx context.Context) error {
a.log.Info("api server listening...", "port", a.serverConfig.Port)
server := http.Server{Addr: fmt.Sprintf(":%d", a.serverConfig.Port), Handler: a.Router}
err := httputil.ListenAndServeContext(ctx, &server)
server := http.Server{Addr: fmt.Sprintf(":%d", a.serverConfig.Port), Handler: a.router}
addr := fmt.Sprintf(":%d", a.serverConfig.Port)
listener, err := net.Listen("tcp", addr)
if err != nil {
a.log.Error("Listen:", err)
return err
}
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
if !ok {
return fmt.Errorf("failed to get TCP address from network listener")
}
// Update the port in the config in case the OS chose a different port
// than the one we requested (e.g. using port 0 to fetch a random open port)
a.serverConfig.Port = tcpAddr.Port
err = http.Serve(listener, server.Handler)
if err != nil {
a.log.Error("api server stopped", "err", err)
a.log.Error("api server stopped with error", "err", err)
} else {
a.log.Info("api server stopped")
}
return err
}
func (a *Api) startMetricsServer(ctx context.Context) error {
// startMetricsServer ... Starts the metrics server
func (a *API) startMetricsServer(ctx context.Context) error {
a.log.Info("starting metrics server...", "port", a.metricsConfig.Port)
err := metrics.ListenAndServe(ctx, a.metricsRegistry, a.metricsConfig.Host, a.metricsConfig.Port)
if err != nil {
......
......@@ -7,7 +7,7 @@ import (
"net/http/httptest"
"testing"
"github.com/ethereum-optimism/optimism/indexer/api/routes"
"github.com/ethereum-optimism/optimism/indexer/api/models"
"github.com/ethereum-optimism/optimism/indexer/config"
"github.com/ethereum-optimism/optimism/indexer/database"
"github.com/ethereum-optimism/optimism/op-service/testlog"
......@@ -100,7 +100,7 @@ func TestHealthz(t *testing.T) {
assert.Nil(t, err)
responseRecorder := httptest.NewRecorder()
api.Router.ServeHTTP(responseRecorder, request)
api.router.ServeHTTP(responseRecorder, request)
assert.Equal(t, http.StatusOK, responseRecorder.Code)
}
......@@ -112,11 +112,11 @@ func TestL1BridgeDepositsHandler(t *testing.T) {
assert.Nil(t, err)
responseRecorder := httptest.NewRecorder()
api.Router.ServeHTTP(responseRecorder, request)
api.router.ServeHTTP(responseRecorder, request)
assert.Equal(t, http.StatusOK, responseRecorder.Code)
var resp routes.DepositResponse
var resp models.DepositResponse
err = json.Unmarshal(responseRecorder.Body.Bytes(), &resp)
assert.Nil(t, err)
......@@ -135,9 +135,9 @@ func TestL2BridgeWithdrawalsByAddressHandler(t *testing.T) {
assert.Nil(t, err)
responseRecorder := httptest.NewRecorder()
api.Router.ServeHTTP(responseRecorder, request)
api.router.ServeHTTP(responseRecorder, request)
var resp routes.WithdrawalResponse
var resp models.WithdrawalResponse
err = json.Unmarshal(responseRecorder.Body.Bytes(), &resp)
assert.Nil(t, err)
......
package models
// DepositItem ... Deposit item model for API responses
type DepositItem struct {
Guid string `json:"guid"`
From string `json:"from"`
To string `json:"to"`
Timestamp uint64 `json:"timestamp"`
L1BlockHash string `json:"l1BlockHash"`
L1TxHash string `json:"l1TxHash"`
L2TxHash string `json:"l2TxHash"`
Amount string `json:"amount"`
L1TokenAddress string `json:"l1TokenAddress"`
L2TokenAddress string `json:"l2TokenAddress"`
}
// DepositResponse ... Data model for API JSON response
type DepositResponse struct {
Cursor string `json:"cursor"`
HasNextPage bool `json:"hasNextPage"`
Items []DepositItem `json:"items"`
}
// WithdrawalItem ... Data model for API JSON response
type WithdrawalItem struct {
Guid string `json:"guid"`
From string `json:"from"`
To string `json:"to"`
TransactionHash string `json:"transactionHash"`
MessageHash string `json:"messageHash"`
Timestamp uint64 `json:"timestamp"`
L2BlockHash string `json:"l2BlockHash"`
Amount string `json:"amount"`
ProofTransactionHash string `json:"proofTransactionHash"`
ClaimTransactionHash string `json:"claimTransactionHash"`
L1TokenAddress string `json:"l1TokenAddress"`
L2TokenAddress string `json:"l2TokenAddress"`
}
// WithdrawalResponse ... Data model for API JSON response
type WithdrawalResponse struct {
Cursor string `json:"cursor"`
HasNextPage bool `json:"hasNextPage"`
Items []WithdrawalItem `json:"items"`
}
......@@ -3,24 +3,30 @@ package routes
import (
"encoding/json"
"net/http"
)
const (
InternalServerError = "Internal server error"
"github.com/ethereum/go-ethereum/log"
// defaultPageLimit ... Default page limit for pagination
defaultPageLimit = 100
)
func jsonResponse(w http.ResponseWriter, logger log.Logger, data interface{}, statusCode int) {
// jsonResponse ... Marshals and writes a JSON response provided arbitrary data
func jsonResponse(w http.ResponseWriter, data interface{}, statusCode int) error {
w.Header().Set("Content-Type", "application/json")
jsonData, err := json.Marshal(data)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
logger.Error("Failed to marshal JSON: %v", err)
return
http.Error(w, InternalServerError, http.StatusInternalServerError)
return err
}
w.WriteHeader(statusCode)
_, err = w.Write(jsonData)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
logger.Error("Failed to write JSON data", err)
return
http.Error(w, InternalServerError, http.StatusInternalServerError)
return err
}
return nil
}
......@@ -2,36 +2,18 @@ package routes
import (
"net/http"
"strconv"
"github.com/ethereum-optimism/optimism/indexer/api/models"
"github.com/ethereum-optimism/optimism/indexer/database"
"github.com/ethereum/go-ethereum/common"
"github.com/go-chi/chi/v5"
)
type DepositItem struct {
Guid string `json:"guid"`
From string `json:"from"`
To string `json:"to"`
Timestamp uint64 `json:"timestamp"`
L1BlockHash string `json:"l1BlockHash"`
L1TxHash string `json:"l1TxHash"`
L2TxHash string `json:"l2TxHash"`
Amount string `json:"amount"`
L1TokenAddress string `json:"l1TokenAddress"`
L2TokenAddress string `json:"l2TokenAddress"`
}
type DepositResponse struct {
Cursor string `json:"cursor"`
HasNextPage bool `json:"hasNextPage"`
Items []DepositItem `json:"items"`
}
func newDepositResponse(deposits *database.L1BridgeDepositsResponse) DepositResponse {
items := make([]DepositItem, len(deposits.Deposits))
// newDepositResponse ... Converts a database.L1BridgeDepositsResponse to an api.DepositResponse
func newDepositResponse(deposits *database.L1BridgeDepositsResponse) models.DepositResponse {
items := make([]models.DepositItem, len(deposits.Deposits))
for i, deposit := range deposits.Deposits {
item := DepositItem{
item := models.DepositItem{
Guid: deposit.L1BridgeDeposit.TransactionSourceHash.String(),
L1BlockHash: deposit.L1BlockHash.String(),
Timestamp: deposit.L1BridgeDeposit.Tx.Timestamp,
......@@ -46,39 +28,51 @@ func newDepositResponse(deposits *database.L1BridgeDepositsResponse) DepositResp
items[i] = item
}
return DepositResponse{
return models.DepositResponse{
Cursor: deposits.Cursor,
HasNextPage: deposits.HasNextPage,
Items: items,
}
}
// L1DepositsHandler ... Handles /api/v0/deposits/{address} GET requests
func (h Routes) L1DepositsHandler(w http.ResponseWriter, r *http.Request) {
address := common.HexToAddress(chi.URLParam(r, "address"))
addressValue := chi.URLParam(r, "address")
cursor := r.URL.Query().Get("cursor")
limitQuery := r.URL.Query().Get("limit")
defaultLimit := 100
limit := defaultLimit
if limitQuery != "" {
parsedLimit, err := strconv.Atoi(limitQuery)
if err != nil {
http.Error(w, "Limit could not be parsed into a number", http.StatusBadRequest)
h.Logger.Error("Invalid limit")
h.Logger.Error(err.Error())
}
limit = parsedLimit
address, err := h.v.ParseValidateAddress(addressValue)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
h.logger.Error("Invalid address param", "param", addressValue)
h.logger.Error(err.Error())
return
}
deposits, err := h.BridgeTransfersView.L1BridgeDepositsByAddress(address, cursor, limit)
err = h.v.ValidateCursor(cursor)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
h.logger.Error("Invalid cursor param", "param", cursor, "err", err.Error())
}
limit, err := h.v.ParseValidateLimit(limitQuery)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
h.logger.Error("Invalid limit param", "param", limitQuery, "err", err.Error())
return
}
deposits, err := h.view.L1BridgeDepositsByAddress(address, cursor, limit)
if err != nil {
http.Error(w, "Internal server error reading deposits", http.StatusInternalServerError)
h.Logger.Error("Unable to read deposits from DB")
h.Logger.Error(err.Error())
h.logger.Error("Unable to read deposits from DB", "err", err.Error())
return
}
response := newDepositResponse(deposits)
jsonResponse(w, h.Logger, response, http.StatusOK)
err = jsonResponse(w, response, http.StatusOK)
if err != nil {
h.logger.Error("Error writing response", "err", err)
}
}
......@@ -9,14 +9,14 @@ import (
func (h Routes) DocsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
docs := docgen.MarkdownRoutesDoc(h.Router, docgen.MarkdownOpts{
docs := docgen.MarkdownRoutesDoc(h.router, docgen.MarkdownOpts{
ProjectPath: "github.com/ethereum-optimism/optimism/indexer",
// Intro text included at the top of the generated markdown file.
Intro: "Generated documentation for Optimism indexer",
})
_, err := w.Write([]byte(docs))
if err != nil {
h.Logger.Error("error writing docs", "err", err)
h.logger.Error("error writing docs", "err", err)
http.Error(w, "Internal server error fetching docs", http.StatusInternalServerError)
}
}
......@@ -6,16 +6,19 @@ import (
"github.com/go-chi/chi/v5"
)
// Routes ... Route handler struct
type Routes struct {
Logger log.Logger
BridgeTransfersView database.BridgeTransfersView
Router *chi.Mux
logger log.Logger
view database.BridgeTransfersView
router *chi.Mux
v *Validator
}
// NewRoutes ... Construct a new route handler instance
func NewRoutes(logger log.Logger, bv database.BridgeTransfersView, r *chi.Mux) Routes {
return Routes{
Logger: logger,
BridgeTransfersView: bv,
Router: r,
logger: logger,
view: bv,
router: r,
}
}
package routes
import (
"strconv"
"errors"
"github.com/ethereum/go-ethereum/common"
)
// Validator ... Validates API user request parameters
type Validator struct{}
// ParseValidateLimit ... Validates and parses the limit query parameters
func (v *Validator) ParseValidateLimit(limit string) (int, error) {
if limit == "" {
return defaultPageLimit, nil
}
val, err := strconv.Atoi(limit)
if err != nil {
return 0, errors.New("limit must be an integer value")
}
if val <= 0 {
return 0, errors.New("limit must be greater than 0")
}
// TODO - Add a check against a max limit value
return val, nil
}
// ParseValidateAddress ... Validates and parses the address query parameter
func (v *Validator) ParseValidateAddress(addr string) (common.Address, error) {
if !common.IsHexAddress(addr) {
return common.Address{}, errors.New("address must be represented as a valid hexadecimal string")
}
parsedAddr := common.HexToAddress(addr)
if parsedAddr == common.HexToAddress("0x0") {
return common.Address{}, errors.New("address cannot be the zero address")
}
return parsedAddr, nil
}
// ValidateCursor ... Validates and parses the cursor query parameter
func (v *Validator) ValidateCursor(cursor string) error {
if cursor == "" {
return nil
}
if len(cursor) != 66 { // 0x + 64 chars
return errors.New("cursor must be a 32 byte hex string")
}
if cursor[:2] != "0x" {
return errors.New("cursor must begin with 0x")
}
return nil
}
package routes
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestParseValidateLimit(t *testing.T) {
v := Validator{}
// (1) Happy case
limit := "100"
_, err := v.ParseValidateLimit(limit)
require.NoError(t, err, "limit should be valid")
// (2) Boundary validation
limit = "0"
_, err = v.ParseValidateLimit(limit)
require.Error(t, err, "limit must be greater than 0")
// (3) Type validation
limit = "abc"
_, err = v.ParseValidateLimit(limit)
require.Error(t, err, "limit must be an integer value")
}
func TestParseValidateAddress(t *testing.T) {
v := Validator{}
// (1) Happy case
addr := "0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5"
_, err := v.ParseValidateAddress(addr)
require.NoError(t, err, "address should be pass")
// (2) Invalid hex
addr = "🫡"
_, err = v.ParseValidateAddress(addr)
require.Error(t, err, "address must be represented as a valid hexadecimal string")
// (3) Zero address
addr = "0x0000000000000000000000000000000000000000"
_, err = v.ParseValidateAddress(addr)
require.Error(t, err, "address cannot be black-hole value")
}
func Test_ParseValidateCursor(t *testing.T) {
v := Validator{}
// (1) Happy case
cursor := "0xf3fd2eb696dab4263550b938726f9b3606e334cce6ebe27446bc26cb700b94e0"
err := v.ValidateCursor(cursor)
require.NoError(t, err, "cursor should be pass")
// (2) Invalid length
cursor = "0x000"
err = v.ValidateCursor(cursor)
require.Error(t, err, "cursor must be 32 byte hex string")
// (3) Invalid hex
cursor = "0🫡"
err = v.ValidateCursor(cursor)
require.Error(t, err, "cursor must start with 0x")
}
......@@ -2,38 +2,18 @@ package routes
import (
"net/http"
"strconv"
"github.com/ethereum-optimism/optimism/indexer/api/models"
"github.com/ethereum-optimism/optimism/indexer/database"
"github.com/ethereum/go-ethereum/common"
"github.com/go-chi/chi/v5"
)
type WithdrawalItem struct {
Guid string `json:"guid"`
From string `json:"from"`
To string `json:"to"`
TransactionHash string `json:"transactionHash"`
Timestamp uint64 `json:"timestamp"`
L2BlockHash string `json:"l2BlockHash"`
Amount string `json:"amount"`
ProofTransactionHash string `json:"proofTransactionHash"`
ClaimTransactionHash string `json:"claimTransactionHash"`
L1TokenAddress string `json:"l1TokenAddress"`
L2TokenAddress string `json:"l2TokenAddress"`
}
type WithdrawalResponse struct {
Cursor string `json:"cursor"`
HasNextPage bool `json:"hasNextPage"`
Items []WithdrawalItem `json:"items"`
}
// FIXME make a pure function that returns a struct instead of newWithdrawalResponse
func newWithdrawalResponse(withdrawals *database.L2BridgeWithdrawalsResponse) WithdrawalResponse {
items := make([]WithdrawalItem, len(withdrawals.Withdrawals))
// newWithdrawalResponse ... Converts a database.L2BridgeWithdrawalsResponse to an api.WithdrawalResponse
func newWithdrawalResponse(withdrawals *database.L2BridgeWithdrawalsResponse) models.WithdrawalResponse {
items := make([]models.WithdrawalItem, len(withdrawals.Withdrawals))
for i, withdrawal := range withdrawals.Withdrawals {
item := WithdrawalItem{
item := models.WithdrawalItem{
Guid: withdrawal.L2BridgeWithdrawal.TransactionWithdrawalHash.String(),
L2BlockHash: withdrawal.L2BlockHash.String(),
From: withdrawal.L2BridgeWithdrawal.Tx.FromAddress.String(),
......@@ -48,36 +28,50 @@ func newWithdrawalResponse(withdrawals *database.L2BridgeWithdrawalsResponse) Wi
items[i] = item
}
return WithdrawalResponse{
return models.WithdrawalResponse{
Cursor: withdrawals.Cursor,
HasNextPage: withdrawals.HasNextPage,
Items: items,
}
}
// L2WithdrawalsHandler ... Handles /api/v0/withdrawals/{address} GET requests
func (h Routes) L2WithdrawalsHandler(w http.ResponseWriter, r *http.Request) {
address := common.HexToAddress(chi.URLParam(r, "address"))
addressValue := chi.URLParam(r, "address")
cursor := r.URL.Query().Get("cursor")
limitQuery := r.URL.Query().Get("limit")
defaultLimit := 100
limit := defaultLimit
if limitQuery != "" {
parsedLimit, err := strconv.Atoi(limitQuery)
if err != nil {
http.Error(w, "Limit could not be parsed into a number", http.StatusBadRequest)
h.Logger.Error("Invalid limit")
h.Logger.Error(err.Error())
}
limit = parsedLimit
address, err := h.v.ParseValidateAddress(addressValue)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
h.logger.Error("Invalid address param", "param", addressValue, "err", err)
return
}
withdrawals, err := h.BridgeTransfersView.L2BridgeWithdrawalsByAddress(address, cursor, limit)
err = h.v.ValidateCursor(cursor)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
h.logger.Error("Invalid cursor param", "param", cursor, "err", err)
return
}
limit, err := h.v.ParseValidateLimit(limitQuery)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
h.logger.Error("Invalid query params", "err", err)
return
}
withdrawals, err := h.view.L2BridgeWithdrawalsByAddress(address, cursor, limit)
if err != nil {
http.Error(w, "Internal server error reading withdrawals", http.StatusInternalServerError)
h.Logger.Error("Unable to read withdrawals from DB")
h.Logger.Error(err.Error())
h.logger.Error("Unable to read withdrawals from DB", "err", err.Error())
return
}
response := newWithdrawalResponse(withdrawals)
jsonResponse(w, h.Logger, response, http.StatusOK)
err = jsonResponse(w, response, http.StatusOK)
if err != nil {
h.logger.Error("Error writing response", "err", err.Error())
}
}
package client
import (
"fmt"
"io"
"net/http"
"time"
"encoding/json"
"github.com/ethereum-optimism/optimism/indexer/api"
"github.com/ethereum-optimism/optimism/indexer/api/models"
"github.com/ethereum-optimism/optimism/indexer/node"
"github.com/ethereum/go-ethereum/common"
)
const (
urlParams = "?cursor=%s&limit=%d"
defaultPagingLimit = 100
// method names
healthz = "get_health"
deposits = "get_deposits"
withdrawals = "get_withdrawals"
)
// Option ... Provides configuration through callback injection
type Option func(*Client) error
// WithMetrics ... Triggers metric optionality
func WithMetrics(m node.Metricer) Option {
return func(c *Client) error {
c.metrics = m
return nil
}
}
// WithTimeout ... Embeds a timeout limit to request
func WithTimeout(t time.Duration) Option {
return func(c *Client) error {
c.c.Timeout = t
return nil
}
}
// Config ... Indexer client config struct
type Config struct {
PaginationLimit int
BaseURL string
}
// Client ... Indexer client struct
type Client struct {
cfg *Config
c *http.Client
metrics node.Metricer
}
// NewClient ... Construct a new indexer client
func NewClient(cfg *Config, opts ...Option) (*Client, error) {
if cfg.PaginationLimit <= 0 {
cfg.PaginationLimit = defaultPagingLimit
}
c := &http.Client{}
client := &Client{cfg: cfg, c: c}
for _, opt := range opts {
err := opt(client)
if err != nil {
return nil, err
}
}
return client, nil
}
// doRecordRequest ... Performs a read request on a provided endpoint w/ telemetry
func (c *Client) doRecordRequest(method string, endpoint string) ([]byte, error) {
var record func(error) = nil
if c.metrics != nil {
record = c.metrics.RecordRPCClientRequest(method)
}
resp, err := c.c.Get(endpoint)
if record != nil {
record(err)
}
if err != nil {
return nil, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
err = resp.Body.Close()
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("endpoint failed with status code %d", resp.StatusCode)
}
return body, resp.Body.Close()
}
// HealthCheck ... Checks the health of the indexer API
func (c *Client) HealthCheck() error {
_, err := c.doRecordRequest(healthz, c.cfg.BaseURL+api.HealthPath)
if err != nil {
return err
}
return nil
}
// GetDepositsByAddress ... Gets a deposit response object provided an L1 address and cursor
func (c *Client) GetDepositsByAddress(l1Address common.Address, cursor string) (*models.DepositResponse, error) {
var dResponse *models.DepositResponse
url := c.cfg.BaseURL + api.DepositsPath + l1Address.String() + urlParams
endpoint := fmt.Sprintf(url, cursor, c.cfg.PaginationLimit)
resp, err := c.doRecordRequest(deposits, endpoint)
if err != nil {
return nil, err
}
if err := json.Unmarshal(resp, &dResponse); err != nil {
return nil, err
}
return dResponse, nil
}
// GetAllDepositsByAddress ... Gets all deposits provided a L1 address
func (c *Client) GetAllDepositsByAddress(l1Address common.Address) ([]models.DepositItem, error) {
var deposits []models.DepositItem
cursor := ""
for {
dResponse, err := c.GetDepositsByAddress(l1Address, cursor)
if err != nil {
return nil, err
}
deposits = append(deposits, dResponse.Items...)
if !dResponse.HasNextPage {
break
}
cursor = dResponse.Cursor
}
return deposits, nil
}
// GetAllWithdrawalsByAddress ... Gets all withdrawals provided a L2 address
func (c *Client) GetAllWithdrawalsByAddress(l2Address common.Address) ([]models.WithdrawalItem, error) {
var withdrawals []models.WithdrawalItem
cursor := ""
for {
wResponse, err := c.GetWithdrawalsByAddress(l2Address, cursor)
if err != nil {
return nil, err
}
withdrawals = append(withdrawals, wResponse.Items...)
if !wResponse.HasNextPage {
break
}
cursor = wResponse.Cursor
}
return withdrawals, nil
}
// GetWithdrawalsByAddress ... Gets a withdrawal response object provided an L2 address and cursor
func (c *Client) GetWithdrawalsByAddress(l2Address common.Address, cursor string) (*models.WithdrawalResponse, error) {
var wResponse *models.WithdrawalResponse
url := c.cfg.BaseURL + api.WithdrawalsPath + l2Address.String() + urlParams
endpoint := fmt.Sprintf(url, cursor, c.cfg.PaginationLimit)
resp, err := c.doRecordRequest(withdrawals, endpoint)
if err != nil {
return nil, err
}
if err := json.Unmarshal(resp, &wResponse); err != nil {
return nil, err
}
return wResponse, nil
}
......@@ -69,7 +69,7 @@ func runApi(ctx *cli.Context) error {
defer db.Close()
api := api.NewApi(log, db.BridgeTransfers, cfg.HTTPServer, cfg.MetricsServer)
return api.Start(ctx.Context)
return api.Run(ctx.Context)
}
func runMigrations(ctx *cli.Context) error {
......
......@@ -4,6 +4,7 @@ import (
"context"
"os"
oplog "github.com/ethereum-optimism/optimism/op-service/log"
"github.com/ethereum-optimism/optimism/op-service/opio"
"github.com/ethereum/go-ethereum/log"
)
......@@ -22,6 +23,7 @@ func main() {
cancel()
}()
oplog.SetupDefaults()
app := newCli(GitCommit, GitDate)
if err := app.RunContext(ctx, os.Args); err != nil {
log.Error("application failed", "err", err)
......
......@@ -128,12 +128,11 @@ type L1BridgeDepositsResponse struct {
HasNextPage bool
}
// L1BridgeDepositsByAddress retrieves a list of deposits intiated by the specified address,
// L1BridgeDepositsByAddress retrieves a list of deposits initiated by the specified address,
// coupled with the L1/L2 transaction hashes that complete the bridge transaction.
func (db *bridgeTransfersDB) L1BridgeDepositsByAddress(address common.Address, cursor string, limit int) (*L1BridgeDepositsResponse, error) {
defaultLimit := 100
if limit <= 0 {
limit = defaultLimit
return nil, fmt.Errorf("limit must be greater than 0")
}
cursorClause := ""
......@@ -152,7 +151,7 @@ func (db *bridgeTransfersDB) L1BridgeDepositsByAddress(address common.Address, c
// Coalesce l1 transaction deposits that are simply ETH sends
ethTransactionDeposits := db.gorm.Model(&L1TransactionDeposit{})
ethTransactionDeposits = ethTransactionDeposits.Where(Transaction{FromAddress: address}).Where("data = '0x' AND amount > 0")
ethTransactionDeposits = ethTransactionDeposits.Where(&Transaction{FromAddress: address}).Where("data = '0x' AND amount > 0")
ethTransactionDeposits = ethTransactionDeposits.Joins("INNER JOIN l1_contract_events ON l1_contract_events.guid = initiated_l1_event_guid")
ethTransactionDeposits = ethTransactionDeposits.Select(`
from_address, to_address, amount, data, source_hash AS transaction_source_hash,
......@@ -164,6 +163,7 @@ l1_transaction_deposits.timestamp, NULL AS cross_domain_message_hash, ? AS local
}
depositsQuery := db.gorm.Model(&L1BridgeDeposit{})
depositsQuery = depositsQuery.Where(&Transaction{FromAddress: address})
depositsQuery = depositsQuery.Joins("INNER JOIN l1_transaction_deposits ON l1_transaction_deposits.source_hash = transaction_source_hash")
depositsQuery = depositsQuery.Joins("INNER JOIN l1_contract_events ON l1_contract_events.guid = l1_transaction_deposits.initiated_l1_event_guid")
depositsQuery = depositsQuery.Select(`
......@@ -241,14 +241,14 @@ type L2BridgeWithdrawalsResponse struct {
HasNextPage bool
}
// L2BridgeDepositsByAddress retrieves a list of deposits intiated by the specified address, coupled with the L1/L2 transaction hashes
// that complete the bridge transaction. The hashes that correspond to with the Bedrock multistep withdrawal process are also surfaced
// L2BridgeDepositsByAddress retrieves a list of deposits initiated by the specified address, coupled with the L1/L2 transaction hashes
// that complete the bridge transaction. The hashes that correspond with the Bedrock multi-step withdrawal process are also surfaced
func (db *bridgeTransfersDB) L2BridgeWithdrawalsByAddress(address common.Address, cursor string, limit int) (*L2BridgeWithdrawalsResponse, error) {
defaultLimit := 100
if limit <= 0 {
limit = defaultLimit
return nil, fmt.Errorf("limit must be greater than 0")
}
// (1) Generate cursor clause provided a cursor tx hash
cursorClause := ""
if cursor != "" {
withdrawalHash := common.HexToHash(cursor)
......@@ -260,12 +260,17 @@ func (db *bridgeTransfersDB) L2BridgeWithdrawalsByAddress(address common.Address
cursorClause = fmt.Sprintf("l2_transaction_withdrawals.timestamp <= %d", txWithdrawal.Tx.Timestamp)
}
// (2) Generate query for fetching ETH withdrawal data
// This query is a UNION (A | B) of two sub-queries:
// - (A) ETH sends from L2 to L1
// - (B) Bridge withdrawals from L2 to L1
// TODO join with l1_bridged_tokens and l2_bridged_tokens
ethAddressString := predeploys.LegacyERC20ETHAddr.String()
// Coalesce l2 transaction withdrawals that are simply ETH sends
ethTransactionWithdrawals := db.gorm.Model(&L2TransactionWithdrawal{})
ethTransactionWithdrawals = ethTransactionWithdrawals.Where(Transaction{FromAddress: address}).Where(`data = '0x' AND amount > 0`)
ethTransactionWithdrawals = ethTransactionWithdrawals.Where(&Transaction{FromAddress: address}).Where(`data = '0x' AND amount > 0`)
ethTransactionWithdrawals = ethTransactionWithdrawals.Joins("INNER JOIN l2_contract_events ON l2_contract_events.guid = l2_transaction_withdrawals.initiated_l2_event_guid")
ethTransactionWithdrawals = ethTransactionWithdrawals.Joins("LEFT JOIN l1_contract_events AS proven_l1_events ON proven_l1_events.guid = l2_transaction_withdrawals.proven_l1_event_guid")
ethTransactionWithdrawals = ethTransactionWithdrawals.Joins("LEFT JOIN l1_contract_events AS finalized_l1_events ON finalized_l1_events.guid = l2_transaction_withdrawals.finalized_l1_event_guid")
......@@ -279,6 +284,7 @@ l2_transaction_withdrawals.timestamp, NULL AS cross_domain_message_hash, ? AS lo
}
withdrawalsQuery := db.gorm.Model(&L2BridgeWithdrawal{})
withdrawalsQuery = withdrawalsQuery.Where(&Transaction{FromAddress: address})
withdrawalsQuery = withdrawalsQuery.Joins("INNER JOIN l2_transaction_withdrawals ON withdrawal_hash = l2_bridge_withdrawals.transaction_withdrawal_hash")
withdrawalsQuery = withdrawalsQuery.Joins("INNER JOIN l2_contract_events ON l2_contract_events.guid = l2_transaction_withdrawals.initiated_l2_event_guid")
withdrawalsQuery = withdrawalsQuery.Joins("LEFT JOIN l1_contract_events AS proven_l1_events ON proven_l1_events.guid = l2_transaction_withdrawals.proven_l1_event_guid")
......@@ -296,7 +302,10 @@ l2_bridge_withdrawals.timestamp, cross_domain_message_hash, local_token_address,
query = query.Joins("UNION (?)", ethTransactionWithdrawals)
query = query.Select("*").Order("timestamp DESC").Limit(limit + 1)
withdrawals := []L2BridgeWithdrawalWithTransactionHashes{}
// (3) Execute query and process results
result := query.Find(&withdrawals)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, nil
......
......@@ -173,4 +173,5 @@ func TestE2EBridgeL2CrossDomainMessenger(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, event)
require.Equal(t, event.TransactionHash, finalizedReceipt.TxHash)
}
......@@ -2,6 +2,7 @@ package e2e_tests
import (
"context"
"crypto/ecdsa"
"fmt"
"math/big"
"testing"
......@@ -16,6 +17,7 @@ import (
"github.com/ethereum-optimism/optimism/op-bindings/bindings"
"github.com/ethereum-optimism/optimism/op-bindings/predeploys"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/params"
......@@ -50,7 +52,7 @@ func TestE2EBridgeTransfersStandardBridgeETHDeposit(t *testing.T) {
}))
cursor := ""
limit := 0
limit := 100
aliceDeposits, err := testSuite.DB.BridgeTransfers.L1BridgeDepositsByAddress(aliceAddr, cursor, limit)
......@@ -116,7 +118,7 @@ func TestE2EBridgeTransfersOptimismPortalETHReceive(t *testing.T) {
return l1Header != nil && l1Header.Number.Uint64() >= portalDepositReceipt.BlockNumber.Uint64(), nil
}))
aliceDeposits, err := testSuite.DB.BridgeTransfers.L1BridgeDepositsByAddress(aliceAddr, "", 0)
aliceDeposits, err := testSuite.DB.BridgeTransfers.L1BridgeDepositsByAddress(aliceAddr, "", 1)
require.NoError(t, err)
require.NotNil(t, aliceDeposits)
require.Len(t, aliceDeposits.Deposits, 1)
......@@ -143,7 +145,7 @@ func TestE2EBridgeTransfersOptimismPortalETHReceive(t *testing.T) {
}))
// Still nil as the withdrawal did not occur through the standard bridge
aliceDeposits, err = testSuite.DB.BridgeTransfers.L1BridgeDepositsByAddress(aliceAddr, "", 0)
aliceDeposits, err = testSuite.DB.BridgeTransfers.L1BridgeDepositsByAddress(aliceAddr, "", 1)
require.NoError(t, err)
require.Nil(t, aliceDeposits.Deposits[0].L1BridgeDeposit.CrossDomainMessageHash)
}
......@@ -185,7 +187,7 @@ func TestE2EBridgeTransfersCursoredDeposits(t *testing.T) {
}))
// Get All
aliceDeposits, err := testSuite.DB.BridgeTransfers.L1BridgeDepositsByAddress(aliceAddr, "", 0)
aliceDeposits, err := testSuite.DB.BridgeTransfers.L1BridgeDepositsByAddress(aliceAddr, "", 3)
require.NotNil(t, aliceDeposits)
require.NoError(t, err)
require.Len(t, aliceDeposits.Deposits, 3)
......@@ -198,14 +200,14 @@ func TestE2EBridgeTransfersCursoredDeposits(t *testing.T) {
require.Len(t, aliceDeposits.Deposits, 2)
require.True(t, aliceDeposits.HasNextPage)
aliceDeposits, err = testSuite.DB.BridgeTransfers.L1BridgeDepositsByAddress(aliceAddr, aliceDeposits.Cursor, 2)
aliceDeposits, err = testSuite.DB.BridgeTransfers.L1BridgeDepositsByAddress(aliceAddr, aliceDeposits.Cursor, 1)
require.NoError(t, err)
require.NotNil(t, aliceDeposits)
require.Len(t, aliceDeposits.Deposits, 1)
require.False(t, aliceDeposits.HasNextPage)
// Returns the results in the right order
aliceDeposits, err = testSuite.DB.BridgeTransfers.L1BridgeDepositsByAddress(aliceAddr, "", 100)
aliceDeposits, err = testSuite.DB.BridgeTransfers.L1BridgeDepositsByAddress(aliceAddr, "", 3)
require.NotNil(t, aliceDeposits)
require.NoError(t, err)
for i := 0; i < 3; i++ {
......@@ -252,7 +254,7 @@ func TestE2EBridgeTransfersStandardBridgeETHWithdrawal(t *testing.T) {
return l2Header != nil && l2Header.Number.Uint64() >= withdrawReceipt.BlockNumber.Uint64(), nil
}))
aliceWithdrawals, err := testSuite.DB.BridgeTransfers.L2BridgeWithdrawalsByAddress(aliceAddr, "", 0)
aliceWithdrawals, err := testSuite.DB.BridgeTransfers.L2BridgeWithdrawalsByAddress(aliceAddr, "", 3)
require.NoError(t, err)
require.Len(t, aliceWithdrawals.Withdrawals, 1)
require.Equal(t, withdrawTx.Hash().String(), aliceWithdrawals.Withdrawals[0].L2TransactionHash.String())
......@@ -290,7 +292,7 @@ func TestE2EBridgeTransfersStandardBridgeETHWithdrawal(t *testing.T) {
return l1Header != nil && l1Header.Number.Uint64() >= finalizeReceipt.BlockNumber.Uint64(), nil
}))
aliceWithdrawals, err = testSuite.DB.BridgeTransfers.L2BridgeWithdrawalsByAddress(aliceAddr, "", 0)
aliceWithdrawals, err = testSuite.DB.BridgeTransfers.L2BridgeWithdrawalsByAddress(aliceAddr, "", 100)
require.NoError(t, err)
require.Equal(t, proveReceipt.TxHash, aliceWithdrawals.Withdrawals[0].ProvenL1TransactionHash)
require.Equal(t, finalizeReceipt.TxHash, aliceWithdrawals.Withdrawals[0].FinalizedL1TransactionHash)
......@@ -304,7 +306,6 @@ func TestE2EBridgeTransfersStandardBridgeETHWithdrawal(t *testing.T) {
func TestE2EBridgeTransfersL2ToL1MessagePasserETHReceive(t *testing.T) {
testSuite := createE2ETestSuite(t)
optimismPortal, err := bindings.NewOptimismPortal(testSuite.OpCfg.L1Deployments.OptimismPortalProxy, testSuite.L1Client)
require.NoError(t, err)
l2ToL1MessagePasser, err := bindings.NewOptimismPortal(predeploys.L2ToL1MessagePasserAddr, testSuite.L2Client)
......@@ -337,7 +338,7 @@ func TestE2EBridgeTransfersL2ToL1MessagePasserETHReceive(t *testing.T) {
return l2Header != nil && l2Header.Number.Uint64() >= l2ToL1WithdrawReceipt.BlockNumber.Uint64(), nil
}))
aliceWithdrawals, err := testSuite.DB.BridgeTransfers.L2BridgeWithdrawalsByAddress(aliceAddr, "", 0)
aliceWithdrawals, err := testSuite.DB.BridgeTransfers.L2BridgeWithdrawalsByAddress(aliceAddr, "", 100)
require.NoError(t, err)
require.Len(t, aliceWithdrawals.Withdrawals, 1)
require.Equal(t, l2ToL1MessagePasserWithdrawTx.Hash().String(), aliceWithdrawals.Withdrawals[0].L2TransactionHash.String())
......@@ -370,7 +371,7 @@ func TestE2EBridgeTransfersL2ToL1MessagePasserETHReceive(t *testing.T) {
return l1Header != nil && l1Header.Number.Uint64() >= finalizeReceipt.BlockNumber.Uint64(), nil
}))
aliceWithdrawals, err = testSuite.DB.BridgeTransfers.L2BridgeWithdrawalsByAddress(aliceAddr, "", 0)
aliceWithdrawals, err = testSuite.DB.BridgeTransfers.L2BridgeWithdrawalsByAddress(aliceAddr, "", 100)
require.NoError(t, err)
require.Equal(t, proveReceipt.TxHash, aliceWithdrawals.Withdrawals[0].ProvenL1TransactionHash)
require.Equal(t, finalizeReceipt.TxHash, aliceWithdrawals.Withdrawals[0].FinalizedL1TransactionHash)
......@@ -414,7 +415,7 @@ func TestE2EBridgeTransfersCursoredWithdrawals(t *testing.T) {
}))
// Get All
aliceWithdrawals, err := testSuite.DB.BridgeTransfers.L2BridgeWithdrawalsByAddress(aliceAddr, "", 0)
aliceWithdrawals, err := testSuite.DB.BridgeTransfers.L2BridgeWithdrawalsByAddress(aliceAddr, "", 100)
require.NotNil(t, aliceWithdrawals)
require.NoError(t, err)
require.Len(t, aliceWithdrawals.Withdrawals, 3)
......@@ -427,7 +428,7 @@ func TestE2EBridgeTransfersCursoredWithdrawals(t *testing.T) {
require.Len(t, aliceWithdrawals.Withdrawals, 2)
require.True(t, aliceWithdrawals.HasNextPage)
aliceWithdrawals, err = testSuite.DB.BridgeTransfers.L2BridgeWithdrawalsByAddress(aliceAddr, aliceWithdrawals.Cursor, 2)
aliceWithdrawals, err = testSuite.DB.BridgeTransfers.L2BridgeWithdrawalsByAddress(aliceAddr, aliceWithdrawals.Cursor, 1)
require.NotNil(t, aliceWithdrawals)
require.NoError(t, err)
require.Len(t, aliceWithdrawals.Withdrawals, 1)
......@@ -445,3 +446,73 @@ func TestE2EBridgeTransfersCursoredWithdrawals(t *testing.T) {
require.Equal(t, int64(3-i)*params.Ether, withdrawal.L2BridgeWithdrawal.Tx.Amount.Int64())
}
}
func TestClientGetWithdrawals(t *testing.T) {
testSuite := createE2ETestSuite(t)
// (1) Generate contract bindings for the L1 and L2 standard bridges
optimismPortal, err := bindings.NewOptimismPortal(testSuite.OpCfg.L1Deployments.OptimismPortalProxy, testSuite.L1Client)
require.NoError(t, err)
l2ToL1MessagePasser, err := bindings.NewOptimismPortal(predeploys.L2ToL1MessagePasserAddr, testSuite.L2Client)
require.NoError(t, err)
// (2) Create test actors that will deposit and withdraw using the standard bridge
aliceAddr := testSuite.OpCfg.Secrets.Addresses().Alice
bobAddr := testSuite.OpCfg.Secrets.Addresses().Bob
type actor struct {
addr common.Address
priv *ecdsa.PrivateKey
}
actors := []actor{
{
addr: aliceAddr,
priv: testSuite.OpCfg.Secrets.Alice,
},
{
addr: bobAddr,
priv: testSuite.OpCfg.Secrets.Bob,
},
}
// (3) Iterate over each actor and deposit / withdraw
for _, actor := range actors {
l2Opts, err := bind.NewKeyedTransactorWithChainID(actor.priv, testSuite.OpCfg.L2ChainIDBig())
require.NoError(t, err)
l2Opts.Value = big.NewInt(params.Ether)
// (3.a) Deposit user funds into L2 via OptimismPortal contract
l1Opts, err := bind.NewKeyedTransactorWithChainID(actor.priv, testSuite.OpCfg.L1ChainIDBig())
require.NoError(t, err)
l1Opts.Value = l2Opts.Value
depositTx, err := optimismPortal.Receive(l1Opts)
require.NoError(t, err)
_, err = wait.ForReceiptOK(context.Background(), testSuite.L1Client, depositTx.Hash())
require.NoError(t, err)
// (3.b) Initiate withdrawal transaction via L2ToL1MessagePasser contract
l2ToL1MessagePasserWithdrawTx, err := l2ToL1MessagePasser.Receive(l2Opts)
require.NoError(t, err)
l2ToL1WithdrawReceipt, err := wait.ForReceiptOK(context.Background(), testSuite.L2Client, l2ToL1MessagePasserWithdrawTx.Hash())
require.NoError(t, err)
// (3.c) wait for indexer processor to catchup with the L2 block containing the withdrawal tx
require.NoError(t, wait.For(context.Background(), 500*time.Millisecond, func() (bool, error) {
l2Header := testSuite.Indexer.BridgeProcessor.LatestL2Header
return l2Header != nil && l2Header.Number.Uint64() >= l2ToL1WithdrawReceipt.BlockNumber.Uint64(), nil
}))
// (3.d) Ensure that withdrawal and deposit txs are retrievable via API
deposits, err := testSuite.Client.GetAllDepositsByAddress(actor.addr)
require.NoError(t, err)
require.Len(t, deposits, 1)
require.Equal(t, depositTx.Hash().String(), deposits[0].L1TxHash)
withdrawals, err := testSuite.Client.GetAllWithdrawalsByAddress(actor.addr)
require.NoError(t, err)
require.Len(t, withdrawals, 1)
require.Equal(t, l2ToL1MessagePasserWithdrawTx.Hash().String(), withdrawals[0].TransactionHash)
}
}
......@@ -9,6 +9,8 @@ import (
"time"
"github.com/ethereum-optimism/optimism/indexer"
"github.com/ethereum-optimism/optimism/indexer/api"
"github.com/ethereum-optimism/optimism/indexer/client"
"github.com/ethereum-optimism/optimism/indexer/config"
"github.com/ethereum-optimism/optimism/indexer/database"
......@@ -21,9 +23,19 @@ import (
"github.com/stretchr/testify/require"
)
/*
NOTE - Most of the current bridge tests fetch chain data via direct database queries. These could all
be transitioned to use the API client instead to better simulate/validate real-world usage.
Supporting this would potentially require adding new API endpoints for the specific query lookup types.
*/
type E2ETestSuite struct {
t *testing.T
// API
Client *client.Client
API *api.API
// Indexer
DB *database.DB
Indexer *indexer.Indexer
......@@ -37,6 +49,7 @@ type E2ETestSuite struct {
L2Client *ethclient.Client
}
// createE2ETestSuite ... Create a new E2E test suite
func createE2ETestSuite(t *testing.T) E2ETestSuite {
dbUser := os.Getenv("DB_USER")
dbName := setupTestDatabase(t)
......@@ -105,14 +118,54 @@ func createE2ETestSuite(t *testing.T) E2ETestSuite {
require.NoError(t, err)
indexerCtx, indexerStop := context.WithCancel(context.Background())
t.Cleanup(func() { indexerStop() })
go func() {
err := indexer.Run(indexerCtx)
require.NoError(t, err)
if err != nil { // panicking here ensures that the test will exit
// during service failure. Using t.Fail() wouldn't be caught
// until all awaiting routines finish which would never happen.
panic(err)
}
}()
apiLog := testlog.Logger(t, log.LvlInfo).New("role", "indexer_api")
apiCfg := config.ServerConfig{
Host: "127.0.0.1",
Port: 0,
}
mCfg := config.ServerConfig{
Host: "127.0.0.1",
Port: 0,
}
api := api.NewApi(apiLog, db.BridgeTransfers, apiCfg, mCfg)
apiCtx, apiStop := context.WithCancel(context.Background())
go func() {
err := api.Run(apiCtx)
if err != nil {
panic(err)
}
}()
t.Cleanup(func() {
apiStop()
indexerStop()
})
// Wait for the API to start listening
time.Sleep(1 * time.Second)
client, err := client.NewClient(&client.Config{
PaginationLimit: 100,
BaseURL: fmt.Sprintf("http://%s:%d", apiCfg.Host, api.Port()),
})
require.NoError(t, err)
return E2ETestSuite{
t: t,
Client: client,
DB: db,
Indexer: indexer,
OpCfg: &opCfg,
......
......@@ -48,7 +48,7 @@ func DialEthClient(rpcUrl string, metrics Metricer) (EthClient, error) {
return nil, err
}
client := &client{rpc: newRPC(rpcClient, metrics)}
client := &client{rpc: NewRPC(rpcClient, metrics)}
return client, nil
}
......@@ -207,7 +207,7 @@ type rpcClient struct {
metrics Metricer
}
func newRPC(client *rpc.Client, metrics Metricer) RPC {
func NewRPC(client *rpc.Client, metrics Metricer) RPC {
return &rpcClient{client, metrics}
}
......
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