From 1b676b08136e5be2a7971b2a2d00d46f967c284f Mon Sep 17 00:00:00 2001 From: Kevin Halliday Date: Wed, 5 Feb 2025 09:12:05 -0500 Subject: [PATCH] feat(solver): v1 quote api endpoint (#2967) Add v1 quotes api endpoint. issue: #2921 --------- Co-authored-by: corver --- solver/app/app.go | 4 +- solver/app/quote.go | 208 +++++++++++++++++++++---- solver/app/quote_internal_test.go | 246 ++++++++++++++++++++++++++++++ solver/app/reject.go | 180 +++++++++------------- solver/app/server.go | 1 + 5 files changed, 496 insertions(+), 143 deletions(-) create mode 100644 solver/app/quote_internal_test.go diff --git a/solver/app/app.go b/solver/app/app.go index 234ddad8f..5dd2338a2 100644 --- a/solver/app/app.go +++ b/solver/app/app.go @@ -92,7 +92,9 @@ func Run(ctx context.Context, cfg Config) error { } log.Info(ctx, "Serving API", "address", cfg.APIAddr) - apiChan := serveAPI(cfg.APIAddr, make(map[string]http.Handler)) // TODO(corver): Implement handler. + apiChan := serveAPI(cfg.APIAddr, map[string]http.Handler{ + "/api/v1/quote": newQuoteHandler(newQuoter(backends, solverAddr)), + }) select { case <-ctx.Done(): diff --git a/solver/app/quote.go b/solver/app/quote.go index f7d3f3237..c6f84d62e 100644 --- a/solver/app/quote.go +++ b/solver/app/quote.go @@ -1,49 +1,203 @@ package app import ( + "context" + "encoding/json" "math/big" + "net/http" "github.com/omni-network/omni/lib/errors" + "github.com/omni-network/omni/lib/ethclient/ethbackend" + "github.com/omni-network/omni/lib/log" + + "github.com/ethereum/go-ethereum/common" ) +// NOTE: Quote request / response types mirror SolvertNet.OrderData, built +// specifically for EVM -> EVM orders via SolverNetInbox / Outbox contracts, +// with ERC7683 type hash matching SolverNetInbox.ORDERDATA_TYPEHASH. +// +// To support multiple order types with this api (e.g. EVM -> Solana, Solana -> EVM) +// we'd need a more generic request / response format that discriminates on +// order type hash. + +// QuoteRequest is the expected request body for the /api/v1/quote endpoint. +type QuoteRequest struct { + SourceChainID uint64 `json:"sourceChainId"` + DestinationChainID uint64 `json:"destChainId"` + FillDeadline uint64 `json:"fillDeadline"` + Calls []Call `json:"calls"` + Expenses []Expense `json:"expenses"` + DepositToken common.Address `json:"depositToken"` +} + +// QuoteResponse is the response json for the /quote endpoint. +type QuoteResponse struct { + Rejected bool `json:"rejected,omitempty"` + RejectReason string `json:"rejectReason,omitempty"` + RejectDescription string `json:"rejectDescription,omitempty"` + Deposit *Deposit `json:"deposit,omitempty"` + Error *Error `json:"error,omitempty"` +} + +func (r QuoteResponse) StatusCode() int { + if r.Error != nil { + return r.Error.Code + } + + return http.StatusOK +} + +// Expense is a solver expense on the destination (matches bindings.SolverNetExpense). +type Expense struct { + Spender common.Address `json:"spender"` + Token common.Address `json:"token"` + Amount *big.Int `json:"amount"` +} + +// Call is a call to be made on the destination (matches bindings.SolverNetCall). +type Call struct { + Target common.Address `json:"target"` + Selector [4]byte `json:"selector"` + Value *big.Int `json:"value"` + Params []byte `json:"params"` +} + +// Deposit is a user deposit on the source (matches bindings.SolverNetDeposit). +type Deposit struct { + Token common.Address `json:"token"` + Amount *big.Int `json:"amount"` +} + +// Error is a json response for http errors (e.g 4xx, 5xx), not used for rejections. +type Error struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` +} + +type quoteFunc func(context.Context, QuoteRequest) (Deposit, error) + +// newQuoter returns a quoteFunc that can be used to quote deposits for expenses. +// It is the logic behind the /quote endpoint. +func newQuoter(backends ethbackend.Backends, solverAddr common.Address) quoteFunc { + return func(ctx context.Context, req QuoteRequest) (Deposit, error) { + backend, err := backends.Backend(req.DestinationChainID) + if err != nil { + return Deposit{}, newRejection(rejectUnsupportedDestChain, err) + } + + depositTkn, ok := tokens.find(req.SourceChainID, req.DepositToken) + if !ok { + return Deposit{}, newRejection(rejectUnsupportedDeposit, errors.New("unsupported deposit token", "addr", req.DepositToken)) + } + + var expenses []Payment + for _, e := range req.Expenses { + tkn, ok := tokens.find(req.DestinationChainID, e.Token) + if !ok { + return Deposit{}, newRejection(rejectUnsupportedExpense, errors.New("unsupported expense token", "addr", e.Token)) + } + expenses = append(expenses, Payment{ + Token: tkn, + Amount: e.Amount, + }) + } + + quote, err := getQuote([]Token{depositTkn}, expenses) + if err != nil { + return Deposit{}, err + } + + if err := checkLiquidity(ctx, expenses, backend, solverAddr); err != nil { + return Deposit{}, err + } + + return Deposit{ + Token: quote[0].Token.Address, + Amount: quote[0].Amount, + }, nil + } +} + +// newQuoteHandler returns a handler for the /quote endpoint. +// It is responsible to http request / response handling, and delegates +// logic to a quoteFunc. +func newQuoteHandler(quoteFunc quoteFunc) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, rr *http.Request) { + ctx := rr.Context() + + w.Header().Set("Content-Type", "application/json") + + // TODO: better request / response logging + + write := func(res QuoteResponse) { + // Write response header first, before body. + w.WriteHeader(res.StatusCode()) + if err := json.NewEncoder(w).Encode(res); err != nil { + log.Error(ctx, "[BUG] error writing /quote response", err) + } + } + + writeError := func(statusCode int, err error) { + log.DebugErr(ctx, "Error handling /quote request", err) + + write(QuoteResponse{ + Error: &Error{ + Code: statusCode, + Status: http.StatusText(statusCode), + Message: err.Error(), + }, + }) + } + + var req QuoteRequest + if err := json.NewDecoder(rr.Body).Decode(&req); err != nil { + writeError(http.StatusBadRequest, errors.Wrap(err, "decode request")) + return + } + + deposit, err := quoteFunc(ctx, req) + if r := new(RejectionError); errors.As(err, &r) { // RejectionError + write(QuoteResponse{ + Rejected: true, + RejectReason: r.Reason.String(), + RejectDescription: r.Err.Error(), + }) + } else if err != nil { // Error + writeError(http.StatusInternalServerError, err) + } else { + write(QuoteResponse{Deposit: &deposit}) // Success + } + }) +} + // getQuote returns payment in `depositTkns` required to pay for `expenses`. // // For now, this is a simple quote that requires a single expense, paid // for by an equal amount of an equivalent deposit token. Token equivalence is // determined by symbol (ex arbitrum "ETH" is equivalent to optimism "ETH"). -func getQuote(depositTkns []Token, expenses []Payment) ([]Payment, RejectOrErr) { +func getQuote(depositTkns []Token, expenses []Payment) ([]Payment, error) { if len(depositTkns) != 1 { - return nil, RejectOrErr{ - Reason: rejectInvalidDeposit, - Err: errors.New("only single deposit token supported"), - } + return nil, newRejection(rejectInvalidDeposit, errors.New("only single deposit token supported")) } if len(expenses) != 1 { - return nil, RejectOrErr{ - Reason: rejectInvalidExpense, - Err: errors.New("only single expense supported"), - } + return nil, newRejection(rejectInvalidExpense, errors.New("only single expense supported")) } expense := expenses[0] depositTkn := depositTkns[0] if expense.Token.Symbol != depositTkn.Symbol { - return nil, RejectOrErr{ - Reason: rejectInvalidDeposit, - Err: errors.New("deposit token must match expense token"), - } + return nil, newRejection(rejectInvalidDeposit, errors.New("deposit token must match expense token")) } // make sure chain class (e.g. mainnet, testnet) matches // we should reject with UnsupportedDestChain before this. the solver is // initialized by network, which only includes chains of the same class if expense.Token.ChainClass != depositTkn.ChainClass { - return nil, RejectOrErr{ - Reason: rejectInvalidDeposit, - Err: errors.New("deposit and expense must be of the same chain class (e.g. mainnet, testnet)"), - } + return nil, newRejection(rejectInvalidDeposit, errors.New("deposit and expense must be of the same chain class (e.g. mainnet, testnet)")) } return []Payment{ @@ -51,15 +205,11 @@ func getQuote(depositTkns []Token, expenses []Payment) ([]Payment, RejectOrErr) Token: depositTkn, Amount: expense.Amount, }, - }, RejectOrErr{} + }, nil } // coversQuote checks if `deposits` match or exceed a `quote` for expenses. -func coversQuote(deposits, quote []Payment) RejectOrErr { - if len(quote) != len(deposits) { - return RejectOrErr{} - } - +func coversQuote(deposits, quote []Payment) error { byTkn := func(ps []Payment) map[Token]*big.Int { res := make(map[Token]*big.Int) for _, p := range ps { @@ -75,19 +225,13 @@ func coversQuote(deposits, quote []Payment) RejectOrErr { for tkn, q := range quoteByTkn { d, ok := depositsByTkn[tkn] if !ok { - return RejectOrErr{ - Reason: rejectInsufficientDeposit, - Err: errors.New("missing deposit", "token", tkn), - } + return newRejection(rejectInsufficientDeposit, errors.New("missing deposit", "token", tkn)) } if d.Cmp(q) < 0 { - return RejectOrErr{ - Reason: rejectInsufficientDeposit, - Err: errors.New("insufficient deposit", "token", tkn, "deposit", d, "quote", q), - } + return newRejection(rejectInsufficientDeposit, errors.New("insufficient deposit", "token", tkn, "deposit", d, "quote", q)) } } - return RejectOrErr{} + return nil } diff --git a/solver/app/quote_internal_test.go b/solver/app/quote_internal_test.go new file mode 100644 index 000000000..83dec529e --- /dev/null +++ b/solver/app/quote_internal_test.go @@ -0,0 +1,246 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + + "github.com/omni-network/omni/e2e/app/eoa" + "github.com/omni-network/omni/lib/ethclient/mock" + "github.com/omni-network/omni/lib/evmchain" + "github.com/omni-network/omni/lib/netconf" + + "github.com/ethereum/go-ethereum/common" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +// TODO: merge TestQuote & TestShouldReject test cases, as reject cases should match +// +//nolint:tparallel // subtests use same mock controller +func TestQuote(t *testing.T) { + t.Parallel() + + // static setup + ctx := context.Background() + solver := eoa.MustAddress(netconf.Devnet, eoa.RoleSolver) + + // mock backends, to manipulate balances + backends, clients := makeMockBackends(t, + // mock omega chains for tests + evmchain.IDOmniOmega, + evmchain.IDHolesky, + evmchain.IDBaseSepolia, + + // add one mainnet chain, to make sure testnet ETH cannot be used for mainnet ETH + evmchain.IDOptimism, + ) + + client := func(chainID uint64) *mock.MockClient { + c, ok := clients[chainID] + require.True(t, ok, "client for chainID %d not found", chainID) + + return c + } + + mockNativeBalance := func(chainID uint64, balance *big.Int) func() { + return func() { + client(chainID).EXPECT().BalanceAt(ctx, solver, nil).Return(balance, nil) + } + } + + mockERC20Balance := func(chainID uint64, balance *big.Int) func() { + return func() { + // TODO: match eth msg param to IERC20(addr).balanceOf call + ctx := gomock.Any() + msg := gomock.Any() + client(chainID).EXPECT().CallContract(ctx, msg, nil).Return(abiEncodeBig(t, balance), nil) + } + } + + tests := []struct { + name string + mock func() + req QuoteRequest + res QuoteResponse + }{ + { + name: "insufficient native balance", + mock: mockNativeBalance(evmchain.IDOmniOmega, big.NewInt(0)), + req: QuoteRequest{ + // quote 1 native OMNI for erc20 OMNI on omega + SourceChainID: evmchain.IDHolesky, + DestinationChainID: evmchain.IDOmniOmega, + Expenses: []Expense{{Amount: big.NewInt(1), Token: common.Address{}}}, // native + DepositToken: omniERC20(netconf.Omega).Address, + }, + res: QuoteResponse{ + // solver does not have enough native balance + Rejected: true, + RejectReason: rejectInsufficientInventory.String(), + }, + }, + { + name: "sufficient native balance", + mock: mockNativeBalance(evmchain.IDOmniOmega, big.NewInt(1)), + req: QuoteRequest{ + // quote 1 native OMNI for erc20 OMNI on omega + SourceChainID: evmchain.IDHolesky, + DestinationChainID: evmchain.IDOmniOmega, + Expenses: []Expense{{Amount: big.NewInt(1), Token: common.Address{}}}, // native + DepositToken: omniERC20(netconf.Omega).Address, + }, + res: QuoteResponse{ + // 1 erc20 OMNI required + Deposit: &Deposit{ + Amount: big.NewInt(1), + Token: omniERC20(netconf.Omega).Address, + }, + }, + }, + { + name: "insufficient ERC20 balance", + mock: mockERC20Balance(evmchain.IDHolesky, big.NewInt(0)), + req: QuoteRequest{ + // request 1 erc20 OMNI for 1 native OMNI on omega + SourceChainID: evmchain.IDOmniOmega, + DestinationChainID: evmchain.IDHolesky, + Expenses: []Expense{{Amount: big.NewInt(1), Token: omniERC20(netconf.Omega).Address}}, + DepositToken: common.Address{}, // native + }, + res: QuoteResponse{ + // solver does not have enough erc20 balance + Rejected: true, + RejectReason: rejectInsufficientInventory.String(), + }, + }, + { + name: "sufficient ERC20 balance", + mock: mockERC20Balance(evmchain.IDHolesky, big.NewInt(1)), + req: QuoteRequest{ + // request 1 erc20 OMNI for 1 native OMNI on omega + SourceChainID: evmchain.IDOmniOmega, + DestinationChainID: evmchain.IDHolesky, + Expenses: []Expense{{Amount: big.NewInt(1), Token: omniERC20(netconf.Omega).Address}}, + DepositToken: common.Address{}, // native + }, + res: QuoteResponse{ + // 1 native OMNI required + Deposit: &Deposit{ + Amount: big.NewInt(1), + Token: common.Address{}, + }, + }, + }, + { + name: "unsupported expense token", + req: QuoteRequest{ + // request unsupported erc20 for native OMNI on omega + SourceChainID: evmchain.IDOmniOmega, + DestinationChainID: evmchain.IDHolesky, + Expenses: []Expense{{Amount: big.NewInt(1), Token: common.HexToAddress("0x01")}}, // unsupported token + DepositToken: common.Address{}, // native + }, + res: QuoteResponse{ + // expense token is not supported + Rejected: true, + RejectReason: rejectUnsupportedExpense.String(), + }, + }, + { + name: "unsupported dest chain", + req: QuoteRequest{ + SourceChainID: evmchain.IDOmniOmega, + DestinationChainID: 1234567, // unsupported chain + }, + res: QuoteResponse{ + // destination chain is not supported + Rejected: true, + RejectReason: rejectUnsupportedDestChain.String(), + }, + }, + { + name: "invalid deposit (token mismatch)", + req: QuoteRequest{ + // deposit native ETH for native OMNi + SourceChainID: evmchain.IDHolesky, + DestinationChainID: evmchain.IDOmniOmega, + Expenses: []Expense{{Amount: big.NewInt(1), Token: common.Address{}}}, // native + DepositToken: common.Address{}, // native + }, + + res: QuoteResponse{ + // deposit token does not match expense token + Rejected: true, + RejectReason: rejectInvalidDeposit.String(), + }, + }, + { + name: "invalid deposit (mismatch chain class)", + req: QuoteRequest{ + // deposit native testnet ETH for mainnet ETH + SourceChainID: evmchain.IDHolesky, // testnet chain + DestinationChainID: evmchain.IDOptimism, // mainnet chain + Expenses: []Expense{{Amount: big.NewInt(1), Token: common.Address{}}}, // native + DepositToken: common.Address{}, // native + }, + res: QuoteResponse{ + // deposit token does not match expense token + Rejected: true, + RejectReason: rejectInvalidDeposit.String(), + }, + }, + { + name: "invalid expenses (multiple expenses)", + req: QuoteRequest{ + SourceChainID: evmchain.IDBaseSepolia, + DestinationChainID: evmchain.IDHolesky, + Expenses: []Expense{ + {Amount: big.NewInt(1), Token: omniERC20(netconf.Omega).Address}, + {Amount: big.NewInt(1), Token: common.Address{}}, // native[ + }, + DepositToken: common.Address{}, // native + }, + res: QuoteResponse{ + // multiple expenses are not supported + Rejected: true, + RejectReason: rejectInvalidExpense.String(), + }, + }, + } + + handler := newQuoteHandler(newQuoter(backends, solver)) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.mock != nil { + tt.mock() + } + + body, err := json.Marshal(tt.req) + require.NoError(t, err) + + ctx := context.Background() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "api/v1/quote", bytes.NewBuffer(body)) + require.NoError(t, err) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + + var res QuoteResponse + err = json.NewDecoder(rr.Body).Decode(&res) + require.NoError(t, err) + + require.Equal(t, tt.res.Rejected, res.Rejected) + require.Equal(t, tt.res.RejectReason, res.RejectReason) + require.Equal(t, tt.res.Deposit, res.Deposit) + }) + } +} diff --git a/solver/app/reject.go b/solver/app/reject.go index 8c505efa2..617537f08 100644 --- a/solver/app/reject.go +++ b/solver/app/reject.go @@ -2,6 +2,7 @@ package app import ( "context" + "fmt" "github.com/omni-network/omni/lib/errors" "github.com/omni-network/omni/lib/ethclient/ethbackend" @@ -26,24 +27,21 @@ const ( rejectUnsupportedDestChain rejectReason = 8 ) -type RejectOrErr struct { - Reason rejectReason - Err error +// RejectionError implement error, but represents a logical (expected) rejection, not an unexpected system error. +// We combine rejections with errors for detailed internal structured errors. +type RejectionError struct { + Reason rejectReason // Succinct human-readable reason for rejection. + Err error // Internal detailed reject condition } // ShouldReject returns true if reject reason is not none. -func (r RejectOrErr) ShouldReject() bool { - return r.Reason != rejectNone +func (r *RejectionError) Error() string { + return fmt.Sprintf("%s: %v", r.Reason.String(), r.Err) } -// ShouldError returns true if there was an error without a rejection. -func (r RejectOrErr) ShouldError() bool { - return r.Reason == rejectNone && r.Err != nil -} - -// ShouldReturn returns true if there was a rejection or an error. -func (r RejectOrErr) ShouldReturn() bool { - return r.Reason != rejectNone || r.Err != nil +// newRejection is a convenience function to create a new RejectionError error. +func newRejection(reason rejectReason, err error) *RejectionError { + return &RejectionError{Reason: reason, Err: err} } // newShouldRejector returns as ShouldReject function for the given network. @@ -58,103 +56,83 @@ func newShouldRejector( chainName func(uint64) string, ) func(ctx context.Context, srcChainID uint64, order Order) (rejectReason, bool, error) { return func(ctx context.Context, srcChainID uint64, order Order) (rejectReason, bool, error) { - // rejectOrErr returns either an error, or a rejectReason with shouldReject == true. - // For rejections, it swallows the error, only sampling / logging it. - rejectOrErr := func(r RejectOrErr) (rejectReason, bool, error) { - if !r.ShouldReturn() { - return rejectNone, false, errors.New("[BUG] unexpected rejectOrErr call") + // Internal logic just return errors (convert them to rejections below) + err := func(ctx context.Context, srcChainID uint64, order Order) error { + if srcChainID != order.SourceChainID { + return errors.New("source chain id mismatch [BUG]", "got", order.SourceChainID, "expected", srcChainID) } - if r.ShouldError() { - return rejectNone, false, r.Err + backend, err := backends.Backend(order.DestinationChainID) + if err != nil { + return newRejection(rejectUnsupportedDestChain, err) } - err := errors.Wrap(r.Err, "reject", - "order_id", order.ID.String(), - "dest_chain_id", order.DestinationChainID, - "src_chain_id", order.SourceChainID, - "target", targetName(order)) - - rejectedOrders.WithLabelValues( - chainName(order.SourceChainID), - chainName(order.DestinationChainID), - targetName(order), - r.Reason.String(), - ).Inc() - - log.InfoErr(ctx, "Rejecting request", err, "reason", r.Reason) - - return r.Reason, true, nil - } + deposits, err := parseDeposits(order) + if err != nil { + return err + } - reject := func(reason rejectReason, err error) (rejectReason, bool, error) { - return rejectOrErr(RejectOrErr{Reason: reason, Err: err}) - } + expenses, err := parseExpenses(order) + if err != nil { + return err + } - returnErr := func(err error) (rejectReason, bool, error) { - return rejectOrErr(RejectOrErr{Err: err}) - } + if err := checkQuote(deposits, expenses); err != nil { + return err + } - if srcChainID != order.SourceChainID { - return returnErr(errors.New("source chain id mismatch [BUG]", "got", order.SourceChainID, "expected", srcChainID)) - } + return checkLiquidity(ctx, expenses, backend, solverAddr) + }(ctx, srcChainID, order) - backend, err := backends.Backend(order.DestinationChainID) - if err != nil { - return reject(rejectUnsupportedDestChain, err) + if err == nil { // No error, no rejection + return rejectNone, false, nil } - deposits, r := parseDeposits(order) - if r.ShouldReturn() { - return rejectOrErr(r) + r := new(RejectionError) + if !errors.As(err, &r) { // Error, but no rejection + return rejectNone, false, err } - expenses, r := parseExpenses(order) - if r.ShouldReturn() { - return rejectOrErr(r) - } + // Handle rejection + rejectedOrders.WithLabelValues( + chainName(order.SourceChainID), + chainName(order.DestinationChainID), + targetName(order), + r.Reason.String(), + ).Inc() - r = checkQuote(deposits, expenses) - if r.ShouldReturn() { - return rejectOrErr(r) - } + err = errors.Wrap(r.Err, "reject", + "reason", r.Reason.String(), + "order_id", order.ID.String(), + "dest_chain_id", order.DestinationChainID, + "src_chain_id", order.SourceChainID, + "target", targetName(order)) - r = checkLiquidity(ctx, expenses, backend, solverAddr) - if r.ShouldReturn() { - return rejectOrErr(r) - } + log.InfoErr(ctx, "Rejecting request", err) - return rejectNone, false, nil + return r.Reason, true, nil } } // parseDeposits parses order.MinReceived, checks all tokens are supported, returns the list of deposits. -func parseDeposits(order Order) ([]Payment, RejectOrErr) { +func parseDeposits(order Order) ([]Payment, error) { var deposits []Payment for _, output := range order.MinReceived { chainID := output.ChainId.Uint64() // inbox contract order resolution should ensure minReceived[].output.chainId matches order.SourceChainID if chainID != order.SourceChainID { - return nil, RejectOrErr{ - Err: errors.New("min received chain id mismatch [BUG]", "got", chainID, "expected", order.SourceChainID), - } + return nil, errors.New("min received chain id mismatch [BUG]", "got", chainID, "expected", order.SourceChainID) } addr := toEthAddr(output.Token) if !cmpAddrs(addr, output.Token) { - return nil, RejectOrErr{ - Reason: rejectUnsupportedDeposit, - Err: errors.New("non-eth addressed token", "addr", hexutil.Encode(output.Token[:])), - } + return nil, newRejection(rejectUnsupportedDeposit, errors.New("non-eth addressed token", "addr", hexutil.Encode(output.Token[:]))) } tkn, ok := tokens.find(chainID, addr) if !ok { - return nil, RejectOrErr{ - Reason: rejectUnsupportedDeposit, - Err: errors.New("unsupported token", "addr", addr), - } + return nil, newRejection(rejectUnsupportedDeposit, errors.New("unsupported token", "addr", addr)) } deposits = append(deposits, Payment{ @@ -163,36 +141,28 @@ func parseDeposits(order Order) ([]Payment, RejectOrErr) { }) } - return deposits, RejectOrErr{} + return deposits, nil } // parseExpenses parses order.MaxSpent, checks all tokens are supported, returns the list of expenses. -func parseExpenses(order Order) ([]Payment, RejectOrErr) { +func parseExpenses(order Order) ([]Payment, error) { var expenses []Payment for _, output := range order.MaxSpent { chainID := output.ChainId.Uint64() // inbox contract order resolution should ensure maxSpent[].output.chainId matches order.DestinationChainID if chainID != order.DestinationChainID { - return nil, RejectOrErr{ - Err: errors.New("max spent chain id mismatch [BUG]", "got", chainID, "expected", order.DestinationChainID), - } + return nil, errors.New("max spent chain id mismatch [BUG]", "got", chainID, "expected", order.DestinationChainID) } addr := toEthAddr(output.Token) if !cmpAddrs(addr, output.Token) { - return nil, RejectOrErr{ - Reason: rejectUnsupportedExpense, - Err: errors.New("non-eth addressed token", "addr", hexutil.Encode(output.Token[:])), - } + return nil, newRejection(rejectUnsupportedExpense, errors.New("non-eth addressed token", "addr", hexutil.Encode(output.Token[:]))) } tkn, ok := tokens.find(chainID, addr) if !ok { - return nil, RejectOrErr{ - Reason: rejectUnsupportedExpense, - Err: errors.New("unsupported token", "addr", addr), - } + return nil, newRejection(rejectUnsupportedExpense, errors.New("unsupported token", "addr", addr)) } expenses = append(expenses, Payment{ @@ -201,46 +171,36 @@ func parseExpenses(order Order) ([]Payment, RejectOrErr) { }) } - return expenses, RejectOrErr{} + return expenses, nil } // checkQuote checks if deposits match or exceed quote for expenses. // only single expense supported with matching deposit is supported. -func checkQuote(deposits, expenses []Payment) RejectOrErr { - quote, r := getQuote(tkns(deposits), expenses) - if r.ShouldReturn() { - return r - } - - r = coversQuote(deposits, quote) - if r.ShouldReturn() { - return r +func checkQuote(deposits, expenses []Payment) error { + quote, err := getQuote(tkns(deposits), expenses) + if err != nil { + return err } - return RejectOrErr{} + return coversQuote(deposits, quote) } // checkLiquidity checks that the solver has enough liquidity to pay for the expenses. -func checkLiquidity(ctx context.Context, expenses []Payment, backend *ethbackend.Backend, solverAddr common.Address) RejectOrErr { +func checkLiquidity(ctx context.Context, expenses []Payment, backend *ethbackend.Backend, solverAddr common.Address) error { for _, expense := range expenses { bal, err := balanceOf(ctx, expense.Token, backend, solverAddr) if err != nil { - return RejectOrErr{ - Err: errors.Wrap(err, "get balance", "token", expense.Token.Symbol), - } + return errors.Wrap(err, "get balance", "token", expense.Token.Symbol) } // TODO: for native tokens, even if we have enough, we don't want to // spend out whole balance. we'll need to keep some for gas if bal.Cmp(expense.Amount) < 0 { - return RejectOrErr{ - Reason: rejectInsufficientInventory, - Err: errors.New("insufficient balance", "token", expense.Token.Symbol), - } + return newRejection(rejectInsufficientInventory, errors.New("insufficient balance", "token", expense.Token.Symbol)) } } - return RejectOrErr{} + return nil } func tkns(payments []Payment) []Token { diff --git a/solver/app/server.go b/solver/app/server.go index d69bf86a2..488d62262 100644 --- a/solver/app/server.go +++ b/solver/app/server.go @@ -14,6 +14,7 @@ func serveAPI(address string, endpoints map[string]http.Handler) <-chan error { mux := http.NewServeMux() endpoints["/live"] = newLiveHandler() + endpoints["/"] = newLiveHandler() // Also serve live from root for easy health checks for endpoint, handler := range endpoints { mux.Handle(endpoint, instrumentHandler(endpoint, handler))