synapsecns/sanguine

View on GitHub
services/rfq/relayer/relapi/handler.go

Summary

Maintainability
B
4 hrs
Test Coverage
package relapi

import (
    "encoding/json"
    "fmt"
    "github.com/synapsecns/sanguine/core"
    "math/big"
    "net/http"

    "github.com/synapsecns/sanguine/core/metrics"
    "go.opentelemetry.io/otel/attribute"

    "github.com/ethereum/go-ethereum/accounts/abi"
    "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"
    "github.com/synapsecns/sanguine/services/rfq/util"

    "github.com/ethereum/go-ethereum/common"
    "github.com/ethereum/go-ethereum/common/hexutil"
    "github.com/gin-gonic/gin"
    "github.com/synapsecns/sanguine/services/rfq/relayer/chain"
    "github.com/synapsecns/sanguine/services/rfq/relayer/reldb"
)

// Handler is the REST API handler.
type Handler struct {
    metrics   metrics.Handler
    db        reldb.Service
    chains    map[uint32]*chain.Chain
    cfg       relconfig.Config
    submitter submitter.TransactionSubmitter
}

// NewHandler creates a new REST API handler.
func NewHandler(metricsHelper metrics.Handler, db reldb.Service, chains map[uint32]*chain.Chain, cfg relconfig.Config, txSubmitter submitter.TransactionSubmitter) *Handler {
    return &Handler{
        metrics:   metricsHelper,
        db:        db, // Store the database connection in the handler
        chains:    chains,
        cfg:       cfg,
        submitter: txSubmitter,
    }
}

// GetHealth returns a successful response to signify the API is up and running.
func (h *Handler) GetHealth(c *gin.Context) {
    c.JSON(http.StatusOK, gin.H{"status": "ok"})
}

const unspecifiedTxHash = "Must specify 'hash' (corresponding to origin tx)"

// GetTxRetry retries a transaction based on tx hash.
func (h *Handler) GetTxRetry(c *gin.Context) {
    txHashStr := c.Query("hash")
    if txHashStr == "" {
        c.JSON(http.StatusBadRequest, gin.H{"error": unspecifiedTxHash})
        return
    }

    txHash := common.HexToHash(txHashStr)
    quoteRequest, err := h.db.GetQuoteRequestByOriginTxHash(c, txHash)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    chainID := quoteRequest.Transaction.DestChainId
    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 := chainHandler.SubmitRelay(c, *quoteRequest)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("could not submit relay: %s", err.Error())})
        return
    }

    resp := GetTxRetryResponse{
        TxID:      hexutil.Encode(quoteRequest.TransactionID[:]),
        ChainID:   chainID,
        Nonce:     nonce,
        GasAmount: gasAmount.String(),
    }
    c.JSON(http.StatusOK, resp)
}

// GetQuoteRequestByTxID gets the quote request by tx id.
func (h *Handler) GetQuoteRequestByTxID(c *gin.Context) {
    txIDStr := c.Query("id")
    if txIDStr == "" {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Must specify 'id'"})
        return
    }

    txIDBytes, err := hexutil.Decode(txIDStr)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid txID"})
        return
    }
    var txID [32]byte
    copy(txID[:], txIDBytes)

    quoteRequest, err := h.db.GetQuoteRequestByID(c, txID)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    resp := GetQuoteRequestResponse{
        Sender:          quoteRequest.Sender.String(),
        Status:          quoteRequest.Status.String(),
        TxID:            hexutil.Encode(quoteRequest.TransactionID[:]),
        QuoteRequestRaw: common.Bytes2Hex(quoteRequest.RawRequest),
        OriginTxHash:    quoteRequest.OriginTxHash.String(),
        DestTxHash:      quoteRequest.DestTxHash.String(),
        OriginChainID:   quoteRequest.Transaction.OriginChainId,
        DestChainID:     quoteRequest.Transaction.DestChainId,
        OriginToken:     quoteRequest.Transaction.OriginToken.Hex(),
        DestToken:       quoteRequest.Transaction.DestToken.Hex(),
    }
    c.JSON(http.StatusOK, resp)
}

