mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
big.Int.Uint64 and .Int64 are documented "if x cannot be represented, the result is undefined". On calldata that is not a lossy conversion to reason about, it is a value substitution, and the substitution happens twice in series: Uint64 drops everything above bit 63, and the Go conversion that follows it -- uint24(x), which is uint32(x) because uint24 is an alias -- drops everything above bit 31. A reader who checks only the first concludes the site is safe. contract.Unsigned and contract.Signed refuse a word that does not fit its declared width, once, where the bytes become a value. Six production sites use them; nothing downstream has to remember which of its arguments were bounded. DecodePoolKey read fee and tickSpacing as 24-bit fields and enforced neither. Above 2^32 the narrowing substituted: a tickSpacing word of 2^32+6 arrived as 6, and 2^32+60 produced the same pool id as 60. Between 2^24 and 2^32 the field held the caller's value faithfully while EncodePoolKeyABI wrote only its low three bytes, so Fee 3000 and Fee 2^24+3000 hash to the SAME id -- PoolKey.ID is not injective over its own Go type, and the decoder handed back a key whose fields are not the ones its id was derived from. The encoder is unchanged; changing it renames every pool that exists. What changed is that no such key can be built from calldata, which empties the collision class at the only boundary that produces PoolKeys from untrusted bytes. Same bound on the two tick fields in DecodeModifyLiquidityInput and decodePMLifecycle. Three engines narrowed INSIDE the comparison meant to bound the value, so the check did not merely miss, it inverted. A margin removal to 2^64x leverage read as leverage zero and passed; a perp open at 2^64 in hundredths did the same; increasePosition wrapped the quotient into a uint32 field that then set the liquidation price. Leverage is now compared at full width. CalculateFee clamped AFTER narrowing, so a violent enough move came out cheaper than a calm one; it clamps in 256 bits and narrows once the clamp proves it fits. GetTimeToRepayment saturates rather than wrapping a horizon into a small number of blocks. fhe declares euint128/160/256 and loads them through EncryptUint64, whose bit loop reads value>>i off a uint64 -- zero for every i above 63. The container is genuinely wider (tfheRandom fills all 256 bits), so the missing bits were not rounded away, they were encrypted AS ZERO: measured, asEuint256(2^200) decrypted to 0, and asEaddress on two addresses sharing their last eight bytes produced the same plaintext, so a homomorphic equality over encrypted addresses answered true for two different accounts. Casts to a narrower type still truncate, matching Solidity and the existing asEuint4/8/16/32 masks; what is refused is a plaintext wider than the loader, which is not a cast but a value the encryptor cannot carry. Widening it needs an EncryptBig upstream -- BitCiphertext's fields are unexported and it has no constructor from bits. Same bound on the scalar ops, once ahead of the switch rather than five times inside it. Consensus-visible: words that decoded now refuse. Fee or tickSpacing wider than 24 bits, a tick wider than int24, an FHE plaintext or scalar wider than 64 bits. Every one of them previously resolved to a different value than the caller sent. Four prior lanes had pinned three of these as characterisation tests naming themselves DEFECT, each instructing that the test be inverted when the bound arrived; they are inverted here rather than deleted, so the history of why stays legible. 24 of 24 mutations caught, no survivors. contract 100.0%, dex 99.1 -> 99.2. Both CGO settings, -count=1, -count=2 and -race.
190 lines
7.6 KiB
Go
190 lines
7.6 KiB
Go
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package contract
|
|
|
|
import (
|
|
"errors"
|
|
"math/big"
|
|
"testing"
|
|
)
|
|
|
|
// pow2 is 2^n.
|
|
func pow2(n uint) *big.Int { return new(big.Int).Lsh(big.NewInt(1), n) }
|
|
|
|
// TestUnsignedAdmitsExactlyItsWidth walks every width and asserts the boundary
|
|
// from both sides. The largest representable value must be admitted and the
|
|
// smallest unrepresentable one refused; a check that is off by one in either
|
|
// direction fails here rather than in whatever consensus rule depends on it.
|
|
func TestUnsignedAdmitsExactlyItsWidth(t *testing.T) {
|
|
for bits := uint(0); bits <= 64; bits++ {
|
|
max := new(big.Int).Sub(pow2(bits), big.NewInt(1)) // 2^bits - 1
|
|
got, err := Unsigned(max, bits)
|
|
if err != nil {
|
|
t.Fatalf("Unsigned(2^%d-1, %d) refused the largest representable value: %v", bits, bits, err)
|
|
}
|
|
if got != max.Uint64() {
|
|
t.Fatalf("Unsigned(2^%d-1, %d) = %d, want %d", bits, bits, got, max.Uint64())
|
|
}
|
|
if _, err := Unsigned(pow2(bits), bits); !errors.Is(err, ErrRange) {
|
|
t.Fatalf("Unsigned(2^%d, %d) admitted a value one past the width: %v", bits, bits, err)
|
|
}
|
|
if got, err := Unsigned(big.NewInt(0), bits); err != nil || got != 0 {
|
|
t.Fatalf("Unsigned(0, %d) = (%d, %v), want (0, nil)", bits, got, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUnsignedRefusesWhatTheBareConversionSubstitutes names the values the
|
|
// conversion this replaces turns into something else, and where each
|
|
// substitution happens. There are two distinct steps and they are worth keeping
|
|
// apart:
|
|
//
|
|
// - Uint64 itself substitutes only past 2^64. Below that it is faithful.
|
|
// - The Go conversion that FOLLOWS it — uint24(x), which is uint32(x) because
|
|
// uint24 is an alias — substitutes past 2^32, on a value Uint64 handed over
|
|
// intact.
|
|
//
|
|
// So `uint24(w.Uint64())` on a 256-bit word has two independent truncations in
|
|
// series, and a reader checking only the first concludes the site is safe.
|
|
// Refusing at the declared width covers both, because it happens before either.
|
|
func TestUnsignedRefusesWhatTheBareConversionSubstitutes(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
x *big.Int
|
|
bits uint
|
|
// what `uint32(x.Uint64())` yields — the full narrowing as written at
|
|
// the call sites this replaces.
|
|
narrowed uint32
|
|
}{
|
|
{"2^32+6 as a 24-bit tick", new(big.Int).Add(pow2(32), big.NewInt(6)), 24, 6},
|
|
{"2^24+3000 as a 24-bit fee", new(big.Int).Add(pow2(24), big.NewInt(3000)), 24, 1<<24 + 3000},
|
|
{"2^64+6 as a 64-bit amount", new(big.Int).Add(pow2(64), big.NewInt(6)), 64, 6},
|
|
{"2^255 as a 64-bit amount", pow2(255), 64, 0},
|
|
{"2^256-1 as a 24-bit fee", new(big.Int).Sub(pow2(256), big.NewInt(1)), 24, 1<<32 - 1},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := uint32(tc.x.Uint64()); got != tc.narrowed {
|
|
t.Fatalf("fixture: uint32(%s.Uint64()) = %d, want %d — the substitution this "+
|
|
"refusal exists to prevent has changed", tc.x, got, tc.narrowed)
|
|
}
|
|
if _, err := Unsigned(tc.x, tc.bits); !errors.Is(err, ErrRange) {
|
|
t.Fatalf("Unsigned(%s, %d) admitted it: %v", tc.x, tc.bits, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The signed half of the same point, at the exact word the dex decoder used to
|
|
// turn into an ordinary in-range tick.
|
|
func TestSignedRefusesWhatTheBareConversionSubstitutes(t *testing.T) {
|
|
x := new(big.Int).Add(pow2(32), big.NewInt(6)) // 2^32 + 6
|
|
if got := x.Int64(); got != 1<<32+6 {
|
|
t.Fatalf("fixture: Int64 is not the truncating step here, it returned %d", got)
|
|
}
|
|
if got := int32(x.Int64()); got != 6 {
|
|
t.Fatalf("fixture: int32(2^32+6) = %d, want 6", got)
|
|
}
|
|
if _, err := Signed(x, 24); !errors.Is(err, ErrRange) {
|
|
t.Fatalf("Signed admitted 2^32+6 as an int24: %v", err)
|
|
}
|
|
// And the value that is in int32 but not in int24 — the range where the
|
|
// conversion is faithful and the field still cannot hold it.
|
|
if _, err := Signed(new(big.Int).Add(pow2(24), big.NewInt(60)), 24); !errors.Is(err, ErrRange) {
|
|
t.Fatalf("Signed admitted 2^24+60 as an int24: %v", err)
|
|
}
|
|
}
|
|
|
|
// A negative value has no unsigned reading, and Uint64 answers with its
|
|
// magnitude's low word — so -(2^64+6) arrives as 6, a positive number.
|
|
func TestUnsignedRefusesNegative(t *testing.T) {
|
|
neg := new(big.Int).Neg(new(big.Int).Add(pow2(64), big.NewInt(6)))
|
|
if got := neg.Uint64(); got != 6 {
|
|
t.Fatalf("fixture: (-(2^64+6)).Uint64() = %d, want 6", got)
|
|
}
|
|
if _, err := Unsigned(neg, 64); !errors.Is(err, ErrRange) {
|
|
t.Fatalf("Unsigned admitted a negative value: %v", err)
|
|
}
|
|
if _, err := Unsigned(big.NewInt(-1), 64); !errors.Is(err, ErrRange) {
|
|
t.Fatalf("Unsigned(-1, 64) admitted it: %v", err)
|
|
}
|
|
}
|
|
|
|
// A width the return type cannot hold refuses rather than returning a value that
|
|
// does not mean what the caller asked for.
|
|
func TestUnsignedRefusesWidthPastUint64(t *testing.T) {
|
|
for _, bits := range []uint{65, 128, 160, 256} {
|
|
if _, err := Unsigned(big.NewInt(1), bits); !errors.Is(err, ErrRange) {
|
|
t.Fatalf("Unsigned(1, %d) did not fail closed: %v", bits, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSignedAdmitsExactlyItsWidth walks both ends of the two's-complement span.
|
|
// The asymmetry is the point: -2^(bits-1) is representable and +2^(bits-1) is
|
|
// not, and the two share a bit length, so a check written on BitLen alone gets
|
|
// exactly one of these wrong.
|
|
func TestSignedAdmitsExactlyItsWidth(t *testing.T) {
|
|
for bits := uint(1); bits <= 64; bits++ {
|
|
hi := pow2(bits - 1)
|
|
max := new(big.Int).Sub(hi, big.NewInt(1)) // 2^(bits-1) - 1
|
|
lo := new(big.Int).Neg(hi) // -2^(bits-1)
|
|
|
|
if got, err := Signed(max, bits); err != nil || got != max.Int64() {
|
|
t.Fatalf("Signed(2^%d-1, %d) = (%d, %v), want the largest representable value",
|
|
bits-1, bits, got, err)
|
|
}
|
|
if got, err := Signed(lo, bits); err != nil || got != lo.Int64() {
|
|
t.Fatalf("Signed(-2^%d, %d) = (%d, %v), want the smallest representable value",
|
|
bits-1, bits, got, err)
|
|
}
|
|
if _, err := Signed(hi, bits); !errors.Is(err, ErrRange) {
|
|
t.Fatalf("Signed(2^%d, %d) admitted a value one past the top: %v", bits-1, bits, err)
|
|
}
|
|
if _, err := Signed(new(big.Int).Sub(lo, big.NewInt(1)), bits); !errors.Is(err, ErrRange) {
|
|
t.Fatalf("Signed(-2^%d-1, %d) admitted a value one past the bottom: %v", bits-1, bits, err)
|
|
}
|
|
if got, err := Signed(big.NewInt(0), bits); err != nil || got != 0 {
|
|
t.Fatalf("Signed(0, %d) = (%d, %v), want (0, nil)", bits, got, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSignedRefusesWidthItCannotHold(t *testing.T) {
|
|
for _, bits := range []uint{0, 65, 128, 256} {
|
|
if _, err := Signed(big.NewInt(0), bits); !errors.Is(err, ErrRange) {
|
|
t.Fatalf("Signed(0, %d) did not fail closed: %v", bits, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Signed does not mutate its argument. The bound is built by shifting a package
|
|
// level `one`, and reusing that allocation for the negation is the kind of thing
|
|
// that silently corrupts a shared constant.
|
|
func TestSignedLeavesItsInputAndTheShiftBaseAlone(t *testing.T) {
|
|
x := new(big.Int).Lsh(big.NewInt(1), 200)
|
|
want := new(big.Int).Set(x)
|
|
for range 4 {
|
|
if _, err := Signed(x, 24); !errors.Is(err, ErrRange) {
|
|
t.Fatalf("Signed refused for the wrong reason: %v", err)
|
|
}
|
|
if _, err := Unsigned(x, 24); !errors.Is(err, ErrRange) {
|
|
t.Fatalf("Unsigned refused for the wrong reason: %v", err)
|
|
}
|
|
}
|
|
if x.Cmp(want) != 0 {
|
|
t.Fatalf("the argument was mutated: %s, want %s", x, want)
|
|
}
|
|
if one.Cmp(big.NewInt(1)) != 0 {
|
|
t.Fatalf("the shift base was mutated to %s", one)
|
|
}
|
|
}
|
|
|
|
// Both errors are ErrInvalidInput, so a precompile that already refuses on that
|
|
// sentinel keeps refusing without knowing this type exists.
|
|
func TestRangeIsAnInvalidInput(t *testing.T) {
|
|
if !errors.Is(ErrRange, ErrInvalidInput) {
|
|
t.Fatal("ErrRange does not wrap ErrInvalidInput")
|
|
}
|
|
}
|