Files
zeekayandHanzo Dev 4e05024a06 Refuse a hop address, a foreign quote and a slippage outside a percentage
An adversarial pass over the calldata found three more ways a crafted quote
became a signable transaction.

A hop address was taken as given. An address is twenty bytes and the path packs
it against a uint24 with nothing marking the boundary, so a short one shifted
every byte after it — and the path still parsed, as a route through pools
nobody named. Width is the only thing separating the two, so both hop addresses
are now checked the way the swapper already was.

A quote says which chain priced it, and that was ignored. Building this chain's
router call from another chain's quote addresses pools by the same numbers on a
book that never quoted them, and these chains share deployment addresses, so it
is a real trade at a price nobody was shown.

Slippage is a percentage of an amount and lives between none of it and all of
it. Below zero the factor inverted and the bound grew a minus sign; above a
hundred it ran past a word. Clamping the percentage bounds both directions at
one place, where the alternative was clamping every product it reaches.

IsAddress is exported: whether a chain has an address to address a transaction
to is one question, and the process that mounts these handlers has to ask it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 17:30:19 -07:00

134 lines
3.8 KiB
Go

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: every value a word holds. 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).Set(maxWord)
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 !IsAddress(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 !IsAddress(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,
},
})
}
}