Files
zeekay 5ec478c3ed stableswap, math: two halts from calldata, and a pool that leaked a wei per swap
A precompile panic aborts the block rather than the call, so both of these
stopped every validator on any chain with stableswap enabled.

An amplification coefficient of zero divides by zero in all three Newton ops --
computeY's c/(A*n*n) and D/(A*n), and computeD's denominator (A*n-1)*D +
(n+1)*dP, which crosses exactly zero. A 40,000-input sweep hit it 665 times. A
is now read and refused at the boundary, which is also the honest answer: the
invariant is undefined at A=0.

addLiquidity divides by D0, and computeD returns zero for an empty pool.
Balances and totalSupply arrive as independent calldata, so "no assets, LP
tokens outstanding" is reachable. Post-fix sweep: zero panics in 40,000 inputs.

computeY used floor division, so its iterates drifted BELOW the true root in
55% of solves, by up to 2 wei, which getDy's single -1 margin did not cover.
The invariant therefore DECREASED across a swap -- 27 losses in 19,996 random
swaps, a wei at a time out of the pool. Newton converges from above on this
convex function, so ceiling division restores the one-sidedness the proof
depends on. After: zero losses, 19,847 gains.

math returned every result through padTo32, which keeps only the low 32 bytes.
MulDiv(2^255, 4, 1) returned 0. MulDiv(max, max, 1) returned 1. And dex.MulDiv
PANICS on the same input -- two behaviours for one operation, one of them a
plausible-looking wrong number in the primitive that computes fees and share
ratios. Now refuses with ErrOverflow, matching FullMath.sol. Consensus-visible;
it needs a coordinated upgrade.

Coverage: dex 57.1 -> 99.1, stableswap 80.6 -> 98.6, swap 87.6 -> 99.6,
v3 78.1 -> 98.7, math 78.5 -> 97.6. 50 of 52 mutations caught, both survivors
proven unreachable rather than assumed. dex is now order-independent: -count=2
and -shuffle=on both pass, 843 tests run individually and 260 shuffle seeds
clean. The leak was a package-level resolver latch that installed on top of
whatever was live; t.Cleanup had fixed the exit and not the entry.

Also corrected five comments describing the CLOB price limit as IEEE-754
float64 bits. It is exact big.Int on an integer grid, and a reviewer would have
believed consensus compared float bit patterns.
2026-08-28 13:46:40 -07:00

214 lines
6.2 KiB
Go

// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package math implements fixed-point arithmetic precompiles.
// Address: 0x0400000000000000000000000000000000000050 (DEX math range)
//
// Operations:
// - 0x01: MulDiv(a, b, denominator) -> (a * b) / denominator without overflow
// - 0x02: MulDivRoundUp(a, b, denominator) -> ceiling division
// - 0x03: Sqrt(x) -> floor(sqrt(x)) using Newton's method
// - 0x04: Log2(x) -> floor(log2(x)) * 2^128 (Q128.128)
// - 0x05: Exp(x) -> e^x in Q128.128 format
// - 0x06: Pow(base, exp) -> base^exp via repeated squaring
//
// Gas: 50 base + 5 per 32-byte word
// Saves ~2000 gas per MulDiv vs Solidity (no overflow checks in Go native)
package math
import (
"errors"
"math/big"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/contract"
)
var (
ContractAddress = common.HexToAddress("0x0400000000000000000000000000000000000050")
Precompile = &fixedPointMathPrecompile{}
_ contract.StatefulPrecompiledContract = Precompile
ErrInvalidInput = contract.ErrInvalidInput
// ErrOutOfGas aliases the canonical sentinel so callers can
// errors.Is(err, contract.ErrOutOfGas) across every precompile.
ErrOutOfGas = contract.ErrOutOfGas
ErrDivByZero = errors.New("division by zero")
ErrUnknownOp = errors.New("unknown math operation")
ErrOverflow = errors.New("result overflows uint256")
)
// maxU256 bounds every result this precompile returns as a number. padTo32 keeps
// only the LOW 32 bytes, so without this bound an out-of-range result is not an
// error — it is a different, wrong, entirely plausible number.
var maxU256 = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
const (
OpMulDiv = 0x01
OpMulDivRoundUp = 0x02
OpSqrt = 0x03
OpLog2 = 0x04
OpExp = 0x05
OpPow = 0x06
GasBase = 50
GasWord = 5
)
// q128 = 2^128 for Q128.128 fixed-point
var q128 = new(big.Int).Lsh(big.NewInt(1), 128)
type fixedPointMathPrecompile struct{}
func (p *fixedPointMathPrecompile) Run(
accessibleState contract.AccessibleState,
caller common.Address,
addr common.Address,
input []byte,
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
if len(input) < 1 {
return nil, suppliedGas, ErrInvalidInput
}
op := input[0]
data := input[1:]
words := uint64(len(data)+31) / 32
gasCost := GasBase + words*GasWord
gas, err := contract.DeductGas(suppliedGas, gasCost)
if err != nil {
return nil, 0, err
}
switch op {
case OpMulDiv:
return mulDiv(data, gas, false)
case OpMulDivRoundUp:
return mulDiv(data, gas, true)
case OpSqrt:
return sqrt(data, gas)
case OpLog2:
return log2(data, gas)
case OpExp:
return exp(data, gas)
case OpPow:
return pow(data, gas)
default:
return nil, gas, ErrUnknownOp
}
}
// mulDiv computes (a * b) / d, optionally rounding up.
func mulDiv(data []byte, gas uint64, roundUp bool) ([]byte, uint64, error) {
if len(data) < 96 {
return nil, gas, ErrInvalidInput
}
a := new(big.Int).SetBytes(data[:32])
b := new(big.Int).SetBytes(data[32:64])
d := new(big.Int).SetBytes(data[64:96])
if d.Sign() == 0 {
return nil, gas, ErrDivByZero
}
result := new(big.Int).Mul(a, b)
if roundUp {
// ceil(a*b/d) = (a*b + d - 1) / d
result.Add(result, new(big.Int).Sub(d, big.NewInt(1)))
}
result.Div(result, d)
// REFUSE an out-of-range result instead of returning its low 256 bits. The
// intermediate a*b is a full 512-bit product, so a quotient past uint256 is
// ordinary calldata, not an exotic case: MulDiv(2^255, 4, 1) is 2^257, whose low
// 256 bits are ZERO, and MulDiv(max, max, 1) truncates to ONE. Returning those is
// worse than failing — the caller cannot tell a wrapped answer from a real one,
// and this is the primitive fees and share ratios are computed with. Solidity's
// FullMath.sol requires the same bound; erroring here is what makes the EVM revert.
if result.Cmp(maxU256) > 0 {
return nil, gas, ErrOverflow
}
return padTo32(result.Bytes()), gas, nil
}
// sqrt computes floor(sqrt(x)) using big.Int.Sqrt.
func sqrt(data []byte, gas uint64) ([]byte, uint64, error) {
if len(data) < 32 {
return nil, gas, ErrInvalidInput
}
x := new(big.Int).SetBytes(data[:32])
if x.Sign() < 0 {
return nil, gas, ErrInvalidInput
}
result := new(big.Int).Sqrt(x)
return padTo32(result.Bytes()), gas, nil
}
// log2 computes floor(log2(x)) as a uint256. Returns 0 for x=0.
func log2(data []byte, gas uint64) ([]byte, uint64, error) {
if len(data) < 32 {
return nil, gas, ErrInvalidInput
}
x := new(big.Int).SetBytes(data[:32])
if x.Sign() <= 0 {
return padTo32(nil), gas, nil
}
result := big.NewInt(int64(x.BitLen() - 1))
return padTo32(result.Bytes()), gas, nil
}
// exp computes e^x where x is a Q128.128 fixed-point number.
// Uses Taylor series: e^x = sum(x^n / n!) for n=0..30
// Result is Q128.128.
func exp(data []byte, gas uint64) ([]byte, uint64, error) {
if len(data) < 32 {
return nil, gas, ErrInvalidInput
}
x := new(big.Int).SetBytes(data[:32])
// Start with 1.0 in Q128.128
result := new(big.Int).Set(q128)
term := new(big.Int).Set(q128)
for n := int64(1); n <= 30; n++ {
// term = term * x / (n * 2^128)
term.Mul(term, x)
term.Div(term, new(big.Int).Mul(big.NewInt(n), q128))
if term.Sign() == 0 {
break
}
result.Add(result, term)
}
// Same rule as mulDiv: e^x leaves uint256 for any x above ~88 in Q128.128, and the
// truncated remainder is a plausible-looking number with no relation to e^x.
if result.Cmp(maxU256) > 0 {
return nil, gas, ErrOverflow
}
return padTo32(result.Bytes()), gas, nil
}
// pow computes base^exponent via binary exponentiation.
// Both base and exponent are uint256.
func pow(data []byte, gas uint64) ([]byte, uint64, error) {
if len(data) < 64 {
return nil, gas, ErrInvalidInput
}
base := new(big.Int).SetBytes(data[:32])
exponent := new(big.Int).SetBytes(data[32:64])
// Use modular exponentiation with 2^256 as the modulus to stay in uint256 range.
mod := new(big.Int).Lsh(big.NewInt(1), 256)
result := new(big.Int).Exp(base, exponent, mod)
return padTo32(result.Bytes()), gas, nil
}
// padTo32 left-pads a byte slice to 32 bytes.
func padTo32(b []byte) []byte {
if len(b) >= 32 {
return b[len(b)-32:]
}
padded := make([]byte, 32)
copy(padded[32-len(b):], b)
return padded
}