mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
The D-Chain, hook and market surfaces had behaviour no test observed. e2e/clob moves to the module versions this repo builds against. LLM.md records the two orthogonal calldata bounds — Cursor for how many bytes, Unsigned/Signed for which values — and why hand-writing either is the bug they exist to close.
581 lines
24 KiB
Go
581 lines
24 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package dex
|
|
|
|
import (
|
|
"bytes"
|
|
"math/big"
|
|
"testing"
|
|
|
|
"github.com/luxfi/crypto"
|
|
"github.com/luxfi/geth/common"
|
|
)
|
|
|
|
// zzmop_hooks_test.go covers hooks.go. The security-relevant properties:
|
|
//
|
|
// - A hook's CAPABILITIES are carried by its ADDRESS, so a hook cannot claim a
|
|
// permission its address does not encode, and an unregistered hook derives exactly
|
|
// the permissions its trailing bits encode (never a default-open set).
|
|
// - The commit-reveal MEV guard's digest must cover EVERY revealed field: a digest
|
|
// that ignores a field is an authorization bypass (the revealer could swap that
|
|
// field after committing), so each field is tampered independently.
|
|
// - The hook selectors are the V4 IHooks keccak prefixes, byte for byte.
|
|
|
|
// zzmpHookAddr builds a hook address whose trailing two bytes encode `flags`.
|
|
func zzmpHookAddr(prefix byte, flags HookFlags) common.Address {
|
|
var a common.Address
|
|
a[0] = prefix
|
|
a[18] = byte(flags >> 8)
|
|
a[19] = byte(flags)
|
|
return a
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// selector derivation — the init() invariant, asserted independently
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestZzmpHookSelectorsAreTheKeccakPrefixes recomputes every hook selector from its V4
|
|
// signature. This is the SAME invariant the package's init() hard-panics on, asserted
|
|
// here as a test so a signature drift is a red test rather than a boot-time crash.
|
|
func TestZzmpHookSelectorsAreTheKeccakPrefixes(t *testing.T) {
|
|
sigs := map[string][]byte{
|
|
"beforeInitialize": SigBeforeInitialize,
|
|
"afterInitialize": SigAfterInitialize,
|
|
"beforeAddLiquidity": SigBeforeAddLiquidity,
|
|
"afterAddLiquidity": SigAfterAddLiquidity,
|
|
"beforeRemoveLiquidity": SigBeforeRemoveLiquidity,
|
|
"afterRemoveLiquidity": SigAfterRemoveLiquidity,
|
|
"beforeSwap": SigBeforeSwap,
|
|
"afterSwap": SigAfterSwap,
|
|
"beforeDonate": SigBeforeDonate,
|
|
"afterDonate": SigAfterDonate,
|
|
}
|
|
if len(sigs) != len(HookSignatures) {
|
|
t.Fatalf("the selector table and the signature table disagree: %d vs %d entries", len(sigs), len(HookSignatures))
|
|
}
|
|
seen := map[string]string{}
|
|
for name, sel := range sigs {
|
|
sig, ok := HookSignatures[name]
|
|
if !ok {
|
|
t.Fatalf("hook %q has no entry in HookSignatures", name)
|
|
}
|
|
want := crypto.Keccak256([]byte(sig))[:4]
|
|
if !bytes.Equal(sel, want) {
|
|
t.Fatalf("selector for %q = %x, want keccak(%q)[:4] = %x", name, sel, sig, want)
|
|
}
|
|
if len(sel) != 4 {
|
|
t.Fatalf("selector for %q is %d bytes, want 4", name, len(sel))
|
|
}
|
|
if prev, dup := seen[string(sel)]; dup {
|
|
t.Fatalf("selectors collide: %q and %q both derive %x", prev, name, sel)
|
|
}
|
|
seen[string(sel)] = name
|
|
}
|
|
// hookSig itself is the keccak prefix and is deterministic.
|
|
for i := 0; i < 64; i++ {
|
|
if !bytes.Equal(hookSig("beforeSwap(address)"), crypto.Keccak256([]byte("beforeSwap(address)"))[:4]) {
|
|
t.Fatal("hookSig is not the keccak prefix")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// permissions live in the address
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestZzmpHookPermissionsRoundTripOverTheWholeBitmap pins encode/decode as exact
|
|
// inverses across every single flag AND the full mask, so no permission can be silently
|
|
// dropped or invented in translation.
|
|
func TestZzmpHookPermissionsRoundTripOverTheWholeBitmap(t *testing.T) {
|
|
all := []HookFlags{
|
|
HookBeforeInitialize, HookAfterInitialize,
|
|
HookBeforeAddLiquidity, HookAfterAddLiquidity,
|
|
HookBeforeRemoveLiquidity, HookAfterRemoveLiquidity,
|
|
HookBeforeSwap, HookAfterSwap,
|
|
HookBeforeDonate, HookAfterDonate,
|
|
HookBeforeSwapReturnsDelta, HookAfterSwapReturnsDelta,
|
|
HookAfterAddLiquidityReturnsDelta, HookAfterRemoveLiquidityReturnsDelta,
|
|
}
|
|
// One flag at a time: decode-then-encode must return exactly that one bit.
|
|
for _, f := range all {
|
|
if got := EncodeHookPermissions(DecodeHookPermissions(f)); got != f {
|
|
t.Fatalf("single flag %#x did not round-trip, got %#x", f, got)
|
|
}
|
|
if !HasPermission(zzmpHookAddr(0x11, f), f) {
|
|
t.Fatalf("HasPermission missed the flag %#x encoded in the address", f)
|
|
}
|
|
// And it grants NOTHING else.
|
|
for _, other := range all {
|
|
if other == f {
|
|
continue
|
|
}
|
|
if HasPermission(zzmpHookAddr(0x11, f), other) {
|
|
t.Fatalf("a hook with only %#x also claims %#x", f, other)
|
|
}
|
|
}
|
|
}
|
|
// The empty and full sets.
|
|
if got := EncodeHookPermissions(HookPermissions{}); got != 0 {
|
|
t.Fatalf("the empty permission set encodes to %#x", got)
|
|
}
|
|
if got := EncodeHookPermissions(DecodeHookPermissions(HookAllMask)); got != HookAllMask {
|
|
t.Fatalf("the full mask did not round-trip: %#x", got)
|
|
}
|
|
// Every bit in the mask is accounted for by exactly one named flag.
|
|
var union HookFlags
|
|
for _, f := range all {
|
|
if union&f != 0 {
|
|
t.Fatalf("flag %#x overlaps an earlier flag", f)
|
|
}
|
|
union |= f
|
|
}
|
|
if union != HookAllMask {
|
|
t.Fatalf("the named flags cover %#x, want HookAllMask %#x", union, HookAllMask)
|
|
}
|
|
}
|
|
|
|
// TestZzmpValidateHookAddressBindsCapabilitiesToTheAddress pins the V4 rule: a hook may
|
|
// only claim what its trailing bits encode.
|
|
func TestZzmpValidateHookAddressBindsCapabilitiesToTheAddress(t *testing.T) {
|
|
perms := HookPermissions{BeforeSwap: true, AfterSwap: true}
|
|
flags := EncodeHookPermissions(perms)
|
|
addr := zzmpHookAddr(0xAB, flags)
|
|
|
|
if err := ValidateHookAddress(addr, perms); err != nil {
|
|
t.Fatalf("an address encoding exactly its claim must validate, got %v", err)
|
|
}
|
|
// Claiming MORE than the address encodes is refused.
|
|
more := perms
|
|
more.BeforeDonate = true
|
|
if err := ValidateHookAddress(addr, more); err != ErrHookInvalidAddress {
|
|
t.Fatalf("over-claiming: want ErrHookInvalidAddress, got %v", err)
|
|
}
|
|
// Claiming LESS is refused too (the address is the exact statement, not a bound).
|
|
less := HookPermissions{BeforeSwap: true}
|
|
if err := ValidateHookAddress(addr, less); err != ErrHookInvalidAddress {
|
|
t.Fatalf("under-claiming: want ErrHookInvalidAddress, got %v", err)
|
|
}
|
|
// Only the trailing two bytes matter: the leading bytes are free.
|
|
other := zzmpHookAddr(0x01, flags)
|
|
if err := ValidateHookAddress(other, perms); err != nil {
|
|
t.Fatalf("the address prefix must not affect validation, got %v", err)
|
|
}
|
|
if GetHookPermissionsFromAddress(addr) != perms {
|
|
t.Fatalf("GetHookPermissionsFromAddress: want %+v, got %+v", perms, GetHookPermissionsFromAddress(addr))
|
|
}
|
|
}
|
|
|
|
// TestZzmpIsHookEnabledDerivesFromTheAddressWhenUnregistered pins the fallback: an
|
|
// address that was never registered still resolves exactly the permissions its trailing
|
|
// bits encode — it is never treated as fully-permitted, and never as fully-denied when
|
|
// its bits say otherwise.
|
|
func TestZzmpIsHookEnabledDerivesFromTheAddressWhenUnregistered(t *testing.T) {
|
|
hr := NewHookRegistry()
|
|
addr := zzmpHookAddr(0x22, HookBeforeSwap|HookAfterDonate)
|
|
|
|
if _, ok := hr.GetHookFlags(addr); ok {
|
|
t.Fatal("precondition: the hook must be unregistered")
|
|
}
|
|
if !hr.IsHookEnabled(addr, HookBeforeSwap) {
|
|
t.Fatal("an unregistered hook must still resolve the flags its address encodes")
|
|
}
|
|
if !hr.IsHookEnabled(addr, HookAfterDonate) {
|
|
t.Fatal("an unregistered hook must resolve EVERY flag its address encodes")
|
|
}
|
|
if hr.IsHookEnabled(addr, HookAfterSwap) {
|
|
t.Fatal("an unregistered hook must NOT be granted a flag its address does not encode")
|
|
}
|
|
// An address encoding nothing grants nothing.
|
|
empty := zzmpHookAddr(0x33, 0)
|
|
for _, f := range []HookFlags{HookBeforeSwap, HookAfterSwap, HookAllMask} {
|
|
if hr.IsHookEnabled(empty, f) {
|
|
t.Fatalf("a zero-flag address granted %#x", f)
|
|
}
|
|
}
|
|
|
|
// Registration requires the address to MATCH the claimed flags...
|
|
if err := hr.RegisterHook(addr, HookBeforeSwap); err != ErrHookInvalidAddress {
|
|
t.Fatalf("registering mismatched flags: want ErrHookInvalidAddress, got %v", err)
|
|
}
|
|
if _, ok := hr.GetHookFlags(addr); ok {
|
|
t.Fatal("a refused registration was recorded")
|
|
}
|
|
// ...and once registered the registry answers from the record.
|
|
if err := hr.RegisterHook(addr, HookBeforeSwap|HookAfterDonate); err != nil {
|
|
t.Fatalf("RegisterHook: %v", err)
|
|
}
|
|
flags, ok := hr.GetHookFlags(addr)
|
|
if !ok || flags != HookBeforeSwap|HookAfterDonate {
|
|
t.Fatalf("GetHookFlags: %#x/%v", flags, ok)
|
|
}
|
|
if !hr.IsHookEnabled(addr, HookBeforeSwap) || hr.IsHookEnabled(addr, HookAfterSwap) {
|
|
t.Fatal("the registered answer must equal the address-derived answer")
|
|
}
|
|
}
|
|
|
|
// TestZzmpGenerateHookAddressStampsTheFlagsDeterministically pins the CREATE2-style
|
|
// derivation: same (deployer, salt, permissions) => same address, byte for byte, with
|
|
// the permission bits stamped into the trailing two bytes.
|
|
func TestZzmpGenerateHookAddressStampsTheFlagsDeterministically(t *testing.T) {
|
|
deployer := common.HexToAddress("0x000000000000000000000000000000000000D001")
|
|
salt := [32]byte{0xAA, 0xBB}
|
|
perms := HookPermissions{BeforeSwap: true, AfterSwap: true, AfterSwapReturnsDelta: true}
|
|
|
|
first := GenerateHookAddress(deployer, salt, perms)
|
|
for i := 0; i < 64; i++ {
|
|
if got := GenerateHookAddress(deployer, salt, perms); got != first {
|
|
t.Fatalf("GenerateHookAddress is not deterministic on iteration %d", i)
|
|
}
|
|
}
|
|
if err := ValidateHookAddress(first, perms); err != nil {
|
|
t.Fatalf("a generated address must validate against its own permissions, got %v", err)
|
|
}
|
|
// A different salt, deployer or permission set yields a different address.
|
|
if GenerateHookAddress(deployer, [32]byte{0xAA, 0xBC}, perms) == first {
|
|
t.Fatal("a different salt produced the same address")
|
|
}
|
|
if GenerateHookAddress(common.HexToAddress("0x000000000000000000000000000000000000D002"), salt, perms) == first {
|
|
t.Fatal("a different deployer produced the same address")
|
|
}
|
|
if GenerateHookAddress(deployer, salt, HookPermissions{BeforeSwap: true}) == first {
|
|
t.Fatal("a different permission set produced the same address")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// param packing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestZzmpPackSwapParamsEncodesTheDirectionBitBothWays pins the ZeroForOne byte in both
|
|
// packers for BOTH directions — a packer that only ever wrote 1 would hand every hook
|
|
// the wrong direction on a !zeroForOne swap.
|
|
func TestZzmpPackSwapParamsEncodesTheDirectionBitBothWays(t *testing.T) {
|
|
key := newTestPoolKey()
|
|
sender := common.HexToAddress("0x0000000000000000000000000000000000000501")
|
|
delta := NewBalanceDelta(big.NewInt(11), big.NewInt(22))
|
|
hookData := []byte{0xF0, 0x0D}
|
|
|
|
for _, zeroForOne := range []bool{true, false} {
|
|
params := SwapParams{ZeroForOne: zeroForOne, AmountSpecified: big.NewInt(1_234), SqrtPriceLimitX96: big.NewInt(5_678)}
|
|
|
|
before := PackBeforeSwapParams(sender, key, params, hookData)
|
|
off := 4 + 20 + len(key.ToBytes())
|
|
wantBit := byte(0)
|
|
if zeroForOne {
|
|
wantBit = 1
|
|
}
|
|
if before[off] != wantBit {
|
|
t.Fatalf("beforeSwap direction byte for zeroForOne=%v: want %d, got %d", zeroForOne, wantBit, before[off])
|
|
}
|
|
if !bytes.Equal(before[:4], SigBeforeSwap) {
|
|
t.Fatalf("beforeSwap packing must lead with its selector, got %x", before[:4])
|
|
}
|
|
if !bytes.HasSuffix(before, hookData) {
|
|
t.Fatal("beforeSwap packing dropped the hookData tail")
|
|
}
|
|
if got := new(big.Int).SetBytes(before[off+1 : off+33]); got.Int64() != 1_234 {
|
|
t.Fatalf("beforeSwap amountSpecified: want 1234, got %s", got)
|
|
}
|
|
if got := new(big.Int).SetBytes(before[off+33 : off+65]); got.Int64() != 5_678 {
|
|
t.Fatalf("beforeSwap sqrtPriceLimit: want 5678, got %s", got)
|
|
}
|
|
|
|
after := PackAfterSwapParams(sender, key, params, delta, hookData)
|
|
if after[off] != wantBit {
|
|
t.Fatalf("afterSwap direction byte for zeroForOne=%v: want %d, got %d", zeroForOne, wantBit, after[off])
|
|
}
|
|
if !bytes.Equal(after[:4], SigAfterSwap) {
|
|
t.Fatalf("afterSwap packing must lead with its selector, got %x", after[:4])
|
|
}
|
|
if got := new(big.Int).SetBytes(after[off+33 : off+65]); got.Int64() != 11 {
|
|
t.Fatalf("afterSwap delta0: want 11, got %s", got)
|
|
}
|
|
if got := new(big.Int).SetBytes(after[off+65 : off+97]); got.Int64() != 22 {
|
|
t.Fatalf("afterSwap delta1: want 22, got %s", got)
|
|
}
|
|
// The two directions produce DIFFERENT bytes — the direction is really carried.
|
|
flipped := params
|
|
flipped.ZeroForOne = !zeroForOne
|
|
if bytes.Equal(before, PackBeforeSwapParams(sender, key, flipped, hookData)) {
|
|
t.Fatal("flipping zeroForOne did not change the packed beforeSwap bytes")
|
|
}
|
|
if bytes.Equal(after, PackAfterSwapParams(sender, key, flipped, delta, hookData)) {
|
|
t.Fatal("flipping zeroForOne did not change the packed afterSwap bytes")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestZzmpUnpackHookDeltaReturnNeedsTwoFullWords(t *testing.T) {
|
|
// Anything under two words is "no delta modification", never a partial read.
|
|
for n := 0; n < 64; n++ {
|
|
delta, err := UnpackHookDeltaReturn(make([]byte, n))
|
|
if err != nil {
|
|
t.Fatalf("UnpackHookDeltaReturn(%d bytes): unexpected error %v", n, err)
|
|
}
|
|
if delta != nil {
|
|
t.Fatalf("UnpackHookDeltaReturn(%d bytes) invented a delta %+v", n, delta)
|
|
}
|
|
}
|
|
data := make([]byte, 64)
|
|
big.NewInt(7).FillBytes(data[0:32])
|
|
big.NewInt(9).FillBytes(data[32:64])
|
|
delta, err := UnpackHookDeltaReturn(data)
|
|
if err != nil || delta == nil {
|
|
t.Fatalf("UnpackHookDeltaReturn(64 bytes): %+v/%v", delta, err)
|
|
}
|
|
if delta.Amount0.Int64() != 7 || delta.Amount1.Int64() != 9 {
|
|
t.Fatalf("unpacked delta: got (%s, %s), want (7, 9)", delta.Amount0, delta.Amount1)
|
|
}
|
|
// Trailing bytes past the two words are ignored, not misread.
|
|
long, err := UnpackHookDeltaReturn(append(data, 0xFF, 0xFF))
|
|
if err != nil || long.Amount0.Int64() != 7 || long.Amount1.Int64() != 9 {
|
|
t.Fatalf("trailing bytes changed the unpacked delta: %+v/%v", long, err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// dynamic fee
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestZzmpCalculateFeeFallsBackToBaseAndCapsAtMax pins both degenerate inputs (too few
|
|
// observations, and two observations at the SAME timestamp — a division by zero if it
|
|
// were not guarded) and the ceiling.
|
|
func TestZzmpCalculateFeeFallsBackToBaseAndCapsAtMax(t *testing.T) {
|
|
vfc := &VolatilityFeeCalculator{BaseFee: 3_000, MaxFee: 10_000, VolatilityScale: 100, WindowSize: 60}
|
|
|
|
if got := vfc.CalculateFee(nil); got != vfc.BaseFee {
|
|
t.Fatalf("no observations: want the base fee %d, got %d", vfc.BaseFee, got)
|
|
}
|
|
if got := vfc.CalculateFee([]TWAPObservation{{Timestamp: 1, TickCumulative: big.NewInt(0)}}); got != vfc.BaseFee {
|
|
t.Fatalf("one observation: want the base fee %d, got %d", vfc.BaseFee, got)
|
|
}
|
|
// TWO observations at the SAME timestamp: timeDelta == 0. Without the guard this is
|
|
// a division by zero — a panic reachable from fee calculation.
|
|
same := []TWAPObservation{
|
|
{Timestamp: 42, TickCumulative: big.NewInt(0)},
|
|
{Timestamp: 42, TickCumulative: big.NewInt(1_000)},
|
|
}
|
|
zzmpNoPanic(t, "CalculateFee with a zero time delta", func() {
|
|
if got := vfc.CalculateFee(same); got != vfc.BaseFee {
|
|
t.Errorf("zero time delta: want the base fee %d, got %d", vfc.BaseFee, got)
|
|
}
|
|
})
|
|
|
|
// A real move raises the fee above the base but never past the ceiling.
|
|
moved := []TWAPObservation{
|
|
{Timestamp: 100, TickCumulative: big.NewInt(0)},
|
|
{Timestamp: 110, TickCumulative: big.NewInt(10_000_000)},
|
|
}
|
|
got := vfc.CalculateFee(moved)
|
|
if got <= vfc.BaseFee {
|
|
t.Fatalf("a large tick move must raise the fee above the base %d, got %d", vfc.BaseFee, got)
|
|
}
|
|
if got > vfc.MaxFee {
|
|
t.Fatalf("the fee must be capped at MaxFee %d, got %d", vfc.MaxFee, got)
|
|
}
|
|
// A NEGATIVE move is symmetric (volatility is a magnitude, not a direction).
|
|
down := []TWAPObservation{
|
|
{Timestamp: 100, TickCumulative: big.NewInt(0)},
|
|
{Timestamp: 110, TickCumulative: big.NewInt(-10_000_000)},
|
|
}
|
|
if vfc.CalculateFee(down) != got {
|
|
t.Fatalf("volatility must be direction-symmetric: up=%d down=%d", got, vfc.CalculateFee(down))
|
|
}
|
|
// A small move stays at (or just above) the base rather than exploding.
|
|
small := []TWAPObservation{
|
|
{Timestamp: 100, TickCumulative: big.NewInt(0)},
|
|
{Timestamp: 110, TickCumulative: big.NewInt(10)},
|
|
}
|
|
if s := vfc.CalculateFee(small); s < vfc.BaseFee || s > vfc.MaxFee {
|
|
t.Fatalf("a small move produced %d, outside [%d, %d]", s, vfc.BaseFee, vfc.MaxFee)
|
|
}
|
|
}
|
|
|
|
// TestZzmpCalculateFeeIsMonotoneAndNeverWrapsOnAViolentMove is the arithmetic-domain
|
|
// test the moderate-move cases above do NOT reach. The volatility term is an unbounded
|
|
// big.Int while the fee is a uint24 (a uint32 alias), so a clamp performed AFTER the
|
|
// narrowing wraps twice — Uint64() drops everything above bit 63 and the uint24 add drops
|
|
// everything above bit 31 — and the ceiling then picks the WRAPPED value because it is
|
|
// small. The economic consequence is inverted: the most violently moving pool becomes the
|
|
// CHEAPEST to trade, exactly when the dynamic fee is supposed to bite hardest.
|
|
//
|
|
// The invariant asserted here is the one that survives any implementation: the fee is
|
|
// always inside [BaseFee, MaxFee], and it is MONOTONE — a larger move is never cheaper
|
|
// than a smaller one.
|
|
func TestZzmpCalculateFeeIsMonotoneAndNeverWrapsOnAViolentMove(t *testing.T) {
|
|
vfc := &VolatilityFeeCalculator{BaseFee: 3_000, MaxFee: 10_000, VolatilityScale: 100, WindowSize: 60}
|
|
|
|
obs := func(tickDelta *big.Int) []TWAPObservation {
|
|
return []TWAPObservation{
|
|
{Timestamp: 100, TickCumulative: big.NewInt(0)},
|
|
{Timestamp: 101, TickCumulative: tickDelta},
|
|
}
|
|
}
|
|
|
|
// Tick deltas that step across every narrowing boundary the computation passes
|
|
// through: the uint24 ceiling, the uint32 wrap, the uint64 wrap, and far past it.
|
|
deltas := []*big.Int{
|
|
big.NewInt(0),
|
|
big.NewInt(1_000),
|
|
big.NewInt(1 << 24),
|
|
big.NewInt(1 << 31),
|
|
big.NewInt(1 << 32),
|
|
new(big.Int).Lsh(big.NewInt(1), 63),
|
|
new(big.Int).Lsh(big.NewInt(1), 64),
|
|
new(big.Int).Lsh(big.NewInt(1), 96),
|
|
new(big.Int).Lsh(big.NewInt(1), 200),
|
|
}
|
|
|
|
prev := uint24(0)
|
|
for i, d := range deltas {
|
|
var fee uint24
|
|
zzmpNoPanic(t, "CalculateFee on an extreme tick move", func() { fee = vfc.CalculateFee(obs(d)) })
|
|
if fee < vfc.BaseFee {
|
|
t.Fatalf("tick delta %s produced fee %d, BELOW the base fee %d — the volatility term wrapped", d, fee, vfc.BaseFee)
|
|
}
|
|
if fee > vfc.MaxFee {
|
|
t.Fatalf("tick delta %s produced fee %d, ABOVE MaxFee %d", d, fee, vfc.MaxFee)
|
|
}
|
|
if i > 0 && fee < prev {
|
|
t.Fatalf("tick delta %s produced fee %d, CHEAPER than the smaller move's %d — a violently moving pool must never be the cheapest to trade", d, fee, prev)
|
|
}
|
|
prev = fee
|
|
}
|
|
// Past the ceiling the fee saturates at MaxFee rather than falling back down.
|
|
if got := vfc.CalculateFee(obs(new(big.Int).Lsh(big.NewInt(1), 200))); got != vfc.MaxFee {
|
|
t.Fatalf("an unbounded move must saturate at MaxFee %d, got %d", vfc.MaxFee, got)
|
|
}
|
|
|
|
// THE CRAFTED CASE. Random huge deltas mostly survive a narrow-then-clamp because the
|
|
// wrapped remainder still lands above MaxFee and the ceiling hides it. The inversion is
|
|
// only visible when the low bits are chosen to vanish: pick the tick delta so the
|
|
// volatility term is an exact multiple of 2^32, and a narrow-then-clamp reads it as
|
|
// ZERO — returning the BASE fee for an astronomically volatile pool, i.e. the cheapest
|
|
// possible trade at the worst possible moment.
|
|
//
|
|
// CalculateFee computes volatilityBps = |tickDelta| * VolatilityScale / 10000 over a
|
|
// 1-second window, so tickDelta = target * 10000 / VolatilityScale inverts it exactly.
|
|
for _, shift := range []uint{32, 64, 96, 128} {
|
|
target := new(big.Int).Lsh(big.NewInt(1), shift) // volatilityBps, an exact multiple of 2^32
|
|
tickDelta := new(big.Int).Div(new(big.Int).Mul(target, big.NewInt(10_000)), big.NewInt(int64(vfc.VolatilityScale)))
|
|
|
|
var fee uint24
|
|
zzmpNoPanic(t, "CalculateFee on a crafted low-bits-zero move", func() { fee = vfc.CalculateFee(obs(tickDelta)) })
|
|
if fee != vfc.MaxFee {
|
|
t.Fatalf("a volatility term of 2^%d produced fee %d, want MaxFee %d — the clamp ran AFTER the narrowing, "+
|
|
"so the low bits vanished and the most violently moving pool became the cheapest to trade",
|
|
shift, fee, vfc.MaxFee)
|
|
}
|
|
}
|
|
// The negative extreme is symmetric and equally clamped (magnitude, not direction).
|
|
neg := new(big.Int).Neg(new(big.Int).Lsh(big.NewInt(1), 200))
|
|
if got := vfc.CalculateFee(obs(neg)); got != vfc.MaxFee {
|
|
t.Fatalf("an unbounded NEGATIVE move must also saturate at MaxFee %d, got %d", vfc.MaxFee, got)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// commit-reveal — EVERY digest field
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// zzmpCommitHash recomputes the commitment preimage the validator expects.
|
|
func zzmpCommitHash(r *RevealedSwap) [32]byte {
|
|
data := make([]byte, 0, 20+1+32+32)
|
|
data = append(data, r.Sender.Bytes()...)
|
|
if r.ZeroForOne {
|
|
data = append(data, 1)
|
|
} else {
|
|
data = append(data, 0)
|
|
}
|
|
amount := make([]byte, 32)
|
|
r.Amount.FillBytes(amount)
|
|
data = append(data, amount...)
|
|
minOut := make([]byte, 32)
|
|
r.MinOutput.FillBytes(minOut)
|
|
data = append(data, minOut...)
|
|
var h [32]byte
|
|
copy(h[:], crypto.Keccak256(data))
|
|
return h
|
|
}
|
|
|
|
// TestZzmpValidateRevealCoversEveryCommittedField is the authorization test that matters:
|
|
// the commitment digest must bind EVERY revealed field. Each field is tampered on its own
|
|
// — a digest that ignored any one of them would let a revealer swap that field after the
|
|
// commit landed, which is exactly the front-running the commit-reveal exists to stop.
|
|
func TestZzmpValidateRevealCoversEveryCommittedField(t *testing.T) {
|
|
crv := &CommitRevealValidator{CommitmentPeriod: 3}
|
|
honest := &RevealedSwap{
|
|
Sender: common.HexToAddress("0x0000000000000000000000000000000000000601"),
|
|
ZeroForOne: true,
|
|
Amount: big.NewInt(1_000),
|
|
MinOutput: big.NewInt(950),
|
|
}
|
|
commit := &CommittedSwap{CommitHash: zzmpCommitHash(honest), Sender: honest.Sender, CommitBlock: 10}
|
|
|
|
if err := crv.ValidateReveal(commit, honest); err != nil {
|
|
t.Fatalf("the honest reveal must validate, got %v", err)
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
field string
|
|
mutate func(*RevealedSwap)
|
|
}{
|
|
{"Sender", func(r *RevealedSwap) {
|
|
r.Sender = common.HexToAddress("0x0000000000000000000000000000000000000602")
|
|
}},
|
|
{"ZeroForOne", func(r *RevealedSwap) { r.ZeroForOne = !r.ZeroForOne }},
|
|
{"Amount", func(r *RevealedSwap) { r.Amount = big.NewInt(1_001) }},
|
|
{"MinOutput", func(r *RevealedSwap) { r.MinOutput = big.NewInt(1) }},
|
|
} {
|
|
tampered := *honest
|
|
tc.mutate(&tampered)
|
|
if err := crv.ValidateReveal(commit, &tampered); err == nil {
|
|
t.Fatalf("a reveal with a tampered %s was ACCEPTED — the commitment digest does not bind that field", tc.field)
|
|
}
|
|
}
|
|
|
|
// The digest is deterministic: the same reveal always hashes the same way.
|
|
for i := 0; i < 64; i++ {
|
|
if zzmpCommitHash(honest) != commit.CommitHash {
|
|
t.Fatalf("the commitment digest is not deterministic on iteration %d", i)
|
|
}
|
|
}
|
|
// Two DIFFERENT reveals never share a commitment.
|
|
other := *honest
|
|
other.Amount = big.NewInt(999)
|
|
if zzmpCommitHash(&other) == commit.CommitHash {
|
|
t.Fatal("two distinct reveals produced the same commitment")
|
|
}
|
|
// A commitment over a DIFFERENT reveal does not validate this one.
|
|
if err := crv.ValidateReveal(&CommittedSwap{CommitHash: zzmpCommitHash(&other)}, honest); err == nil {
|
|
t.Fatal("a reveal validated against another reveal's commitment")
|
|
}
|
|
// A zero commitment (never committed) validates nothing.
|
|
if err := crv.ValidateReveal(&CommittedSwap{}, honest); err == nil {
|
|
t.Fatal("a reveal validated against an empty commitment")
|
|
}
|
|
}
|
|
|
|
// TestZzmpValidateCommitmentEnforcesTheWaitingPeriod pins the timing half of the guard:
|
|
// a reveal before the commitment period has elapsed is refused, at the exact boundary.
|
|
func TestZzmpValidateCommitmentEnforcesTheWaitingPeriod(t *testing.T) {
|
|
crv := &CommitRevealValidator{CommitmentPeriod: 5}
|
|
commit := &CommittedSwap{CommitBlock: 100}
|
|
|
|
for _, block := range []uint64{0, 100, 101, 104} {
|
|
if err := crv.ValidateCommitment(commit, block); err == nil {
|
|
t.Fatalf("reveal at block %d (committed at 100, period 5) must be refused", block)
|
|
}
|
|
}
|
|
for _, block := range []uint64{105, 106, 1_000} {
|
|
if err := crv.ValidateCommitment(commit, block); err != nil {
|
|
t.Fatalf("reveal at block %d must be allowed, got %v", block, err)
|
|
}
|
|
}
|
|
// A zero period is immediately revealable.
|
|
if err := (&CommitRevealValidator{}).ValidateCommitment(commit, 100); err != nil {
|
|
t.Fatalf("a zero commitment period must allow an immediate reveal, got %v", err)
|
|
}
|
|
}
|