Skip to content

Commit

Permalink
mimimal viable withdrawal api
Browse files Browse the repository at this point in the history
  • Loading branch information
trajan0x committed Jun 29, 2024
1 parent 1d6c328 commit 3b80d3f
Show file tree
Hide file tree
Showing 5 changed files with 234 additions and 19 deletions.
2 changes: 1 addition & 1 deletion ethergo/submitter/submitter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
163 changes: 156 additions & 7 deletions services/rfq/relayer/relapi/handler.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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
}

Check warning on line 149 in services/rfq/relayer/relapi/handler.go

View check run for this annotation

Codecov / codecov/patch

services/rfq/relayer/relapi/handler.go#L144-L149

Added lines #L144 - L149 were not covered by tests

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

Check warning on line 155 in services/rfq/relayer/relapi/handler.go

View check run for this annotation

Codecov / codecov/patch

services/rfq/relayer/relapi/handler.go#L152-L155

Added lines #L152 - L155 were not covered by tests

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

Check warning on line 161 in services/rfq/relayer/relapi/handler.go

View check run for this annotation

Codecov / codecov/patch

services/rfq/relayer/relapi/handler.go#L158-L161

Added lines #L158 - L161 were not covered by tests

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
}

Check warning on line 170 in services/rfq/relayer/relapi/handler.go

View check run for this annotation

Codecov / codecov/patch

services/rfq/relayer/relapi/handler.go#L163-L170

Added lines #L163 - L170 were not covered by tests

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
}

Check warning on line 191 in services/rfq/relayer/relapi/handler.go

View check run for this annotation

Codecov / codecov/patch

services/rfq/relayer/relapi/handler.go#L172-L191

Added lines #L172 - L191 were not covered by tests

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
}

Check warning on line 200 in services/rfq/relayer/relapi/handler.go

View check run for this annotation

Codecov / codecov/patch

services/rfq/relayer/relapi/handler.go#L193-L200

Added lines #L193 - L200 were not covered by tests
}

c.JSON(http.StatusOK, gin.H{"nonce": nonce})

Check warning on line 203 in services/rfq/relayer/relapi/handler.go

View check run for this annotation

Codecov / codecov/patch

services/rfq/relayer/relapi/handler.go#L203

Added line #L203 was not covered by tests
}

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

Check warning on line 212 in services/rfq/relayer/relapi/handler.go

View check run for this annotation

Codecov / codecov/patch

services/rfq/relayer/relapi/handler.go#L208-L212

Added lines #L208 - L212 were not covered by tests
}

if prospectiveChainID == chainID && prospectiveAddress == tokenAddress {
return true
}

Check warning on line 217 in services/rfq/relayer/relapi/handler.go

View check run for this annotation

Codecov / codecov/patch

services/rfq/relayer/relapi/handler.go#L215-L217

Added lines #L215 - L217 were not covered by tests
}

return false

Check warning on line 220 in services/rfq/relayer/relapi/handler.go

View check run for this annotation

Codecov / codecov/patch

services/rfq/relayer/relapi/handler.go#L220

Added line #L220 was not covered by tests
}

func toAddressIsWhitelisted(cfg relconfig.Config, to common.Address) bool {
for _, addr := range cfg.WithdrawalWhitelist {
if common.HexToAddress(addr) == to {
return true
}

Check warning on line 227 in services/rfq/relayer/relapi/handler.go

View check run for this annotation

Codecov / codecov/patch

services/rfq/relayer/relapi/handler.go#L223-L227

Added lines #L223 - L227 were not covered by tests
}
return false

Check warning on line 229 in services/rfq/relayer/relapi/handler.go

View check run for this annotation

Codecov / codecov/patch

services/rfq/relayer/relapi/handler.go#L229

Added line #L229 was not covered by tests
}

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

Check warning on line 256 in services/rfq/relayer/relapi/handler.go

View check run for this annotation

Codecov / codecov/patch

services/rfq/relayer/relapi/handler.go#L245-L256

Added lines #L245 - L256 were not covered by tests
}

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

Check warning on line 273 in services/rfq/relayer/relapi/handler.go

View check run for this annotation

Codecov / codecov/patch

services/rfq/relayer/relapi/handler.go#L271-L273

Added lines #L271 - L273 were not covered by tests

wr.TokenAddress = common.HexToAddress(aux.TokenAddress)
wr.To = common.HexToAddress(aux.To)

return nil
}
34 changes: 34 additions & 0 deletions services/rfq/relayer/relapi/handler_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
30 changes: 19 additions & 11 deletions services/rfq/relayer/relapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}

Expand All @@ -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)
Expand All @@ -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)
}

Check warning on line 124 in services/rfq/relayer/relapi/server.go

View check run for this annotation

Codecov / codecov/patch

services/rfq/relayer/relapi/server.go#L123-L124

Added lines #L123 - L124 were not covered by tests

r.engine = engine

connection := baseServer.Server{}
Expand Down
24 changes: 24 additions & 0 deletions services/rfq/relayer/relconfig/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}

Check warning on line 169 in services/rfq/relayer/relconfig/config.go

View check run for this annotation

Codecov / codecov/patch

services/rfq/relayer/relconfig/config.go#L164-L169

Added lines #L164 - L169 were not covered by tests

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

Check warning on line 180 in services/rfq/relayer/relconfig/config.go

View check run for this annotation

Codecov / codecov/patch

services/rfq/relayer/relconfig/config.go#L171-L180

Added lines #L171 - L180 were not covered by tests
}

// LoadConfig loads the config from the given path.
func LoadConfig(path string) (config Config, err error) {
input, err := os.ReadFile(filepath.Clean(path))
Expand Down

0 comments on commit 3b80d3f

Please sign in to comment.