mirror of
https://github.com/luxfi/graph.git
synced 2026-09-24 00:26:07 +00:00
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]>
198 lines
7.3 KiB
Go
198 lines
7.3 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|