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:
2026-08-13 16:58:58 -07:00
co-authored by Hanzo Dev
parent ccc70cd13e
commit a9310b0af3
6 changed files with 968 additions and 80 deletions
+132
View File
@@ -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,
},
})
}
}
+197
View File
@@ -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)
}
})
}
}
+98
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+281
View File
@@ -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)
}
})
}
}