// GetQuoteRequestByTxHash gets the quote request by tx hash.
func (h *Handler) GetQuoteRequestByTxHash(c *gin.Context) {
    txHashStr := c.Query("hash")
    if txHashStr == "" {
        c.JSON(http.StatusBadRequest, gin.H{"error": unspecifiedTxHash})
        return
    }

    txHash := common.HexToHash(txHashStr)
    quoteRequest, err := h.db.GetQuoteRequestByOriginTxHash(c, txHash)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    resp := GetQuoteRequestResponse{
        Sender:          quoteRequest.Sender.String(),
        Status:          quoteRequest.Status.String(),
        TxID:            hexutil.Encode(quoteRequest.TransactionID[:]),
        QuoteRequestRaw: common.Bytes2Hex(quoteRequest.RawRequest),
        OriginTxHash:    quoteRequest.OriginTxHash.String(),
        DestTxHash:      quoteRequest.DestTxHash.String(),
        OriginChainID:   quoteRequest.Transaction.OriginChainId,
        DestChainID:     quoteRequest.Transaction.DestChainId,
        OriginToken:     quoteRequest.Transaction.OriginToken.Hex(),
        DestToken:       quoteRequest.Transaction.DestToken.Hex(),
    }
    c.JSON(http.StatusOK, resp)
}

// Withdraw withdraws tokens from the relayer.
//
//nolint:cyclop
func (h *Handler) Withdraw(c *gin.Context) {
    ctx, span := h.metrics.Tracer().Start(c, "withdraw")
    var err error
    defer func() {
        metrics.EndSpanWithErr(span, err)
    }()

    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

    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
    }

    //nolint: nestif
    if util.IsGasToken(req.TokenAddress) {
        nonce, err = h.submitter.SubmitTransaction(ctx, big.NewInt(int64(req.ChainID)), func(transactor *bind.TransactOpts) (tx *types.Transaction, err error) {
            bc := bind.NewBoundContract(req.To, abi.ABI{}, h.chains[req.ChainID].Client, h.chains[req.ChainID].Client, h.chains[req.ChainID].Client)
            transactor.Value = core.CopyBigInt(value)
            return bc.Transfer(transactor)
        })
        if err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("could not submit transaction: %s", err.Error())})
            return
        }
    } else {
        var erc20Contract *ierc20.IERC20
        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(ctx, big.NewInt(int64(req.ChainID)), func(transactor *bind.TransactOpts) (tx *types.Transaction, err error) {
            // nolint: wrapcheck
            return erc20Contract.Transfer(transactor, req.To, value)
        })
        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})
}

// GetTxByNonceRequest is the request for getting a transaction hash by nonce.
type GetTxByNonceRequest struct {
    ChainID uint32 `json:"chain_id"`
    Nonce   uint64 `json:"nonce"`
}

// GetTxHashByNonce gets the transaction hash by submitter nonce.
func (h *Handler) GetTxHashByNonce(c *gin.Context) {
    ctx, span := h.metrics.Tracer().Start(c, "txByNonce")
    var err error
    defer func() {
        metrics.EndSpanWithErr(span, err)
    }()

    chainIDStr := c.Query("chain_id")
    nonceStr := c.Query("nonce")

    chainID, ok := new(big.Int).SetString(chainIDStr, 10)
    if !ok {
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid chainID"})
        return
    }

    span.SetAttributes(attribute.Int("chain_id", int(chainID.Uint64())))

    nonce, ok := new(big.Int).SetString(nonceStr, 10)
    if !ok {
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid nonce"})
        return
    }
    span.SetAttributes(attribute.Int("nonce", int(nonce.Uint64())))

    tx, err := h.submitter.GetSubmissionStatus(ctx, chainID, nonce.Uint64())
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("could not get tx hash: %s", err.Error())})
        return
    }

    if tx.HasTx() {
        c.JSON(http.StatusOK, gin.H{"withdrawTxHash": tx.TxHash().String()})
        return
    }

    c.JSON(http.StatusNotFound, gin.H{"error": "transaction not found"})
}

// 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
}