mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
Both came out as an empty list, so WETH/USDC on Ethereum — one of the deepest pools in existence — was reported as "no venue here holds this pair" whenever its RPC was down. That sends a reader looking for liquidity instead of at their endpoint, which is the same mistake as blaming the pair for an exact-output request and blaming an upstream registry for our own chain. The venues already distinguished the two answers: no liquidity is (nil, nil), a chain they could not reach is (nil, err). QueryAllVenues discarded both. It now returns the reason, and only when EVERY arm it asked failed — one arm down while another answers is a quote, not an outage. It also stops returning the routing label, which named V4_NATIVE for a V3 pool on somebody else's chain and was read by one test assertion and nothing else. Underneath, EVMClient marks the answers it never received: a connection failure, a 5xx, a 429, a body that is not JSON-RPC. A contract answering by REVERTING stays what it always was — this pool does not exist — because three of the four V3 fee tiers revert on most pairs and that is the ordinary case. Measured with chain 1 pointed at a closed port: chain 1 answers 502 naming the dial error, 96369 still quotes 997001483075056712, a pair with no pool still answers 404, and /providers stays 200 so one dark chain does not take a pod out of its own Service. Co-authored-by: Hanzo Dev <[email protected]>
221 lines
5.6 KiB
Go
221 lines
5.6 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math/big"
|
|
"sort"
|
|
"sync"
|
|
)
|
|
|
|
// VenueRouter fans out quote requests to multiple venues in parallel
|
|
// and returns the best result.
|
|
type VenueRouter struct {
|
|
venues []Venue
|
|
}
|
|
|
|
// NewVenueRouter creates a VenueRouter with the given venues.
|
|
func NewVenueRouter(venues ...Venue) *VenueRouter {
|
|
return &VenueRouter{venues: venues}
|
|
}
|
|
|
|
// Venues returns the registered venues.
|
|
func (vr *VenueRouter) Venues() []Venue {
|
|
return vr.venues
|
|
}
|
|
|
|
// QueryAllVenues asks every venue at once and returns what they hold, best
|
|
// first, and whether the chain could be read at all.
|
|
//
|
|
// A venue distinguishes the two answers already: no liquidity is (nil, nil),
|
|
// and a chain it could not reach is (nil, err). Both were discarded here and
|
|
// came out as an empty list, so a dead endpoint and an empty pool were the same
|
|
// answer — and the surface reported one of the deepest pools in existence as a
|
|
// pair nobody holds, which sends a reader looking for liquidity instead of at
|
|
// their endpoint.
|
|
//
|
|
// The error is returned only when EVERY venue asked failed. One arm down while
|
|
// another answers is a quote, not an outage.
|
|
func (vr *VenueRouter) QueryAllVenues(ctx context.Context, req VenueQuoteRequest) ([]VenueQuote, error) {
|
|
type result struct {
|
|
asked bool
|
|
quote *VenueQuote
|
|
err error
|
|
}
|
|
|
|
results := make([]result, len(vr.venues))
|
|
var wg sync.WaitGroup
|
|
|
|
for i, v := range vr.venues {
|
|
wg.Add(1)
|
|
go func(idx int, venue Venue) {
|
|
defer wg.Done()
|
|
if req.PreferredVenue != "" && venue.Name() != req.PreferredVenue {
|
|
return
|
|
}
|
|
q, err := venue.Quote(ctx, req)
|
|
results[idx] = result{asked: true, quote: q, err: err}
|
|
}(i, v)
|
|
}
|
|
wg.Wait()
|
|
|
|
var quotes []VenueQuote
|
|
var asked, failed int
|
|
var why error
|
|
|
|
for _, res := range results {
|
|
if !res.asked {
|
|
continue // a preferred venue was named and this is not it
|
|
}
|
|
asked++
|
|
switch {
|
|
case res.err != nil:
|
|
failed++
|
|
why = res.err
|
|
case res.quote != nil:
|
|
quotes = append(quotes, *res.quote)
|
|
}
|
|
}
|
|
|
|
// Sort by amountOut descending.
|
|
sort.Slice(quotes, func(i, j int) bool {
|
|
ai, _ := new(big.Int).SetString(quotes[i].AmountOut, 10)
|
|
aj, _ := new(big.Int).SetString(quotes[j].AmountOut, 10)
|
|
if ai == nil {
|
|
return false
|
|
}
|
|
if aj == nil {
|
|
return true
|
|
}
|
|
return ai.Cmp(aj) > 0
|
|
})
|
|
|
|
// Every arm that was asked failed, and none of them held anything: the
|
|
// chain could not be read. One arm down while another answers is a quote.
|
|
if failed > 0 && failed == asked {
|
|
return nil, why
|
|
}
|
|
return quotes, nil
|
|
}
|
|
|
|
// ListVenueInfo returns info about all registered venues.
|
|
func (vr *VenueRouter) ListVenueInfo() []VenueInfo {
|
|
infos := make([]VenueInfo, len(vr.venues))
|
|
for i, v := range vr.venues {
|
|
vType := "off_chain"
|
|
if v.IsExecutable() {
|
|
vType = "on_chain"
|
|
}
|
|
infos[i] = VenueInfo{
|
|
Name: v.Name(),
|
|
Status: "active",
|
|
Executable: v.IsExecutable(),
|
|
Type: vType,
|
|
}
|
|
}
|
|
return infos
|
|
}
|
|
|
|
// ComputePriceImpact estimates price impact from the spread between
|
|
// the two best venue quotes.
|
|
func ComputePriceImpact(quotes []VenueQuote) string {
|
|
if len(quotes) < 2 {
|
|
return "0.00"
|
|
}
|
|
best, _ := new(big.Int).SetString(quotes[0].AmountOut, 10)
|
|
second, _ := new(big.Int).SetString(quotes[1].AmountOut, 10)
|
|
if best == nil || second == nil || best.Sign() == 0 {
|
|
return "0.00"
|
|
}
|
|
diff := new(big.Int).Sub(best, second)
|
|
diff.Mul(diff, big.NewInt(10000))
|
|
diff.Div(diff, best)
|
|
whole := diff.Int64() / 100
|
|
frac := diff.Int64() % 100
|
|
return fmt.Sprintf("%d.%02d", whole, frac)
|
|
}
|
|
|
|
// ComputeExecutionPrice returns the execution price as amountOut*1e18/amountIn.
|
|
func ComputeExecutionPrice(amountInStr, amountOutStr string) string {
|
|
amountIn, ok := new(big.Int).SetString(amountInStr, 10)
|
|
if !ok || amountIn.Sign() == 0 {
|
|
return "0"
|
|
}
|
|
amountOut, ok := new(big.Int).SetString(amountOutStr, 10)
|
|
if !ok {
|
|
return "0"
|
|
}
|
|
e18 := new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil)
|
|
price := new(big.Int).Mul(amountOut, e18)
|
|
price.Div(price, amountIn)
|
|
return price.String()
|
|
}
|
|
|
|
// ParseBPS parses a basis-point string (digits only) into an int.
|
|
func ParseBPS(s string) int {
|
|
n := 0
|
|
for _, c := range s {
|
|
if c >= '0' && c <= '9' {
|
|
n = n*10 + int(c-'0')
|
|
} else {
|
|
return 0
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// ValidateVenueQuoteRequest validates the fields of a VenueQuoteRequest.
|
|
func ValidateVenueQuoteRequest(req VenueQuoteRequest) error {
|
|
if !isValidHexAddr(req.TokenIn) {
|
|
return fmt.Errorf("invalid tokenIn address")
|
|
}
|
|
if !isValidHexAddr(req.TokenOut) {
|
|
return fmt.Errorf("invalid tokenOut address")
|
|
}
|
|
if req.TokenIn == req.TokenOut {
|
|
return fmt.Errorf("tokenIn and tokenOut must be different")
|
|
}
|
|
if !isPositiveDecStr(req.Amount) {
|
|
return fmt.Errorf("amount must be a positive integer")
|
|
}
|
|
if req.Type != VenueQuoteTypeExactInput && req.Type != VenueQuoteTypeExactOutput {
|
|
return fmt.Errorf("type must be EXACT_INPUT or EXACT_OUTPUT")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isValidHexAddr checks that s is a 0x-prefixed 40-char hex address.
|
|
func isValidHexAddr(s string) bool {
|
|
s = trimHexPrefix(s)
|
|
if len(s) != 40 {
|
|
return false
|
|
}
|
|
for _, c := range s {
|
|
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// isPositiveDecStr returns true if s is a non-zero decimal integer string.
|
|
func isPositiveDecStr(s string) bool {
|
|
if s == "" || s == "0" {
|
|
return false
|
|
}
|
|
for _, c := range s {
|
|
if c < '0' || c > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// trimHexPrefix removes a 0x prefix from a hex string.
|
|
func trimHexPrefix(s string) string {
|
|
if len(s) >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X') {
|
|
return s[2:]
|
|
}
|
|
return s
|
|
}
|