From 3b80d3f16eeed69e67b55f83193a9dc2527176e3 Mon Sep 17 00:00:00 2001 From: Trajan0x Date: Fri, 28 Jun 2024 23:21:45 -0400 Subject: [PATCH] mimimal viable withdrawal api --- ethergo/submitter/submitter.go | 2 +- services/rfq/relayer/relapi/handler.go | 163 +++++++++++++++++++- services/rfq/relayer/relapi/handler_test.go | 34 ++++ services/rfq/relayer/relapi/server.go | 30 ++-- services/rfq/relayer/relconfig/config.go | 24 +++ 5 files changed, 234 insertions(+), 19 deletions(-) create mode 100644 services/rfq/relayer/relapi/handler_test.go diff --git a/ethergo/submitter/submitter.go b/ethergo/submitter/submitter.go index df1c107ddd..8710cb7827 100644 --- a/ethergo/submitter/submitter.go +++ b/ethergo/submitter/submitter.go @@ -39,7 +39,7 @@ import ( var logger = log.Logger("ethergo-submitter") -const meterName = "github.com/synapsecns/sanguine/services/rfq/api/rest" +const meterName = "github.com/synapsecns/sanguine/ethergo/submitter" // TransactionSubmitter is the interface for submitting transactions to the chain. type TransactionSubmitter interface { diff --git a/services/rfq/relayer/relapi/handler.go b/services/rfq/relayer/relapi/handler.go index 3b80387b46..7c5c5850a7 100644 --- a/services/rfq/relayer/relapi/handler.go +++ b/services/rfq/relayer/relapi/handler.go @@ -1,7 +1,14 @@ package relapi import ( + "encoding/json" "fmt" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/core/types" + "github.com/synapsecns/sanguine/ethergo/submitter" + "github.com/synapsecns/sanguine/services/rfq/contracts/ierc20" + "github.com/synapsecns/sanguine/services/rfq/relayer/relconfig" + "math/big" "net/http" "github.com/ethereum/go-ethereum/common" @@ -13,15 +20,19 @@ import ( // Handler is the REST API handler. type Handler struct { - db reldb.Service - chains map[uint32]*chain.Chain + db reldb.Service + chains map[uint32]*chain.Chain + cfg relconfig.Config + submitter submitter.TransactionSubmitter } // NewHandler creates a new REST API handler. -func NewHandler(db reldb.Service, chains map[uint32]*chain.Chain) *Handler { +func NewHandler(db reldb.Service, chains map[uint32]*chain.Chain, cfg relconfig.Config, txSubmitter submitter.TransactionSubmitter) *Handler { return &Handler{ - db: db, // Store the database connection in the handler - chains: chains, + db: db, // Store the database connection in the handler + chains: chains, + cfg: cfg, + submitter: txSubmitter, } } @@ -107,14 +118,14 @@ func (h *Handler) GetTxRetry(c *gin.Context) { } chainID := quoteRequest.Transaction.DestChainId - chain, ok := h.chains[chainID] + chainHandler, ok := h.chains[chainID] if !ok { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("No contract found for chain: %d", chainID)}) return } // `quoteRequest == nil` case should be handled by the db query above - nonce, gasAmount, err := chain.SubmitRelay(c, *quoteRequest) + nonce, gasAmount, err := chainHandler.SubmitRelay(c, *quoteRequest) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("could not submit relay: %s", err.Error())}) return @@ -128,3 +139,141 @@ func (h *Handler) GetTxRetry(c *gin.Context) { } c.JSON(http.StatusOK, resp) } + +// Withdraw withdraws tokens from the relayer. +func (h *Handler) Withdraw(c *gin.Context) { + var req WithdrawRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // validate the token address + if !tokenIDExists(h.cfg, req.TokenAddress, int(req.ChainID)) { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid token address %s for chain %d", req.TokenAddress.Hex(), req.ChainID)}) + return + } + + // validate the withdrawal address + if !toAddressIsWhitelisted(h.cfg, req.To) { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("withdrawal address %s is not whitelisted", req.To.Hex())}) + return + } + + var nonce uint64 + var err error + + value, ok := new(big.Int).SetString(req.Amount, 10) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid amount %s", req.Amount)}) + return + } + + if chain.IsGasToken(req.TokenAddress) { + nonce, err = h.submitter.SubmitTransaction(c, big.NewInt(int64(req.ChainID)), func(transactor *bind.TransactOpts) (tx *types.Transaction, err error) { + tx = types.NewTx(&types.LegacyTx{ + Nonce: transactor.Nonce.Uint64(), + To: &req.To, + Value: value, + }) + + return tx, nil + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("could not submit transaction: %s", err.Error())}) + return + } + } else { + erc20Contract, err := ierc20.NewIERC20(req.TokenAddress, h.chains[req.ChainID].Client) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("could not create erc20 contract: %s", err.Error())}) + return + } + + nonce, err = h.submitter.SubmitTransaction(c, big.NewInt(int64(req.ChainID)), func(transactor *bind.TransactOpts) (tx *types.Transaction, err error) { + tx, err = erc20Contract.Transfer(transactor, req.To, value) + return tx, fmt.Errorf("could not create transfer transaction: %w", err) + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("could not submit transaction: %s", err.Error())}) + return + } + } + + c.JSON(http.StatusOK, gin.H{"nonce": nonce}) +} + +// tokenIDExists checks if a token ID exists in the config. +// note: this method assumes that SanitizeTokenID is a method of relconfig.Config +func tokenIDExists(cfg relconfig.Config, tokenAddress common.Address, chainID int) bool { + for quotableToken := range cfg.QuotableTokens { + prospectiveChainID, prospectiveAddress, err := relconfig.DecodeTokenID(quotableToken) + if err != nil { + continue + } + + if prospectiveChainID == chainID && prospectiveAddress == tokenAddress { + return true + } + } + + return false +} + +func toAddressIsWhitelisted(cfg relconfig.Config, to common.Address) bool { + for _, addr := range cfg.WithdrawalWhitelist { + if common.HexToAddress(addr) == to { + return true + } + } + return false +} + +// WithdrawRequest is the request to withdraw tokens from the relayer. +type WithdrawRequest struct { + // ChainID is the chain ID of the chain to withdraw from. + ChainID uint32 `json:"chain_id"` + // Amount is the amount to withdraw, in wei. + Amount string `json:"amount"` + // TokenAddress is the address of the token to withdraw. + TokenAddress common.Address `json:"token_address"` + // To is the address to withdraw to. + To common.Address `json:"to"` +} + +// MarshalJSON handles JSON marshaling for WithdrawRequest. +func (wr *WithdrawRequest) MarshalJSON() ([]byte, error) { + type Alias WithdrawRequest + // nolint: wrapcheck + return json.Marshal(&struct { + TokenAddress string `json:"token_address"` + To string `json:"to"` + *Alias + }{ + TokenAddress: wr.TokenAddress.Hex(), + To: wr.To.Hex(), + Alias: (*Alias)(wr), + }) +} + +// UnmarshalJSON has JSON unmarshalling for WithdrawRequest. +func (wr *WithdrawRequest) UnmarshalJSON(data []byte) error { + type Alias WithdrawRequest + aux := &struct { + TokenAddress string `json:"token_address"` + To string `json:"to"` + *Alias + }{ + Alias: (*Alias)(wr), + } + + if err := json.Unmarshal(data, aux); err != nil { + //nolint: wrapcheck + return err + } + + wr.TokenAddress = common.HexToAddress(aux.TokenAddress) + wr.To = common.HexToAddress(aux.To) + + return nil +} diff --git a/services/rfq/relayer/relapi/handler_test.go b/services/rfq/relayer/relapi/handler_test.go new file mode 100644 index 0000000000..d3c52517c4 --- /dev/null +++ b/services/rfq/relayer/relapi/handler_test.go @@ -0,0 +1,34 @@ +package relapi_test + +import ( + "encoding/json" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/synapsecns/sanguine/services/rfq/relayer/relapi" + "testing" +) + +func TestWithdrawRequestJSON(t *testing.T) { + original := relapi.WithdrawRequest{ + ChainID: 1, + Amount: "100", + TokenAddress: common.HexToAddress("0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"), + To: common.HexToAddress("0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"), + } + + // Marshal to JSON + data, err := json.Marshal(original) + assert.NoError(t, err) + + // Unmarshal back to struct + var unmarshalled relapi.WithdrawRequest + err = json.Unmarshal(data, &unmarshalled) + assert.NoError(t, err) + + // Check if the original and unmarshalled structs are the same + assert.Equal(t, original, unmarshalled) + + // Check the JSON string explicitly + expectedJSON := `{"chain_id":1,"amount":"100","token_address":"0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","to":"0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"}` + assert.JSONEq(t, expectedJSON, string(data)) +} diff --git a/services/rfq/relayer/relapi/server.go b/services/rfq/relayer/relapi/server.go index 46d3354311..709a6c3b12 100644 --- a/services/rfq/relayer/relapi/server.go +++ b/services/rfq/relayer/relapi/server.go @@ -25,11 +25,12 @@ import ( // RelayerAPIServer is a struct that holds the configuration, database connection, gin engine, RPC client, metrics handler, and fast bridge contracts. // It is used to initialize and run the API server. type RelayerAPIServer struct { - cfg relconfig.Config - db reldb.Service - engine *gin.Engine - handler metrics.Handler - chains map[uint32]*chain.Chain + cfg relconfig.Config + db reldb.Service + engine *gin.Engine + handler metrics.Handler + chains map[uint32]*chain.Chain + submitter submitter.TransactionSubmitter } // NewRelayerAPI holds the configuration, database connection, gin engine, RPC client, metrics handler, and fast bridge contracts. @@ -86,10 +87,11 @@ func NewRelayerAPI( } return &RelayerAPIServer{ - cfg: cfg, - db: store, - handler: handler, - chains: chains, + cfg: cfg, + db: store, + handler: handler, + chains: chains, + submitter: submitter, }, nil } @@ -98,15 +100,17 @@ const ( getQuoteStatusByTxHashRoute = "/status" getQuoteStatusByTxIDRoute = "/status/by_tx_id" getRetryRoute = "/retry" + postWithdrawRoute = "/withdraw" ) var logger = log.Logger("relayer-api") // Run runs the rest api server. func (r *RelayerAPIServer) Run(ctx context.Context) error { - // TODO: Use Gin Helper engine := ginhelper.New(logger) - h := NewHandler(r.db, r.chains) + // default tracing middleware + engine.Use(r.handler.Gin()...) + h := NewHandler(r.db, r.chains, r.cfg, r.submitter) // Assign GET routes engine.GET(getHealthRoute, h.GetHealth) @@ -115,6 +119,10 @@ func (r *RelayerAPIServer) Run(ctx context.Context) error { engine.GET(getRetryRoute, h.GetTxRetry) engine.GET(metrics.MetricsPathDefault, gin.WrapH(r.handler.Handler())) + if r.cfg.EnableAPIWithdrawals { + engine.POST(postWithdrawRoute, h.Withdraw) + } + r.engine = engine connection := baseServer.Server{} diff --git a/services/rfq/relayer/relconfig/config.go b/services/rfq/relayer/relconfig/config.go index 1c4222fa10..00055b11e1 100644 --- a/services/rfq/relayer/relconfig/config.go +++ b/services/rfq/relayer/relconfig/config.go @@ -51,6 +51,10 @@ type Config struct { QuoteSubmissionTimeout time.Duration `yaml:"quote_submission_timeout"` // CCTPRelayerConfig is the embedded cctp relayer config (optional). CCTPRelayerConfig *cctpConfig.Config `yaml:"cctp_relayer_config"` + // EnableAPIWithdrawals enables withdrawals via the API. + EnableAPIWithdrawals bool `yaml:"enable_api_withdrawals"` + // WithdrawalWhitelist is a list of addresses that are allowed to withdraw. + WithdrawalWhitelist []string `yaml:"withdrawal_whitelist"` } // ChainConfig represents the configuration for a chain. @@ -156,6 +160,26 @@ func SanitizeTokenID(id string) (sanitized string, err error) { return sanitized, nil } +// DecodeTokenID decodes a token ID into a chain ID and address. +func DecodeTokenID(id string) (chainID int, addr common.Address, err error) { + // defensive coding, first check if the token ID is valid + _, err = SanitizeTokenID(id) + if err != nil { + return chainID, addr, err + } + + split := strings.Split(id, tokenIDDelimiter) + if len(split) != 2 { + return chainID, addr, fmt.Errorf("invalid token ID: %s", id) + } + chainID, err = strconv.Atoi(split[0]) + if err != nil { + return chainID, addr, fmt.Errorf("invalid chain ID: %s", split[0]) + } + addr = common.HexToAddress(split[1]) + return chainID, addr, nil +} + // LoadConfig loads the config from the given path. func LoadConfig(path string) (config Config, err error) { input, err := os.ReadFile(filepath.Clean(path))