mirror of
https://github.com/luxfi/graph.git
synced 2026-09-24 00:26:07 +00:00
Build the swap transaction and the approval beside the quote that priced it
The swap form's four questions were split across two services: the indexer answered what can be traded and what it pays, while a TypeScript service answered what to approve and what to sign. The last two need the route the first two produced, so the split meant a quote crossed a process boundary to be turned into calldata. HandleSwap encodes the router call from a quote and bounds it by the caller's slippage. It reads no state — the quote already carries the route, the amounts and each hop's fee, and re-reading here would be a second opinion about a price the caller has already accepted, a block later than the one they saw. HandleCheckApproval reads the allowance the router holds and returns the grant when it falls short. Silence from a token is not an allowance of zero, and an unreachable node is not either; both would skip an approval and leave the swap to revert. The conversation with a chain moves to one type. It was a method on the quoter, which left the approval endpoint either reaching through a quoter to ask an unrelated question or growing a second copy of the batching, timeout and decode. Calldata is asserted against bytes recorded from the service being retired, across exact-input, exact-output, single-hop and multi-hop. A wallet signs these, so a byte is the unit of correctness. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Whether the router may already spend this token, and the transaction that
|
||||
// lets it if not.
|
||||
//
|
||||
// An ERC-20 moves only on the owner's say-so, so a swap of one is two
|
||||
// transactions: grant, then trade. Asking first is what keeps the swap form
|
||||
// from showing a second signature prompt to someone who granted it months ago,
|
||||
// and from omitting one for someone who never did.
|
||||
//
|
||||
// Two cases need no approval and both answer null rather than an amount of
|
||||
// zero: the chain's own coin, which is sent with the call and never spent from
|
||||
// a balance, and an allowance that already covers the trade.
|
||||
|
||||
// unlimited is what an approval grants. A person approves a router once and
|
||||
// trades against it for as long as they use it; an approval sized to the
|
||||
// trade in hand would put a signature in front of every swap after it.
|
||||
var unlimited = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
|
||||
|
||||
var (
|
||||
selAllowance = selector("allowance(address,address)")
|
||||
selApprove = selector("approve(address,uint256)")
|
||||
)
|
||||
|
||||
type approvalRequest struct {
|
||||
WalletAddress string `json:"walletAddress"`
|
||||
Token string `json:"token"`
|
||||
Amount string `json:"amount"`
|
||||
ChainID *int64 `json:"chainId"`
|
||||
}
|
||||
|
||||
type approvalResponse struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Approval *approvalTx `json:"approval"`
|
||||
}
|
||||
|
||||
type approvalTx struct {
|
||||
To string `json:"to"`
|
||||
From string `json:"from"`
|
||||
Data string `json:"data"`
|
||||
Value string `json:"value"`
|
||||
ChainID int64 `json:"chainId"`
|
||||
}
|
||||
|
||||
// HandleCheckApproval answers whether the router can spend a token on the
|
||||
// caller's behalf. router is the same SwapRouter02 HandleSwap addresses — the
|
||||
// approval has to name the contract that will actually do the spending, so the
|
||||
// two endpoints read one address or the grant is useless.
|
||||
func HandleCheckApproval(chainID int64, rpc, router string) http.HandlerFunc {
|
||||
n := dial(rpc)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
var req approvalRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
badQuote(w, "body must be JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if !isHexAddress(req.WalletAddress) {
|
||||
badQuote(w, "walletAddress must be an address")
|
||||
return
|
||||
}
|
||||
if req.Token == "" {
|
||||
badQuote(w, "token is required")
|
||||
return
|
||||
}
|
||||
amount, ok := parseWei(req.Amount)
|
||||
if !ok {
|
||||
badQuote(w, "amount must be a decimal wei string")
|
||||
return
|
||||
}
|
||||
if req.ChainID != nil && *req.ChainID != chainID {
|
||||
badQuote(w, "unsupported chainId "+strconv.FormatInt(*req.ChainID, 10))
|
||||
return
|
||||
}
|
||||
|
||||
wallet := checksumAddress(req.WalletAddress)
|
||||
none := approvalResponse{RequestID: requestID()}
|
||||
|
||||
// The coin itself is not an ERC-20 and has nothing to approve.
|
||||
if isNativeAddr(req.Token) {
|
||||
writeJSON(w, http.StatusOK, none)
|
||||
return
|
||||
}
|
||||
if !isHexAddress(req.Token) {
|
||||
badQuote(w, "token must be an address")
|
||||
return
|
||||
}
|
||||
token := checksumAddress(req.Token)
|
||||
|
||||
res, err := n.call(r.Context(), []ethCall{{
|
||||
To: token,
|
||||
Data: selAllowance + addrWord(wallet) + addrWord(router),
|
||||
}})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{
|
||||
"errorCode": "INTERNAL_ERROR",
|
||||
"detail": "Internal server error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// A token that answers nothing has granted nothing. Reading silence as
|
||||
// an allowance would skip the approval and leave the swap to revert.
|
||||
allowance := new(big.Int)
|
||||
if got := words(res[0]); len(got) > 0 {
|
||||
allowance = got[0]
|
||||
}
|
||||
if allowance.Cmp(amount) >= 0 {
|
||||
writeJSON(w, http.StatusOK, none)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, approvalResponse{
|
||||
RequestID: requestID(),
|
||||
Approval: &approvalTx{
|
||||
To: token,
|
||||
From: wallet,
|
||||
Data: selApprove + addrWord(router) + numWord(unlimited),
|
||||
Value: "0",
|
||||
ChainID: chainID,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// As with the swap calldata, the approval bytes below were recorded from
|
||||
// exchange-api against Lux mainnet. The allowance is served by a stub node so
|
||||
// the test states what the chain said rather than waiting to find out — and so
|
||||
// the two answers that matter, "already approved" and "not approved", can both
|
||||
// be exercised on demand.
|
||||
|
||||
// stubNode answers an allowance() call with the value given, and records what
|
||||
// it was asked. Serving real JSON-RPC rather than faking the call keeps the
|
||||
// encoding of the request under test too.
|
||||
func stubNode(t *testing.T, allowance string) (*httptest.Server, *string) {
|
||||
t.Helper()
|
||||
var seen string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
// An eth_call's params are the call and then the block to read it at,
|
||||
// so the array is not one type and only its head is a call.
|
||||
var reqs []struct {
|
||||
Method string `json:"method"`
|
||||
Params []json.RawMessage `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &reqs); err != nil || len(reqs) == 0 || len(reqs[0].Params) == 0 {
|
||||
t.Errorf("node asked something that is not a JSON-RPC batch: %s", body)
|
||||
return
|
||||
}
|
||||
if reqs[0].Method != "eth_call" {
|
||||
t.Errorf("method = %q, want eth_call", reqs[0].Method)
|
||||
}
|
||||
var call struct{ To, Data string }
|
||||
if err := json.Unmarshal(reqs[0].Params[0], &call); err != nil {
|
||||
t.Errorf("first param is not a call: %s", reqs[0].Params[0])
|
||||
}
|
||||
seen = call.To + " " + call.Data
|
||||
fmt.Fprintf(w, `[{"jsonrpc":"2.0","id":0,"result":"0x%064s"}]`, allowance)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &seen
|
||||
}
|
||||
|
||||
func serveApproval(t *testing.T, rpc, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /v1/check_approval", HandleCheckApproval(luxMain, rpc, router))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/check_approval", strings.NewReader(body)))
|
||||
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response is not JSON: %v\n%s", err, rec.Body.String())
|
||||
}
|
||||
return rec.Code, out
|
||||
}
|
||||
|
||||
// A wallet that has granted nothing gets the grant transaction, and it must be
|
||||
// an unlimited approval addressed to the router — the exact bytes exchange-api
|
||||
// returned for the same request.
|
||||
func TestApprovalNeededMatchesRecording(t *testing.T) {
|
||||
srv, asked := stubNode(t, "0") // no allowance
|
||||
code, out := serveApproval(t, srv.URL, `{
|
||||
"walletAddress":"0x00000000000000000000000000000000DeaDBeef",
|
||||
"token":"`+lusd+`","amount":"1000000000000000000","chainId":96369}`)
|
||||
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %v", code, out)
|
||||
}
|
||||
// The allowance was read from the token, for this wallet, against the router.
|
||||
want := strings.ToLower(lusd) + " " + selAllowance +
|
||||
"00000000000000000000000000000000000000000000000000000000deadbeef" +
|
||||
"000000000000000000000000939bc0bca6f9b9c52e6e3ad8a3c590b5d9b9d10e"
|
||||
if strings.ToLower(*asked) != want {
|
||||
t.Errorf("allowance call:\n got %s\n want %s", strings.ToLower(*asked), want)
|
||||
}
|
||||
|
||||
approval, ok := out["approval"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("approval is not an object: %v", out)
|
||||
}
|
||||
assertSwap(t, approval, map[string]any{
|
||||
"to": lusd,
|
||||
"from": "0x00000000000000000000000000000000DeaDBeef",
|
||||
"value": "0",
|
||||
"chainId": float64(luxMain),
|
||||
"data": "0x095ea7b3" +
|
||||
"000000000000000000000000939bc0bca6f9b9c52e6e3ad8a3c590b5d9b9d10e" + // the router
|
||||
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", // unlimited
|
||||
})
|
||||
}
|
||||
|
||||
// An allowance that already covers the trade is not a smaller approval, it is
|
||||
// no approval — the swap form shows one signature instead of two.
|
||||
func TestApprovalNotNeededWhenAllowanceCovers(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name, allowance, amount string
|
||||
}{
|
||||
{"exactly covers", "de0b6b3a7640000", "1000000000000000000"},
|
||||
{"more than covers", "ffffffffffffffff", "1000000000000000000"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
srv, _ := stubNode(t, c.allowance)
|
||||
code, out := serveApproval(t, srv.URL, `{
|
||||
"walletAddress":"`+trader+`","token":"`+lusd+`","amount":"`+c.amount+`"}`)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", code)
|
||||
}
|
||||
if out["approval"] != nil {
|
||||
t.Errorf("approval = %v, want null", out["approval"])
|
||||
}
|
||||
if id, _ := out["requestId"].(string); id == "" {
|
||||
t.Error("requestId missing")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// One unit short is still short. This is the boundary the whole endpoint turns
|
||||
// on, so it is asserted rather than assumed.
|
||||
func TestApprovalNeededWhenAllowanceOneShort(t *testing.T) {
|
||||
srv, _ := stubNode(t, "de0b6b3a763ffff") // 1e18 - 1
|
||||
_, out := serveApproval(t, srv.URL, `{
|
||||
"walletAddress":"`+trader+`","token":"`+lusd+`","amount":"1000000000000000000"}`)
|
||||
if out["approval"] == nil {
|
||||
t.Error("approval = null, but the allowance is one unit short of the amount")
|
||||
}
|
||||
}
|
||||
|
||||
// The chain's own coin is sent with the swap, not spent from a balance, so
|
||||
// there is nothing to approve and no call to make.
|
||||
func TestApprovalNativeNeedsNoneAndAsksNothing(t *testing.T) {
|
||||
srv, asked := stubNode(t, "0")
|
||||
code, out := serveApproval(t, srv.URL, `{
|
||||
"walletAddress":"`+trader+`","token":"0x0000000000000000000000000000000000000000",
|
||||
"amount":"1000000000000000000","chainId":96369}`)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", code)
|
||||
}
|
||||
if out["approval"] != nil {
|
||||
t.Errorf("approval = %v, want null for the native coin", out["approval"])
|
||||
}
|
||||
if *asked != "" {
|
||||
t.Errorf("asked the chain %q about the native coin; it should ask nothing", *asked)
|
||||
}
|
||||
}
|
||||
|
||||
// A node that cannot be reached is not an allowance of zero. Answering "no
|
||||
// approval needed" there would send someone into a swap that reverts.
|
||||
func TestApprovalNodeFailureIsNotZeroAllowance(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "down", http.StatusBadGateway)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
code, out := serveApproval(t, srv.URL, `{
|
||||
"walletAddress":"`+trader+`","token":"`+lusd+`","amount":"1000000000000000000"}`)
|
||||
if code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500 when the node is unreachable: %v", code, out)
|
||||
}
|
||||
if out["errorCode"] != "INTERNAL_ERROR" {
|
||||
t.Errorf("errorCode = %v, want INTERNAL_ERROR", out["errorCode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalRejectsBadRequests(t *testing.T) {
|
||||
srv, _ := stubNode(t, "0")
|
||||
for _, c := range []struct{ name, body, detail string }{
|
||||
{"no wallet", `{"token":"` + lusd + `","amount":"1"}`,
|
||||
"walletAddress must be an address"},
|
||||
{"no token", `{"walletAddress":"` + trader + `","amount":"1"}`,
|
||||
"token is required"},
|
||||
{"amount not wei", `{"walletAddress":"` + trader + `","token":"` + lusd + `","amount":"1.5"}`,
|
||||
"amount must be a decimal wei string"},
|
||||
{"foreign chain", `{"walletAddress":"` + trader + `","token":"` + lusd + `","amount":"1","chainId":1}`,
|
||||
"unsupported chainId 1"},
|
||||
{"not JSON", `{`, "body must be JSON"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
code, out := serveApproval(t, srv.URL, c.body)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", code)
|
||||
}
|
||||
if out["errorCode"] != "VALIDATION_ERROR" || out["detail"] != c.detail {
|
||||
t.Errorf("got %v, want detail %q", out, c.detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// How this package talks to a chain.
|
||||
//
|
||||
// One type, because "ask the node something" is one job. The quoter reads pool
|
||||
// state, the approval endpoint reads an allowance, and both want the same
|
||||
// thing: a batch of eth_calls at head, answered positionally. Two copies of
|
||||
// that would be two places for a timeout, a status check, or a decode to
|
||||
// differ, and the difference would show up as one endpoint working while its
|
||||
// neighbour reports a revert that never happened.
|
||||
|
||||
// node is a chain's read side: an endpoint and the client that dials it.
|
||||
type node struct {
|
||||
rpc string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func dial(rpc string) *node {
|
||||
return &node{rpc: rpc, client: &http.Client{Timeout: quoteBudget}}
|
||||
}
|
||||
|
||||
type ethCall struct {
|
||||
To string
|
||||
Data string
|
||||
}
|
||||
|
||||
type rpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params []any `json:"params"`
|
||||
}
|
||||
|
||||
func ethCallReq(id int, to, data string) rpcRequest {
|
||||
return rpcRequest{JSONRPC: "2.0", ID: id, Method: "eth_call",
|
||||
Params: []any{map[string]string{"to": to, "data": data}, "latest"}}
|
||||
}
|
||||
|
||||
// call runs a batch of eth_calls at head and returns each result positionally.
|
||||
func (n *node) call(ctx context.Context, calls []ethCall) ([]string, error) {
|
||||
reqs := make([]rpcRequest, len(calls))
|
||||
for i, c := range calls {
|
||||
reqs[i] = ethCallReq(i, c.To, c.Data)
|
||||
}
|
||||
return n.batch(ctx, reqs)
|
||||
}
|
||||
|
||||
// batch sends one JSON-RPC batch and returns each result positionally by id.
|
||||
//
|
||||
// A call that reverted comes back empty, and that is an answer — "this contract
|
||||
// does not do that" is exactly how a pool's venue is settled. A batch that does
|
||||
// not come back at all is an error, because then nothing is known and an empty
|
||||
// result would be read as a revert that never happened.
|
||||
func (n *node) batch(ctx context.Context, reqs []rpcRequest) ([]string, error) {
|
||||
out := make([]string, len(reqs))
|
||||
if len(reqs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
body, err := json.Marshal(reqs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.rpc, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := n.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("rpc: %s", resp.Status)
|
||||
}
|
||||
|
||||
var results []struct {
|
||||
ID int `json:"id"`
|
||||
Result string `json:"result"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range results {
|
||||
if r.ID >= 0 && r.ID < len(out) {
|
||||
out[r.ID] = r.Result
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
+5
-80
@@ -1,7 +1,6 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -257,10 +256,9 @@ func (r *route) priced() bool {
|
||||
type Quoter struct {
|
||||
store *storage.Store
|
||||
chainID int64
|
||||
rpc string
|
||||
node *node
|
||||
quoterV2 string // the V3 periphery quoter; empty when the chain has no V3 venue
|
||||
wrapped string // wrapped native, lower-cased
|
||||
client *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
venues map[string]string // pool address → "v2"/"v3"; a pool's venue never changes
|
||||
@@ -273,10 +271,9 @@ func NewQuoter(store *storage.Store, chainID int64, rpc, quoterV2, wrapped strin
|
||||
return &Quoter{
|
||||
store: store,
|
||||
chainID: chainID,
|
||||
rpc: rpc,
|
||||
node: dial(rpc),
|
||||
quoterV2: strings.ToLower(strings.TrimSpace(quoterV2)),
|
||||
wrapped: strings.ToLower(strings.TrimSpace(wrapped)),
|
||||
client: &http.Client{Timeout: quoteBudget},
|
||||
venues: map[string]string{},
|
||||
}
|
||||
}
|
||||
@@ -563,7 +560,7 @@ func (q *Quoter) classify(ctx context.Context, pools []*pool) error {
|
||||
ethCall{To: p.addr, Data: selGetReserves},
|
||||
ethCall{To: p.addr, Data: selSlot0})
|
||||
}
|
||||
res, err := q.call(ctx, calls)
|
||||
res, err := q.node.call(ctx, calls)
|
||||
if err != nil {
|
||||
return err // the node did not answer; nothing is learned, nothing recorded
|
||||
}
|
||||
@@ -615,7 +612,7 @@ func (q *Quoter) priceReady(ctx context.Context, candidates []*route, exactIn bo
|
||||
index[i] = [2]int{len(calls), len(c)}
|
||||
calls = append(calls, c...)
|
||||
}
|
||||
res, err := q.call(ctx, calls)
|
||||
res, err := q.node.call(ctx, calls)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -847,7 +844,7 @@ func (q *Quoter) decorate(ctx context.Context, r *route) uint64 {
|
||||
headAt := len(reqs)
|
||||
reqs = append(reqs, rpcRequest{JSONRPC: "2.0", ID: headAt, Method: "eth_blockNumber", Params: []any{}})
|
||||
|
||||
res, err := q.batch(ctx, reqs)
|
||||
res, err := q.node.batch(ctx, reqs)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
@@ -998,81 +995,9 @@ func echoToken(addr string) string {
|
||||
return checksumAddress(addr)
|
||||
}
|
||||
|
||||
// ── the node ──────────────────────────────────────────────────────────────
|
||||
|
||||
// wordHex is the length of one ABI word rendered as hex characters.
|
||||
const wordHex = 64
|
||||
|
||||
type ethCall struct {
|
||||
To string
|
||||
Data string
|
||||
}
|
||||
|
||||
type rpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params []any `json:"params"`
|
||||
}
|
||||
|
||||
func ethCallReq(id int, to, data string) rpcRequest {
|
||||
return rpcRequest{JSONRPC: "2.0", ID: id, Method: "eth_call",
|
||||
Params: []any{map[string]string{"to": to, "data": data}, "latest"}}
|
||||
}
|
||||
|
||||
// call runs a batch of eth_calls at head and returns each result positionally.
|
||||
func (q *Quoter) call(ctx context.Context, calls []ethCall) ([]string, error) {
|
||||
reqs := make([]rpcRequest, len(calls))
|
||||
for i, c := range calls {
|
||||
reqs[i] = ethCallReq(i, c.To, c.Data)
|
||||
}
|
||||
return q.batch(ctx, reqs)
|
||||
}
|
||||
|
||||
// batch sends one JSON-RPC batch and returns each result positionally by id.
|
||||
//
|
||||
// A call that reverted comes back empty, and that is an answer — "this contract
|
||||
// does not do that" is exactly how a pool's venue is settled. A batch that does
|
||||
// not come back at all is an error, because then nothing is known and an empty
|
||||
// result would be read as a revert that never happened.
|
||||
func (q *Quoter) batch(ctx context.Context, reqs []rpcRequest) ([]string, error) {
|
||||
out := make([]string, len(reqs))
|
||||
if len(reqs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
body, err := json.Marshal(reqs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, q.rpc, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := q.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("rpc: %s", resp.Status)
|
||||
}
|
||||
|
||||
var results []struct {
|
||||
ID int `json:"id"`
|
||||
Result string `json:"result"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range results {
|
||||
if r.ID >= 0 && r.ID < len(out) {
|
||||
out[r.ID] = r.Result
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── ABI ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// numWord renders an integer as a 32-byte argument.
|
||||
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
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 !isHexAddress(q.Swapper) {
|
||||
badQuote(w, "quote.swapper must be an address")
|
||||
return
|
||||
}
|
||||
swapper := checksumAddress(q.Swapper)
|
||||
|
||||
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 {
|
||||
fee, err := strconv.ParseInt(h.Fee, 10, 64)
|
||||
if h.TokenIn.Address == "" || h.TokenOut.Address == "" || h.Fee == "" || err != nil {
|
||||
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 := 0.5
|
||||
if q.Slippage != nil {
|
||||
slippage = *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.
|
||||
value := "0"
|
||||
if isNativeAddr(q.Input.Token) {
|
||||
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)
|
||||
}
|
||||
|
||||
func up(amount *big.Int, pct float64) *big.Int {
|
||||
return div10k(amount, 10000+bps(pct))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The bytes below were recorded from exchange-api, the TypeScript service this
|
||||
// endpoint replaces, against Lux mainnet. They are the whole point of the test:
|
||||
// a wallet signs this calldata, so "equivalent" is not a standard that means
|
||||
// anything here — a single byte out addresses a different pool or moves a
|
||||
// different amount. Each case asserts the full response, not a field of it.
|
||||
|
||||
const (
|
||||
router = "0x939bC0Bca6F9B9c52E6e3AD8A3C590b5d9B9D10E"
|
||||
wlux = "0x4888E4a2Ee0F03051c72D2BD3ACf755eD3498B3E"
|
||||
lusd = "0x848Cff46eb323f323b6Bbe1Df274E40793d7f2c2"
|
||||
cyrus = "0x0A78f7Ce8D65e0FD4D6B78848483bA3C4fb895c5"
|
||||
trader = "0x9011E888251AB053B7bD1cdB598Db4f9DEd94714"
|
||||
luxMain = 96369
|
||||
)
|
||||
|
||||
// serveSwap runs a body through the handler as an HTTP request, so what the
|
||||
// test exercises is the route a caller reaches and not a function beside it.
|
||||
func serveSwap(t *testing.T, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /v1/swap", HandleSwap(luxMain, router, wlux))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/swap", strings.NewReader(body)))
|
||||
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response is not JSON: %v\n%s", err, rec.Body.String())
|
||||
}
|
||||
return rec.Code, out
|
||||
}
|
||||
|
||||
func swapCall(t *testing.T, body string) map[string]any {
|
||||
t.Helper()
|
||||
code, out := serveSwap(t, body)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %v", code, out)
|
||||
}
|
||||
// requestId is a fresh correlator per call and is the one field that cannot
|
||||
// match a recording. It must still be there, and be a value.
|
||||
if id, ok := out["requestId"].(string); !ok || id == "" {
|
||||
t.Fatalf("requestId missing or empty: %v", out)
|
||||
}
|
||||
swap, ok := out["swap"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("no swap object: %v", out)
|
||||
}
|
||||
return swap
|
||||
}
|
||||
|
||||
func assertSwap(t *testing.T, got map[string]any, want map[string]any) {
|
||||
t.Helper()
|
||||
for k, w := range want {
|
||||
g, ok := got[k]
|
||||
if !ok {
|
||||
t.Errorf("%s: missing from response", k)
|
||||
continue
|
||||
}
|
||||
if g != w {
|
||||
t.Errorf("%s:\n got %v\n want %v", k, g, w)
|
||||
}
|
||||
}
|
||||
for k := range got {
|
||||
if _, ok := want[k]; !ok {
|
||||
t.Errorf("%s: unexpected field %v — exchange-api did not send it", k, got[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A single-hop exact-input trade, native in. exactInputSingle takes a fixed
|
||||
// tuple, so every argument sits inline and the amountOutMinimum is the quoted
|
||||
// output less the caller's 0.5%.
|
||||
func TestSwapExactInputSingleMatchesRecording(t *testing.T) {
|
||||
got := swapCall(t, `{"quote":{
|
||||
"chainId":96369,
|
||||
"swapper":"`+trader+`",
|
||||
"input":{"token":"0x0000000000000000000000000000000000000000","amount":"1000000000000000000"},
|
||||
"output":{"token":"`+lusd+`","amount":"611022579164403","recipient":"`+trader+`"},
|
||||
"tradeType":"EXACT_INPUT",
|
||||
"slippage":0.5,
|
||||
"gasUseEstimate":"79990",
|
||||
"route":[[{"type":"v3-pool","address":"0x37011bB281676f85962fb35C674f7E9EB7584452",
|
||||
"tokenIn":{"address":"0x0000000000000000000000000000000000000000","chainId":96369,"symbol":"LUX","decimals":"18"},
|
||||
"tokenOut":{"address":"`+lusd+`","chainId":96369,"symbol":"LUSD","decimals":"18"},
|
||||
"fee":"3000","amountIn":"1000000000000000000","amountOut":"611022579164403"}]]
|
||||
}}`)
|
||||
|
||||
assertSwap(t, got, map[string]any{
|
||||
"to": router,
|
||||
"from": trader,
|
||||
"value": "1000000000000000000", // native in rides as msg.value
|
||||
"chainId": float64(luxMain),
|
||||
"gasLimit": "79990",
|
||||
"data": "0x04e45aaf" +
|
||||
"0000000000000000000000004888e4a2ee0f03051c72d2bd3acf755ed3498b3e" + // tokenIn, sentinel wrapped
|
||||
"000000000000000000000000848cff46eb323f323b6bbe1df274e40793d7f2c2" + // tokenOut
|
||||
"0000000000000000000000000000000000000000000000000000000000000bb8" + // fee 3000
|
||||
"0000000000000000000000009011e888251ab053b7bd1cdb598db4f9ded94714" + // recipient
|
||||
"0000000000000000000000000000000000000000000000000de0b6b3a7640000" + // amountIn 1e18
|
||||
"000000000000000000000000000000000000000000000000000228f174dca7a4" + // amountOutMinimum
|
||||
"0000000000000000000000000000000000000000000000000000000000000000", // sqrtPriceLimitX96
|
||||
})
|
||||
}
|
||||
|
||||
// Two hops. The path is a variable-length argument, so the call is offsets then
|
||||
// values then the packed path — address, uint24 fee, address, uint24 fee,
|
||||
// address — padded out to a whole word.
|
||||
func TestSwapExactInputMultiHopMatchesRecording(t *testing.T) {
|
||||
got := swapCall(t, `{"quote":{
|
||||
"chainId":96369,
|
||||
"swapper":"`+trader+`",
|
||||
"input":{"token":"0x0000000000000000000000000000000000000000","amount":"1000000000000000000"},
|
||||
"output":{"token":"`+cyrus+`","amount":"10552437","recipient":"`+trader+`"},
|
||||
"tradeType":"EXACT_INPUT",
|
||||
"slippage":0.5,
|
||||
"gasUseEstimate":"159895",
|
||||
"route":[[
|
||||
{"type":"v3-pool","address":"0x37011bB281676f85962fb35C674f7E9EB7584452",
|
||||
"tokenIn":{"address":"0x0000000000000000000000000000000000000000","chainId":96369,"symbol":"LUX","decimals":"18"},
|
||||
"tokenOut":{"address":"`+lusd+`","chainId":96369,"symbol":"LUSD","decimals":"18"},
|
||||
"fee":"3000","amountIn":"1000000000000000000","amountOut":"611022579164403"},
|
||||
{"type":"v3-pool","address":"0x0000000000000000000000000000000000000001",
|
||||
"tokenIn":{"address":"`+lusd+`","chainId":96369,"symbol":"LUSD","decimals":"18"},
|
||||
"tokenOut":{"address":"`+cyrus+`","chainId":96369,"symbol":"CYRUS","decimals":"6"},
|
||||
"fee":"3000","amountIn":"611022579164403","amountOut":"10552437"}]]
|
||||
}}`)
|
||||
|
||||
assertSwap(t, got, map[string]any{
|
||||
"to": router,
|
||||
"from": trader,
|
||||
"value": "1000000000000000000",
|
||||
"chainId": float64(luxMain),
|
||||
"gasLimit": "159895",
|
||||
"data": "0xb858183f" +
|
||||
"0000000000000000000000000000000000000000000000000000000000000020" + // offset to the tuple
|
||||
"0000000000000000000000000000000000000000000000000000000000000080" + // offset to path within it
|
||||
"0000000000000000000000009011e888251ab053b7bd1cdb598db4f9ded94714" + // recipient
|
||||
"0000000000000000000000000000000000000000000000000de0b6b3a7640000" + // amountIn
|
||||
"0000000000000000000000000000000000000000000000000000000000a0365a" + // amountOutMinimum
|
||||
"0000000000000000000000000000000000000000000000000000000000000042" + // path length, 66 bytes
|
||||
"4888e4a2ee0f03051c72d2bd3acf755ed3498b3e" + "000bb8" +
|
||||
"848cff46eb323f323b6bbe1df274e40793d7f2c2" + "000bb8" +
|
||||
"0a78f7ce8d65e0fd4d6b78848483ba3c4fb895c5" +
|
||||
"000000000000000000000000000000000000000000000000000000000000", // pad to a word
|
||||
})
|
||||
}
|
||||
|
||||
// Exact output inverts the bound: the caller names what it wants out and the
|
||||
// call carries the most it will pay in.
|
||||
func TestSwapExactOutputSingleMatchesRecording(t *testing.T) {
|
||||
got := swapCall(t, `{"quote":{
|
||||
"chainId":96369,
|
||||
"swapper":"`+trader+`",
|
||||
"input":{"token":"0x0000000000000000000000000000000000000000","amount":"1636688789921865294"},
|
||||
"output":{"token":"`+lusd+`","amount":"1000000000000000","recipient":"`+trader+`"},
|
||||
"tradeType":"EXACT_OUTPUT",
|
||||
"slippage":0.5,
|
||||
"gasUseEstimate":"80746",
|
||||
"route":[[{"type":"v3-pool","address":"0x37011bB281676f85962fb35C674f7E9EB7584452",
|
||||
"tokenIn":{"address":"0x0000000000000000000000000000000000000000","chainId":96369,"symbol":"LUX","decimals":"18"},
|
||||
"tokenOut":{"address":"`+lusd+`","chainId":96369,"symbol":"LUSD","decimals":"18"},
|
||||
"fee":"3000","amountIn":"1636688789921865294","amountOut":"1000000000000000"}]]
|
||||
}}`)
|
||||
|
||||
assertSwap(t, got, map[string]any{
|
||||
"to": router,
|
||||
"from": trader,
|
||||
"value": "1636688789921865294",
|
||||
"chainId": float64(luxMain),
|
||||
"gasLimit": "80746",
|
||||
"data": "0x5023b4df" +
|
||||
"0000000000000000000000004888e4a2ee0f03051c72d2bd3acf755ed3498b3e" +
|
||||
"000000000000000000000000848cff46eb323f323b6bbe1df274e40793d7f2c2" +
|
||||
"0000000000000000000000000000000000000000000000000000000000000bb8" +
|
||||
"0000000000000000000000009011e888251ab053b7bd1cdb598db4f9ded94714" +
|
||||
"00000000000000000000000000000000000000000000000000038d7ea4c68000" + // amountOut
|
||||
"00000000000000000000000000000000000000000000000016d3c294f0cfffbc" + // amountInMaximum
|
||||
"0000000000000000000000000000000000000000000000000000000000000000",
|
||||
})
|
||||
}
|
||||
|
||||
// An ERC-20 input is transferred, not sent, so nothing rides as value.
|
||||
func TestSwapErc20InputCarriesNoValue(t *testing.T) {
|
||||
got := swapCall(t, `{"quote":{
|
||||
"chainId":96369,
|
||||
"swapper":"`+trader+`",
|
||||
"input":{"token":"`+lusd+`","amount":"1000000000000000"},
|
||||
"output":{"token":"`+cyrus+`","amount":"10552437","recipient":"`+trader+`"},
|
||||
"tradeType":"EXACT_INPUT",
|
||||
"slippage":0.5,
|
||||
"route":[[{"type":"v3-pool","address":"0x0000000000000000000000000000000000000001",
|
||||
"tokenIn":{"address":"`+lusd+`","chainId":96369,"symbol":"LUSD","decimals":"18"},
|
||||
"tokenOut":{"address":"`+cyrus+`","chainId":96369,"symbol":"CYRUS","decimals":"6"},
|
||||
"fee":"3000","amountIn":"1000000000000000","amountOut":"10552437"}]]
|
||||
}}`)
|
||||
|
||||
if got["value"] != "0" {
|
||||
t.Errorf("value = %v, want 0 for an ERC-20 input", got["value"])
|
||||
}
|
||||
// A quote with no gas estimate leaves the field off rather than sending 0.
|
||||
if _, present := got["gasLimit"]; present {
|
||||
t.Errorf("gasLimit present with no estimate on the quote: %v", got["gasLimit"])
|
||||
}
|
||||
}
|
||||
|
||||
// Slippage is what makes the bound a bound. A wider tolerance must accept less
|
||||
// out, and it must move in integer basis points rather than through a float.
|
||||
func TestSwapSlippageWidensTheBound(t *testing.T) {
|
||||
body := func(slip string) string {
|
||||
return `{"quote":{"chainId":96369,"swapper":"` + trader + `",
|
||||
"input":{"token":"` + lusd + `","amount":"1000000000000000"},
|
||||
"output":{"token":"` + cyrus + `","amount":"10000000","recipient":"` + trader + `"},
|
||||
"tradeType":"EXACT_INPUT","slippage":` + slip + `,
|
||||
"route":[[{"type":"v3-pool","address":"0x0000000000000000000000000000000000000001",
|
||||
"tokenIn":{"address":"` + lusd + `","chainId":96369,"symbol":"LUSD","decimals":"18"},
|
||||
"tokenOut":{"address":"` + cyrus + `","chainId":96369,"symbol":"CYRUS","decimals":"6"},
|
||||
"fee":"3000","amountIn":"1000000000000000","amountOut":"10000000"}]]}}`
|
||||
}
|
||||
// exactInputSingle is seven inline words after the selector; amountOutMinimum
|
||||
// is the sixth. Reading it back as a number keeps the assertion about the
|
||||
// bound rather than about hex.
|
||||
for _, c := range []struct {
|
||||
slip string
|
||||
want int64
|
||||
}{
|
||||
{"0", 10_000_000}, // no tolerance: the bound is the quote itself
|
||||
{"0.5", 9_950_000}, // 0.5% of 10_000_000 is 50_000
|
||||
{"5", 9_500_000}, // 5% is 500_000
|
||||
{"100", 0}, // a tolerance of everything bounds nothing
|
||||
} {
|
||||
got := swapCall(t, body(c.slip))
|
||||
data, _ := got["data"].(string)
|
||||
w := words(strings.TrimPrefix(data, "0x")[8:])
|
||||
if len(w) != 7 {
|
||||
t.Fatalf("slippage %s: got %d words, want 7", c.slip, len(w))
|
||||
}
|
||||
if w[5].Int64() != c.want {
|
||||
t.Errorf("slippage %s: amountOutMinimum = %d, want %d", c.slip, w[5].Int64(), c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The failures a caller can cause must be told apart from a server fault.
|
||||
func TestSwapRejectsBadRequests(t *testing.T) {
|
||||
for _, c := range []struct{ name, body, detail string }{
|
||||
{"no route", `{"quote":{"swapper":"` + trader + `","route":[]}}`,
|
||||
"quote.route must contain at least one hop"},
|
||||
{"no swapper", `{"quote":{"swapper":"nope","route":[[{"fee":"3000",
|
||||
"tokenIn":{"address":"` + lusd + `"},"tokenOut":{"address":"` + cyrus + `"}}]]}}`,
|
||||
"quote.swapper must be an address"},
|
||||
{"zero amounts", `{"quote":{"swapper":"` + trader + `",
|
||||
"input":{"token":"` + lusd + `","amount":"0"},"output":{"token":"` + cyrus + `","amount":"0"},
|
||||
"route":[[{"fee":"3000","tokenIn":{"address":"` + lusd + `"},"tokenOut":{"address":"` + cyrus + `"}}]]}}`,
|
||||
"quote input/output amounts must be > 0"},
|
||||
{"hop without fee", `{"quote":{"swapper":"` + trader + `",
|
||||
"input":{"token":"` + lusd + `","amount":"1"},"output":{"token":"` + cyrus + `","amount":"1"},
|
||||
"route":[[{"tokenIn":{"address":"` + lusd + `"},"tokenOut":{"address":"` + cyrus + `"}}]]}}`,
|
||||
"quote.route hop 0 is missing tokenIn/tokenOut/fee"},
|
||||
{"not JSON", `{`, "body must be JSON"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
code, out := serveSwap(t, c.body)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", code)
|
||||
}
|
||||
if out["errorCode"] != "VALIDATION_ERROR" || out["detail"] != c.detail {
|
||||
t.Errorf("got %v, want detail %q", out, c.detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user