Files
zeekayandHanzo Dev f82a8aace1 A dead endpoint is not an empty pool
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]>
2026-09-09 01:45:45 -07:00

143 lines
4.5 KiB
Go

package gateway
import (
"context"
"errors"
"fmt"
"math/big"
)
// QuoterV2.quoteExactInputSingle((address,address,uint256,uint24,uint160))
//
// The struct is a single static tuple, so it encodes inline: five words after
// the selector, no offset. The older QuoterV1 took the same five values as
// flat arguments under a different selector; a chain still running V1 answers
// nothing to this one, which reads as a pair with no pool rather than a wrong
// answer.
var selectorV3QuoteExactInputSingle = lxrMustDecodeHex("c6a5026a")
// The fee tiers a V3 deployment publishes, in hundredths of a basis point.
// A quote asks each and keeps the best, because which tier holds the liquidity
// for a pair is a fact about that pair and not about the chain.
var v3FeeTiers = []uint32{100, 500, 3000, 10000}
// UniswapV3Venue quotes from any Uniswap V3-compatible QuoterV2.
//
// It exists because the gateway could read a V2 router and the native
// precompiles and nothing in between, so every V3 market — which on most
// chains is where the liquidity is — was invisible. Reading the quoter over
// JSON-RPC needs no API key and no upstream account: the pool is the source.
type UniswapV3Venue struct {
evm *EVMClient
quoter string
router string
venueName string
tiers []uint32
}
// UniswapV3Config configures a V3 venue.
type UniswapV3Config struct {
// RPCURL is the EVM RPC endpoint.
RPCURL string
// QuoterAddress is the QuoterV2 contract (0x-prefixed).
QuoterAddress string
// RouterAddress is the SwapRouter02 that executes what the quoter priced.
// Empty means this venue quotes and does not build.
RouterAddress string
// Name identifies this venue (e.g. "uniswap_v3", "pancake_v3").
Name string
// FeeTiers overrides the tiers asked. Empty means the four V3 publishes.
FeeTiers []uint32
}
// NewUniswapV3Venue creates a venue that quotes via a QuoterV2.
func NewUniswapV3Venue(cfg UniswapV3Config) *UniswapV3Venue {
name := cfg.Name
if name == "" {
name = "uniswap_v3"
}
tiers := cfg.FeeTiers
if len(tiers) == 0 {
tiers = v3FeeTiers
}
return &UniswapV3Venue{
evm: NewEVMClient(cfg.RPCURL),
quoter: cfg.QuoterAddress,
router: cfg.RouterAddress,
venueName: name,
tiers: tiers,
}
}
func (v *UniswapV3Venue) Name() string { return v.venueName }
func (v *UniswapV3Venue) IsExecutable() bool { return true }
func (v *UniswapV3Venue) Quote(ctx context.Context, req VenueQuoteRequest) (*VenueQuote, error) {
if req.Type != VenueQuoteTypeExactInput {
return nil, nil
}
amountIn, ok := new(big.Int).SetString(req.Amount, 10)
if !ok || amountIn.Sign() <= 0 {
return nil, fmt.Errorf("invalid amount: %s", req.Amount)
}
inAddr, err := lxrDecodeAddress(req.TokenIn)
if err != nil {
return nil, fmt.Errorf("tokenIn: %w", err)
}
outAddr, err := lxrDecodeAddress(req.TokenOut)
if err != nil {
return nil, fmt.Errorf("tokenOut: %w", err)
}
best := new(big.Int)
var bestFee uint32
var unreachable error
for _, fee := range v.tiers {
// (tokenIn, tokenOut, amountIn, fee, sqrtPriceLimitX96) — a static
// tuple, so five words inline. The price limit is zero: no bound.
calldata := make([]byte, 0, 4+32*5)
calldata = append(calldata, selectorV3QuoteExactInputSingle...)
calldata = append(calldata, lxrPadAddress(inAddr)...)
calldata = append(calldata, lxrPadAddress(outAddr)...)
calldata = append(calldata, lxrPadUint256(amountIn)...)
calldata = append(calldata, lxrPadUint256(new(big.Int).SetUint64(uint64(fee)))...)
calldata = append(calldata, lxrPadUint256(big.NewInt(0))...)
result, err := v.evm.CallContract(ctx, v.quoter, calldata)
if errors.Is(err, ErrUnreachable) {
// Not an answer at all. Every tier will say the same, so the loop
// still finishes and the reason is kept for the end.
unreachable = err
continue
}
if err != nil || len(result) < 32 {
// A tier with no pool reverts. That is the ordinary answer to
// three of the four tiers on most pairs, not a failure to report.
continue
}
out := new(big.Int).SetBytes(result[0:32])
if out.Cmp(best) > 0 {
best = out
bestFee = fee
}
}
if best.Sign() <= 0 {
// No tier held anything. If nothing was ever reached, that is why.
return nil, unreachable
}
return &VenueQuote{
Venue: v.venueName,
AmountOut: best.String(),
// The tier the quote came from, in hundredths of a basis point, which
// is the unit V3 states a fee in.
Fee: fmt.Sprintf("%d", bestFee),
GasEstimate: "180000",
Executable: true,
}, nil
}