An amount too wide for a word was encoded as its high bytes

lxrPadUint256 wrote a number wider than 32 bytes as b[:32] — its LEADING bytes,
not its low ones — so a value one bit over came out as a completely unrelated
quantity in the field that says how much to spend. The quote accepted any
string and the swap checked only the sign, so nothing stopped one arriving.

Two changes, and the boundary one is the real fix. wholeUnits is one parser for
every path that takes an amount: a whole number of a token's smallest unit, in
decimal, that fits in a uint256 — refused at the edge, where the sentence can
say what is wrong with it. And the encoder now keeps the LOW bytes, which is
what the EVM does to a uint256, so the failure mode of anything that slips past
is truncation rather than a different number entirely.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-09 01:32:19 -07:00
co-authored by Hanzo Dev
parent a0574347c9
commit d8ea54427c
3 changed files with 74 additions and 5 deletions
+8 -1
View File
@@ -49,12 +49,19 @@ func lxrPadAddress(addr [20]byte) []byte {
// lxrPadUint256 encodes a big.Int as a 32-byte big-endian word.
// Nil is treated as zero.
// lxrPadUint256 right-aligns a number in a 32-byte word.
//
// A value too large to fit is written as its LOW 32 bytes, which is what the
// EVM itself does to a uint256, rather than its high ones — the previous
// `b[:32]` kept the leading bytes, so a number one bit too wide came out as a
// completely unrelated quantity in a field that spends money. Nothing should
// reach here that wide: `wholeUnits` refuses it where an amount enters.
func lxrPadUint256(n *big.Int) []byte {
var word [32]byte
if n != nil && n.Sign() > 0 {
b := n.Bytes()
if len(b) > 32 {
b = b[:32] // truncate (should not happen for valid uint256)
b = b[len(b)-32:]
}
copy(word[32-len(b):], b)
}
+34 -4
View File
@@ -287,6 +287,10 @@ func (s *Server) handleQuote(w http.ResponseWriter, r *http.Request) {
s.writeError(w, http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err))
return
}
if _, err := wholeUnits(req.Amount); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
ctx := s.requestContext(r)
asked := s.convertQuoteRequest(req)
@@ -445,6 +449,10 @@ func (s *Server) handleQuotes(w http.ResponseWriter, r *http.Request) {
s.writeError(w, http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err))
return
}
if _, err := wholeUnits(req.Amount); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
ctx := s.requestContext(r)
asked := s.convertQuoteRequest(req)
@@ -490,9 +498,8 @@ func (s *Server) handleSwap(w http.ResponseWriter, r *http.Request) {
s.writeError(w, http.StatusBadRequest, fmt.Errorf("recipient is required"))
return
}
amount, ok := new(big.Int).SetString(req.Amount, 10)
if !ok || amount.Sign() <= 0 {
s.writeError(w, http.StatusBadRequest, fmt.Errorf("amount %q is not a whole number of the token's smallest unit", req.Amount))
if _, err := wholeUnits(req.Amount); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
@@ -526,7 +533,7 @@ func (s *Server) handleSwap(w http.ResponseWriter, r *http.Request) {
tx, err := venue.Swap(SwapOrder{
TokenIn: req.TokenIn,
TokenOut: req.TokenOut,
AmountIn: amount,
AmountIn: asked.Amount,
MinOut: leastAccepted(best.TokenOut.Amount, req.Slippage),
Recipient: req.Recipient,
Deadline: req.Deadline,
@@ -547,6 +554,29 @@ func (s *Server) handleSwap(w http.ResponseWriter, r *http.Request) {
s.writeJSON(w, http.StatusOK, swapResponse{Swap: *tx, Quote: best})
}
// maxUint256 is the widest number an EVM word holds.
var maxUint256 = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
// wholeUnits reads an amount: a whole number of a token's smallest unit, as a
// decimal string, that fits in one EVM word.
//
// One parser for every path that takes an amount, because the alternative is
// what was here — the quote accepting anything and the swap checking only the
// sign, so a number wider than a word reached the encoder and was written as
// its HIGH 32 bytes. The caller asked to swap one number and got calldata for
// another. Refused at the edge instead, where the sentence can say what is
// wrong with it.
func wholeUnits(amount string) (*big.Int, error) {
n, ok := new(big.Int).SetString(amount, 10)
if !ok || n.Sign() <= 0 {
return nil, fmt.Errorf("amount %q is not a whole number of the token's smallest unit", amount)
}
if n.Cmp(maxUint256) > 0 {
return nil, fmt.Errorf("amount %q does not fit in a uint256", amount)
}
return n, nil
}
// leastAccepted is the amountOutMinimum for a quote taken at a tolerance.
//
// A router given the quoted amount as its floor reverts on any movement at
+32
View File
@@ -248,3 +248,35 @@ func unwrap(t *testing.T, data string) []byte {
n := new(big.Int).SetBytes(raw[4+32*4 : 4+32*5]).Int64()
return raw[4+32*5 : 4+32*5+int(n)]
}
// An amount is a whole number of a token's smallest unit that fits in one EVM
// word. Anything else is refused where it enters, because the encoder cannot
// refuse: it wrote a too-wide number as its HIGH 32 bytes, so a caller asking
// to swap one number got calldata for a completely unrelated one.
func TestAnAmountIsAWholeNumberThatFitsInAWord(t *testing.T) {
tooWide := new(big.Int).Lsh(big.NewInt(1), 256).String()
for _, c := range []struct{ amount, why string }{
{"", "empty"},
{"0", "zero"},
{"-1", "negative"},
{"1.5", "not whole"},
{"1e18", "not decimal digits"},
{"twelve", "not a number"},
{tooWide, "one bit wider than a word"},
} {
if _, err := wholeUnits(c.amount); err == nil {
t.Errorf("%s (%q) was accepted", c.why, c.amount)
}
}
widest := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
if got, err := wholeUnits(widest.String()); err != nil || got.Cmp(widest) != 0 {
t.Errorf("the widest word was refused: %v %v", got, err)
}
// And the encoder keeps the LOW bytes, which is what the EVM does, rather
// than the leading ones.
over := new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(7))
if got := new(big.Int).SetBytes(lxrPadUint256(over)); got.Int64() != 7 {
t.Errorf("a value one word over encoded as %s, want its low bytes (7)", got)
}
}