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]>
123 lines
3.8 KiB
Go
123 lines
3.8 KiB
Go
package gateway
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// ErrUnreachable means the endpoint was never reached: no answer came back at
|
|
// all, as opposed to a contract answering by reverting.
|
|
//
|
|
// Every venue here treats a reverting call as "this pool does not exist", which
|
|
// is right — three of the four V3 fee tiers revert on most pairs. Without this
|
|
// distinction a dead endpoint reverts every tier at once and reads as a pair
|
|
// nobody holds, so WETH/USDC on Ethereum came back empty when its RPC was down.
|
|
var ErrUnreachable = errors.New("endpoint unreachable")
|
|
|
|
// EVMClient sends eth_call JSON-RPC requests to an EVM endpoint.
|
|
// Used by all on-chain venues (V2, V3, V4/Native) for quoting.
|
|
type EVMClient struct {
|
|
rpcURL string
|
|
httpClient *http.Client
|
|
reqID atomic.Uint64
|
|
}
|
|
|
|
// NewEVMClient creates an EVMClient targeting the given RPC URL.
|
|
func NewEVMClient(rpcURL string) *EVMClient {
|
|
return &EVMClient{
|
|
rpcURL: rpcURL,
|
|
httpClient: &http.Client{
|
|
Timeout: 5 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// ask sends one JSON-RPC method and returns its result string.
|
|
//
|
|
// Every question this client asks a chain is one round trip with a hex string
|
|
// coming back, so the transport is written once and the methods sit on top.
|
|
func (c *EVMClient) ask(ctx context.Context, method string, params []any) (string, error) {
|
|
body, err := json.Marshal(map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"method": method,
|
|
"params": params,
|
|
"id": c.reqID.Add(1),
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal rpc request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.rpcURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", fmt.Errorf("create http request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: %v", ErrUnreachable, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: %v", ErrUnreachable, err)
|
|
}
|
|
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests {
|
|
// The endpoint refused to answer rather than the contract answering.
|
|
return "", fmt.Errorf("%w: %s", ErrUnreachable, resp.Status)
|
|
}
|
|
|
|
var rpcResp struct {
|
|
Result string `json:"result"`
|
|
Error *struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
|
|
return "", fmt.Errorf("%w: %s answered %d with something that is not JSON-RPC", ErrUnreachable, c.rpcURL, resp.StatusCode)
|
|
}
|
|
if rpcResp.Error != nil {
|
|
return "", fmt.Errorf("rpc error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
|
|
}
|
|
return strings.TrimPrefix(rpcResp.Result, "0x"), nil
|
|
}
|
|
|
|
// CallContract performs eth_call against the given contract address with the
|
|
// provided calldata. Returns the raw response bytes (hex-decoded).
|
|
func (c *EVMClient) CallContract(ctx context.Context, to string, data []byte) ([]byte, error) {
|
|
result, err := c.ask(ctx, "eth_call", []any{
|
|
map[string]string{"to": to, "data": "0x" + hex.EncodeToString(data)},
|
|
"latest",
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if result == "" {
|
|
// eth_call answers 0x two ways: the contract reverted, or there is no
|
|
// contract at that address at all. "empty rpc response" named the
|
|
// transport and left a reader looking at their own endpoint.
|
|
return nil, fmt.Errorf("%s answered nothing: it reverted, or nothing is deployed there", to)
|
|
}
|
|
return hex.DecodeString(result)
|
|
}
|
|
|
|
// Code returns the bytecode at an address, empty where nothing is deployed.
|
|
func (c *EVMClient) Code(ctx context.Context, addr string) ([]byte, error) {
|
|
result, err := c.ask(ctx, "eth_getCode", []any{addr, "latest"})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return hex.DecodeString(result)
|
|
}
|