Skip to content

Commit

Permalink
feat(solver): v1 quote api endpoint (#2967)
Browse files Browse the repository at this point in the history
Add v1 quotes api endpoint.

issue: #2921

---------

Co-authored-by: corver <[email protected]>
  • Loading branch information
kevinhalliday and corverroos authored Feb 5, 2025
1 parent 25548d5 commit 1b676b0
Show file tree
Hide file tree
Showing 5 changed files with 496 additions and 143 deletions.
4 changes: 3 additions & 1 deletion solver/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
208 changes: 176 additions & 32 deletions solver/app/quote.go
Original file line number Diff line number Diff line change
@@ -1,65 +1,215 @@
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{
{
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 {
Expand All @@ -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
}
Loading

0 comments on commit 1b676b0

Please sign in to comment.