mirror of
https://github.com/luxfi/graph.git
synced 2026-09-24 00:26:07 +00:00
The quoter prices constant-product pairs as well as concentrated ones; this builds only the concentrated router's calls. A pair carries reserves and no fee tier, so the refusal read "missing tokenIn/tokenOut/fee" and sent the reader after a field that does not belong on that hop. The gap itself stands: a route the quote endpoint returns is one the swap endpoint cannot build. Nothing on this chain routes through a pair today — the deepest holds eight dollars — so it is a matter of the two endpoints agreeing about venues, not a break. Naming the venue is what makes that visible when it happens. Co-authored-by: Hanzo Dev <[email protected]>
303 lines
12 KiB
Go
303 lines
12 KiB
Go
package engine
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"math/big"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// The transaction a person signs to take the quote they were shown.
|
|
//
|
|
// This asks the chain nothing. Everything it needs is in the quote it is handed
|
|
// — the route, the amounts, the fee of each hop — and the only work is to say
|
|
// that in the router's own language and bound it by the slippage the caller
|
|
// accepted. Reading state here would be a second opinion about a price the
|
|
// caller has already agreed to, arriving a block later than the one they saw.
|
|
//
|
|
// The bound is the whole point of the endpoint. An exact-input trade names the
|
|
// least it will accept out; an exact-output trade names the most it will pay
|
|
// in. Past that limit the router reverts, which is the difference between a bad
|
|
// fill and no fill.
|
|
//
|
|
// There is no Permit2 here, so the router spends against a plain ERC-20
|
|
// approval and `permitData` on a quote is always null. HandleCheckApproval is
|
|
// the other half of that.
|
|
|
|
var (
|
|
selExactInputSingle = selector("exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))")
|
|
selExactInput = selector("exactInput((bytes,address,uint256,uint256))")
|
|
selExactOutputSingle = selector("exactOutputSingle((address,address,uint24,address,uint256,uint256,uint160))")
|
|
selExactOutput = selector("exactOutput((bytes,address,uint256,uint256))")
|
|
)
|
|
|
|
// ── the wire ──────────────────────────────────────────────────────────────
|
|
|
|
type swapRequest struct {
|
|
Quote swapQuote `json:"quote"`
|
|
}
|
|
|
|
// swapQuote is the part of a quote this endpoint reads back. It is deliberately
|
|
// a subset: the caller returns the quote it was given, and the fields not named
|
|
// here are for the person looking at it, not for building the call.
|
|
type swapQuote struct {
|
|
ChainID int64 `json:"chainId"`
|
|
Swapper string `json:"swapper"`
|
|
Input quoteSide `json:"input"`
|
|
Output quoteSide `json:"output"`
|
|
TradeType string `json:"tradeType"`
|
|
Slippage *float64 `json:"slippage"`
|
|
Route [][]hopJSON `json:"route"`
|
|
// GasUseEstimate rides along to the wallet as a gas limit. A quote that
|
|
// carried none leaves the field off entirely rather than sending a zero,
|
|
// which a wallet would read as "this transaction may use no gas".
|
|
GasUseEstimate string `json:"gasUseEstimate"`
|
|
}
|
|
|
|
type swapResponse struct {
|
|
RequestID string `json:"requestId"`
|
|
Swap swapCallJSON `json:"swap"`
|
|
}
|
|
|
|
type swapCallJSON struct {
|
|
To string `json:"to"`
|
|
From string `json:"from"`
|
|
Data string `json:"data"`
|
|
Value string `json:"value"`
|
|
ChainID int64 `json:"chainId"`
|
|
GasLimit string `json:"gasLimit,omitempty"`
|
|
}
|
|
|
|
// ── the handler ───────────────────────────────────────────────────────────
|
|
|
|
// HandleSwap builds the router call for a quote. router is this chain's V3
|
|
// periphery SwapRouter02 — the contract the calldata is addressed to and the
|
|
// contract check_approval asks about, so both endpoints name one address.
|
|
func HandleSwap(chainID int64, router, wrapped string) http.HandlerFunc {
|
|
wrapped = strings.ToLower(strings.TrimSpace(wrapped))
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
var req swapRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
badQuote(w, "body must be JSON")
|
|
return
|
|
}
|
|
q := req.Quote
|
|
|
|
if len(q.Route) == 0 || len(q.Route[0]) == 0 {
|
|
badQuote(w, "quote.route must contain at least one hop")
|
|
return
|
|
}
|
|
if !IsAddress(q.Swapper) {
|
|
badQuote(w, "quote.swapper must be an address")
|
|
return
|
|
}
|
|
swapper := checksumAddress(q.Swapper)
|
|
|
|
// A quote names the chain it was priced on. This process serves one
|
|
// chain, and building its router call from another chain's quote would
|
|
// address pools by the same numbers on a book that never quoted them —
|
|
// which on chains that share deployment addresses is a real trade at a
|
|
// price nobody was shown. The caller said which chain; believe it, and
|
|
// refuse when it is not this one.
|
|
if q.ChainID != 0 && q.ChainID != chainID {
|
|
badQuote(w, "quote is for chain "+strconv.FormatInt(q.ChainID, 10)+
|
|
", this one serves "+strconv.FormatInt(chainID, 10))
|
|
return
|
|
}
|
|
|
|
amountIn, okIn := parseWei(q.Input.Amount)
|
|
amountOut, okOut := parseWei(q.Output.Amount)
|
|
if !okIn || !okOut || amountIn.Sign() <= 0 || amountOut.Sign() <= 0 {
|
|
badQuote(w, "quote input/output amounts must be > 0")
|
|
return
|
|
}
|
|
|
|
// The route names the tokens; the native sentinel among them routes as
|
|
// the wrapped token, exactly as the quote priced it.
|
|
hops := q.Route[0]
|
|
tokens := make([]string, 0, len(hops)+1)
|
|
fees := make([]int64, 0, len(hops))
|
|
for i, h := range hops {
|
|
// The quoter prices constant-product pairs as well as concentrated
|
|
// ones, and this builds only the concentrated router's calls. A
|
|
// pair has no fee tier to pack into a path, so saying a field is
|
|
// missing would send someone looking for a field that does not
|
|
// belong on that hop. Name the venue instead.
|
|
if h.Type != "" && h.Type != "v3-pool" {
|
|
badQuote(w, "quote.route hop "+strconv.Itoa(i)+" is a "+h.Type+
|
|
"; this endpoint builds v3-pool routes")
|
|
return
|
|
}
|
|
// An address is twenty bytes and a fee is a uint24. The path packs
|
|
// them end to end with nothing marking where one stops, so width is
|
|
// the only thing that says which is which. Anything narrower or
|
|
// wider shifts every byte after it and the path still parses — as a
|
|
// route through pools nobody named. A negative fee renders as hex
|
|
// with a minus in it, which is not a number at all.
|
|
fee, err := strconv.ParseInt(h.Fee, 10, 64)
|
|
if !IsAddress(h.TokenIn.Address) || !IsAddress(h.TokenOut.Address) ||
|
|
err != nil || fee < 0 || fee > 1<<24-1 {
|
|
badQuote(w, "quote.route hop "+strconv.Itoa(i)+" is missing tokenIn/tokenOut/fee")
|
|
return
|
|
}
|
|
if i == 0 {
|
|
tokens = append(tokens, wrap(h.TokenIn.Address, wrapped))
|
|
}
|
|
tokens = append(tokens, wrap(h.TokenOut.Address, wrapped))
|
|
fees = append(fees, fee)
|
|
}
|
|
|
|
// Slippage is a percentage of the amount, so it lives between none and
|
|
// all of it. Outside that the factor it becomes goes negative or past
|
|
// what a word holds, and the bound stops being a bound.
|
|
slippage := 0.5
|
|
if q.Slippage != nil {
|
|
slippage = math.Min(100, math.Max(0, *q.Slippage))
|
|
}
|
|
exactIn := q.TradeType != "EXACT_OUTPUT"
|
|
|
|
var data string
|
|
switch {
|
|
case exactIn && len(hops) == 1:
|
|
data = selExactInputSingle +
|
|
addrWord(tokens[0]) + addrWord(tokens[1]) + numWord(big.NewInt(fees[0])) +
|
|
addrWord(swapper) + numWord(amountIn) + numWord(down(amountOut, slippage)) +
|
|
numWord(zero)
|
|
case exactIn:
|
|
// A multi-hop exact-input path reads the way the trade travels.
|
|
data = selExactInput + pathCall(encodePath(tokens, fees), swapper, amountIn, down(amountOut, slippage))
|
|
case len(hops) == 1:
|
|
data = selExactOutputSingle +
|
|
addrWord(tokens[0]) + addrWord(tokens[1]) + numWord(big.NewInt(fees[0])) +
|
|
addrWord(swapper) + numWord(amountOut) + numWord(up(amountIn, slippage)) +
|
|
numWord(zero)
|
|
default:
|
|
// A multi-hop exact-output path is encoded backwards: the router
|
|
// works out what each hop must receive by walking from the amount
|
|
// asked for back to the amount paid.
|
|
data = selExactOutput + pathCall(encodePath(reverse(tokens), reverse(fees)), swapper, amountOut, up(amountIn, slippage))
|
|
}
|
|
|
|
// Native input is not transferred, it is sent. The router wraps whatever
|
|
// arrives with the call, so the value carries the input amount and the
|
|
// path still names the wrapped token.
|
|
//
|
|
// Which token went in is stated twice on a quote — once at the top and
|
|
// once on the first hop — and a quote that carries only the hop is still
|
|
// a quote. Reading just the top would send no value with it and the swap
|
|
// would revert for a reason nothing in the request explains.
|
|
in := q.Input.Token
|
|
if in == "" {
|
|
in = hops[0].TokenIn.Address
|
|
}
|
|
value := "0"
|
|
if isNativeAddr(in) {
|
|
value = amountIn.String()
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, swapResponse{
|
|
RequestID: requestID(),
|
|
Swap: swapCallJSON{
|
|
To: checksumAddress(router),
|
|
From: swapper,
|
|
Data: data,
|
|
Value: value,
|
|
ChainID: chainID,
|
|
GasLimit: q.GasUseEstimate,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── amounts ───────────────────────────────────────────────────────────────
|
|
|
|
// Slippage is a percent, and it is applied in integer basis points because the
|
|
// alternative is a float multiplying someone's balance.
|
|
|
|
func down(amount *big.Int, pct float64) *big.Int {
|
|
f := 10000 - bps(pct)
|
|
if f < 0 {
|
|
f = 0
|
|
}
|
|
return div10k(amount, f)
|
|
}
|
|
|
|
// up inflates a bound and stops at what a word holds. A bound wider than the
|
|
// type can express is not a wider bound; it is a different call.
|
|
func up(amount *big.Int, pct float64) *big.Int {
|
|
n := div10k(amount, 10000+bps(pct))
|
|
if n.Cmp(maxWord) > 0 {
|
|
return new(big.Int).Set(maxWord)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func bps(pct float64) int64 { return int64(math.Round(pct * 100)) }
|
|
|
|
func div10k(amount *big.Int, factor int64) *big.Int {
|
|
n := new(big.Int).Mul(amount, big.NewInt(factor))
|
|
return n.Div(n, big.NewInt(10000))
|
|
}
|
|
|
|
// ── ABI ───────────────────────────────────────────────────────────────────
|
|
|
|
// pathCall lays out the three (bytes path, address, uint256, uint256) calls.
|
|
// The struct holds a variable-length path, so the argument is a pointer to a
|
|
// tuple that is itself a header and a tail — hence two offsets before the
|
|
// values and the path last.
|
|
func pathCall(path string, recipient string, amount, limit *big.Int) string {
|
|
const tupleAt, pathAt = 0x20, 0x80
|
|
bytes := len(path) / 2
|
|
pad := (wordHex - len(path)%wordHex) % wordHex
|
|
return numWord(big.NewInt(tupleAt)) +
|
|
numWord(big.NewInt(pathAt)) +
|
|
addrWord(recipient) +
|
|
numWord(amount) +
|
|
numWord(limit) +
|
|
numWord(big.NewInt(int64(bytes))) +
|
|
path + strings.Repeat("0", pad)
|
|
}
|
|
|
|
// encodePath packs a route the way the router reads it: each address followed
|
|
// by the fee of the pool it enters, with no padding between them. An address is
|
|
// 20 bytes and a fee is a uint24, so the widths are fixed and position alone
|
|
// says which is which — a fee written any narrower would shift every byte after
|
|
// it and address a different pool.
|
|
func encodePath(tokens []string, fees []int64) string {
|
|
var b strings.Builder
|
|
for i, t := range tokens {
|
|
b.WriteString(strings.ToLower(strings.TrimPrefix(t, "0x")))
|
|
if i < len(fees) {
|
|
b.WriteString(fmt.Sprintf("%06x", fees[i]))
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func reverse[T any](in []T) []T {
|
|
out := make([]T, len(in))
|
|
for i, v := range in {
|
|
out[len(in)-1-i] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ── addresses ─────────────────────────────────────────────────────────────
|
|
|
|
func isNativeAddr(addr string) bool {
|
|
return strings.EqualFold(strings.TrimSpace(addr), nativeSentinel)
|
|
}
|
|
|
|
// wrap substitutes the wrapped token for the native sentinel. A pool holds the
|
|
// wrapped token; the sentinel is only ever a way for a caller to say "the coin".
|
|
func wrap(addr, wrapped string) string {
|
|
if isNativeAddr(addr) {
|
|
return wrapped
|
|
}
|
|
return strings.ToLower(addr)
|
|
}
|