Files
zeekay fe4430cec6 dex, fhe, contract: a 256-bit word narrowed to a machine integer without saying so
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.
2026-08-28 15:08:29 -07:00

526 lines
19 KiB
Go

// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"encoding/binary"
"errors"
"math/big"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
)
// HookRegistry manages hook contract registrations and validations
type HookRegistry struct {
// registeredHooks maps hook addresses to their capabilities
registeredHooks map[common.Address]HookFlags
}
// NewHookRegistry creates a new hook registry
func NewHookRegistry() *HookRegistry {
return &HookRegistry{
registeredHooks: make(map[common.Address]HookFlags),
}
}
// HookPermissions contains the flags derived from a hook address.
// V4: permissions are encoded in the TRAILING (least significant) bits of the hook address.
type HookPermissions struct {
BeforeInitialize bool
AfterInitialize bool
BeforeAddLiquidity bool
AfterAddLiquidity bool
BeforeRemoveLiquidity bool
AfterRemoveLiquidity bool
BeforeSwap bool
AfterSwap bool
BeforeDonate bool
AfterDonate bool
BeforeSwapReturnsDelta bool
AfterSwapReturnsDelta bool
AfterAddLiquidityReturnsDelta bool
AfterRemoveLiquidityReturnsDelta bool
}
// HookCallResult contains the result of a hook call
type HookCallResult struct {
Success bool
ReturnData []byte
GasUsed uint64
DeltaChange *BalanceDelta // Optional delta modification by hook
}
// Hook function signatures (4-byte selectors).
// Derived from keccak256 of V4 IHooks function signatures, matching Uniswap V4 exactly.
// Verified at init() time — any mismatch is a hard panic at startup.
var (
SigBeforeInitialize = hookSig("beforeInitialize(address,(address,address,uint24,int24,address),uint160,bytes)")
SigAfterInitialize = hookSig("afterInitialize(address,(address,address,uint24,int24,address),uint160,int24,bytes)")
SigBeforeAddLiquidity = hookSig("beforeAddLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)")
SigAfterAddLiquidity = hookSig("afterAddLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),(int128,int128),(int128,int128),bytes)")
SigBeforeRemoveLiquidity = hookSig("beforeRemoveLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)")
SigAfterRemoveLiquidity = hookSig("afterRemoveLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),(int128,int128),(int128,int128),bytes)")
SigBeforeSwap = hookSig("beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)")
SigAfterSwap = hookSig("afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),(int128,int128),bytes)")
SigBeforeDonate = hookSig("beforeDonate(address,(address,address,uint24,int24,address),uint256,uint256,bytes)")
SigAfterDonate = hookSig("afterDonate(address,(address,address,uint24,int24,address),uint256,uint256,bytes)")
)
// hookSig computes the first 4 bytes of keccak256(sig) as a byte slice.
func hookSig(sig string) []byte {
h := crypto.Keccak256([]byte(sig))
return []byte{h[0], h[1], h[2], h[3]}
}
// V4 IHooks function signatures used to derive selectors.
// Exported for test verification.
var HookSignatures = map[string]string{
"beforeInitialize": "beforeInitialize(address,(address,address,uint24,int24,address),uint160,bytes)",
"afterInitialize": "afterInitialize(address,(address,address,uint24,int24,address),uint160,int24,bytes)",
"beforeAddLiquidity": "beforeAddLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)",
"afterAddLiquidity": "afterAddLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),(int128,int128),(int128,int128),bytes)",
"beforeRemoveLiquidity": "beforeRemoveLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)",
"afterRemoveLiquidity": "afterRemoveLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),(int128,int128),(int128,int128),bytes)",
"beforeSwap": "beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)",
"afterSwap": "afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),(int128,int128),bytes)",
"beforeDonate": "beforeDonate(address,(address,address,uint24,int24,address),uint256,uint256,bytes)",
"afterDonate": "afterDonate(address,(address,address,uint24,int24,address),uint256,uint256,bytes)",
}
func init() {
// Verify all hook selectors match their signatures at startup.
sigs := map[string][]byte{
"beforeInitialize": SigBeforeInitialize,
"afterInitialize": SigAfterInitialize,
"beforeAddLiquidity": SigBeforeAddLiquidity,
"afterAddLiquidity": SigAfterAddLiquidity,
"beforeRemoveLiquidity": SigBeforeRemoveLiquidity,
"afterRemoveLiquidity": SigAfterRemoveLiquidity,
"beforeSwap": SigBeforeSwap,
"afterSwap": SigAfterSwap,
"beforeDonate": SigBeforeDonate,
"afterDonate": SigAfterDonate,
}
for name, sel := range sigs {
sig, ok := HookSignatures[name]
if !ok {
panic("hook selector " + name + " missing from HookSignatures map")
}
expected := crypto.Keccak256([]byte(sig))[:4]
if sel[0] != expected[0] || sel[1] != expected[1] || sel[2] != expected[2] || sel[3] != expected[3] {
panic("hook selector mismatch for " + name)
}
}
}
// Hook errors
var (
ErrHookNotRegistered = errors.New("hook not registered")
ErrHookCallFailed = errors.New("hook call failed")
ErrHookInvalidAddress = errors.New("hook address doesn't match capabilities")
ErrHookDeltaOverflow = errors.New("hook delta modification overflow")
ErrHookUnauthorizedDelta = errors.New("hook not authorized to modify delta")
)
// ValidateHookAddress validates that a hook address encodes the claimed permissions.
// V4: the TRAILING (least significant) bits of the address encode hook capabilities.
func ValidateHookAddress(addr common.Address, permissions HookPermissions) error {
encoded := EncodeHookPermissions(permissions)
// V4: last 2 bytes of address (bytes 18-19) encode permission flags
addrFlags := binary.BigEndian.Uint16(addr[18:20])
if addrFlags != uint16(encoded) {
return ErrHookInvalidAddress
}
return nil
}
// EncodeHookPermissions encodes permissions into a HookFlags bitmap (V4 bit layout).
func EncodeHookPermissions(p HookPermissions) HookFlags {
var flags HookFlags
if p.BeforeInitialize {
flags |= HookBeforeInitialize
}
if p.AfterInitialize {
flags |= HookAfterInitialize
}
if p.BeforeAddLiquidity {
flags |= HookBeforeAddLiquidity
}
if p.AfterAddLiquidity {
flags |= HookAfterAddLiquidity
}
if p.BeforeRemoveLiquidity {
flags |= HookBeforeRemoveLiquidity
}
if p.AfterRemoveLiquidity {
flags |= HookAfterRemoveLiquidity
}
if p.BeforeSwap {
flags |= HookBeforeSwap
}
if p.AfterSwap {
flags |= HookAfterSwap
}
if p.BeforeDonate {
flags |= HookBeforeDonate
}
if p.AfterDonate {
flags |= HookAfterDonate
}
if p.BeforeSwapReturnsDelta {
flags |= HookBeforeSwapReturnsDelta
}
if p.AfterSwapReturnsDelta {
flags |= HookAfterSwapReturnsDelta
}
if p.AfterAddLiquidityReturnsDelta {
flags |= HookAfterAddLiquidityReturnsDelta
}
if p.AfterRemoveLiquidityReturnsDelta {
flags |= HookAfterRemoveLiquidityReturnsDelta
}
return flags
}
// DecodeHookPermissions decodes a HookFlags bitmap into V4 permissions.
func DecodeHookPermissions(flags HookFlags) HookPermissions {
return HookPermissions{
BeforeInitialize: flags&HookBeforeInitialize != 0,
AfterInitialize: flags&HookAfterInitialize != 0,
BeforeAddLiquidity: flags&HookBeforeAddLiquidity != 0,
AfterAddLiquidity: flags&HookAfterAddLiquidity != 0,
BeforeRemoveLiquidity: flags&HookBeforeRemoveLiquidity != 0,
AfterRemoveLiquidity: flags&HookAfterRemoveLiquidity != 0,
BeforeSwap: flags&HookBeforeSwap != 0,
AfterSwap: flags&HookAfterSwap != 0,
BeforeDonate: flags&HookBeforeDonate != 0,
AfterDonate: flags&HookAfterDonate != 0,
BeforeSwapReturnsDelta: flags&HookBeforeSwapReturnsDelta != 0,
AfterSwapReturnsDelta: flags&HookAfterSwapReturnsDelta != 0,
AfterAddLiquidityReturnsDelta: flags&HookAfterAddLiquidityReturnsDelta != 0,
AfterRemoveLiquidityReturnsDelta: flags&HookAfterRemoveLiquidityReturnsDelta != 0,
}
}
// GetHookPermissionsFromAddress extracts permissions from hook address.
// V4: permissions are in the TRAILING (least significant) bits -- last 2 bytes of address.
func GetHookPermissionsFromAddress(addr common.Address) HookPermissions {
flags := HookFlags(binary.BigEndian.Uint16(addr[18:20]))
return DecodeHookPermissions(flags)
}
// HasPermission checks if an address has a specific hook permission.
// V4: checks the TRAILING bits of the address.
func HasPermission(addr common.Address, flag HookFlags) bool {
addrFlags := HookFlags(binary.BigEndian.Uint16(addr[18:20]))
return addrFlags&flag != 0
}
// RegisterHook registers a hook contract with its capabilities.
// V4: validates that the TRAILING bits of the address match the claimed flags.
func (hr *HookRegistry) RegisterHook(addr common.Address, flags HookFlags) error {
// Validate address trailing bits match flags
addrFlags := HookFlags(binary.BigEndian.Uint16(addr[18:20]))
if addrFlags != flags {
return ErrHookInvalidAddress
}
hr.registeredHooks[addr] = flags
return nil
}
// GetHookFlags returns the flags for a registered hook
func (hr *HookRegistry) GetHookFlags(addr common.Address) (HookFlags, bool) {
flags, ok := hr.registeredHooks[addr]
return flags, ok
}
// IsHookEnabled checks if a specific hook type is enabled for an address.
// V4: derives from TRAILING bits if not registered.
func (hr *HookRegistry) IsHookEnabled(addr common.Address, flag HookFlags) bool {
flags, ok := hr.registeredHooks[addr]
if !ok {
// If not registered, derive from address trailing bits
flags = HookFlags(binary.BigEndian.Uint16(addr[18:20]))
}
return flags&flag != 0
}
// =========================================================================
// Hook Data Structures for Common Hook Patterns
// =========================================================================
// DynamicFeeHookData contains data for dynamic fee hooks
type DynamicFeeHookData struct {
BaseFee uint24 // Base fee in basis points
VolatilityFee uint24 // Additional fee based on volatility
TimeDecay uint64 // Fee decay over time
LastUpdate uint64 // Timestamp of last update
}
// TWAPHookData contains data for TWAP oracle hooks
type TWAPHookData struct {
CumulativePrice0 *big.Int // Cumulative price0 * time
CumulativePrice1 *big.Int // Cumulative price1 * time
LastBlockTime uint64 // Last block timestamp
LastTick int24 // Last tick value
Observations []TWAPObservation
}
// TWAPObservation is a single TWAP data point
type TWAPObservation struct {
Timestamp uint64
TickCumulative *big.Int
SecondsPerLiquidity *big.Int
Initialized bool
}
// LimitOrderHookData contains data for limit order hooks
type LimitOrderHookData struct {
Orders map[[32]byte]*LimitOrder
OrdersByTick map[int24][][32]byte
ExecutedOrders [][32]byte
}
// LimitOrder represents a limit order in the hook
type LimitOrder struct {
ID [32]byte
Owner common.Address
ZeroForOne bool // Direction
Tick int24 // Price tick
Amount *big.Int // Amount to swap
Filled *big.Int // Amount filled
Status LimitOrderStatus
CreatedAt uint64
}
// LimitOrderStatus represents the status of a limit order
type LimitOrderStatus uint8
const (
LimitOrderPending LimitOrderStatus = iota
LimitOrderPartialFilled
LimitOrderFilled
LimitOrderCancelled
)
// MEVProtectionHookData contains data for MEV protection hooks
type MEVProtectionHookData struct {
CommittedSwaps map[[32]byte]*CommittedSwap
RevealedSwaps map[[32]byte]*RevealedSwap
CommitmentPeriod uint64 // Blocks to wait before reveal
MaxSlippage uint64 // Max allowed slippage (basis points)
}
// CommittedSwap is a committed (hidden) swap
type CommittedSwap struct {
CommitHash [32]byte
Sender common.Address
CommitBlock uint64
Amount *big.Int // Encrypted or hidden
}
// RevealedSwap is a revealed swap after commitment period
type RevealedSwap struct {
CommitHash [32]byte
Sender common.Address
ZeroForOne bool
Amount *big.Int
MinOutput *big.Int
RevealBlock uint64
}
// =========================================================================
// Hook Implementation Helpers
// =========================================================================
// GenerateHookAddress generates a valid hook address for given permissions.
// V4: permission flags are encoded in the TRAILING (last 2) bytes of the address.
func GenerateHookAddress(deployer common.Address, salt [32]byte, permissions HookPermissions) common.Address {
flags := EncodeHookPermissions(permissions)
// CREATE2-style address derivation using keccak256
data := make([]byte, 1+20+32) // 0xff + deployer + salt
data[0] = 0xff
copy(data[1:21], deployer.Bytes())
copy(data[21:53], salt[:])
hash := crypto.Keccak256(data)
// Take last 20 bytes as address, then set trailing permission flags
var addr common.Address
copy(addr[:], hash[12:32])
binary.BigEndian.PutUint16(addr[18:20], uint16(flags))
return addr
}
// PackBeforeSwapParams packs parameters for beforeSwap hook call
func PackBeforeSwapParams(sender common.Address, key PoolKey, params SwapParams, hookData []byte) []byte {
// Simplified packing - real implementation would use ABI encoding
data := make([]byte, 0, 256)
data = append(data, SigBeforeSwap...)
data = append(data, sender.Bytes()...)
data = append(data, key.ToBytes()...)
// Pack swap params
if params.ZeroForOne {
data = append(data, 1)
} else {
data = append(data, 0)
}
amountBytes := make([]byte, 32)
params.AmountSpecified.FillBytes(amountBytes)
data = append(data, amountBytes...)
priceLimitBytes := make([]byte, 32)
params.SqrtPriceLimitX96.FillBytes(priceLimitBytes)
data = append(data, priceLimitBytes...)
data = append(data, hookData...)
return data
}
// PackAfterSwapParams packs parameters for afterSwap hook call
func PackAfterSwapParams(sender common.Address, key PoolKey, params SwapParams, delta BalanceDelta, hookData []byte) []byte {
data := make([]byte, 0, 320)
data = append(data, SigAfterSwap...)
data = append(data, sender.Bytes()...)
data = append(data, key.ToBytes()...)
// Pack swap params
if params.ZeroForOne {
data = append(data, 1)
} else {
data = append(data, 0)
}
amountBytes := make([]byte, 32)
params.AmountSpecified.FillBytes(amountBytes)
data = append(data, amountBytes...)
// Pack delta
delta0Bytes := make([]byte, 32)
delta.Amount0.FillBytes(delta0Bytes)
data = append(data, delta0Bytes...)
delta1Bytes := make([]byte, 32)
delta.Amount1.FillBytes(delta1Bytes)
data = append(data, delta1Bytes...)
data = append(data, hookData...)
return data
}
// UnpackHookDeltaReturn unpacks a hook's delta return value
// Hooks can optionally return a modified delta
func UnpackHookDeltaReturn(data []byte) (*BalanceDelta, error) {
if len(data) < 64 {
return nil, nil // No delta modification
}
amount0 := new(big.Int).SetBytes(data[0:32])
amount1 := new(big.Int).SetBytes(data[32:64])
delta := NewBalanceDelta(amount0, amount1)
return &delta, nil
}
// =========================================================================
// Common Hook Implementations (as examples)
// =========================================================================
// VolatilityFeeCalculator calculates dynamic fees based on volatility
type VolatilityFeeCalculator struct {
BaseFee uint24
MaxFee uint24
VolatilityScale uint64
WindowSize uint64 // Observation window in seconds
}
// CalculateFee calculates the dynamic fee based on recent price movement
func (vfc *VolatilityFeeCalculator) CalculateFee(observations []TWAPObservation) uint24 {
if len(observations) < 2 {
return vfc.BaseFee
}
// Calculate price volatility from observations
// Simplified - real implementation would use proper volatility calculation
lastObs := observations[len(observations)-1]
prevObs := observations[len(observations)-2]
timeDelta := lastObs.Timestamp - prevObs.Timestamp
if timeDelta == 0 {
return vfc.BaseFee
}
tickDelta := new(big.Int).Sub(lastObs.TickCumulative, prevObs.TickCumulative)
avgTick := new(big.Int).Div(tickDelta, big.NewInt(int64(timeDelta)))
// Convert tick movement to volatility fee
volatilityBps := new(big.Int).Abs(avgTick)
volatilityBps.Mul(volatilityBps, big.NewInt(int64(vfc.VolatilityScale)))
volatilityBps.Div(volatilityBps, big.NewInt(10000))
// Clamp in 256 bits, then narrow — the other order wraps twice and inverts the
// answer. volatilityBps.Uint64() drops everything above bit 63, the uint24 (a
// uint32 alias) add drops everything above bit 31, and min then picks the
// wrapped value because it is small. A pool moving violently enough came out
// the cheapest to trade.
fee := new(big.Int).Add(volatilityBps, new(big.Int).SetUint64(uint64(vfc.BaseFee)))
if maxFee := new(big.Int).SetUint64(uint64(vfc.MaxFee)); fee.Cmp(maxFee) > 0 {
fee = maxFee
}
return uint24(fee.Uint64())
}
// CommitRevealValidator validates commit-reveal MEV protection
type CommitRevealValidator struct {
CommitmentPeriod uint64
}
// ValidateCommitment checks if a commitment is valid
func (crv *CommitRevealValidator) ValidateCommitment(commit *CommittedSwap, currentBlock uint64) error {
if currentBlock < commit.CommitBlock+crv.CommitmentPeriod {
return errors.New("commitment period not elapsed")
}
return nil
}
// ValidateReveal checks if a reveal matches its commitment.
func (crv *CommitRevealValidator) ValidateReveal(commit *CommittedSwap, reveal *RevealedSwap) error {
// Verify hash using keccak256 (matches on-chain preimage)
data := make([]byte, 0, 20+1+32+32)
data = append(data, reveal.Sender.Bytes()...)
if reveal.ZeroForOne {
data = append(data, 1)
} else {
data = append(data, 0)
}
amountBytes := make([]byte, 32)
reveal.Amount.FillBytes(amountBytes)
data = append(data, amountBytes...)
minOutputBytes := make([]byte, 32)
reveal.MinOutput.FillBytes(minOutputBytes)
data = append(data, minOutputBytes...)
var computedHash [32]byte
copy(computedHash[:], crypto.Keccak256(data))
if computedHash != commit.CommitHash {
return errors.New("reveal does not match commitment")
}
return nil
}