services/rfq/api/rest/server_test.go
package rest_test
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"strconv"
"time"
"github.com/shopspring/decimal"
apiClient "github.com/synapsecns/sanguine/services/rfq/api/client"
"github.com/synapsecns/sanguine/services/rfq/api/db"
"github.com/synapsecns/sanguine/services/rfq/api/rest"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/crypto"
"github.com/synapsecns/sanguine/ethergo/signer/wallet"
"github.com/synapsecns/sanguine/services/rfq/api/model"
"github.com/synapsecns/sanguine/services/rfq/relayer/relapi"
)
func (c *ServerSuite) TestNewQuoterAPIServer() {
// Start the API server in a separate goroutine and wait for it to initialize.
c.startQuoterAPIServer()
client := &http.Client{}
req, err := http.NewRequestWithContext(c.GetTestContext(), http.MethodGet, fmt.Sprintf("http://localhost:%d/quotes", c.port), nil)
c.Require().NoError(err)
resp, err := client.Do(req)
c.Require().NoError(err)
defer func() {
err = resp.Body.Close()
c.Require().NoError(err)
}()
c.Equal(http.StatusOK, resp.StatusCode)
c.GetTestContext().Done()
}
// TestEIP191_SuccessfulSignature tests the EIP191 signature process for successful authentication.
func (c *ServerSuite) TestEIP191_SuccessfulSignature() {
// Start the API server in a separate goroutine and wait for it to initialize.
c.startQuoterAPIServer()
// Prepare the authorization header with a signed timestamp.
header, err := c.prepareAuthHeader(c.testWallet)
if err != nil {
c.Error(err)
return
}
// Perform a PUT request to the API server with the authorization header.
resp, err := c.sendPutQuoteRequest(header)
if err != nil {
c.Error(err)
return
}
defer func() {
err = resp.Body.Close()
c.Require().NoError(err)
}()
// Check for X-Api-Version on the response
c.Equal(resp.Header.Get("X-Api-Version"), rest.APIversions.Versions[0].Version)
// Assert that the response status code is HTTP 200 OK.
c.Equal(http.StatusOK, resp.StatusCode)
}
// TestEIP191_SuccessfulSignature_vCodeNormalize tests the EIP191 signature process for successful authentication.
// using a recovery ID (v) value of 27/28 instead of the 0/1 value. Should be normalized & still authenticate successfully.
func (c *ServerSuite) TestEIP191_SuccessfulSignature_vCodeNormalize() {
// Start the API server in a separate goroutine and wait for it to initialize.
c.startQuoterAPIServer()
// Prepare the authorization header with a signed timestamp.
header, err := c.prepareAuthHeader(c.testWallet)
if err != nil {
c.Error(err)
return
}
// swap in v code 27/28 for 0/1 respectively.
if header[len(header)-2:] == "00" {
header = header[:len(header)-2] + "1b"
} else if header[len(header)-2:] == "01" {
header = header[:len(header)-2] + "1c"
}
// Perform a PUT request to the API server with the authorization header.
resp, err := c.sendPutQuoteRequest(header)
if err != nil {
c.Error(err)
return
}
defer func() {
err = resp.Body.Close()
c.Require().NoError(err)
}()
// Assert that the response status code is HTTP 200 OK.
c.Equal(http.StatusOK, resp.StatusCode)
}
// TestEIP191_UnsuccessfulSignature tests the EIP191 signature process with an incorrect wallet signature.
func (c *ServerSuite) TestEIP191_UnsuccessfulSignature() {
// Start the API server in a separate goroutine and wait for it to initialize.
c.startQuoterAPIServer()
// Prepare the authorization header with a signed timestamp using an incorrect wallet.
randomWallet, err := wallet.FromRandom()
c.Require().NoError(err)
header, err := c.prepareAuthHeader(randomWallet)
if err != nil {
c.Error(err)
return
}
// Perform a PUT request to the API server with the incorrect authorization header.
resp, err := c.sendPutQuoteRequest(header)
if err != nil {
c.Error(err)
return
}
defer func() {
err = resp.Body.Close()
c.Require().NoError(err)
}()
// Assert that the response status code is HTTP 400 Bad Request.
c.Equal(http.StatusBadRequest, resp.StatusCode)
}
// TestEIP191_SuccessfulPutSubmission tests a successful PUT request submission.
func (c *ServerSuite) TestEIP191_SuccessfulPutSubmission() {
// Start the API server in a separate goroutine and wait for it to initialize.
c.startQuoterAPIServer()
// Prepare the authorization header with a signed timestamp.
header, err := c.prepareAuthHeader(c.testWallet)
c.Require().NoError(err)
// Perform a PUT request to the API server with the authorization header.
resp, err := c.sendPutQuoteRequest(header)
c.Require().NoError(err)
defer func() {
_ = resp.Body.Close()
}()
// Check for X-Api-Version on the response
c.Equal(resp.Header.Get("X-Api-Version"), rest.APIversions.Versions[0].Version)
// Assert that the response status code is HTTP 200 OK.
c.Assert().Equal(http.StatusOK, resp.StatusCode)
}
func (c *ServerSuite) TestPutAndGetQuote() {
c.startQuoterAPIServer()
header, err := c.prepareAuthHeader(c.testWallet)
c.Require().NoError(err)
// Send PUT request
putResp, err := c.sendPutQuoteRequest(header)
c.Require().NoError(err)
defer func() {
err = putResp.Body.Close()
c.Require().NoError(err)
}()
c.Assert().Equal(http.StatusOK, putResp.StatusCode)
// Check for X-Api-Version on the response
c.Equal(putResp.Header.Get("X-Api-Version"), rest.APIversions.Versions[0].Version)
// Send GET request to verify the PUT
client := &http.Client{}
req, err := http.NewRequestWithContext(c.GetTestContext(), http.MethodGet, fmt.Sprintf("http://localhost:%d/quotes?originChainId=1&originTokenAddr=0xOriginTokenAddrdestChainId=42161&destTokenAddr=0xDestTokenAddr", c.port), nil)
c.Require().NoError(err)
getResp, err := client.Do(req)
c.Require().NoError(err)
defer func() {
_ = getResp.Body.Close()
}()
c.Assert().Equal(http.StatusOK, getResp.StatusCode)
// Check for X-Api-Version on the response
c.Equal(getResp.Header.Get("X-Api-Version"), rest.APIversions.Versions[0].Version)
var quotes []*model.GetQuoteResponse
err = json.NewDecoder(getResp.Body).Decode("es)
c.Require().NoError(err)
// Check if the newly added quote is present
found := false
for _, q := range quotes {
if q.FixedFee == "10" {
found = true
break
}
}
c.Assert().True(found, "Newly added quote not found")
}
func (c *ServerSuite) TestGetOpenQuoteRequests() {
// Start the API server
c.startQuoterAPIServer()
// Insert some test quote requests
testRequests := []*model.PutRFQRequest{
{
Data: model.QuoteData{
OriginChainID: 1,
DestChainID: 42161,
OriginTokenAddr: "0xOriginTokenAddr",
DestTokenAddr: "0xDestTokenAddr",
OriginAmountExact: "100.0",
ExpirationWindow: 100,
},
},
{
Data: model.QuoteData{
OriginChainID: 1,
DestChainID: 42161,
OriginTokenAddr: "0xOriginTokenAddr",
DestTokenAddr: "0xDestTokenAddr",
OriginAmountExact: "100.0",
ExpirationWindow: 100,
},
},
{
Data: model.QuoteData{
OriginChainID: 1,
DestChainID: 42161,
OriginTokenAddr: "0xOriginTokenAddr",
DestTokenAddr: "0xDestTokenAddr",
OriginAmountExact: "100.0",
ExpirationWindow: 100,
},
},
}
statuses := []db.ActiveQuoteRequestStatus{db.Received, db.Pending, db.Expired}
for i, req := range testRequests {
err := c.database.InsertActiveQuoteRequest(c.GetTestContext(), req, strconv.Itoa(i))
c.Require().NoError(err)
err = c.database.UpdateActiveQuoteRequestStatus(c.GetTestContext(), strconv.Itoa(i), nil, statuses[i])
c.Require().NoError(err)
}
// Prepare the authorization header
header, err := c.prepareAuthHeader(c.testWallet)
c.Require().NoError(err)
// Send GET request to fetch open quote requests
client := &http.Client{}
req, err := http.NewRequestWithContext(c.GetTestContext(), http.MethodGet, fmt.Sprintf("http://localhost:%d%s", c.port, rest.RFQRoute), nil)
c.Require().NoError(err)
req.Header.Add("Authorization", header)
chainIDsJSON, err := json.Marshal([]uint64{1, 42161})
c.Require().NoError(err)
req.Header.Add("Chains", string(chainIDsJSON))
resp, err := client.Do(req)
c.Require().NoError(err)
defer func() {
err = resp.Body.Close()
c.Require().NoError(err)
}()
// Check the response status code
c.Assert().Equal(http.StatusOK, resp.StatusCode)
// Check for X-Api-Version on the response
c.Equal(resp.Header.Get("X-Api-Version"), rest.APIversions.Versions[0].Version)
// Parse the response body
var openRequests []*model.GetOpenQuoteRequestsResponse
err = json.NewDecoder(resp.Body).Decode(&openRequests)
c.Require().NoError(err)
// Verify the number of open requests (should be 2: Received and Pending)
c.Assert().Len(openRequests, 2)
}
func (c *ServerSuite) TestPutAndGetQuoteByRelayer() {
c.startQuoterAPIServer()
header, err := c.prepareAuthHeader(c.testWallet)
c.Require().NoError(err)
// Send PUT request
putResp, err := c.sendPutQuoteRequest(header)
c.Require().NoError(err)
defer func() {
err = putResp.Body.Close()
c.Require().NoError(err)
}()
c.Assert().Equal(http.StatusOK, putResp.StatusCode)
// Check for X-Api-Version on the response
c.Equal(putResp.Header.Get("X-Api-Version"), rest.APIversions.Versions[0].Version)
// Send GET request to verify the PUT
client := &http.Client{}
req, err := http.NewRequestWithContext(c.GetTestContext(), http.MethodGet, fmt.Sprintf("http://localhost:%d/quotes?relayerAddress=%s", c.port, c.testWallet.Address().Hex()), nil)
c.Require().NoError(err)
getResp, err := client.Do(req)
c.Require().NoError(err)
defer func() {
_ = getResp.Body.Close()
}()
c.Assert().Equal(http.StatusOK, getResp.StatusCode)
// Check for X-Api-Version on the response
c.Equal(getResp.Header.Get("X-Api-Version"), rest.APIversions.Versions[0].Version)
var quotes []*model.GetQuoteResponse
err = json.NewDecoder(getResp.Body).Decode("es)
c.Require().NoError(err)
// Check if the newly added quote is present
found := false
for _, q := range quotes {
if q.FixedFee == "10" {
found = true
break
}
}
c.Assert().True(found, "Newly added quote not found")
}
func (c *ServerSuite) TestMultiplePutRequestsWithIncorrectAuth() {
// Start the API server in a separate goroutine and wait for it to initialize.
c.startQuoterAPIServer()
// Create a random wallet for incorrect authorization
randomWallet, err := wallet.FromRandom()
c.Require().NoError(err)
// Prepare the authorization header with a signed timestamp using the incorrect wallet
header, err := c.prepareAuthHeader(randomWallet)
c.Require().NoError(err)
// Perform multiple PUT requests to the API server with the incorrect authorization header
for i := 0; i < 3; i++ {
resp, err := c.sendPutQuoteRequest(header)
c.Require().NoError(err)
defer func() {
err = resp.Body.Close()
c.Require().NoError(err)
}()
// Read the response body
body, err := io.ReadAll(resp.Body)
c.Require().NoError(err)
// Check for X-Api-Version on the response
c.Equal(resp.Header.Get("X-Api-Version"), rest.APIversions.Versions[0].Version)
switch resp.StatusCode {
case http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden:
// These are acceptable error status codes for failed authentication
c.Assert().True(true, "Request %d correctly failed with status %d", i+1, resp.StatusCode)
case http.StatusOK:
// The ModifyQuote method returns 200 OK with an empty body on success
c.Assert().Empty(string(body), "Request %d should return an empty body on success", i+1)
// Since this shouldn't happen with incorrect auth, fail the test
c.Fail("Request %d unexpectedly succeeded, while submitting incorrect authentication", i+1)
default:
c.Fail("Unexpected status code %d for request %d", resp.StatusCode, i+1)
}
}
}
func (c *ServerSuite) TestFilterQuoteAge() {
now := time.Now()
// insert quote outside age range
quotes := []*db.Quote{
{OriginChainID: 1, UpdatedAt: now.Add(-time.Hour)},
{OriginChainID: 2, UpdatedAt: now.Add(-time.Minute)},
}
filteredQuotes := rest.FilterQuoteAge(c.cfg, quotes)
// verify old quote is filtered out
c.Equal(1, len(filteredQuotes))
c.Equal(quotes[1], filteredQuotes[0])
}
func (c *ServerSuite) TestGetPassiveQuote() {
userRequestAmount := big.NewInt(1_000_000)
passiveQuotes := []*db.Quote{
{
RelayerAddr: "0x1",
OriginChainID: validateChainID(c.originChainID),
OriginTokenAddr: originTokenAddr,
DestChainID: validateChainID(c.destChainID),
DestTokenAddr: destTokenAddr,
DestAmount: decimal.NewFromBigInt(new(big.Int).Sub(userRequestAmount, big.NewInt(104)), 0),
MaxOriginAmount: decimal.NewFromBigInt(userRequestAmount, 0),
FixedFee: decimal.NewFromInt(1000),
UpdatedAt: time.Now(),
},
{
RelayerAddr: "0x2",
OriginChainID: validateChainID(c.originChainID),
OriginTokenAddr: originTokenAddr,
DestChainID: validateChainID(c.destChainID),
DestTokenAddr: destTokenAddr,
DestAmount: decimal.NewFromBigInt(new(big.Int).Sub(userRequestAmount, big.NewInt(103)), 0),
MaxOriginAmount: decimal.NewFromBigInt(userRequestAmount, 0),
FixedFee: decimal.NewFromInt(1000),
UpdatedAt: time.Now().Add(-time.Minute),
},
{
RelayerAddr: "0x3",
OriginChainID: validateChainID(c.originChainID),
OriginTokenAddr: originTokenAddr,
DestChainID: validateChainID(c.destChainID),
DestTokenAddr: destTokenAddr,
DestAmount: decimal.NewFromBigInt(new(big.Int).Sub(userRequestAmount, big.NewInt(102)), 0),
MaxOriginAmount: decimal.NewFromBigInt(userRequestAmount, 0),
FixedFee: decimal.NewFromInt(1000),
UpdatedAt: time.Now().Add(-time.Minute * 15),
},
{
RelayerAddr: "0x4",
OriginChainID: validateChainID(c.originChainID),
OriginTokenAddr: originTokenAddr,
DestChainID: validateChainID(c.destChainID),
DestTokenAddr: destTokenAddr,
DestAmount: decimal.NewFromBigInt(new(big.Int).Sub(userRequestAmount, big.NewInt(101)), 0),
MaxOriginAmount: decimal.NewFromBigInt(userRequestAmount, 0),
FixedFee: decimal.NewFromInt(1000),
UpdatedAt: time.Now().Add(-time.Hour),
},
}
userQuoteReq := &model.PutRFQRequest{
Data: model.QuoteData{
OriginChainID: c.originChainID,
OriginTokenAddr: originTokenAddr,
DestChainID: c.destChainID,
DestTokenAddr: destTokenAddr,
OriginAmountExact: userRequestAmount.String(),
ExpirationWindow: 0,
},
QuoteTypes: []string{"active", "passive"},
}
// get the best passive quote; this should be the one with highest dest amount, but within the MaxQuoteAge window
quote, err := rest.GetPassiveQuote(c.cfg, passiveQuotes, userQuoteReq)
c.Require().NoError(err)
c.Assert().Equal("998897", *quote.DestAmount)
c.Assert().Equal(passiveQuotes[1].RelayerAddr, *quote.RelayerAddress)
}
func (c *ServerSuite) TestPutAck() {
c.startQuoterAPIServer()
// Send GET request
testTxID := "0x123"
header, err := c.prepareAuthHeader(c.testWallet)
c.Require().NoError(err)
resp, err := c.sendPutAckRequest(header, testTxID)
c.Require().NoError(err)
c.Equal(http.StatusOK, resp.StatusCode)
// Expect ack with shouldRelay=true
var result relapi.PutRelayAckResponse
err = json.NewDecoder(resp.Body).Decode(&result)
c.Require().NoError(err)
expectedResult := relapi.PutRelayAckResponse{
TxID: testTxID,
ShouldRelay: true,
RelayerAddress: c.testWallet.Address().Hex(),
}
c.Equal(expectedResult, result)
err = resp.Body.Close()
c.Require().NoError(err)
// Check for X-Api-Version on the response
c.Equal(resp.Header.Get("X-Api-Version"), rest.APIversions.Versions[0].Version)
// Send another request with same txID
header, err = c.prepareAuthHeader(c.testWallet)
c.Require().NoError(err)
resp, err = c.sendPutAckRequest(header, testTxID)
c.Require().NoError(err)
c.Equal(http.StatusOK, resp.StatusCode)
// Expect ack with shouldRelay=true
err = json.NewDecoder(resp.Body).Decode(&result)
c.Require().NoError(err)
expectedResult = relapi.PutRelayAckResponse{
TxID: testTxID,
ShouldRelay: true,
RelayerAddress: c.testWallet.Address().Hex(),
}
c.Equal(expectedResult, result)
err = resp.Body.Close()
c.Require().NoError(err)
c.GetTestContext().Done()
}
// startQuoterAPIServer starts the API server and waits for it to initialize.
func (c *ServerSuite) startQuoterAPIServer() {
go func() {
err := c.QuoterAPIServer.Run(c.GetTestContext())
c.Require().NoError(err)
}()
time.Sleep(2 * time.Second) // Wait for the server to start.
}
// prepareAuthHeader generates an authorization header using EIP191 signature with the given private key.
func (c *ServerSuite) prepareAuthHeader(wallet wallet.Wallet) (string, error) {
// Get the current Unix timestamp as a string.
now := strconv.Itoa(int(time.Now().Unix()))
// Prepare the data to be signed.
data := "\x19Ethereum Signed Message:\n" + strconv.Itoa(len(now)) + now
digest := crypto.Keccak256([]byte(data))
// Sign the data with the provided private key.
sig, err := crypto.Sign(digest, wallet.PrivateKey())
if err != nil {
return "", fmt.Errorf("failed to sign data: %w", err)
}
signature := hexutil.Encode(sig)
// Return the combined header value.
return now + ":" + signature, nil
}
// sendPutQuoteRequest sends a PUT request to the server with the given authorization header.
func (c *ServerSuite) sendPutQuoteRequest(header string) (*http.Response, error) {
// Prepare the PUT request with JSON data.
client := &http.Client{}
putData := model.PutRelayerQuoteRequest{
DestChainID: 42161,
DestTokenAddr: "0xDestTokenAddr",
DestAmount: "100.0",
MaxOriginAmount: "200.0",
FixedFee: "10.0",
}
jsonData, err := json.Marshal(putData)
if err != nil {
return nil, fmt.Errorf("failed to marshal putData: %w", err)
}
req, err := http.NewRequestWithContext(c.GetTestContext(), http.MethodPut, fmt.Sprintf("http://localhost:%d/quotes", c.port), bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create PUT request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Add("Authorization", header)
// Send the request to the server.
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send PUT request: %w", err)
}
return resp, nil
}
// sendPutAckRequest sends a PUT request to the server with the given authorization header.
func (c *ServerSuite) sendPutAckRequest(header string, txID string) (*http.Response, error) {
// Prepare the PUT request.
client := &http.Client{}
putData := model.PutAckRequest{
TxID: txID,
DestChainID: 42161,
}
jsonData, err := json.Marshal(putData)
if err != nil {
return nil, fmt.Errorf("failed to marshal putData: %w", err)
}
req, err := http.NewRequestWithContext(c.GetTestContext(), http.MethodPut, fmt.Sprintf("http://localhost:%d/ack", c.port), bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create PUT request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Add("Authorization", header)
// Send the request to the server.
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send PUT request: %w", err)
}
return resp, nil
}
func (c *ServerSuite) TestContracts() {
// Start the API server in a separate goroutine and wait for it to initialize.
c.startQuoterAPIServer()
client, err := apiClient.NewUnauthenticatedClient(c.handler, fmt.Sprintf("http://localhost:%d", c.port))
c.Require().NoError(err)
contracts, err := client.GetRFQContracts(c.GetTestContext())
c.Require().NoError(err)
c.Require().Len(contracts.Contracts, 2)
}