diff --git a/contrib/opbot/botmd/botmd.go b/contrib/opbot/botmd/botmd.go index 32ce34bfae..5d8b3ec395 100644 --- a/contrib/opbot/botmd/botmd.go +++ b/contrib/opbot/botmd/botmd.go @@ -6,6 +6,7 @@ import ( "github.com/slack-io/slacker" "github.com/synapsecns/sanguine/contrib/opbot/config" + "github.com/synapsecns/sanguine/contrib/opbot/internal" "github.com/synapsecns/sanguine/contrib/opbot/signoz" screenerClient "github.com/synapsecns/sanguine/contrib/screener-api/client" "github.com/synapsecns/sanguine/core/dbcommon" @@ -29,6 +30,7 @@ type Bot struct { signozClient *signoz.Client signozEnabled bool rpcClient omnirpcClient.RPCClient + rfqClient internal.RFQClient signer signer.Signer submitter submitter.TransactionSubmitter screener screenerClient.ScreenerClient @@ -42,10 +44,11 @@ func NewBot(handler metrics.Handler, cfg config.Config) *Bot { sugaredLogger := otelzap.New(experimentalLogger.MakeZapLogger()).Sugar() bot := Bot{ - handler: handler, - cfg: cfg, - server: server, - logger: experimentalLogger.MakeWrappedSugaredLogger(sugaredLogger), + handler: handler, + cfg: cfg, + server: server, + logger: experimentalLogger.MakeWrappedSugaredLogger(sugaredLogger), + rfqClient: internal.NewRFQClient(handler, cfg.RFQIndexerAPIURL), } // you should be able to run opbot even without signoz. diff --git a/contrib/opbot/botmd/commands.go b/contrib/opbot/botmd/commands.go index b72f4ff7c2..34f5084fb4 100644 --- a/contrib/opbot/botmd/commands.go +++ b/contrib/opbot/botmd/commands.go @@ -11,7 +11,6 @@ import ( "regexp" "sort" "strings" - "sync" "time" "github.com/dustin/go-humanize" @@ -21,14 +20,13 @@ import ( "github.com/hako/durafmt" "github.com/slack-go/slack" "github.com/slack-io/slacker" + "github.com/synapsecns/sanguine/contrib/opbot/internal" "github.com/synapsecns/sanguine/contrib/opbot/signoz" "github.com/synapsecns/sanguine/core/retry" "github.com/synapsecns/sanguine/ethergo/chaindata" - "github.com/synapsecns/sanguine/ethergo/client" "github.com/synapsecns/sanguine/ethergo/submitter" rfqClient "github.com/synapsecns/sanguine/services/rfq/api/client" "github.com/synapsecns/sanguine/services/rfq/contracts/fastbridge" - "github.com/synapsecns/sanguine/services/rfq/relayer/relapi" ) func (b *Bot) requiresSignoz(definition *slacker.CommandDefinition) *slacker.CommandDefinition { @@ -159,62 +157,17 @@ func (b *Bot) traceCommand() *slacker.CommandDefinition { func (b *Bot) rfqLookupCommand() *slacker.CommandDefinition { return &slacker.CommandDefinition{ Command: "rfq ", - Description: "find a rfq transaction by either tx hash or txid on all configured relayers", + Description: "find a rfq transaction by either tx hash or txid from the rfq-indexer api", Examples: []string{ "rfq 0x30f96b45ba689c809f7e936c140609eb31c99b182bef54fccf49778716a7e1ca", }, Handler: func(ctx *slacker.CommandContext) { - type Status struct { - relayer string - *relapi.GetQuoteRequestResponse - } - - var statuses []Status - var sliceMux sync.Mutex - - if len(b.cfg.RelayerURLS) == 0 { - _, err := ctx.Response().Reply("no relayer urls configured") - if err != nil { - log.Println(err) - } - return - } - tx := stripLinks(ctx.Request().Param("tx")) - var wg sync.WaitGroup - // 2 routines per relayer, one for tx hashh one for tx id - wg.Add(len(b.cfg.RelayerURLS) * 2) - for _, relayer := range b.cfg.RelayerURLS { - client := relapi.NewRelayerClient(b.handler, relayer) - go func() { - defer wg.Done() - res, err := client.GetQuoteRequestByTxHash(ctx.Context(), tx) - if err != nil { - log.Printf("error fetching quote request status by tx hash: %v\n", err) - return - } - sliceMux.Lock() - defer sliceMux.Unlock() - statuses = append(statuses, Status{relayer: relayer, GetQuoteRequestResponse: res}) - }() - - go func() { - defer wg.Done() - res, err := client.GetQuoteRequestByTXID(ctx.Context(), tx) - if err != nil { - log.Printf("error fetching quote request status by tx id: %v\n", err) - return - } - sliceMux.Lock() - defer sliceMux.Unlock() - statuses = append(statuses, Status{relayer: relayer, GetQuoteRequestResponse: res}) - }() - } - wg.Wait() - - if len(statuses) == 0 { - _, err := ctx.Response().Reply("no quote request found") + res, status, err := b.rfqClient.GetRFQ(ctx.Context(), tx) + if err != nil { + b.logger.Errorf(ctx.Context(), "error fetching quote request: %v", err) + _, err := ctx.Response().Reply(fmt.Sprintf("error fetching quote request %s", err.Error())) if err != nil { log.Println(err) } @@ -223,51 +176,46 @@ func (b *Bot) rfqLookupCommand() *slacker.CommandDefinition { var slackBlocks []slack.Block - for _, status := range statuses { - client, err := b.rpcClient.GetChainClient(ctx.Context(), int(status.OriginChainID)) - if err != nil { - log.Printf("error getting chain client: %v\n", err) - } - - objects := []*slack.TextBlockObject{ - { - Type: slack.MarkdownType, - Text: fmt.Sprintf("*Relayer*: %s", status.relayer), - }, - { - Type: slack.MarkdownType, - Text: fmt.Sprintf("*Status*: %s", status.Status), - }, - { - Type: slack.MarkdownType, - Text: fmt.Sprintf("*TxID*: %s", toExplorerSlackLink(status.TxID)), - }, - { - Type: slack.MarkdownType, - Text: fmt.Sprintf("*OriginTxHash*: %s", toTXSlackLink(status.OriginTxHash, status.OriginChainID)), - }, - { - Type: slack.MarkdownType, - Text: fmt.Sprintf("*Estimated Tx Age*: %s", getTxAge(ctx.Context(), client, status.GetQuoteRequestResponse)), - }, - } - - if status.DestTxHash == (common.Hash{}).String() { - objects = append(objects, &slack.TextBlockObject{ - Type: slack.MarkdownType, - Text: "*DestTxHash*: not available", - }) - } else { - objects = append(objects, &slack.TextBlockObject{ - Type: slack.MarkdownType, - Text: fmt.Sprintf("*DestTxHash*: %s", toTXSlackLink(status.DestTxHash, status.DestChainID)), - }) - } + objects := []*slack.TextBlockObject{ + { + Type: slack.MarkdownType, + Text: fmt.Sprintf("*Relayer*: %s", res.BridgeRelay.Relayer), + }, + { + Type: slack.MarkdownType, + Text: fmt.Sprintf("*Status*: %s", status), + }, + { + Type: slack.MarkdownType, + Text: fmt.Sprintf("*TxID*: %s", toExplorerSlackLink(res.Bridge.TransactionID)), + }, + { + Type: slack.MarkdownType, + //nolint: gosec + Text: fmt.Sprintf("*OriginTxHash*: %s", toTXSlackLink(res.BridgeRequest.TransactionHash, uint32(res.Bridge.OriginChainID))), + }, + { + Type: slack.MarkdownType, + Text: fmt.Sprintf("*Estimated Tx Age*: %s", humanize.Time(time.Unix(res.BridgeRelay.BlockTimestamp, 0))), + }, + } - slackBlocks = append(slackBlocks, slack.NewSectionBlock(nil, objects, nil)) + if status == "Requested" { + objects = append(objects, &slack.TextBlockObject{ + Type: slack.MarkdownType, + Text: "*DestTxHash*: not available", + }) + } else { + //nolint: gosec + objects = append(objects, &slack.TextBlockObject{ + Type: slack.MarkdownType, + Text: fmt.Sprintf("*DestTxHash*: %s", toTXSlackLink(res.BridgeRelay.TransactionHash, uint32(res.Bridge.DestChainID))), + }) } - _, err := ctx.Response().ReplyBlocks(slackBlocks, slacker.WithUnfurlLinks(false)) + slackBlocks = append(slackBlocks, slack.NewSectionBlock(nil, objects, nil)) + + _, err = ctx.Response().ReplyBlocks(slackBlocks, slacker.WithUnfurlLinks(false)) if err != nil { log.Println(err) } @@ -275,7 +223,7 @@ func (b *Bot) rfqLookupCommand() *slacker.CommandDefinition { } } -// nolint: gocognit, cyclop. +// nolint: gocognit, cyclop, gosec. func (b *Bot) rfqRefund() *slacker.CommandDefinition { return &slacker.CommandDefinition{ Command: "refund ", @@ -292,16 +240,7 @@ func (b *Bot) rfqRefund() *slacker.CommandDefinition { return } - var rawRequest *relapi.GetQuoteRequestResponse - var err error - var relClient relapi.RelayerClient - for _, relayer := range b.cfg.RelayerURLS { - relClient = relapi.NewRelayerClient(b.handler, relayer) - rawRequest, err = getQuoteRequest(ctx.Context(), relClient, tx) - if err == nil { - break - } - } + rawRequest, _, err := b.rfqClient.GetRFQ(ctx.Context(), tx) if err != nil { b.logger.Errorf(ctx.Context(), "error fetching quote request: %v", err) _, err := ctx.Response().Reply("error fetching quote request") @@ -320,7 +259,7 @@ func (b *Bot) rfqRefund() *slacker.CommandDefinition { return } - isScreened, err := b.screener.ScreenAddress(ctx.Context(), rawRequest.Sender) + isScreened, err := b.screener.ScreenAddress(ctx.Context(), rawRequest.Bridge.Sender) if err != nil { _, err := ctx.Response().Reply("error screening address") if err != nil { @@ -336,13 +275,16 @@ func (b *Bot) rfqRefund() *slacker.CommandDefinition { return } - nonce, err := b.submitter.SubmitTransaction(ctx.Context(), big.NewInt(int64(rawRequest.OriginChainID)), func(transactor *bind.TransactOpts) (tx *types.Transaction, err error) { - tx, err = fastBridgeContract.Refund(transactor, common.Hex2Bytes(rawRequest.QuoteRequestRaw)) - if err != nil { - return nil, fmt.Errorf("error submitting refund: %w", err) - } - return tx, nil - }) + nonce, err := b.submitter.SubmitTransaction( + ctx.Context(), + big.NewInt(int64(rawRequest.Bridge.OriginChainID)), + func(transactor *bind.TransactOpts) (tx *types.Transaction, err error) { + tx, err = fastBridgeContract.Refund(transactor, common.Hex2Bytes(rawRequest.Bridge.Request[2:])) + if err != nil { + return nil, fmt.Errorf("error submitting refund: %w", err) + } + return tx, nil + }) if err != nil { log.Printf("error submitting refund: %v\n", err) return @@ -352,7 +294,7 @@ func (b *Bot) rfqRefund() *slacker.CommandDefinition { err = retry.WithBackoff( ctx.Context(), func(ctx context.Context) error { - status, err = b.submitter.GetSubmissionStatus(ctx, big.NewInt(int64(rawRequest.OriginChainID)), nonce) + status, err = b.submitter.GetSubmissionStatus(ctx, big.NewInt(int64(rawRequest.Bridge.OriginChainID)), nonce) if err != nil || !status.HasTx() { b.logger.Errorf(ctx, "error fetching quote request: %v", err) return fmt.Errorf("error fetching quote request: %w", err) @@ -364,12 +306,15 @@ func (b *Bot) rfqRefund() *slacker.CommandDefinition { ) if err != nil { b.logger.Errorf(ctx.Context(), "error fetching quote request: %v", err) - _, err := ctx.Response().Reply(fmt.Sprintf("error fetching explorer link to refund, but nonce is %d", nonce)) - log.Printf("error fetching quote request: %v\n", err) + _, err := ctx.Response().Reply(fmt.Sprintf("refund submitted with nonce %d", nonce)) + if err != nil { + log.Println(err) + } return } - _, err = ctx.Response().Reply(fmt.Sprintf("refund submitted: %s", toExplorerSlackLink(status.TxHash().String()))) + //nolint: gosec + _, err = ctx.Response().Reply(fmt.Sprintf("refund submitted: %s", toTXSlackLink(status.TxHash().String(), uint32(rawRequest.Bridge.OriginChainID)))) if err != nil { log.Println(err) } @@ -377,7 +322,7 @@ func (b *Bot) rfqRefund() *slacker.CommandDefinition { } } -func (b *Bot) makeFastBridge(ctx context.Context, req *relapi.GetQuoteRequestResponse) (*fastbridge.FastBridge, error) { +func (b *Bot) makeFastBridge(ctx context.Context, req *internal.GetRFQByTxIDResponse) (*fastbridge.FastBridge, error) { client, err := rfqClient.NewUnauthenticatedClient(b.handler, b.cfg.RFQApiURL) if err != nil { return nil, fmt.Errorf("error creating rfq client: %w", err) @@ -388,12 +333,13 @@ func (b *Bot) makeFastBridge(ctx context.Context, req *relapi.GetQuoteRequestRes return nil, fmt.Errorf("error fetching rfq contracts: %w", err) } - chainClient, err := b.rpcClient.GetChainClient(ctx, int(req.OriginChainID)) + chainClient, err := b.rpcClient.GetChainClient(ctx, req.Bridge.OriginChainID) if err != nil { return nil, fmt.Errorf("error getting chain client: %w", err) } - contractAddress, ok := contracts.Contracts[req.OriginChainID] + //nolint: gosec + contractAddress, ok := contracts.Contracts[uint32(req.Bridge.OriginChainID)] if !ok { return nil, errors.New("contract address not found") } @@ -405,24 +351,10 @@ func (b *Bot) makeFastBridge(ctx context.Context, req *relapi.GetQuoteRequestRes return fastBridgeHandle, nil } -func getTxAge(ctx context.Context, client client.EVM, res *relapi.GetQuoteRequestResponse) string { - // TODO: add CreatedAt field to GetQuoteRequestStatusResponse so we don't need to make network calls? - receipt, err := client.TransactionReceipt(ctx, common.HexToHash(res.OriginTxHash)) - if err != nil { - return "unknown time ago" - } - txBlock, err := client.HeaderByHash(ctx, receipt.BlockHash) - if err != nil { - return "unknown time ago" - } - - return humanize.Time(time.Unix(int64(txBlock.Time), 0)) -} - func toExplorerSlackLink(ogHash string) string { rfqHash := strings.ToUpper(ogHash) // cut off 0x - if strings.HasPrefix(rfqHash, "0x") { + if strings.HasPrefix(rfqHash, "0X") { rfqHash = strings.ToLower(rfqHash[2:]) } @@ -444,16 +376,3 @@ func stripLinks(input string) string { linkRegex := regexp.MustCompile(`]+\|([^>]+)>`) return linkRegex.ReplaceAllString(input, "$1") } - -func getQuoteRequest(ctx context.Context, client relapi.RelayerClient, tx string) (qr *relapi.GetQuoteRequestResponse, err error) { - if qr, err = client.GetQuoteRequestByTxHash(ctx, tx); err == nil { - return qr, nil - } - - // look up quote request - if qr, err = client.GetQuoteRequestByTXID(ctx, tx); err == nil { - return qr, nil - } - - return nil, fmt.Errorf("error fetching quote request: %w", err) -} diff --git a/contrib/opbot/botmd/commands_test.go b/contrib/opbot/botmd/commands_test.go index b9ab0a39ee..6b5456094e 100644 --- a/contrib/opbot/botmd/commands_test.go +++ b/contrib/opbot/botmd/commands_test.go @@ -1,13 +1,9 @@ package botmd_test import ( - "context" "testing" "github.com/synapsecns/sanguine/contrib/opbot/botmd" - "github.com/synapsecns/sanguine/core/metrics" - omnirpcClient "github.com/synapsecns/sanguine/services/omnirpc/client" - "github.com/synapsecns/sanguine/services/rfq/relayer/relapi" ) func TestStripLinks(t *testing.T) { @@ -18,24 +14,3 @@ func TestStripLinks(t *testing.T) { t.Errorf("StripLinks(%s) = %s; want %s", testLink, got, expected) } } - -func TestTxAge(t *testing.T) { - notExpected := "unknown time ago" // should be a definite time - - status := &relapi.GetQuoteRequestResponse{ - OriginTxHash: "0x954264d120f5f3cf50edc39ebaf88ea9dc647d9d6843b7a120ed3677e23d7890", - OriginChainID: 421611, - } - - ctx := context.Background() - - client := omnirpcClient.NewOmnirpcClient("https://arb1.arbitrum.io/rpc", metrics.Get()) - cc, err := client.GetChainClient(ctx, int(status.OriginChainID)) - if err != nil { - t.Fatalf("GetChainClient() failed: %v", err) - } - - if got := botmd.GetTxAge(context.Background(), cc, status); got == notExpected { - t.Errorf("TxAge(%s) = %s; want not %s", status.OriginTxHash, got, notExpected) - } -} diff --git a/contrib/opbot/botmd/export_test.go b/contrib/opbot/botmd/export_test.go index 79b1e7a0f2..36a3ba400c 100644 --- a/contrib/opbot/botmd/export_test.go +++ b/contrib/opbot/botmd/export_test.go @@ -1,16 +1,5 @@ package botmd -import ( - "context" - - "github.com/synapsecns/sanguine/ethergo/client" - "github.com/synapsecns/sanguine/services/rfq/relayer/relapi" -) - func StripLinks(input string) string { return stripLinks(input) } - -func GetTxAge(ctx context.Context, client client.EVM, res *relapi.GetQuoteRequestResponse) string { - return getTxAge(ctx, client, res) -} diff --git a/contrib/opbot/config/config.go b/contrib/opbot/config/config.go index e9dde19453..0e52bb3e3c 100644 --- a/contrib/opbot/config/config.go +++ b/contrib/opbot/config/config.go @@ -38,6 +38,8 @@ type Config struct { ScreenerURL string `yaml:"screener_url"` // Database is the database config. Database DatabaseConfig `yaml:"database"` + // RFQIndexerAPIURL is the URL of the RFQ indexer API. + RFQIndexerAPIURL string `yaml:"rfq_indexer_api_url"` } // DatabaseConfig represents the configuration for the database. diff --git a/contrib/opbot/internal/client.go b/contrib/opbot/internal/client.go new file mode 100644 index 0000000000..6379a784d8 --- /dev/null +++ b/contrib/opbot/internal/client.go @@ -0,0 +1,71 @@ +// Package internal provides the RFQ client implementation. +package internal + +import ( + "context" + "fmt" + "net/http" + + "github.com/dubonzi/otelresty" + "github.com/go-http-utils/headers" + "github.com/go-resty/resty/v2" + "github.com/synapsecns/sanguine/core/metrics" +) + +const ( + getRFQRoute = "/transaction-id/%s" +) + +// RFQClient is the interface for the RFQ client. +type RFQClient interface { + // GetRFQ gets a quote request by transaction ID. + GetRFQ(ctx context.Context, txIdentifier string) (resp *GetRFQByTxIDResponse, status string, err error) +} + +type rfqClientImpl struct { + client *resty.Client +} + +// NewRFQClient creates a new RFQClient. +func NewRFQClient(handler metrics.Handler, indexerURL string) RFQClient { + client := resty.New() + client.SetBaseURL(indexerURL) + client.SetHeader(headers.UserAgent, "rfq-client") + + otelresty.TraceClient(client, otelresty.WithTracerProvider(handler.GetTracerProvider())) + + return &rfqClientImpl{ + client: client, + } +} + +// GetRFQByTxID gets a quote request by transaction ID or transaction hash. +func (r *rfqClientImpl) GetRFQ(ctx context.Context, txIdentifier string) (*GetRFQByTxIDResponse, string, error) { + var res GetRFQByTxIDResponse + resp, err := r.client.R().SetContext(ctx). + SetResult(&res). + Get(fmt.Sprintf(getRFQRoute, txIdentifier)) + if err != nil { + return nil, "", fmt.Errorf("failed to get quote request by tx ID: %w", err) + } + + if resp.StatusCode() != http.StatusOK { + return nil, "", fmt.Errorf("unexpected status code: %d", resp.StatusCode()) + } + + var status string + switch { + case res.BridgeClaim != (BridgeClaim{}): + status = "Claimed" + case res.BridgeProof != (BridgeProof{}): + status = "Proven" + case res.BridgeRelay != (BridgeRelay{}): + status = "Relayed" + case res.BridgeRequest != (BridgeRequest{}): + status = "Requested" + default: + status = "Unknown" + } + + return &res, status, nil +} diff --git a/contrib/opbot/internal/model.go b/contrib/opbot/internal/model.go new file mode 100644 index 0000000000..7b44f1ab91 --- /dev/null +++ b/contrib/opbot/internal/model.go @@ -0,0 +1,60 @@ +package internal + +// GetRFQByTxIDResponse is the response for GetRFQByTxID. +type GetRFQByTxIDResponse struct { + Bridge Bridge `json:"Bridge"` + BridgeRequest BridgeRequest `json:"BridgeRequest"` + BridgeRelay BridgeRelay `json:"BridgeRelay"` + BridgeProof BridgeProof `json:"BridgeProof"` + BridgeClaim BridgeClaim `json:"BridgeClaim"` +} + +// Bridge contains the bridge information. +type Bridge struct { + TransactionID string `json:"transactionId"` + OriginChain string `json:"originChain"` + DestChain string `json:"destChain"` + OriginChainID int `json:"originChainId"` + DestChainID int `json:"destChainId"` + OriginToken string `json:"originToken"` + DestToken string `json:"destToken"` + OriginAmountFormatted string `json:"originAmountFormatted"` + DestAmountFormatted string `json:"destAmountFormatted"` + Sender string `json:"sender"` + SendChainGas int `json:"sendChainGas"` + Request string `json:"request"` +} + +// BridgeRequest contains the bridge request information. +type BridgeRequest struct { + BlockNumber string `json:"blockNumber"` + BlockTimestamp int64 `json:"blockTimestamp"` + TransactionHash string `json:"transactionHash"` +} + +// BridgeRelay contains the bridge relay information. +type BridgeRelay struct { + BlockNumber string `json:"blockNumber"` + BlockTimestamp int64 `json:"blockTimestamp"` + TransactionHash string `json:"transactionHash"` + Relayer string `json:"relayer"` + To string `json:"to"` +} + +// BridgeProof contains the bridge proof information. +type BridgeProof struct { + BlockNumber string `json:"blockNumber"` + BlockTimestamp int64 `json:"blockTimestamp"` + TransactionHash string `json:"transactionHash"` + Relayer string `json:"relayer"` +} + +// BridgeClaim contains the bridge claim information. +type BridgeClaim struct { + BlockNumber string `json:"blockNumber"` + BlockTimestamp int64 `json:"blockTimestamp"` + TransactionHash string `json:"transactionHash"` + To string `json:"to"` + Relayer string `json:"relayer"` + AmountFormatted string `json:"amountFormatted"` +} diff --git a/ethergo/chaindata/chaindata.go b/ethergo/chaindata/chaindata.go index 0fab04a166..9f20b536b3 100644 --- a/ethergo/chaindata/chaindata.go +++ b/ethergo/chaindata/chaindata.go @@ -162,6 +162,16 @@ var ChainMetadataList = []ChainMetadata{ ChainName: "scroll", Explorer: "https://scrollscan.com", }, + { + ChainID: 59144, + ChainName: "linea", + Explorer: "https://lineascan.build", + }, + { + ChainID: 480, + ChainName: "world chain", + Explorer: "https://worldscan.org", + }, } // ChainNameToChainID converts the chain name to the chain id.