mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
SettleSwap took the caller's PoolKey and never asked whether that pool existed, so key.ID() fed the halt slot, the volume shard and the fill event alike. A fabricated key names a slot nobody set, and the swap settles while the real market is halted. Resolving once above the phase switch and passing the resolved poolID down means no phase -- including one added later -- can key on anything the registry has not admitted. checkHalt no longer accepts a key, so it cannot derive one. Co-authored-by: Hanzo Dev <[email protected]>
934 lines
33 KiB
Go
934 lines
33 KiB
Go
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// Red Team Round 2 — Regression tests for security findings.
|
|
// Each test documents the attack vector and MUST fail on vulnerable code, pass on fixed code.
|
|
// Run: go test -run TestRedTeam -v -count=1
|
|
|
|
package dex
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/luxfi/crypto"
|
|
"github.com/luxfi/geth/common"
|
|
"github.com/luxfi/precompile/contract"
|
|
"github.com/luxfi/precompile/precompileconfig"
|
|
)
|
|
|
|
// =========================================================================
|
|
// Finding 1: decodeABIBytes uint64 overflow (CRITICAL — chain halt)
|
|
//
|
|
// Attack: Craft offset word = 0xFFFFFFFFFFFFFFE0. absOffset+32 wraps to 0.
|
|
// OLD code: panic on slice bounds out of range.
|
|
// NEW code: returns nil (BitLen > 63 check rejects it).
|
|
// =========================================================================
|
|
|
|
func TestRedTeam_DecodeABIBytes_Uint64Overflow_MaxMinus31(t *testing.T) {
|
|
// offset = 0xFFFFFFFFFFFFFFE0 (uint64 max - 31).
|
|
// absOffset+32 wraps to 0x0000000000000000.
|
|
// Without BitLen guard this causes out-of-bounds slice panic.
|
|
args := make([]byte, 320)
|
|
|
|
// Place offset word at position 256 (swap hookData offset slot)
|
|
overflowVal := new(big.Int).SetUint64(0xFFFFFFFFFFFFFFE0)
|
|
copy(args[256:288], bigIntTo32Bytes(overflowVal))
|
|
|
|
// Must not panic. recover() catches if it does.
|
|
didPanic := false
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
didPanic = true
|
|
}
|
|
}()
|
|
result := decodeABIBytes(args, 256)
|
|
if result != nil {
|
|
t.Error("VULN: decodeABIBytes returned non-nil for overflow offset 0xFFFFFFFFFFFFFFE0")
|
|
}
|
|
}()
|
|
if didPanic {
|
|
t.Fatal("VULN: decodeABIBytes panicked on overflow offset 0xFFFFFFFFFFFFFFE0 — chain halt")
|
|
}
|
|
}
|
|
|
|
func TestRedTeam_DecodeABIBytes_Uint64Overflow_Max(t *testing.T) {
|
|
// offset = 0xFFFFFFFFFFFFFFFF (uint64 max).
|
|
args := make([]byte, 320)
|
|
maxU64 := new(big.Int).SetUint64(^uint64(0))
|
|
copy(args[256:288], bigIntTo32Bytes(maxU64))
|
|
|
|
didPanic := false
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
didPanic = true
|
|
}
|
|
}()
|
|
result := decodeABIBytes(args, 256)
|
|
if result != nil {
|
|
t.Error("VULN: decodeABIBytes returned non-nil for uint64 max offset")
|
|
}
|
|
}()
|
|
if didPanic {
|
|
t.Fatal("VULN: decodeABIBytes panicked on uint64 max offset — chain halt")
|
|
}
|
|
}
|
|
|
|
func TestRedTeam_DecodeABIBytes_Uint64Overflow_2Pow63(t *testing.T) {
|
|
// offset = 2^63. BitLen = 64, which exceeds the 63-bit guard.
|
|
args := make([]byte, 320)
|
|
pow63 := new(big.Int).Lsh(big.NewInt(1), 63)
|
|
copy(args[256:288], bigIntTo32Bytes(pow63))
|
|
|
|
didPanic := false
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
didPanic = true
|
|
}
|
|
}()
|
|
result := decodeABIBytes(args, 256)
|
|
if result != nil {
|
|
t.Error("VULN: decodeABIBytes returned non-nil for 2^63 offset")
|
|
}
|
|
}()
|
|
if didPanic {
|
|
t.Fatal("VULN: decodeABIBytes panicked on 2^63 offset — chain halt")
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Finding 2: decodeABIBytes backward offset (data confusion)
|
|
//
|
|
// Attack: Set offset word to point BEFORE the offset position.
|
|
// Example: offsetPos=224, offset value=32 → reads PoolKey area as hookData.
|
|
// OLD code: returns data from the PoolKey area (data confusion).
|
|
// NEW code: returns nil (absOffset < offsetPos+32 check).
|
|
// =========================================================================
|
|
|
|
func TestRedTeam_DecodeABIBytes_BackwardOffset(t *testing.T) {
|
|
// Build a buffer mimicking a donate call (PoolKey + amount0 + amount1 + hookData offset)
|
|
args := make([]byte, 320)
|
|
|
|
// Fill PoolKey area with recognizable pattern
|
|
for i := range 160 {
|
|
args[i] = 0xAA
|
|
}
|
|
|
|
// At offsetPos=224, place offset value=32 (points backward into PoolKey area)
|
|
copy(args[224:256], bigIntTo32Bytes(big.NewInt(32)))
|
|
|
|
// At byte 32 (where backward offset points), place a fake "length" = 4
|
|
copy(args[32:64], bigIntTo32Bytes(big.NewInt(4)))
|
|
|
|
result := decodeABIBytes(args, 224)
|
|
if result != nil {
|
|
t.Errorf("VULN: backward offset returned data from PoolKey area: %x (data confusion attack)", result)
|
|
}
|
|
}
|
|
|
|
func TestRedTeam_DecodeABIBytes_BackwardOffset_Zero(t *testing.T) {
|
|
// Offset = 0 always points before offsetPos (unless offsetPos=0, but
|
|
// even then absOffset < offsetPos+32 = 32 fails).
|
|
args := make([]byte, 320)
|
|
// offset = 0
|
|
// Already zeroed
|
|
|
|
result := decodeABIBytes(args, 224)
|
|
if result != nil {
|
|
t.Error("VULN: zero offset should be rejected as backward")
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Finding 3: decodeABIBytes length overflow
|
|
//
|
|
// Attack: Set length word to uint64 max. dataStart+length wraps around.
|
|
// OLD code: panic on slice bounds.
|
|
// NEW code: returns nil (BitLen > 63 check on length).
|
|
// =========================================================================
|
|
|
|
func TestRedTeam_DecodeABIBytes_LengthOverflow(t *testing.T) {
|
|
// Build valid structure with absOffset pointing forward, but length = uint64 max
|
|
args := make([]byte, 384)
|
|
|
|
// offsetPos = 256, absOffset = 320 (valid forward reference)
|
|
copy(args[256:288], bigIntTo32Bytes(big.NewInt(320)))
|
|
|
|
// At absOffset=320: length = 0xFFFFFFFFFFFFFFFF
|
|
maxU64 := new(big.Int).SetUint64(^uint64(0))
|
|
copy(args[320:352], bigIntTo32Bytes(maxU64))
|
|
|
|
didPanic := false
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
didPanic = true
|
|
}
|
|
}()
|
|
result := decodeABIBytes(args, 256)
|
|
if result != nil {
|
|
t.Error("VULN: decodeABIBytes returned non-nil for uint64 max length")
|
|
}
|
|
}()
|
|
if didPanic {
|
|
t.Fatal("VULN: decodeABIBytes panicked on length overflow — chain halt")
|
|
}
|
|
}
|
|
|
|
func TestRedTeam_DecodeABIBytes_LengthExceedsBuffer(t *testing.T) {
|
|
// Length = 1000 but buffer only has 32 bytes after length word.
|
|
args := make([]byte, 384)
|
|
copy(args[256:288], bigIntTo32Bytes(big.NewInt(320)))
|
|
copy(args[320:352], bigIntTo32Bytes(big.NewInt(1000)))
|
|
|
|
result := decodeABIBytes(args, 256)
|
|
if result != nil {
|
|
t.Error("VULN: decodeABIBytes returned data when length exceeds buffer")
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Finding 4: big.Int.Uint64() truncation
|
|
//
|
|
// Attack: Offset word with value = 2^65 + 32 (BitLen = 66).
|
|
// Uint64() silently truncates to 32, which passes bounds checks.
|
|
// OLD code: truncated value passes validation, reads wrong data.
|
|
// NEW code: returns nil (BitLen > 63 rejects it).
|
|
// =========================================================================
|
|
|
|
func TestRedTeam_DecodeABIBytes_BigIntTruncation(t *testing.T) {
|
|
// 2^65 + 288 → Uint64() truncates to 288, which is a valid offset in a 384-byte buffer.
|
|
// The BitLen check must catch this before Uint64() is called.
|
|
truncatedTarget := big.NewInt(288)
|
|
craftedOffset := new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 65), truncatedTarget)
|
|
// Verify the attack: Uint64 gives truncated value
|
|
if craftedOffset.Uint64() != 288 {
|
|
t.Fatalf("test setup: Uint64() should truncate to 288, got %d", craftedOffset.Uint64())
|
|
}
|
|
if craftedOffset.BitLen() <= 64 {
|
|
t.Fatalf("test setup: BitLen should exceed 64, got %d", craftedOffset.BitLen())
|
|
}
|
|
|
|
args := make([]byte, 384)
|
|
// Place the crafted offset at position 256
|
|
offsetBytes := bigIntTo32Bytes(craftedOffset)
|
|
copy(args[256:288], offsetBytes)
|
|
|
|
// At the truncated target (288): place length=4 and some data
|
|
copy(args[288:320], bigIntTo32Bytes(big.NewInt(4)))
|
|
copy(args[320:324], []byte("PWND"))
|
|
|
|
result := decodeABIBytes(args, 256)
|
|
if result != nil {
|
|
t.Errorf("VULN: BigInt truncation allowed read of wrong data: %q", result)
|
|
}
|
|
}
|
|
|
|
func TestRedTeam_DecodeABIBytes_BigIntTruncation_Length(t *testing.T) {
|
|
// Same attack on the length field: length = 2^65 + 4
|
|
args := make([]byte, 384)
|
|
copy(args[256:288], bigIntTo32Bytes(big.NewInt(320))) // valid offset
|
|
|
|
craftedLength := new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 65), big.NewInt(4))
|
|
copy(args[320:352], bigIntTo32Bytes(craftedLength))
|
|
copy(args[352:356], []byte("PWND"))
|
|
|
|
didPanic := false
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
didPanic = true
|
|
}
|
|
}()
|
|
result := decodeABIBytes(args, 256)
|
|
if result != nil {
|
|
t.Errorf("VULN: BigInt truncation on length field returned: %q", result)
|
|
}
|
|
}()
|
|
if didPanic {
|
|
t.Fatal("VULN: BigInt truncation on length caused panic")
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Finding 5: extsloadArray count overflow
|
|
//
|
|
// Attack: count = 2^59 + 1 → count*32 overflows to 32.
|
|
// OLD code: allocates tiny buffer, panics in loop.
|
|
// NEW code: rejects count > 256.
|
|
// =========================================================================
|
|
|
|
func TestRedTeam_ExtsloadArray_CountOverflow(t *testing.T) {
|
|
// count = 2^59 + 1 → count*32 = 2^64 + 32 → overflows to 32
|
|
// Exercised against the SOLE money-path precompile 0x9999 (runSettleExtsloadArray),
|
|
// whose overflow-safe bounds (readLowU64 + subtraction) reject this without panic.
|
|
c := &SettleContract{}
|
|
mockState := &mockAccessibleState{stateDB: NewMockStateDB()}
|
|
|
|
// ABI: selector(4) + offset(32) + count(32)
|
|
input := make([]byte, 4+32+32)
|
|
binary.BigEndian.PutUint32(input[0:4], SelectorExtsloadArray)
|
|
// offset = 32
|
|
input[4+31] = 0x20
|
|
|
|
// count = 2^59 + 1 (overflows when multiplied by 32)
|
|
countVal := new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 59), big.NewInt(1))
|
|
countBytes := bigIntTo32Bytes(countVal)
|
|
copy(input[4+32:4+64], countBytes)
|
|
|
|
didPanic := false
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
didPanic = true
|
|
}
|
|
}()
|
|
_, _, err := c.Run(mockState, common.Address{}, poolManagerAddr9999, input, 1_000_000, true)
|
|
if err == nil {
|
|
t.Error("VULN: extsloadArray accepted overflow count without error")
|
|
}
|
|
}()
|
|
if didPanic {
|
|
t.Fatal("VULN: extsloadArray panicked on overflow count — chain halt")
|
|
}
|
|
}
|
|
|
|
func TestRedTeam_ExtsloadArray_CountExceedsMax(t *testing.T) {
|
|
// count = 257 with a minimal input is rejected by the remaining-input bounds check
|
|
// (maxN derived from len(input)) on 0x9999's runSettleExtsloadArray.
|
|
c := &SettleContract{}
|
|
mockState := &mockAccessibleState{stateDB: NewMockStateDB()}
|
|
|
|
input := make([]byte, 4+32+32)
|
|
binary.BigEndian.PutUint32(input[0:4], SelectorExtsloadArray)
|
|
input[4+31] = 0x20
|
|
// count = 257
|
|
countBytes := bigIntTo32Bytes(big.NewInt(257))
|
|
copy(input[4+32:4+64], countBytes)
|
|
|
|
_, _, err := c.Run(mockState, common.Address{}, poolManagerAddr9999, input, 1_000_000, true)
|
|
if err == nil {
|
|
t.Error("VULN: extsloadArray accepted count=257 exceeding the remaining-input bound")
|
|
}
|
|
if err != nil && !strings.Contains(err.Error(), "bounds") && !strings.Contains(err.Error(), "exceeds") {
|
|
t.Logf("Got error (acceptable): %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRedTeam_ExtsloadArray_CountZero(t *testing.T) {
|
|
// count = 0 should return empty result without panic.
|
|
c := &SettleContract{}
|
|
stateDB := NewMockStateDB()
|
|
mockState := &mockAccessibleState{stateDB: stateDB}
|
|
|
|
input := make([]byte, 4+32+32)
|
|
binary.BigEndian.PutUint32(input[0:4], SelectorExtsloadArray)
|
|
input[4+31] = 0x20
|
|
// count = 0 (already zeroed)
|
|
|
|
didPanic := false
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
didPanic = true
|
|
}
|
|
}()
|
|
result, _, err := c.Run(mockState, common.Address{}, poolManagerAddr9999, input, 1_000_000, true)
|
|
if err != nil {
|
|
t.Fatalf("extsloadArray count=0 returned error: %v", err)
|
|
}
|
|
// Result should be ABI-encoded empty array: offset(32) + count(32) = 64 bytes
|
|
if len(result) < 64 {
|
|
t.Errorf("expected at least 64 bytes for empty array result, got %d", len(result))
|
|
}
|
|
}()
|
|
if didPanic {
|
|
t.Fatal("VULN: extsloadArray panicked on count=0")
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Finding 6: extsloadArray offset overflow
|
|
//
|
|
// Attack: offset word with BitLen > 64 → rejected.
|
|
// =========================================================================
|
|
|
|
func TestRedTeam_ExtsloadArray_OffsetOverflow(t *testing.T) {
|
|
c := &SettleContract{}
|
|
mockState := &mockAccessibleState{stateDB: NewMockStateDB()}
|
|
|
|
// offset = 2^65 (BitLen = 66). The invariant under attack is "no panic, no
|
|
// over-read": readLowU64 caps the offset to its low 64 bits and ALL subsequent
|
|
// indexing is subtraction-bounded by the remaining input, so a high-bit offset can
|
|
// never index past the buffer. Whether it errors or returns an empty array is
|
|
// immaterial — it must NOT panic (validator halt) and must NOT over-read.
|
|
input := make([]byte, 4+64)
|
|
binary.BigEndian.PutUint32(input[0:4], SelectorExtsloadArray)
|
|
|
|
bigOffset := new(big.Int).Lsh(big.NewInt(1), 65)
|
|
copy(input[4:36], bigIntTo32Bytes(bigOffset))
|
|
|
|
didPanic := false
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
didPanic = true
|
|
}
|
|
}()
|
|
out, _, err := c.Run(mockState, common.Address{}, poolManagerAddr9999, input, 1_000_000, true)
|
|
// If it returns data (no error), the low-64-bit-capped offset/length must have
|
|
// produced a well-formed (empty) ABI array, never a slice past the input.
|
|
if err == nil && len(out) < 64 {
|
|
t.Errorf("VULN: extsloadArray with BitLen>64 offset returned malformed/over-read output (len=%d)", len(out))
|
|
}
|
|
}()
|
|
if didPanic {
|
|
t.Fatal("VULN: extsloadArray panicked on BitLen>64 offset — chain halt")
|
|
}
|
|
}
|
|
|
|
func TestRedTeam_ExtsloadArray_OffsetBeyondInput(t *testing.T) {
|
|
// offset = 99999 but input is only 64 bytes of data → out of bounds.
|
|
c := &SettleContract{}
|
|
mockState := &mockAccessibleState{stateDB: NewMockStateDB()}
|
|
|
|
input := make([]byte, 4+64)
|
|
binary.BigEndian.PutUint32(input[0:4], SelectorExtsloadArray)
|
|
copy(input[4:36], bigIntTo32Bytes(big.NewInt(99999)))
|
|
|
|
_, _, err := c.Run(mockState, common.Address{}, poolManagerAddr9999, input, 1_000_000, true)
|
|
if err == nil {
|
|
t.Error("VULN: extsloadArray accepted offset beyond input length")
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Finding 7: runInitialize hookData uses decodeABIBytes (not raw slice)
|
|
//
|
|
// Verify hookData is correctly decoded via ABI, not including offset+length prefix.
|
|
// =========================================================================
|
|
|
|
func TestRedTeam_Initialize_HookDataABIDecoded(t *testing.T) {
|
|
// Build initialize input: PoolKey(160) + sqrtPriceX96(32) + hookData(ABI-encoded)
|
|
key := PoolKey{
|
|
Currency0: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000001")},
|
|
Currency1: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000002")},
|
|
Fee: 3000,
|
|
TickSpacing: 60,
|
|
Hooks: common.Address{},
|
|
}
|
|
|
|
hookPayload := []byte("init-hook-payload-round2")
|
|
|
|
// Layout: PoolKey(160) + sqrtPriceX96(32) + offset(32) + length(32) + data
|
|
// 0-159 160-191 192-223 224-255 256+
|
|
input := make([]byte, 256+len(hookPayload))
|
|
copy(input[0:160], EncodePoolKeyABI(key))
|
|
copy(input[160:192], bigIntTo32Bytes(new(big.Int).Set(Q96))) // sqrtPriceX96 = Q96
|
|
|
|
// ABI-encoded hookData: offset at 192, pointing to length at 224
|
|
copy(input[192:224], bigIntTo32Bytes(big.NewInt(224)))
|
|
copy(input[224:256], bigIntTo32Bytes(big.NewInt(int64(len(hookPayload)))))
|
|
copy(input[256:], hookPayload)
|
|
|
|
// Verify decodeABIBytes at offsetPos=192 returns exactly the payload
|
|
decoded := decodeABIBytes(input, 192)
|
|
if decoded == nil {
|
|
t.Fatal("REGRESSION: initialize hookData decoded as nil")
|
|
}
|
|
if !bytes.Equal(decoded, hookPayload) {
|
|
t.Errorf("hookData mismatch:\ngot %q\nwant %q", decoded, hookPayload)
|
|
}
|
|
|
|
// The OLD code would have done input[192:] which includes the offset+length+data
|
|
// (64 extra bytes). Verify old approach is wrong.
|
|
rawSlice := input[192:]
|
|
if bytes.Equal(rawSlice, hookPayload) {
|
|
t.Fatal("test invalid: raw slice should not equal payload (it includes 64 bytes of ABI overhead)")
|
|
}
|
|
if len(rawSlice) != 64+len(hookPayload) {
|
|
t.Fatalf("raw slice should be %d bytes (64 overhead + payload), got %d", 64+len(hookPayload), len(rawSlice))
|
|
}
|
|
}
|
|
|
|
func TestRedTeam_Initialize_NoHookData(t *testing.T) {
|
|
// Exactly 192 bytes (PoolKey + sqrtPriceX96, no hookData).
|
|
key := PoolKey{
|
|
Currency0: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000001")},
|
|
Currency1: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000002")},
|
|
Fee: 3000,
|
|
TickSpacing: 60,
|
|
Hooks: common.Address{},
|
|
}
|
|
|
|
input := make([]byte, 192)
|
|
copy(input[0:160], EncodePoolKeyABI(key))
|
|
copy(input[160:192], bigIntTo32Bytes(new(big.Int).Set(Q96)))
|
|
|
|
// The build path checks len(input) > 192 before calling decodeABIBytes; with exactly
|
|
// 192 bytes, hookData stays nil. 0x9010 is fully removed (not a registered precompile,
|
|
// no dispatch), so there is no 0x9010 entry point to assert against — market creation
|
|
// is C-authoritative through 0x9999 (settle_market.go) on top of this build path.
|
|
if len(input) > 192 {
|
|
t.Fatal("test setup error: input should be exactly 192 bytes")
|
|
}
|
|
|
|
// The behavior that matters: the shared PoolManager build path creates a pool with
|
|
// nil hookData on a 192-byte input. This is what 0x9999's market registry composes.
|
|
pm := NewPoolManager(&mockEngine{})
|
|
tick, derr := pm.Initialize(NewMockStateDB(), key, new(big.Int).Set(Q96), nil)
|
|
if derr != nil {
|
|
t.Fatalf("Initialize with nil hookData failed: %v", derr)
|
|
}
|
|
_ = tick
|
|
}
|
|
|
|
// =========================================================================
|
|
// Finding 8: Address sort uses bytes.Compare not Hex()
|
|
//
|
|
// Addresses like 0x0a... vs 0x0B... differ under Hex() vs bytes.Compare:
|
|
// Hex(): 'a'(0x61) > 'B'(0x42), so 0x0a > 0x0B
|
|
// bytes.Compare: 0x0a < 0x0B (correct raw byte ordering)
|
|
// =========================================================================
|
|
|
|
func TestRedTeam_AddressSort_BytesCompareNotHex(t *testing.T) {
|
|
// 0x0aFF... vs 0x0BFF...
|
|
// bytes.Compare: 0x0a (10) < 0x0B (11) → 0x0a is "lower"
|
|
// strings.Compare(Hex()): "0x0aff..." vs "0x0Bff..." → lowercase 'a'(0x61) > 'B'(0x42)
|
|
addrLow := common.HexToAddress("0x0a00000000000000000000000000000000000001")
|
|
addrHigh := common.HexToAddress("0x0B00000000000000000000000000000000000002")
|
|
|
|
// Verify bytes.Compare gives the correct order
|
|
cmp := bytes.Compare(addrLow.Bytes(), addrHigh.Bytes())
|
|
if cmp >= 0 {
|
|
t.Fatalf("test setup: bytes.Compare should give addrLow < addrHigh, got %d", cmp)
|
|
}
|
|
|
|
// Verify Hex() gives the WRONG order (case-insensitive issue)
|
|
hexLow := strings.ToLower(addrLow.Hex())
|
|
hexHigh := strings.ToLower(addrHigh.Hex())
|
|
if hexLow >= hexHigh {
|
|
// If ToLower makes them consistent, try the raw mixed-case comparison
|
|
rawHexLow := addrLow.Hex()
|
|
rawHexHigh := addrHigh.Hex()
|
|
t.Logf("Hex comparison: %s vs %s (raw: %s vs %s)", hexLow, hexHigh, rawHexLow, rawHexHigh)
|
|
}
|
|
|
|
// sortedPoolKey uses bytes.Compare — verify it puts addrLow as Currency0
|
|
key := sortedPoolKey(addrLow, addrHigh, Fee030, TickSpacing030, common.Address{})
|
|
if key.Currency0.Address != addrLow {
|
|
t.Errorf("VULN: sortedPoolKey put wrong address as Currency0.\ngot %s\nwant %s", key.Currency0.Address.Hex(), addrLow.Hex())
|
|
}
|
|
if key.Currency1.Address != addrHigh {
|
|
t.Errorf("VULN: sortedPoolKey put wrong address as Currency1.\ngot %s\nwant %s", key.Currency1.Address.Hex(), addrHigh.Hex())
|
|
}
|
|
|
|
// Verify symmetry: passing in reverse order yields same PoolKey
|
|
keyReversed := sortedPoolKey(addrHigh, addrLow, Fee030, TickSpacing030, common.Address{})
|
|
if key.Currency0.Address != keyReversed.Currency0.Address {
|
|
t.Error("VULN: sortedPoolKey is not symmetric")
|
|
}
|
|
if key.ID() != keyReversed.ID() {
|
|
t.Error("VULN: reversed input produces different pool ID")
|
|
}
|
|
}
|
|
|
|
func TestRedTeam_AddressSort_ZeroForOneCorrect(t *testing.T) {
|
|
// When tokenIn < tokenOut (by bytes.Compare), zeroForOne = true
|
|
addrLow := common.HexToAddress("0x0a00000000000000000000000000000000000001")
|
|
addrHigh := common.HexToAddress("0x0B00000000000000000000000000000000000002")
|
|
|
|
// tokenIn = addrLow, tokenOut = addrHigh → zeroForOne = true
|
|
zeroForOne := bytes.Compare(addrLow.Bytes(), addrHigh.Bytes()) < 0
|
|
if !zeroForOne {
|
|
t.Error("VULN: zeroForOne should be true when tokenIn < tokenOut")
|
|
}
|
|
|
|
// tokenIn = addrHigh, tokenOut = addrLow → zeroForOne = false
|
|
zeroForOne = bytes.Compare(addrHigh.Bytes(), addrLow.Bytes()) < 0
|
|
if zeroForOne {
|
|
t.Error("VULN: zeroForOne should be false when tokenIn > tokenOut")
|
|
}
|
|
}
|
|
|
|
func TestRedTeam_AddressSort_PoolManagerConsistent(t *testing.T) {
|
|
// The PoolManager's areCurrenciesSorted also uses bytes.Compare
|
|
pm := NewPoolManager(&mockEngine{})
|
|
addrLow := Currency{Address: common.HexToAddress("0x0a00000000000000000000000000000000000001")}
|
|
addrHigh := Currency{Address: common.HexToAddress("0x0B00000000000000000000000000000000000002")}
|
|
|
|
if !pm.areCurrenciesSorted(addrLow, addrHigh) {
|
|
t.Error("VULN: areCurrenciesSorted disagrees with bytes.Compare")
|
|
}
|
|
if pm.areCurrenciesSorted(addrHigh, addrLow) {
|
|
t.Error("VULN: areCurrenciesSorted accepts wrong order")
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Finding 9: the router quotes no phantom amounts
|
|
//
|
|
// A quote is advisory, so the guard is that the router fabricates nothing for a
|
|
// pair no market was registered for, and that where a market IS registered the
|
|
// number it returns is the registered price's, bounded by the zero-impact spot.
|
|
// =========================================================================
|
|
|
|
func TestRedTeam_Router_UnregisteredPairQuotesNothing(t *testing.T) {
|
|
stateDB := NewMockStateDB()
|
|
router := NewLXRouter()
|
|
|
|
key := PoolKey{
|
|
Currency0: Currency{Address: testTokenA},
|
|
Currency1: Currency{Address: testTokenB},
|
|
Fee: Fee030,
|
|
TickSpacing: TickSpacing030,
|
|
}
|
|
if MarketExists(stateDB, key) {
|
|
t.Fatal("fixture: the pair under test must have no registered market")
|
|
}
|
|
|
|
// No registry record at any tier: quoteV4 refuses rather than projecting off a
|
|
// zero price, and the aggregate surface reports no liquidity at all.
|
|
if _, _, _, err := router.quoteV4(stateDB, testTokenA, testTokenB, big.NewInt(10_000), 0); !errors.Is(err, ErrPoolNotFound) {
|
|
t.Fatalf("VULN: quoteV4 answered for an unregistered pair: err=%v", err)
|
|
}
|
|
if _, err := router.QuoteExactInputSingle(stateDB, testTokenA, testTokenB, big.NewInt(10_000), 0); !errors.Is(err, ErrInsufficientLiquidity) {
|
|
t.Fatalf("VULN: the router quoted an unregistered pair: err=%v", err)
|
|
}
|
|
|
|
// Register the market at price 1.0 and the projection appears — at the
|
|
// registered price, never above the zero-impact spot (the fee comes off the
|
|
// input, so a fee-bearing tier quotes strictly less).
|
|
rec := MarketRecord{
|
|
Status: MarketStatusActive,
|
|
SqrtPriceX96: new(big.Int).Set(Q96),
|
|
Currency0: testTokenA,
|
|
Currency1: testTokenB,
|
|
Fee: key.Fee,
|
|
TickSpacing: key.TickSpacing,
|
|
}
|
|
storeMarket(stateDB, key.ID(), rec)
|
|
|
|
amount, poolID, _, err := router.quoteV4(stateDB, testTokenA, testTokenB, big.NewInt(10_000), Fee030)
|
|
if err != nil {
|
|
t.Fatalf("a registered market must quote: %v", err)
|
|
}
|
|
if poolID != key.ID() {
|
|
t.Fatalf("quote came from pool %x, want the registered %x", poolID[:4], key.ID())
|
|
}
|
|
if spot := spotOutput(rec, big.NewInt(10_000), true); amount.Cmp(spot) >= 0 {
|
|
t.Fatalf("VULN: quote %s is not below the zero-impact spot %s (phantom quote)", amount, spot)
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Finding 10: Nil BlockContext guard
|
|
//
|
|
// SettleContract.Run() (0x9999, the SOLE money-path precompile) must return an
|
|
// error, not panic, when BlockContext is nil — a missing block context on the
|
|
// block-execution path must fail-closed, never crash the validator.
|
|
// =========================================================================
|
|
|
|
func TestRedTeam_NilBlockContext_NoPanic(t *testing.T) {
|
|
c := &SettleContract{}
|
|
|
|
// Create a mock accessible state that returns nil for GetBlockContext
|
|
nilCtxState := &mockAccessibleStateNilCtx{stateDB: NewMockStateDB()}
|
|
|
|
input := make([]byte, 4+192)
|
|
binary.BigEndian.PutUint32(input[0:4], SelectorInitialize)
|
|
|
|
didPanic := false
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
didPanic = true
|
|
}
|
|
}()
|
|
_, _, err := c.Run(nilCtxState, common.Address{}, poolManagerAddr9999, input, 1_000_000, false)
|
|
if err == nil {
|
|
t.Error("VULN: Run() with nil BlockContext should return error")
|
|
}
|
|
}()
|
|
if didPanic {
|
|
t.Fatal("VULN: Run() panicked on nil BlockContext — chain halt")
|
|
}
|
|
}
|
|
|
|
func TestRedTeam_NilBlockContext_AllSelectors(t *testing.T) {
|
|
// Test write selectors with nil block context on the 0x9999 money path.
|
|
c := &SettleContract{}
|
|
nilCtxState := &mockAccessibleStateNilCtx{stateDB: NewMockStateDB()}
|
|
|
|
selectors := []uint32{
|
|
SelectorInitialize,
|
|
SelectorSwap,
|
|
SelectorModifyLiquidity,
|
|
SelectorDonate,
|
|
SelectorExtsload,
|
|
SelectorExtsloadArray,
|
|
}
|
|
|
|
for _, sel := range selectors {
|
|
t.Run(fmt.Sprintf("selector_0x%08X", sel), func(t *testing.T) {
|
|
input := make([]byte, 260) // enough for most selectors
|
|
binary.BigEndian.PutUint32(input[0:4], sel)
|
|
|
|
didPanic := false
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
didPanic = true
|
|
}
|
|
}()
|
|
_, _, err := c.Run(nilCtxState, common.Address{}, poolManagerAddr9999, input, 1_000_000, false)
|
|
if err == nil {
|
|
t.Errorf("VULN: selector 0x%08X with nil BlockContext should return error", sel)
|
|
}
|
|
}()
|
|
if didPanic {
|
|
t.Fatalf("VULN: selector 0x%08X panicked on nil BlockContext", sel)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Finding 11: Cyclic path rejection
|
|
//
|
|
// Router must reject cyclic paths (repeated tokens).
|
|
// =========================================================================
|
|
|
|
// =========================================================================
|
|
// Finding 12: Flash has no hook calls
|
|
//
|
|
// V4 flash is hookless by design. Verify Flash() works even with a hook
|
|
// address set, and does NOT dispatch to any hook.
|
|
// =========================================================================
|
|
|
|
func TestRedTeam_Flash_NoHookCalls(t *testing.T) {
|
|
pm := NewPoolManager(&mockEngine{})
|
|
stateDB := NewMockStateDB()
|
|
|
|
// Create a pool with a hook address that has all flags set
|
|
hookAddr := common.HexToAddress("0x000000000000000000000000000000000000FFFF")
|
|
|
|
key := PoolKey{
|
|
Currency0: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000001")},
|
|
Currency1: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000002")},
|
|
Fee: Fee030,
|
|
TickSpacing: TickSpacing030,
|
|
Hooks: hookAddr,
|
|
}
|
|
|
|
// Initialize the pool
|
|
_, err := pm.Initialize(stateDB, key, new(big.Int).Set(Q96), nil)
|
|
if err != nil {
|
|
t.Fatalf("Initialize with hooks failed: %v", err)
|
|
}
|
|
|
|
// Flash should succeed without any hook dispatch
|
|
caller := common.HexToAddress("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
|
|
flashParams := FlashParams{
|
|
Amount0: big.NewInt(1000),
|
|
Amount1: big.NewInt(2000),
|
|
Recipient: caller,
|
|
}
|
|
|
|
delta, err := pm.Flash(stateDB, caller, key, flashParams, nil)
|
|
if err != nil {
|
|
t.Fatalf("Flash with hook address failed: %v (hooks should be no-op for flash)", err)
|
|
}
|
|
|
|
// Verify flash returns correct amounts (principal + fee)
|
|
if delta.Amount0.Sign() <= 0 {
|
|
t.Error("Flash should return positive amount0 (principal + fee)")
|
|
}
|
|
if delta.Amount1.Sign() <= 0 {
|
|
t.Error("Flash should return positive amount1 (principal + fee)")
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Finding 13: hooks.go init() verification
|
|
//
|
|
// If hooks.go init() panics, no test in this file would run.
|
|
// This test documents that verification explicitly.
|
|
// =========================================================================
|
|
|
|
func TestRedTeam_HooksInit_DidNotPanic(t *testing.T) {
|
|
// If we reach this test, hooks.go init() succeeded.
|
|
// The init() function in hooks.go verifies all 10 selectors match keccak256
|
|
// and panics on mismatch. The fact that this test is executing proves init()
|
|
// completed without panic.
|
|
|
|
// Verify the HookSignatures map is populated (proof that init ran)
|
|
if len(HookSignatures) != 10 {
|
|
t.Fatalf("HookSignatures should have 10 entries, got %d", len(HookSignatures))
|
|
}
|
|
|
|
// Verify each Sig* variable is non-nil and 4 bytes
|
|
sigs := []struct {
|
|
name string
|
|
sig []byte
|
|
}{
|
|
{"SigBeforeInitialize", SigBeforeInitialize},
|
|
{"SigAfterInitialize", SigAfterInitialize},
|
|
{"SigBeforeAddLiquidity", SigBeforeAddLiquidity},
|
|
{"SigAfterAddLiquidity", SigAfterAddLiquidity},
|
|
{"SigBeforeRemoveLiquidity", SigBeforeRemoveLiquidity},
|
|
{"SigAfterRemoveLiquidity", SigAfterRemoveLiquidity},
|
|
{"SigBeforeSwap", SigBeforeSwap},
|
|
{"SigAfterSwap", SigAfterSwap},
|
|
{"SigBeforeDonate", SigBeforeDonate},
|
|
{"SigAfterDonate", SigAfterDonate},
|
|
}
|
|
for _, s := range sigs {
|
|
if len(s.sig) != 4 {
|
|
t.Errorf("%s has %d bytes, expected 4", s.name, len(s.sig))
|
|
}
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Finding 14: Hook selector verification for all 10 hooks
|
|
//
|
|
// Independently compute keccak256(sig)[:4] for each hook and compare
|
|
// to the Sig* variable. This is a redundant cross-check against init().
|
|
// =========================================================================
|
|
|
|
func TestRedTeam_HookSelectors_IndependentKeccak(t *testing.T) {
|
|
// Map of hook name -> expected Solidity function signature
|
|
// These MUST match Uniswap V4 IHooks.sol exactly
|
|
hookDefs := []struct {
|
|
name string
|
|
sig string
|
|
got []byte
|
|
}{
|
|
{
|
|
"beforeInitialize",
|
|
"beforeInitialize(address,(address,address,uint24,int24,address),uint160,bytes)",
|
|
SigBeforeInitialize,
|
|
},
|
|
{
|
|
"afterInitialize",
|
|
"afterInitialize(address,(address,address,uint24,int24,address),uint160,int24,bytes)",
|
|
SigAfterInitialize,
|
|
},
|
|
{
|
|
"beforeAddLiquidity",
|
|
"beforeAddLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)",
|
|
SigBeforeAddLiquidity,
|
|
},
|
|
{
|
|
"afterAddLiquidity",
|
|
"afterAddLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),(int128,int128),(int128,int128),bytes)",
|
|
SigAfterAddLiquidity,
|
|
},
|
|
{
|
|
"beforeRemoveLiquidity",
|
|
"beforeRemoveLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)",
|
|
SigBeforeRemoveLiquidity,
|
|
},
|
|
{
|
|
"afterRemoveLiquidity",
|
|
"afterRemoveLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),(int128,int128),(int128,int128),bytes)",
|
|
SigAfterRemoveLiquidity,
|
|
},
|
|
{
|
|
"beforeSwap",
|
|
"beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)",
|
|
SigBeforeSwap,
|
|
},
|
|
{
|
|
"afterSwap",
|
|
"afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),(int128,int128),bytes)",
|
|
SigAfterSwap,
|
|
},
|
|
{
|
|
"beforeDonate",
|
|
"beforeDonate(address,(address,address,uint24,int24,address),uint256,uint256,bytes)",
|
|
SigBeforeDonate,
|
|
},
|
|
{
|
|
"afterDonate",
|
|
"afterDonate(address,(address,address,uint24,int24,address),uint256,uint256,bytes)",
|
|
SigAfterDonate,
|
|
},
|
|
}
|
|
|
|
for _, hd := range hookDefs {
|
|
t.Run(hd.name, func(t *testing.T) {
|
|
hash := crypto.Keccak256([]byte(hd.sig))
|
|
want := hash[:4]
|
|
if !bytes.Equal(hd.got, want) {
|
|
t.Errorf("REGRESSION: %s selector mismatch\n got %x\n want %x (keccak256(%q)[:4])",
|
|
hd.name, hd.got, want, hd.sig)
|
|
}
|
|
|
|
// Also verify consistency with HookSignatures map
|
|
mapSig, ok := HookSignatures[hd.name]
|
|
if !ok {
|
|
t.Fatalf("HookSignatures missing %q", hd.name)
|
|
}
|
|
if mapSig != hd.sig {
|
|
t.Errorf("HookSignatures[%q] = %q, test expects %q", hd.name, mapSig, hd.sig)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRedTeam_HookSelectors_HookSigHelper(t *testing.T) {
|
|
// Verify the hookSig() helper produces identical results to manual keccak256
|
|
sigs := []string{
|
|
"beforeInitialize(address,(address,address,uint24,int24,address),uint160,bytes)",
|
|
"afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),(int128,int128),bytes)",
|
|
"beforeDonate(address,(address,address,uint24,int24,address),uint256,uint256,bytes)",
|
|
}
|
|
for _, sig := range sigs {
|
|
got := hookSig(sig)
|
|
want := crypto.Keccak256([]byte(sig))[:4]
|
|
if !bytes.Equal(got, want) {
|
|
t.Errorf("hookSig(%q) = %x, want %x", sig, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Mock for nil BlockContext tests
|
|
// =========================================================================
|
|
|
|
// mockAccessibleStateNilCtx returns nil for GetBlockContext().
|
|
type mockAccessibleStateNilCtx struct {
|
|
stateDB *MockStateDB
|
|
}
|
|
|
|
func (m *mockAccessibleStateNilCtx) GetStateDB() contract.StateDB {
|
|
return &contractStateDBWrapper{inner: m.stateDB}
|
|
}
|
|
|
|
func (m *mockAccessibleStateNilCtx) GetBlockContext() contract.BlockContext {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAccessibleStateNilCtx) GetConsensusContext() context.Context {
|
|
return context.Background()
|
|
}
|
|
|
|
func (m *mockAccessibleStateNilCtx) GetChainConfig() precompileconfig.ChainConfig {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAccessibleStateNilCtx) GetPrecompileEnv() contract.PrecompileEnvironment {
|
|
return nil
|
|
}
|