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]>
1642 lines
76 KiB
Go
1642 lines
76 KiB
Go
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package dex
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"fmt"
|
|
"math/big"
|
|
"testing"
|
|
|
|
lx "github.com/luxfi/dex/pkg/dex"
|
|
"github.com/luxfi/geth/common"
|
|
"github.com/luxfi/precompile/contract"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// zzblueB_router_test.go interrogates the LP-9012 router as an ATTACKER reaches it:
|
|
// through RouterContract.Run, the dispatch a chain hands raw calldata to. Four
|
|
// questions are asked and answered by measurement, not by walking lines:
|
|
//
|
|
// 1. Does any handler read bytes the caller did not declare? (soundness)
|
|
// 2. Is the route a caller gets a FUNCTION of the calldata, or of map order?
|
|
// (determinism — two validators must agree)
|
|
// 3. Is the best-of-N comparison total, and is the empty set refused rather than
|
|
// answered with a zero route?
|
|
// 4. What does a refusal cost, and is that cost the same for every refusal?
|
|
|
|
// =========================================================================
|
|
// Fixtures
|
|
// =========================================================================
|
|
|
|
// blueBPoisoned returns b with attacker-controlled SPARE CAPACITY behind it.
|
|
//
|
|
// This is the production shape, not a curiosity: every geth CALL variant reaches a
|
|
// precompile via scope.Memory.GetPtr(off, size), which is the two-index slice
|
|
// m.store[off:off+size] with no CopyBytes on the path. cap(input) is therefore the
|
|
// rest of EVM memory, which the caller filled with MSTORE. A decoder that slices
|
|
// past len does NOT panic in production — it silently reads bytes the caller chose.
|
|
// So every length check is asserted against a POISONED buffer, where a read past
|
|
// len yields 0xA5 rather than a crash, and the verdict is compared to the clean one.
|
|
func blueBPoisoned(b []byte, spare int) []byte {
|
|
full := make([]byte, len(b)+spare)
|
|
for i := range full {
|
|
full[i] = 0xA5
|
|
}
|
|
copy(full, b)
|
|
return full[:len(b)]
|
|
}
|
|
|
|
// blueBVerdict is everything a caller observes from one Run: the bytes, the gas
|
|
// left, and the error. Two inputs that differ only in undeclared bytes must produce
|
|
// an identical verdict in all three.
|
|
type blueBVerdict struct {
|
|
out []byte
|
|
gas uint64
|
|
err string
|
|
ok bool // err == nil
|
|
}
|
|
|
|
func blueBRun(c *RouterContract, st contract.AccessibleState, in []byte, gas uint64, readOnly bool) blueBVerdict {
|
|
out, left, err := c.Run(st, common.HexToAddress("0x1111111111111111111111111111111111111111"),
|
|
lxRouterAddr, in, gas, readOnly)
|
|
v := blueBVerdict{out: out, gas: left, ok: err == nil}
|
|
if err != nil {
|
|
v.err = err.Error()
|
|
}
|
|
return v
|
|
}
|
|
|
|
// blueBNoBlock is an AccessibleState with NO block context — the shape a host hands
|
|
// a precompile outside block execution. Every handler dereferences
|
|
// GetBlockContext().Number(), so the guard in Run is load-bearing: without it this
|
|
// state is a nil dereference, i.e. a validator panic reachable from calldata.
|
|
type blueBNoBlock struct{ *nativeAtomicState }
|
|
|
|
func (blueBNoBlock) GetBlockContext() contract.BlockContext { return nil }
|
|
|
|
var _ contract.AccessibleState = blueBNoBlock{}
|
|
|
|
// blueBLive builds a RouterContract to run the dispatch against. It is a local
|
|
// instance rather than the package singleton so nothing leaks between tests; it is
|
|
// otherwise identical, because the router holds no state — every venue it quotes
|
|
// reads through the StateDB it is handed.
|
|
func blueBLive() *RouterContract {
|
|
return &RouterContract{router: NewLXRouter()}
|
|
}
|
|
|
|
// blueBAddr renders a distinct, sort-ordered token address: blueBAddr(1) < blueBAddr(2).
|
|
func blueBAddr(n byte) common.Address {
|
|
var a common.Address
|
|
a[0] = n
|
|
a[19] = n
|
|
return a
|
|
}
|
|
|
|
// blueBPool registers a market at (fee, tickSpacing) in the 0x9999 registry — the
|
|
// namespace the money path writes and the router's V4 leg quotes from — at price
|
|
// 1.0, returning its canonical pool ID. Fee-parameterised because the whole point of
|
|
// the determinism tests is to stand four fee tiers side by side.
|
|
func blueBPool(t testing.TB, sdb stateKV, a, b common.Address, fee uint24, ts int24) [32]byte {
|
|
t.Helper()
|
|
key := sortedPoolKey(a, b, fee, ts, common.Address{})
|
|
storeMarket(sdb, key.ID(), MarketRecord{
|
|
Status: MarketStatusActive,
|
|
SqrtPriceX96: new(big.Int).Set(Q96), // price 1.0, tick 0
|
|
Currency0: key.Currency0.Address,
|
|
Currency1: key.Currency1.Address,
|
|
Fee: key.Fee,
|
|
TickSpacing: key.TickSpacing,
|
|
})
|
|
return key.ID()
|
|
}
|
|
|
|
// blueBHop is the output one hop yields at price 1.0: the LP fee comes off the input
|
|
// and the rest passes through one-for-one. Written out here rather than called from
|
|
// the package so the expected value is computed independently of the code under test.
|
|
func blueBHop(in int64, fee uint24) int64 { return in - in*int64(fee)/1_000_000 }
|
|
|
|
// blueBWatchDB counts every touch of one address's storage on the way through to the
|
|
// real StateDB, reads included. The value path type-asserts optional capabilities on
|
|
// the concrete StateDB, so each is forwarded rather than dropped — a decorator that
|
|
// swallowed them would turn a real path into a fail-closed one and the count would
|
|
// measure nothing.
|
|
type blueBWatchDB struct {
|
|
contract.StateDB
|
|
addr common.Address
|
|
n *int
|
|
host *contractStateDBWrapper
|
|
}
|
|
|
|
func (d blueBWatchDB) CodeSizeOf(a common.Address) int { return d.host.inner.CodeSizeOf(a) }
|
|
func (d blueBWatchDB) TokenBalanceOf(token, owner common.Address) *big.Int {
|
|
return d.host.TokenBalanceOf(token, owner)
|
|
}
|
|
func (d blueBWatchDB) TransferTokenFrom(token, from, to common.Address, amount *big.Int) error {
|
|
return d.host.TransferTokenFrom(token, from, to, amount)
|
|
}
|
|
func (d blueBWatchDB) TransferTokenTo(token, to common.Address, amount *big.Int) error {
|
|
return d.host.TransferTokenTo(token, to, amount)
|
|
}
|
|
|
|
func (d blueBWatchDB) GetState(a common.Address, k common.Hash) common.Hash {
|
|
if a == d.addr {
|
|
*d.n++
|
|
}
|
|
return d.StateDB.GetState(a, k)
|
|
}
|
|
|
|
func (d blueBWatchDB) SetState(a common.Address, k, v common.Hash) common.Hash {
|
|
if a == d.addr {
|
|
*d.n++
|
|
}
|
|
return d.StateDB.SetState(a, k, v)
|
|
}
|
|
|
|
type blueBWatchState struct {
|
|
*nativeAtomicState
|
|
addr common.Address
|
|
n *int
|
|
}
|
|
|
|
func (s blueBWatchState) GetStateDB() contract.StateDB {
|
|
host := s.nativeAtomicState.GetStateDB().(*contractStateDBWrapper)
|
|
return blueBWatchDB{StateDB: host, addr: s.addr, n: s.n, host: host}
|
|
}
|
|
|
|
// blueBBindV2 binds the native constant-product reserves the router's V2 venue reads.
|
|
func blueBBindV2(t testing.TB, sdb stateKV, a, b common.Address, base, quote uint64, feeBps uint32) {
|
|
t.Helper()
|
|
id := sortedPoolKey(a, b, Fee030, TickSpacing030, common.Address{}).ID()
|
|
require.NoError(t, BindAMMPool(newEVMStore(sdb), id, base, quote, feeBps))
|
|
}
|
|
|
|
// blueBQuoteBody is the quote argument tail: tokenIn(20) | tokenOut(20) | amountIn(32) | fee(3).
|
|
func blueBQuoteBody(tokenIn, tokenOut common.Address, amountIn *big.Int, fee uint24) []byte {
|
|
out := make([]byte, 0, 75)
|
|
out = append(out, tokenIn.Bytes()...)
|
|
out = append(out, tokenOut.Bytes()...)
|
|
amt := make([]byte, 32)
|
|
amountIn.FillBytes(amt)
|
|
out = append(out, amt...)
|
|
var f [4]byte
|
|
binary.BigEndian.PutUint32(f[:], fee)
|
|
return append(out, f[1:4]...)
|
|
}
|
|
|
|
// blueBSimple is the address-list ExactInput tail:
|
|
// numTokens(1) | tokens | amountIn(32) | amountOutMin(32) | deadline(8).
|
|
func blueBSimple(tokens []common.Address, amountIn *big.Int) []byte {
|
|
out := []byte{byte(len(tokens))}
|
|
for _, tk := range tokens {
|
|
out = append(out, tk.Bytes()...)
|
|
}
|
|
amt := make([]byte, 32)
|
|
amountIn.FillBytes(amt)
|
|
out = append(out, amt...)
|
|
out = append(out, make([]byte, 32)...) // amountOutMinimum
|
|
return append(out, make([]byte, 8)...) // deadline
|
|
}
|
|
|
|
// blueBV4 is the binary-path ExactInput tail:
|
|
// 0xFF | pathLen(2) | path | amountIn(32) | amountOutMin(32) | deadline(8).
|
|
func blueBV4(currencyIn common.Address, keys []PathKey, amountIn *big.Int) []byte {
|
|
path := EncodePath(currencyIn, keys)
|
|
out := make([]byte, 0, 3+len(path)+72)
|
|
out = append(out, 0xFF)
|
|
hdr := make([]byte, 2)
|
|
binary.BigEndian.PutUint16(hdr, uint16(len(path)))
|
|
out = append(out, hdr...)
|
|
out = append(out, path...)
|
|
amt := make([]byte, 32)
|
|
amountIn.FillBytes(amt)
|
|
out = append(out, amt...)
|
|
out = append(out, make([]byte, 32)...)
|
|
return append(out, make([]byte, 8)...)
|
|
}
|
|
|
|
func blueBCall(sel uint32, body []byte) []byte {
|
|
return append(selectorBytes(sel), body...)
|
|
}
|
|
|
|
// blueBQuote is one decoded entry of EncodeQuoteResults' wire form.
|
|
type blueBQuote struct {
|
|
venue SwapVenue
|
|
amount *big.Int
|
|
poolID [32]byte
|
|
gas uint64
|
|
}
|
|
|
|
// blueBDecodeQuotes parses the router's quote answer: count(1) then 73 bytes each of
|
|
// venue(1) | amountOut(32) | poolID(32) | gasEstimate(8). It asserts the buffer
|
|
// length AGREES with the count byte — a decoder that trusted one over the other
|
|
// would hide the encoder's count-byte truncation (see TestBlueBEncodeQuoteResults...).
|
|
func blueBDecodeQuotes(t testing.TB, out []byte) []blueBQuote {
|
|
t.Helper()
|
|
require.NotEmpty(t, out, "a quote answer always carries at least the count byte")
|
|
n := int(out[0])
|
|
require.Lenf(t, out, 1+n*73, "count byte %d must agree with the buffer length", n)
|
|
qs := make([]blueBQuote, n)
|
|
for i := range n {
|
|
o := 1 + i*73
|
|
qs[i].venue = SwapVenue(out[o])
|
|
qs[i].amount = new(big.Int).SetBytes(out[o+1 : o+33])
|
|
copy(qs[i].poolID[:], out[o+33:o+65])
|
|
qs[i].gas = binary.BigEndian.Uint64(out[o+65 : o+73])
|
|
}
|
|
return qs
|
|
}
|
|
|
|
// blueBStorage deep-copies the MockStateDB storage trie so a view can be proven not
|
|
// to have written to it.
|
|
func blueBStorage(m *MockStateDB) map[common.Address]map[common.Hash]common.Hash {
|
|
cp := make(map[common.Address]map[common.Hash]common.Hash, len(m.states))
|
|
for addr, slots := range m.states {
|
|
inner := make(map[common.Hash]common.Hash, len(slots))
|
|
for k, v := range slots {
|
|
inner[k] = v
|
|
}
|
|
cp[addr] = inner
|
|
}
|
|
return cp
|
|
}
|
|
|
|
// blueBSelectors is the router's complete dispatch surface plus a selector that is
|
|
// not on it. Every dispatch property below is asserted over ALL of them, so a new
|
|
// arm cannot be added without answering the same questions.
|
|
var blueBSelectors = []struct {
|
|
name string
|
|
sel uint32
|
|
}{
|
|
{"exactInputSingle", SelectorExactInputSingle},
|
|
{"exactInput", SelectorExactInput},
|
|
{"exactOutputSingle", SelectorExactOutputSingle},
|
|
{"exactOutput", SelectorExactOutput},
|
|
{"quoteExactInputSingle", SelectorQuoteExactInputSingle},
|
|
{"quoteExactInput", SelectorQuoteExactInput},
|
|
{"getBestRoute", SelectorGetBestRoute},
|
|
{"unknown", 0xDEADBEEF},
|
|
}
|
|
|
|
// =========================================================================
|
|
// 1. The dispatch reads only what the caller declared
|
|
// =========================================================================
|
|
|
|
// TestBlueBRouterVerdictIgnoresUndeclaredBytes is the soundness property for the
|
|
// whole front door: for EVERY selector and EVERY truncation of a well-formed body,
|
|
// the answer must depend only on input[:len(input)]. The same bytes are fed twice —
|
|
// once as an exactly-sized slice, once with 512 bytes of 0xA5 spare capacity behind
|
|
// them — and the two verdicts (bytes, gas, error) must be identical.
|
|
//
|
|
// A difference is not cosmetic: it means the handler priced or refused a swap using
|
|
// bytes the caller never paid calldata gas for and never committed to, which an
|
|
// attacker chooses freely via MSTORE. A panic under poison is the same bug caught
|
|
// one slice later.
|
|
func TestBlueBRouterVerdictIgnoresUndeclaredBytes(t *testing.T) {
|
|
h := newSettleHarness(t)
|
|
h.registerMarket(t)
|
|
withV2Configured(t)
|
|
blueBBindV2(t, h.state.stateDB, blueBAddr(1), blueBAddr(2), 1_000_000, 2_000_000, 30)
|
|
|
|
c := blueBLive()
|
|
blueBPool(t, h.state.stateDB, blueBAddr(1), blueBAddr(2), Fee030, TickSpacing030)
|
|
|
|
bodies := map[string][]byte{
|
|
"quote": blueBQuoteBody(blueBAddr(1), blueBAddr(2), big.NewInt(1_000), Fee030),
|
|
"simplePath": blueBSimple([]common.Address{blueBAddr(1), blueBAddr(2), blueBAddr(3)}, big.NewInt(1_000)),
|
|
"v4Path": blueBV4(blueBAddr(1), []PathKey{
|
|
{IntermediateCurrency: blueBAddr(2), Fee: Fee030, TickSpacing: TickSpacing030},
|
|
}, big.NewInt(1_000)),
|
|
}
|
|
|
|
for _, s := range blueBSelectors {
|
|
for shape, body := range bodies {
|
|
full := blueBCall(s.sel, body)
|
|
for cut := 0; cut <= len(full); cut++ {
|
|
clean := full[:cut]
|
|
dirty := blueBPoisoned(clean, 512)
|
|
|
|
var got, want blueBVerdict
|
|
require.NotPanicsf(t, func() { want = blueBRun(c, h.state, clean, 1_000_000, true) },
|
|
"%s/%s cut=%d: a clean input must not panic", s.name, shape, cut)
|
|
require.NotPanicsf(t, func() { got = blueBRun(c, h.state, dirty, 1_000_000, true) },
|
|
"%s/%s cut=%d: poisoned spare capacity must not be reachable", s.name, shape, cut)
|
|
|
|
require.Equalf(t, want.ok, got.ok, "%s/%s cut=%d: verdict flipped on undeclared bytes", s.name, shape, cut)
|
|
require.Equalf(t, want.err, got.err, "%s/%s cut=%d: error text depends on undeclared bytes", s.name, shape, cut)
|
|
require.Equalf(t, want.gas, got.gas, "%s/%s cut=%d: gas depends on undeclared bytes", s.name, shape, cut)
|
|
require.Equalf(t, want.out, got.out, "%s/%s cut=%d: OUTPUT depends on undeclared bytes", s.name, shape, cut)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBlueBRouterNeedsABlockContext: every surviving handler dereferences
|
|
// GetBlockContext().Number(), so Run's nil check is the only thing between a host
|
|
// that has no block context and a validator panic. Assert it fires for EVERY
|
|
// selector — including the retired value selectors and an unknown one, which need no
|
|
// block context at all — because the check sits ahead of the dispatch. Ordering is
|
|
// part of the property: a 3-byte input is still refused for its LENGTH first, since
|
|
// that check comes first and must not be reordered behind a dereference.
|
|
func TestBlueBRouterNeedsABlockContext(t *testing.T) {
|
|
h := newSettleHarness(t)
|
|
st := blueBNoBlock{h.state}
|
|
c := RouterPrecompile // the registered singleton — what a chain dispatches to
|
|
|
|
body := blueBQuoteBody(blueBAddr(1), blueBAddr(2), big.NewInt(1), Fee030)
|
|
for _, s := range blueBSelectors {
|
|
v := blueBRun(c, st, blueBCall(s.sel, body), 1_000_000, true)
|
|
require.Falsef(t, v.ok, "%s must be refused without a block context", s.name)
|
|
require.Equalf(t, "block context unavailable", v.err,
|
|
"%s must be refused by the block-context check, ahead of its own handler", s.name)
|
|
require.Nilf(t, v.out, "%s must return nothing", s.name)
|
|
require.Equalf(t, uint64(1_000_000), v.gas, "%s: the check is not work and charges nothing", s.name)
|
|
}
|
|
|
|
// Length is checked BEFORE the block context: a stub input keeps its own refusal.
|
|
for n := range 4 {
|
|
v := blueBRun(c, st, make([]byte, n), 1_000_000, true)
|
|
require.Falsef(t, v.ok, "a %d-byte input carries no selector", n)
|
|
require.Equalf(t, "input too short", v.err,
|
|
"a %d-byte input must be refused for its length, not for the block context", n)
|
|
}
|
|
|
|
// And with a block context present, the same calls get PAST the check.
|
|
for _, s := range blueBSelectors {
|
|
v := blueBRun(c, h.state, blueBCall(s.sel, body), 1_000_000, true)
|
|
require.NotEqualf(t, "block context unavailable", v.err,
|
|
"%s must not report a missing block context when one is present", s.name)
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// 2. Route selection is a function of the calldata, not of map order
|
|
// =========================================================================
|
|
|
|
// TestBlueBRouteChoiceIsDeterministicUnderTies is the consensus property. quoteV4
|
|
// scans four fee tiers and keeps the strict maximum, so when several tiers price
|
|
// IDENTICALLY the answer is decided entirely by the ORDER of the scan. If that order
|
|
// came from ranging a map, two validators would pick different pools from the same
|
|
// calldata and fork.
|
|
//
|
|
// The setup forces the worst case: four markets, one per standard tier, all at price
|
|
// 1.0, quoted at an amount so small that even the widest standard fee rounds to
|
|
// nothing — a perfect four-way tie, asserted rather than assumed. The winner must be
|
|
// the FIRST tier of the fixed scan slice (Fee001), every time, including from a cold
|
|
// router that shares nothing but the state trie with the warm one (the
|
|
// just-restarted-validator case).
|
|
func TestBlueBRouteChoiceIsDeterministicUnderTies(t *testing.T) {
|
|
h := newSettleHarness(t)
|
|
tin, tout := blueBAddr(1), blueBAddr(2)
|
|
|
|
c := blueBLive()
|
|
tiers := []struct {
|
|
fee uint24
|
|
ts int24
|
|
}{{Fee001, TickSpacing001}, {Fee005, TickSpacing005}, {Fee030, TickSpacing030}, {Fee100, TickSpacing100}}
|
|
ids := make(map[uint24][32]byte, len(tiers))
|
|
for _, tr := range tiers {
|
|
ids[tr.fee] = blueBPool(t, h.state.stateDB, tin, tout, tr.fee, tr.ts)
|
|
}
|
|
|
|
// The tie fixture: at this size the fee rounds to zero on every standard tier, so
|
|
// all four price identically and only the scan order can decide.
|
|
const tieAmount int64 = 99
|
|
for _, tr := range tiers {
|
|
require.Equalf(t, tieAmount, blueBHop(tieAmount, tr.fee),
|
|
"tier %d must price identically at the tie amount, or this test proves nothing about ordering", tr.fee)
|
|
}
|
|
|
|
quoted := blueBDecodeQuotes(t, func() []byte {
|
|
v := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
|
|
blueBQuoteBody(tin, tout, big.NewInt(tieAmount), 0)), 1_000_000, true)
|
|
require.True(t, v.ok, "a registered pair must quote: %s", v.err)
|
|
return v.out
|
|
}())
|
|
require.Len(t, quoted, 1, "only the V4 venue is live here")
|
|
require.Equal(t, VenueV4Native, quoted[0].venue)
|
|
require.Equal(t, ids[Fee001], quoted[0].poolID,
|
|
"a four-way tie must resolve to the FIRST tier of the fixed scan order, not to whichever the map yielded")
|
|
|
|
// Stable across repeats on one router AND across independently built routers —
|
|
// the two validators that must agree.
|
|
want := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
|
|
blueBQuoteBody(tin, tout, big.NewInt(tieAmount), 0)), 1_000_000, true)
|
|
for i := range 64 {
|
|
cold := blueBLive()
|
|
for _, target := range []*RouterContract{c, cold} {
|
|
got := blueBRun(target, h.state, blueBCall(SelectorQuoteExactInputSingle,
|
|
blueBQuoteBody(tin, tout, big.NewInt(tieAmount), 0)), 1_000_000, true)
|
|
require.Equalf(t, want.out, got.out, "run %d: the same calldata must yield the same bytes", i)
|
|
require.Equalf(t, want.gas, got.gas, "run %d: the same calldata must cost the same", i)
|
|
}
|
|
}
|
|
|
|
// A caller's preferred tier goes to the FRONT of the scan, so it wins the tie —
|
|
// deterministically, and only among equals. Sweep every tier.
|
|
for _, tr := range tiers {
|
|
v := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
|
|
blueBQuoteBody(tin, tout, big.NewInt(tieAmount), tr.fee)), 1_000_000, true)
|
|
require.Truef(t, v.ok, "preferred fee %d must still quote: %s", tr.fee, v.err)
|
|
qs := blueBDecodeQuotes(t, v.out)
|
|
require.Lenf(t, qs, 1, "preferred fee %d", tr.fee)
|
|
require.Equalf(t, ids[tr.fee], qs[0].poolID,
|
|
"preferring tier %d must select tier %d's pool", tr.fee, tr.fee)
|
|
// Repeat: the preference must not flap either.
|
|
for range 16 {
|
|
again := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
|
|
blueBQuoteBody(tin, tout, big.NewInt(tieAmount), tr.fee)), 1_000_000, true)
|
|
require.Equalf(t, v.out, again.out, "preferred fee %d must be stable", tr.fee)
|
|
}
|
|
}
|
|
|
|
// Above the rounding floor the tie breaks on PRICE, not on scan order: the
|
|
// cheapest tier wins even from the back of the scan, and naming a dearer tier
|
|
// does not buy it the route.
|
|
for _, pref := range []uint24{0, Fee100} {
|
|
v := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
|
|
blueBQuoteBody(tin, tout, big.NewInt(1_000_000), pref)), 1_000_000, true)
|
|
require.Truef(t, v.ok, "preferred fee %d: %s", pref, v.err)
|
|
qs := blueBDecodeQuotes(t, v.out)
|
|
require.Equalf(t, ids[Fee001], qs[0].poolID,
|
|
"at a size where the fee bites, the cheapest tier must win (preferred %d)", pref)
|
|
}
|
|
}
|
|
|
|
// TestBlueBUnknownFeeTierCannotSteerTheRoute: the preferred fee is caller-supplied
|
|
// and unvalidated, and an unrecognised tier has no tick spacing, so quoteV4 falls
|
|
// back to the standard spacing and builds a pool key nobody opened. That key must
|
|
// simply miss — an attacker must not be able to change WHICH pool a quote prices by
|
|
// naming a tier that does not exist.
|
|
func TestBlueBUnknownFeeTierCannotSteerTheRoute(t *testing.T) {
|
|
h := newSettleHarness(t)
|
|
tin, tout := blueBAddr(1), blueBAddr(2)
|
|
c := blueBLive()
|
|
want := blueBPool(t, h.state.stateDB, tin, tout, Fee030, TickSpacing030)
|
|
|
|
base := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
|
|
blueBQuoteBody(tin, tout, big.NewInt(1_000), 0)), 1_000_000, true)
|
|
require.True(t, base.ok, "the standard-tier pool must quote: %s", base.err)
|
|
require.Equal(t, want, blueBDecodeQuotes(t, base.out)[0].poolID)
|
|
|
|
// Every unrecognised tier, including the extremes of the uint24 domain, must
|
|
// leave the answer untouched.
|
|
for _, fee := range []uint24{1, 7, 99, 101, 2999, 3001, 9999, 10001, FeeMax, 1<<24 - 1} {
|
|
v := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
|
|
blueBQuoteBody(tin, tout, big.NewInt(1_000), fee)), 1_000_000, true)
|
|
require.Truef(t, v.ok, "unknown fee %d must not break the quote: %s", fee, v.err)
|
|
require.Equalf(t, base.out, v.out,
|
|
"unknown fee %d must not change which pool is priced", fee)
|
|
}
|
|
|
|
// The fallback spacing is not arbitrary — it decides which pool a non-standard
|
|
// tier can even name. A market opened at fee 12345 is reachable from the router
|
|
// ONLY if it uses the standard spacing, because that is what the router assumes
|
|
// for a tier it does not know. Open one at each spacing, on a pair with no
|
|
// standard-tier market so nothing else can answer, and only the first is findable.
|
|
const oddFee uint24 = 12_345
|
|
oin, oout := blueBAddr(5), blueBAddr(6)
|
|
findable := blueBPool(t, h.state.stateDB, oin, oout, oddFee, TickSpacing030)
|
|
hidden := blueBPool(t, h.state.stateDB, oin, oout, oddFee, TickSpacing100)
|
|
require.NotEqual(t, findable, hidden, "the two pools must be distinct")
|
|
|
|
v := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
|
|
blueBQuoteBody(oin, oout, big.NewInt(1_000), oddFee)), 1_000_000, true)
|
|
require.True(t, v.ok, "a market at a non-standard tier and the standard spacing is quotable: %s", v.err)
|
|
require.Equal(t, findable, blueBDecodeQuotes(t, v.out)[0].poolID,
|
|
"an unknown fee tier resolves at the STANDARD tick spacing — that is the fallback, and it is load-bearing")
|
|
|
|
// The market at the same fee but a different spacing stays invisible: no request
|
|
// the router accepts can reach it, because the caller cannot name a spacing.
|
|
for _, fee := range []uint24{oddFee, Fee001, Fee005, Fee030, Fee100, 0} {
|
|
q := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
|
|
blueBQuoteBody(oin, oout, big.NewInt(1_000), fee)), 1_000_000, true)
|
|
if !q.ok {
|
|
continue // a tier nothing was opened at prices nothing; it cannot name the hidden pool either
|
|
}
|
|
for _, got := range blueBDecodeQuotes(t, q.out) {
|
|
require.NotEqualf(t, hidden, got.poolID,
|
|
"fee %d: a pool whose spacing is not the one derived from its fee is unreachable", fee)
|
|
}
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// 3. Best-of-N is total, and the empty set is refused
|
|
// =========================================================================
|
|
|
|
// TestBlueBBestRouteIsAMaximumAndRefusesTheEmptySet pins three things about the
|
|
// route-discovery view that a caller's money depends on:
|
|
//
|
|
// - With NO venue able to price the pair, the call must ERROR. It must not answer
|
|
// with a syntactically valid, zero-amount route — a caller that checks only the
|
|
// return length would read that as "route found, output 0".
|
|
// - When venues do price it, the answer must be the MAXIMUM over the full quote
|
|
// list the sibling selector returns — checked by decoding both answers, so the
|
|
// two selectors cannot disagree.
|
|
// - The tie-break must be total and stable: equal outputs resolve to the same
|
|
// venue every time.
|
|
func TestBlueBBestRouteIsAMaximumAndRefusesTheEmptySet(t *testing.T) {
|
|
h := newSettleHarness(t)
|
|
withV2Configured(t)
|
|
tin, tout := blueBAddr(1), blueBAddr(2)
|
|
c := blueBLive()
|
|
|
|
// (a) Nothing bound anywhere: refused, with no bytes at all.
|
|
v := blueBRun(c, h.state, blueBCall(SelectorGetBestRoute,
|
|
blueBQuoteBody(tin, tout, big.NewInt(1_000), 0)), 1_000_000, true)
|
|
require.False(t, v.ok, "a pair no venue can price must be refused")
|
|
require.Nil(t, v.out, "a refused route must return NO bytes — never a zero route a caller reads as valid")
|
|
require.Equal(t, uint64(1_000_000)-GasRouteLookup, v.gas, "the lookup fee is charged even on refusal")
|
|
|
|
// (b) Exact tie between V4 and V2. At price 1.0 an input of 2 is below every
|
|
// standard fee's rounding floor, so V4 prices 2; reserves (1e6, 1e6+2) give
|
|
// ConstantProductOut = 2*(1e6+2)/(1e6+2) = 2.
|
|
const (
|
|
tieIn int64 = 2
|
|
tieBase uint64 = 1_000_000
|
|
tieQuoteResv uint64 = tieBase + 2
|
|
)
|
|
blueBPool(t, h.state.stateDB, tin, tout, Fee030, TickSpacing030)
|
|
blueBBindV2(t, h.state.stateDB, tin, tout, tieBase, tieQuoteResv, 30)
|
|
require.Equal(t, tieIn, blueBHop(tieIn, Fee030), "the V4 half of the tie must price 1:1 at this size")
|
|
require.Equal(t, uint64(tieIn), lx.ConstantProductOut(tieBase, tieQuoteResv, uint64(tieIn)),
|
|
"the V2 half of the tie must match it")
|
|
|
|
all := blueBDecodeQuotes(t, func() []byte {
|
|
q := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
|
|
blueBQuoteBody(tin, tout, big.NewInt(tieIn), 0)), 1_000_000, true)
|
|
require.True(t, q.ok, "both venues price this pair: %s", q.err)
|
|
return q.out
|
|
}())
|
|
require.Len(t, all, 2, "V4 and V2 must both appear")
|
|
require.Equal(t, VenueV4Native, all[0].venue, "V4 is quoted first")
|
|
require.Equal(t, VenueV2, all[1].venue)
|
|
require.Equal(t, 0, all[0].amount.Cmp(all[1].amount), "the fixture must be an exact tie")
|
|
|
|
best := blueBRun(c, h.state, blueBCall(SelectorGetBestRoute,
|
|
blueBQuoteBody(tin, tout, big.NewInt(2), 0)), 1_000_000, true)
|
|
require.True(t, best.ok, "a priced pair must route: %s", best.err)
|
|
bq := blueBDecodeQuotes(t, best.out)
|
|
require.Len(t, bq, 1, "a best route is exactly one result")
|
|
require.Equal(t, VenueV4Native, bq[0].venue, "a tie resolves to the first venue quoted, every time")
|
|
for range 32 {
|
|
again := blueBRun(c, h.state, blueBCall(SelectorGetBestRoute,
|
|
blueBQuoteBody(tin, tout, big.NewInt(2), 0)), 1_000_000, true)
|
|
require.Equal(t, best.out, again.out, "a tie must not flap between venues")
|
|
}
|
|
|
|
// (c) The comparison is a real maximum, not a venue preference: make V2 strictly
|
|
// better and it must win; make it strictly worse and V4 must win back.
|
|
for _, tc := range []struct {
|
|
name string
|
|
base, quote uint64
|
|
amountIn int64
|
|
wantVenue SwapVenue
|
|
}{
|
|
{"v2 strictly better", 1_000_000, 100_000_000, 1_000, VenueV2},
|
|
{"v2 strictly worse", 1_000_000, 2, 1_000, VenueV4Native},
|
|
} {
|
|
blueBBindV2(t, h.state.stateDB, tin, tout, tc.base, tc.quote, 30)
|
|
got := blueBRun(c, h.state, blueBCall(SelectorGetBestRoute,
|
|
blueBQuoteBody(tin, tout, big.NewInt(tc.amountIn), 0)), 1_000_000, true)
|
|
require.Truef(t, got.ok, "%s: %s", tc.name, got.err)
|
|
chosen := blueBDecodeQuotes(t, got.out)[0]
|
|
require.Equalf(t, tc.wantVenue, chosen.venue, "%s: the best route must be the maximum", tc.name)
|
|
|
|
// Cross-check against the full list from the sibling selector.
|
|
list := blueBDecodeQuotes(t, func() []byte {
|
|
q := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
|
|
blueBQuoteBody(tin, tout, big.NewInt(tc.amountIn), 0)), 1_000_000, true)
|
|
require.Truef(t, q.ok, "%s: %s", tc.name, q.err)
|
|
return q.out
|
|
}())
|
|
max := new(big.Int)
|
|
for _, q := range list {
|
|
if q.amount.Cmp(max) > 0 {
|
|
max = q.amount
|
|
}
|
|
}
|
|
require.Equalf(t, 0, chosen.amount.Cmp(max),
|
|
"%s: getBestRoute must return the maximum of quoteExactInputSingle", tc.name)
|
|
}
|
|
}
|
|
|
|
// TestBlueBV2QuoteDiscardsTheBoundFee is a MISPRICING, not a coverage note. The AMM
|
|
// row a market binds carries a fee in basis points, quoteV2 reads it — and drops it
|
|
// on the floor (`base, quote, _, ok := readAMMRow`). lx.ConstantProductOut takes no
|
|
// fee argument, so the V2 venue is quoted on a FEE-FREE curve while its comment
|
|
// calls it "the 0.30% constant-product pool".
|
|
//
|
|
// Consequence on a live chain: the V2 arm's quote is systematically high by the
|
|
// pool's fee, so getBestRoute prefers V2 over a correctly-priced venue whenever the
|
|
// true difference is smaller than that fee. These are advisory views today, but the
|
|
// number they publish is the number an integrator sizes a swap with.
|
|
func TestBlueBV2QuoteDiscardsTheBoundFee(t *testing.T) {
|
|
h := newSettleHarness(t)
|
|
withV2Configured(t)
|
|
tin, tout := blueBAddr(3), blueBAddr(4)
|
|
c := blueBLive()
|
|
|
|
in := blueBCall(SelectorQuoteExactInputSingle, blueBQuoteBody(tin, tout, big.NewInt(1_000), 0))
|
|
|
|
var answers []string
|
|
for _, feeBps := range []uint32{0, 1, 30, 300, 5_000, 10_000} {
|
|
blueBBindV2(t, h.state.stateDB, tin, tout, 1_000_000, 2_000_000, feeBps)
|
|
v := blueBRun(c, h.state, in, 1_000_000, true)
|
|
require.Truef(t, v.ok, "feeBps=%d must quote: %s", feeBps, v.err)
|
|
qs := blueBDecodeQuotes(t, v.out)
|
|
require.Lenf(t, qs, 1, "feeBps=%d", feeBps)
|
|
require.Equal(t, VenueV2, qs[0].venue)
|
|
answers = append(answers, qs[0].amount.String())
|
|
}
|
|
for i := range answers {
|
|
require.Equalf(t, answers[0], answers[i],
|
|
"the bound fee is read and discarded: every feeBps quotes identically (%v)", answers)
|
|
}
|
|
// And that identical answer is the ZERO-fee curve, to the unit.
|
|
require.Equal(t, answers[0], new(big.Int).SetUint64(lx.ConstantProductOut(1_000_000, 2_000_000, 1_000)).String(),
|
|
"the V2 quote is the fee-free xy=k output even for a pool bound at 100% fee")
|
|
}
|
|
|
|
// TestBlueBQuoteV3HasNoSuccessPath: quoteV3 returns a non-nil error on BOTH of its
|
|
// arms — unconfigured, and configured-but-undeployed. There is no third arm. So the
|
|
// V3 branch of QuoteExactInputSingle can never append a result, and no caller can
|
|
// ever be routed to VenueV3, however the chain is configured.
|
|
//
|
|
// This is why router.go's V3 append is unreachable. Asserted as a property over the
|
|
// configuration space rather than left as an uncovered line.
|
|
func TestBlueBQuoteV3HasNoSuccessPath(t *testing.T) {
|
|
h := newSettleHarness(t)
|
|
prevQuoter, prevFactory := v3QuoterAddr, v3FactoryAddr
|
|
t.Cleanup(func() { v3QuoterAddr, v3FactoryAddr = prevQuoter, prevFactory })
|
|
|
|
c := blueBLive()
|
|
tin, tout := blueBAddr(1), blueBAddr(2)
|
|
blueBPool(t, h.state.stateDB, tin, tout, Fee030, TickSpacing030)
|
|
withV2Configured(t)
|
|
blueBBindV2(t, h.state.stateDB, tin, tout, 1_000_000, 2_000_000, 30)
|
|
|
|
for _, quoter := range []common.Address{
|
|
{}, // unconfigured
|
|
common.HexToAddress("0x0000000000000000000000000000000000009013"),
|
|
common.HexToAddress("0xffffffffffffffffffffffffffffffffffffffff"),
|
|
lxRouterAddr,
|
|
} {
|
|
v3QuoterAddr, v3FactoryAddr = quoter, quoter
|
|
|
|
amount, err := NewLXRouter().quoteV3(h.state.stateDB, tin, tout, big.NewInt(1_000), Fee030)
|
|
require.Errorf(t, err, "quoter %s: quoteV3 has no success arm", quoter.Hex())
|
|
require.Equalf(t, 0, amount.Sign(), "quoter %s: a failed V3 quote must be zero", quoter.Hex())
|
|
|
|
// And through the front door: no answer ever carries VenueV3.
|
|
v := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
|
|
blueBQuoteBody(tin, tout, big.NewInt(1_000), Fee030)), 1_000_000, true)
|
|
require.Truef(t, v.ok, "quoter %s: %s", quoter.Hex(), v.err)
|
|
for _, q := range blueBDecodeQuotes(t, v.out) {
|
|
require.NotEqualf(t, VenueV3, q.venue,
|
|
"quoter %s: a V3 route cannot exist while quoteV3 always errors", quoter.Hex())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBlueBQuoteV4PricesOnePoolWhicheverWayTheCallerNamesIt: the pair (A,B) and the
|
|
// pair (B,A) are the same pool. quoteV4 sorts the currencies to build the key and
|
|
// keeps the caller's direction separately, so both orderings must resolve to the
|
|
// SAME pool id — if the sort were skipped, a pair would have two pools and half the
|
|
// liquidity would be invisible from one side.
|
|
func TestBlueBQuoteV4PricesOnePoolWhicheverWayTheCallerNamesIt(t *testing.T) {
|
|
h := newSettleHarness(t)
|
|
lo, hi := blueBAddr(1), blueBAddr(2)
|
|
c := blueBLive()
|
|
id := blueBPool(t, h.state.stateDB, lo, hi, Fee030, TickSpacing030)
|
|
|
|
for _, d := range []struct {
|
|
name string
|
|
tokenIn, tOut common.Address
|
|
wantZeroForOne bool
|
|
}{
|
|
{"ascending (zeroForOne)", lo, hi, true},
|
|
{"descending (oneForZero)", hi, lo, false},
|
|
} {
|
|
v := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
|
|
blueBQuoteBody(d.tokenIn, d.tOut, big.NewInt(1_000), Fee030)), 1_000_000, true)
|
|
require.Truef(t, v.ok, "%s must price: %s", d.name, v.err)
|
|
qs := blueBDecodeQuotes(t, v.out)
|
|
require.Lenf(t, qs, 1, "%s", d.name)
|
|
require.Equalf(t, id, qs[0].poolID, "%s must price the SAME pool", d.name)
|
|
require.Positivef(t, qs[0].amount.Sign(), "%s must return a positive quote", d.name)
|
|
|
|
// The direction flag really is derived from the ordering, not fixed.
|
|
amount, poolID, key, err := NewLXRouter().quoteV4(h.state.stateDB, d.tokenIn, d.tOut, big.NewInt(1_000), Fee030)
|
|
require.NoErrorf(t, err, "%s", d.name)
|
|
require.Equalf(t, id, poolID, "%s", d.name)
|
|
require.Equalf(t, lo, key.Currency0.Address, "%s: currency0 is the LOWER address regardless of call order", d.name)
|
|
require.Equalf(t, hi, key.Currency1.Address, "%s", d.name)
|
|
require.Positivef(t, amount.Sign(), "%s", d.name)
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// 4. Multi-hop: every hop paid for is a hop walked, and the answer composes
|
|
// =========================================================================
|
|
|
|
// TestBlueBMultiHopComposesEveryHop proves the multi-hop quote is a COMPOSITION and
|
|
// not a first-hop answer wearing a multi-hop price. Both encodings are checked
|
|
// against a value computed independently in the test:
|
|
//
|
|
// - simple path A->B->C over two bound constant-product pools: the answer must be
|
|
// ConstantProductOut applied twice, chained.
|
|
// - V4 binary path over two live pools priced at half: the answer must be in/4.
|
|
//
|
|
// A router that walked one hop and returned would still pass a "positive output"
|
|
// test; it cannot pass this one.
|
|
func TestBlueBMultiHopComposesEveryHop(t *testing.T) {
|
|
h := newSettleHarness(t)
|
|
withV2Configured(t)
|
|
a, b, cAddr := blueBAddr(1), blueBAddr(2), blueBAddr(3)
|
|
|
|
t.Run("simple path over the native AMM", func(t *testing.T) {
|
|
const (
|
|
r1x, r1y uint64 = 1_000_000, 2_000_000
|
|
r2x, r2y uint64 = 5_000_000, 3_000_000
|
|
amountIn uint64 = 10_000
|
|
)
|
|
blueBBindV2(t, h.state.stateDB, a, b, r1x, r1y, 30)
|
|
blueBBindV2(t, h.state.stateDB, b, cAddr, r2x, r2y, 30)
|
|
|
|
hop1 := lx.ConstantProductOut(r1x, r1y, amountIn)
|
|
want := lx.ConstantProductOut(r2x, r2y, hop1)
|
|
require.Positive(t, want, "the fixture must produce a positive two-hop output")
|
|
|
|
v := blueBRun(RouterPrecompile, h.state, blueBCall(SelectorQuoteExactInput,
|
|
blueBSimple([]common.Address{a, b, cAddr}, new(big.Int).SetUint64(amountIn))), 1_000_000, true)
|
|
require.True(t, v.ok, "a fully bound two-hop path must quote: %s", v.err)
|
|
require.Len(t, v.out, 32, "a multi-hop quote answers one 32-byte word")
|
|
require.Equal(t, want, new(big.Int).SetBytes(v.out).Uint64(),
|
|
"the answer must be hop2(hop1(in)), not hop1(in)")
|
|
require.NotEqual(t, hop1, new(big.Int).SetBytes(v.out).Uint64(),
|
|
"a first-hop answer would be a silently truncated route")
|
|
require.Equal(t, uint64(1_000_000)-(GasQuoteBase+2*GasQuotePerHop), v.gas,
|
|
"two hops are charged as two hops")
|
|
})
|
|
|
|
t.Run("v4 binary path over registered markets", func(t *testing.T) {
|
|
h := newSettleHarness(t) // a private trie: markets are registered here, not shared
|
|
c := blueBLive()
|
|
blueBPool(t, h.state.stateDB, a, b, Fee030, TickSpacing030)
|
|
blueBPool(t, h.state.stateDB, b, cAddr, Fee030, TickSpacing030)
|
|
|
|
const amountIn int64 = 10_000
|
|
hop1 := blueBHop(amountIn, Fee030)
|
|
hop2 := blueBHop(hop1, Fee030)
|
|
require.Less(t, hop2, hop1, "the fixture must lose something on the second hop, or it proves nothing")
|
|
|
|
keys := []PathKey{
|
|
{IntermediateCurrency: b, Fee: Fee030, TickSpacing: TickSpacing030},
|
|
{IntermediateCurrency: cAddr, Fee: Fee030, TickSpacing: TickSpacing030},
|
|
}
|
|
v := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInput,
|
|
blueBV4(a, keys, big.NewInt(amountIn))), 1_000_000, true)
|
|
require.True(t, v.ok, "a two-hop V4 path over registered markets must quote: %s", v.err)
|
|
require.Len(t, v.out, 32)
|
|
require.Equal(t, hop2, new(big.Int).SetBytes(v.out).Int64(),
|
|
"each hop takes its fee, so the answer must be hop2(hop1(in)) — proving both hops ran")
|
|
require.Equal(t, uint64(1_000_000)-(GasQuoteBase+2*GasQuotePerHop), v.gas)
|
|
|
|
// One hop for comparison: one fee, and a hop cheaper.
|
|
one := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInput,
|
|
blueBV4(a, keys[:1], big.NewInt(amountIn))), 1_000_000, true)
|
|
require.True(t, one.ok, "%s", one.err)
|
|
require.Equal(t, hop1, new(big.Int).SetBytes(one.out).Int64())
|
|
require.Equal(t, uint64(1_000_000)-(GasQuoteBase+GasQuotePerHop), one.gas)
|
|
})
|
|
|
|
t.Run("a dead hop names its own index", func(t *testing.T) {
|
|
h := newSettleHarness(t) // a private trie: only hop 0's pool exists in it
|
|
c := blueBLive()
|
|
blueBPool(t, h.state.stateDB, a, b, Fee030, TickSpacing030) // hop 0 only
|
|
keys := []PathKey{
|
|
{IntermediateCurrency: b, Fee: Fee030, TickSpacing: TickSpacing030},
|
|
{IntermediateCurrency: blueBAddr(9), Fee: Fee030, TickSpacing: TickSpacing030},
|
|
}
|
|
v := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInput,
|
|
blueBV4(a, keys, big.NewInt(10_000))), 1_000_000, true)
|
|
require.False(t, v.ok, "a path whose second hop has no pool must be refused")
|
|
require.Contains(t, v.err, "quote hop 1 failed",
|
|
"the failure must name hop 1 — proving hop 0 succeeded and the walk advanced")
|
|
require.Equal(t, uint64(1_000_000)-(GasQuoteBase+2*GasQuotePerHop), v.gas,
|
|
"a failed walk still consumes exactly the charge computed for the declared hops")
|
|
})
|
|
}
|
|
|
|
// TestBlueBHopCountClampIsUnreachable: runQuoteExactInput clamps `numHops` up to 1
|
|
// when it computes as less than 1. That clamp can never fire, because
|
|
// DecodeExactInputParams never returns a params value with fewer than one hop —
|
|
// the simple format refuses numTokens < 2 and the V4 format refuses a zero-hop path.
|
|
//
|
|
// Rather than leave the statement as an unexplained hole, the DECODER'S
|
|
// POSTCONDITION is asserted over the whole input space that reaches it: every
|
|
// accepted input has len(Path) >= 2 or len(PathKeys) >= 1, so the clamped quantity
|
|
// is always >= 1 before the clamp is consulted.
|
|
func TestBlueBHopCountClampIsUnreachable(t *testing.T) {
|
|
check := func(t *testing.T, in []byte, label string) {
|
|
t.Helper()
|
|
p, err := DecodeExactInputParams(in)
|
|
if err != nil {
|
|
return // refused inputs never reach the clamp
|
|
}
|
|
hops := len(p.Path) - 1
|
|
if len(p.PathKeys) > 0 {
|
|
hops = len(p.PathKeys)
|
|
}
|
|
require.GreaterOrEqualf(t, hops, 1,
|
|
"%s: an ACCEPTED input yielded %d hops — the clamp would have fired", label, hops)
|
|
}
|
|
|
|
// Simple format across the whole count byte, with and without a full tail.
|
|
for n := range 255 {
|
|
toks := make([]common.Address, n)
|
|
for i := range toks {
|
|
toks[i] = blueBAddr(byte(i%250 + 1))
|
|
}
|
|
full := blueBSimple(toks, big.NewInt(1))
|
|
check(t, full, fmt.Sprintf("simple n=%d", n))
|
|
for _, cut := range []int{0, 1, 2, len(full) / 2, len(full) - 8, len(full) - 1} {
|
|
if cut >= 0 && cut <= len(full) {
|
|
check(t, full[:cut], fmt.Sprintf("simple n=%d cut=%d", n, cut))
|
|
}
|
|
}
|
|
}
|
|
|
|
// V4 format from zero hops upward, plus a hand-built zero-hop path.
|
|
for hops := range 8 {
|
|
keys := make([]PathKey, hops)
|
|
for i := range keys {
|
|
keys[i] = PathKey{IntermediateCurrency: blueBAddr(byte(i + 1)), Fee: Fee030, TickSpacing: TickSpacing030}
|
|
}
|
|
full := blueBV4(blueBAddr(1), keys, big.NewInt(1))
|
|
check(t, full, fmt.Sprintf("v4 hops=%d", hops))
|
|
for cut := range len(full) {
|
|
check(t, full[:cut], fmt.Sprintf("v4 hops=%d cut=%d", hops, cut))
|
|
}
|
|
}
|
|
|
|
// The zero-hop V4 path is refused explicitly, which is what makes the V4 branch
|
|
// of the hop count always >= 1.
|
|
_, err := DecodeExactInputParams(blueBV4(blueBAddr(1), nil, big.NewInt(1)))
|
|
require.Error(t, err, "a V4 path with no hops must be refused, not clamped to one hop")
|
|
|
|
// And the simple branch refuses both degenerate counts.
|
|
for _, n := range []int{0, 1} {
|
|
toks := make([]common.Address, n)
|
|
for i := range toks {
|
|
toks[i] = blueBAddr(byte(i + 1))
|
|
}
|
|
_, err := DecodeExactInputParams(blueBSimple(toks, big.NewInt(1)))
|
|
require.Errorf(t, err, "a %d-token simple path must be refused, not clamped", n)
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// 5. What a refusal costs
|
|
// =========================================================================
|
|
|
|
// TestBlueBRefusalPricing measures what each refusal arm charges. It is a
|
|
// MEASUREMENT pinned as a table, because the arms disagree with each other:
|
|
// four of them hand back the entire budget while the decode arms charge their fee.
|
|
// The one that matters is "path too long", which returns the full budget AFTER
|
|
// decoding a path of up to 1424 hops — the only free arm that does real work first.
|
|
func TestBlueBRefusalPricing(t *testing.T) {
|
|
h := newSettleHarness(t)
|
|
c := RouterPrecompile
|
|
const budget uint64 = 1_000_000
|
|
|
|
longPath := make([]common.Address, MaxPathLength+2)
|
|
for i := range longPath {
|
|
longPath[i] = blueBAddr(byte(i + 1))
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
in []byte
|
|
wantGas uint64
|
|
wantErr string
|
|
}{
|
|
{"no selector", []byte{0x0B, 0x00}, budget, "input too short"},
|
|
{"unknown selector", blueBCall(0xDEADBEEF, make([]byte, 80)), budget, "unknown router method selector: deadbeef"},
|
|
{"retired exactInputSingle", blueBCall(SelectorExactInputSingle, make([]byte, 200)), budget, ErrPrecompileMoved.Error()},
|
|
{"retired exactInput", blueBCall(SelectorExactInput, make([]byte, 200)), budget, ErrPrecompileMoved.Error()},
|
|
{"retired exactOutputSingle", blueBCall(SelectorExactOutputSingle, make([]byte, 200)), budget, ErrPrecompileMoved.Error()},
|
|
{"retired exactOutput", blueBCall(SelectorExactOutput, make([]byte, 200)), budget, ErrPrecompileMoved.Error()},
|
|
{"path too long", blueBCall(SelectorQuoteExactInput, blueBSimple(longPath, big.NewInt(1))), budget,
|
|
fmt.Sprintf("path too long: %d hops exceeds maximum of %d", len(longPath)-1, MaxPathLength)},
|
|
{"short quote body", blueBCall(SelectorQuoteExactInputSingle, make([]byte, 8)), budget - GasQuote, "input too short for quote"},
|
|
{"short route body", blueBCall(SelectorGetBestRoute, make([]byte, 8)), budget - GasRouteLookup, "input too short for quote"},
|
|
{"malformed multi-hop", blueBCall(SelectorQuoteExactInput, []byte{0x01}), budget - GasQuoteBase, "path must have at least 2 tokens"},
|
|
} {
|
|
v := blueBRun(c, h.state, tc.in, budget, true)
|
|
require.Falsef(t, v.ok, "%s must be refused", tc.name)
|
|
require.Equalf(t, tc.wantErr, v.err, "%s", tc.name)
|
|
require.Equalf(t, tc.wantGas, v.gas, "%s: refusal price", tc.name)
|
|
}
|
|
|
|
// Four arms hand the whole budget back; two charge. That asymmetry is the point:
|
|
// "path too long" is FREE and follows a decode of the caller's whole path.
|
|
free := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInput, blueBSimple(longPath, big.NewInt(1))), budget, true)
|
|
paid := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInput, []byte{0x01}), budget, true)
|
|
require.Equal(t, budget, free.gas, "the path-length refusal returns the full budget")
|
|
require.Less(t, paid.gas, budget, "the decode refusal does not")
|
|
|
|
// Under-funded callers lose everything and are told only "out of gas" — the
|
|
// malformed body they also sent is never reported.
|
|
for _, tc := range []struct {
|
|
name string
|
|
sel uint32
|
|
body []byte
|
|
fee uint64
|
|
}{
|
|
{"quote single", SelectorQuoteExactInputSingle, make([]byte, 8), GasQuote},
|
|
{"best route", SelectorGetBestRoute, make([]byte, 8), GasRouteLookup},
|
|
{"multi-hop", SelectorQuoteExactInput, []byte{0x01}, GasQuoteBase},
|
|
} {
|
|
for _, gas := range []uint64{0, tc.fee / 2, tc.fee - 1} {
|
|
v := blueBRun(c, h.state, blueBCall(tc.sel, tc.body), gas, true)
|
|
require.Falsef(t, v.ok, "%s at %d gas must be refused", tc.name, gas)
|
|
require.Equalf(t, "out of gas", v.err,
|
|
"%s at %d gas: the shortfall is reported, masking the malformed body", tc.name, gas)
|
|
require.Zerof(t, v.gas, "%s at %d gas: an under-funded refusal leaves nothing", tc.name, gas)
|
|
}
|
|
// One gas above the fee, the same body gets its real diagnosis.
|
|
v := blueBRun(c, h.state, blueBCall(tc.sel, tc.body), tc.fee, true)
|
|
require.False(t, v.ok)
|
|
require.NotEqualf(t, "out of gas", v.err, "%s: funded, the caller learns why the body was refused", tc.name)
|
|
require.Zerof(t, v.gas, "%s: exactly the fee was charged", tc.name)
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// 6. The views are views
|
|
// =========================================================================
|
|
|
|
// TestBlueBRouterViewsWriteNothingAndIgnoreStaticMode: Run never consults readOnly.
|
|
// That is only safe if no reachable arm writes state. Assert it directly — every
|
|
// selector, in both modes, over identical state: the answers must match each other
|
|
// and the storage trie must be byte-identical before and after.
|
|
//
|
|
// If a value-moving arm were ever restored to this dispatch without a readOnly
|
|
// check, this test fails: it would write in static mode, or answer differently.
|
|
func TestBlueBRouterViewsWriteNothingAndIgnoreStaticMode(t *testing.T) {
|
|
h := newSettleHarness(t)
|
|
withV2Configured(t)
|
|
a, b, cAddr := blueBAddr(1), blueBAddr(2), blueBAddr(3)
|
|
blueBBindV2(t, h.state.stateDB, a, b, 1_000_000, 2_000_000, 30)
|
|
blueBBindV2(t, h.state.stateDB, b, cAddr, 5_000_000, 3_000_000, 30)
|
|
|
|
c := blueBLive()
|
|
blueBPool(t, h.state.stateDB, a, b, Fee030, TickSpacing030)
|
|
|
|
inputs := map[string][]byte{
|
|
"quoteSingle": blueBCall(SelectorQuoteExactInputSingle, blueBQuoteBody(a, b, big.NewInt(1_000), Fee030)),
|
|
"bestRoute": blueBCall(SelectorGetBestRoute, blueBQuoteBody(a, b, big.NewInt(1_000), Fee030)),
|
|
"multiHop": blueBCall(SelectorQuoteExactInput, blueBSimple([]common.Address{a, b, cAddr}, big.NewInt(1_000))),
|
|
"v4Hop": blueBCall(SelectorQuoteExactInput, blueBV4(a,
|
|
[]PathKey{{IntermediateCurrency: b, Fee: Fee030, TickSpacing: TickSpacing030}}, big.NewInt(1_000))),
|
|
"retired": blueBCall(SelectorExactInput, make([]byte, 200)),
|
|
"unknown": blueBCall(0xDEADBEEF, make([]byte, 80)),
|
|
"tooShort": []byte{0x0B},
|
|
}
|
|
|
|
before := blueBStorage(h.state.stateDB)
|
|
logsBefore := len(h.state.stateDB.Logs())
|
|
|
|
for name, in := range inputs {
|
|
ro := blueBRun(c, h.state, in, 1_000_000, true)
|
|
rw := blueBRun(c, h.state, in, 1_000_000, false)
|
|
require.Equalf(t, ro.ok, rw.ok, "%s: static mode must not change the verdict", name)
|
|
require.Equalf(t, ro.err, rw.err, "%s: static mode must not change the error", name)
|
|
require.Equalf(t, ro.out, rw.out, "%s: static mode must not change the answer", name)
|
|
require.Equalf(t, ro.gas, rw.gas, "%s: static mode must not change the price", name)
|
|
}
|
|
|
|
require.Equal(t, before, blueBStorage(h.state.stateDB),
|
|
"a router view must not write a single storage slot, in either mode")
|
|
require.Equal(t, logsBefore, len(h.state.stateDB.Logs()),
|
|
"a router view must not emit a log")
|
|
}
|
|
|
|
// TestBlueBNoDispatchedPathTouchesTheRetiredNamespace pins the storage decision
|
|
// behind LXPoolAddress. The PoolManager lineage keys its pool, position, tick,
|
|
// binding and ERC-20 vault slots under that address; 0x9999 keys the money path
|
|
// under LXSettleAddress. Two namespaces are a split brain only if a dispatched
|
|
// selector reads one while another writes the other — and the router's V4 quote leg
|
|
// was exactly that reader, pricing off slots the money path never wrote.
|
|
//
|
|
// The write half is already closed by construction: nothing is registered at 0x9010
|
|
// (TestDex9010_NotRegistered) and 0x9012's value selectors revert PRECOMPILE_MOVED
|
|
// (TestRouterValueSelectorsAreRetired), so no dispatched call reaches a PoolManager
|
|
// mutation. This asserts the read half over the whole dispatched surface, counting
|
|
// reads as well as writes. Nothing reaches the retired namespace, so leaving the
|
|
// constant where it is orphans nothing.
|
|
func TestBlueBNoDispatchedPathTouchesTheRetiredNamespace(t *testing.T) {
|
|
h := newSettleHarness(t)
|
|
withV2Configured(t)
|
|
c := blueBLive()
|
|
tin, tout := blueBAddr(1), blueBAddr(2)
|
|
|
|
// A fixture rich enough that every arm below does real work rather than bailing
|
|
// out early: a registered market for the V4 leg, bound reserves for the V2 leg.
|
|
blueBPool(t, h.state.stateDB, tin, tout, Fee030, TickSpacing030)
|
|
blueBPool(t, h.state.stateDB, tout, blueBAddr(3), Fee030, TickSpacing030)
|
|
blueBBindV2(t, h.state.stateDB, tin, tout, 1_000_000, 2_000_000, 30)
|
|
h.registerMarket(t)
|
|
h.fundCallerNative(1_000_000)
|
|
|
|
var touches int
|
|
watch := blueBWatchState{nativeAtomicState: h.state, addr: poolManagerAddr, n: &touches}
|
|
|
|
// Every dispatched router selector, over a pair each of them can price.
|
|
for _, in := range [][]byte{
|
|
blueBCall(SelectorQuoteExactInputSingle, blueBQuoteBody(tin, tout, big.NewInt(1_000), Fee030)),
|
|
blueBCall(SelectorGetBestRoute, blueBQuoteBody(tin, tout, big.NewInt(1_000), 0)),
|
|
blueBCall(SelectorQuoteExactInput, blueBSimple([]common.Address{tin, tout, blueBAddr(3)}, big.NewInt(1_000))),
|
|
blueBCall(SelectorQuoteExactInput, blueBV4(tin,
|
|
[]PathKey{{IntermediateCurrency: tout, Fee: Fee030, TickSpacing: TickSpacing030}}, big.NewInt(1_000))),
|
|
} {
|
|
v := blueBRun(c, watch, in, 1_000_000, true)
|
|
require.Truef(t, v.ok, "the fixture must let every router arm do real work: %s", v.err)
|
|
}
|
|
|
|
// And the money path, whose own namespace is the other half of the question.
|
|
out, _, err := h.c.Run(watch, h.caller, poolManagerAddr9999,
|
|
prependSelector(SelectorSwap, h.crossCalldata()), 5_000_000, false)
|
|
require.NoError(t, err, "a Phase-A order must settle so the arm is exercised")
|
|
require.Len(t, out, 32, "Phase A returns a claim id")
|
|
|
|
require.Zerof(t, touches,
|
|
"the dispatched surface touched the retired %s namespace %d times", poolManagerAddr, touches)
|
|
}
|
|
|
|
// TestBlueBQuoteRetainsNoProcessMemory. A quote reads the registry and returns; it
|
|
// retains nothing about the pair it was asked about.
|
|
//
|
|
// The quote selectors are read-only views, reachable by eth_call at no cost to the
|
|
// caller and by STATICCALL for the flat quote fee, and the pair is entirely
|
|
// caller-chosen. So any per-pair retention on a process singleton is unbounded
|
|
// growth an attacker drives for free, and it lives as long as the node. The V4 leg
|
|
// used to reach getPool, which caches every id it is asked about — including ids
|
|
// with no pool, stored as a fresh empty Pool and evicted only by a repeat lookup of
|
|
// the SAME id, which a caller who never repeats a pair never triggers.
|
|
//
|
|
// The registry read has no cache to grow, and the singleton's pool maps are now
|
|
// untouched by any quote.
|
|
func TestBlueBQuoteRetainsNoProcessMemory(t *testing.T) {
|
|
h := newSettleHarness(t)
|
|
c := blueBLive()
|
|
pm := DEXPrecompile.poolManager
|
|
before := len(pm.pools) + len(pm.poolStates) + len(pm.positions)
|
|
|
|
const calls = 200
|
|
for i := range calls {
|
|
// A distinct pair per call: nothing is ever looked up twice.
|
|
in := blueBQuoteBody(blueBAddr(byte(i%250+1)), common.BigToAddress(big.NewInt(int64(i)+1<<40)),
|
|
big.NewInt(1_000), 0)
|
|
v := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle, in), 1_000_000, true)
|
|
require.Falsef(t, v.ok, "call %d prices nothing — no market is registered for this pair", i)
|
|
}
|
|
|
|
after := len(pm.pools) + len(pm.poolStates) + len(pm.positions)
|
|
require.Equalf(t, before, after,
|
|
"%d free read-only quotes over fresh pairs retained %d process-cache entries",
|
|
calls, after-before)
|
|
}
|
|
|
|
// =========================================================================
|
|
// 7. Configuration
|
|
// =========================================================================
|
|
|
|
// TestBlueBConfigureAppliesEveryAddress: the router's four venue addresses are
|
|
// PROCESS globals assigned by Configure, each behind a non-zero guard. All four
|
|
// assignments are asserted with addresses that are REAL 40-hex-digit values —
|
|
// common.HexToAddress silently yields the ZERO address for a string that is not
|
|
// valid hex, and a zero address is exactly what the guards skip, so a test written
|
|
// with a malformed literal asserts nothing about the guarded branch.
|
|
func TestBlueBConfigureAppliesEveryAddress(t *testing.T) {
|
|
prev3q, prev3f, prev2r, prev2f := v3QuoterAddr, v3FactoryAddr, v2RouterAddr, v2FactoryAddr
|
|
t.Cleanup(func() {
|
|
v3QuoterAddr, v3FactoryAddr, v2RouterAddr, v2FactoryAddr = prev3q, prev3f, prev2r, prev2f
|
|
})
|
|
|
|
cfgr := &routerConfigurator{}
|
|
q := common.HexToAddress("0x00000000000000000000000000000000000000A1")
|
|
f3 := common.HexToAddress("0x00000000000000000000000000000000000000F3")
|
|
r2 := common.HexToAddress("0x00000000000000000000000000000000000000B2")
|
|
f2 := common.HexToAddress("0x00000000000000000000000000000000000000F2")
|
|
for _, a := range []common.Address{q, f3, r2, f2} {
|
|
require.NotEqual(t, common.Address{}, a, "a fixture address must be non-zero or it tests the wrong branch")
|
|
}
|
|
|
|
// Each field applies on its OWN, so a config that sets one does not smear onto
|
|
// the others. Start from a known zero baseline for exactly this comparison.
|
|
v3QuoterAddr, v3FactoryAddr, v2RouterAddr, v2FactoryAddr = common.Address{}, common.Address{}, common.Address{}, common.Address{}
|
|
for _, tc := range []struct {
|
|
name string
|
|
cfg *RouterConfig
|
|
want [4]common.Address
|
|
}{
|
|
{"v3 quoter only", &RouterConfig{V3QuoterAddress: q}, [4]common.Address{q, {}, {}, {}}},
|
|
{"v3 factory only", &RouterConfig{V3FactoryAddress: f3}, [4]common.Address{q, f3, {}, {}}},
|
|
{"v2 router only", &RouterConfig{V2RouterAddress: r2}, [4]common.Address{q, f3, r2, {}}},
|
|
{"v2 factory only", &RouterConfig{V2FactoryAddress: f2}, [4]common.Address{q, f3, r2, f2}},
|
|
} {
|
|
require.NoErrorf(t, cfgr.Configure(nil, tc.cfg, nil, nil), "%s", tc.name)
|
|
require.Equalf(t, tc.want, [4]common.Address{v3QuoterAddr, v3FactoryAddr, v2RouterAddr, v2FactoryAddr},
|
|
"%s: each address applies independently and none is cleared", tc.name)
|
|
}
|
|
|
|
// The configured V2 address is what actually opens the venue: with it set, a
|
|
// bound pair quotes; zeroed, the same pair does not. That is the assignment
|
|
// having an observable effect rather than only a variable changing.
|
|
h := newSettleHarness(t)
|
|
c := blueBLive()
|
|
tin, tout := blueBAddr(1), blueBAddr(2)
|
|
blueBBindV2(t, h.state.stateDB, tin, tout, 1_000_000, 2_000_000, 30)
|
|
in := blueBCall(SelectorQuoteExactInputSingle, blueBQuoteBody(tin, tout, big.NewInt(1_000), 0))
|
|
|
|
v2RouterAddr = common.Address{}
|
|
require.False(t, blueBRun(c, h.state, in, 1_000_000, true).ok,
|
|
"with no V2 address configured, a bound pair has no venue")
|
|
|
|
require.NoError(t, cfgr.Configure(nil, &RouterConfig{
|
|
V2RouterAddress: common.HexToAddress(DEXPoolManagerAddress),
|
|
}, nil, nil))
|
|
v := blueBRun(c, h.state, in, 1_000_000, true)
|
|
require.True(t, v.ok, "configuring the V2 address opens the venue: %s", v.err)
|
|
require.Equal(t, VenueV2, blueBDecodeQuotes(t, v.out)[0].venue)
|
|
}
|
|
|
|
// =========================================================================
|
|
// 8. The exported decoders (no dispatch reaches them; downstream clients do)
|
|
// =========================================================================
|
|
|
|
// blueBOutSingle compares two SwapExactOutputSingleParams field by field. Written
|
|
// out rather than reflected so a nil *big.Int is distinguished from a zero one.
|
|
func blueBOutSingle(t testing.TB, label string, got, want SwapExactOutputSingleParams) {
|
|
t.Helper()
|
|
require.Equalf(t, want.TokenIn, got.TokenIn, "%s tokenIn", label)
|
|
require.Equalf(t, want.TokenOut, got.TokenOut, "%s tokenOut", label)
|
|
require.Equalf(t, want.Fee, got.Fee, "%s fee", label)
|
|
require.Equalf(t, want.TickSpacing, got.TickSpacing, "%s tickSpacing", label)
|
|
require.Equalf(t, want.Hooks, got.Hooks, "%s hooks", label)
|
|
require.Equalf(t, want.Deadline, got.Deadline, "%s deadline", label)
|
|
require.Equalf(t, 0, got.AmountOut.Cmp(want.AmountOut), "%s amountOut: got %s want %s", label, got.AmountOut, want.AmountOut)
|
|
require.Equalf(t, 0, got.AmountInMaximum.Cmp(want.AmountInMaximum), "%s amountInMaximum", label)
|
|
if want.SqrtPriceLimitX96 == nil {
|
|
require.Nilf(t, got.SqrtPriceLimitX96, "%s sqrtPriceLimit must stay unset when absent", label)
|
|
} else {
|
|
require.NotNilf(t, got.SqrtPriceLimitX96, "%s sqrtPriceLimit", label)
|
|
require.Equalf(t, 0, got.SqrtPriceLimitX96.Cmp(want.SqrtPriceLimitX96), "%s sqrtPriceLimit", label)
|
|
}
|
|
}
|
|
|
|
// TestBlueBExactOutputSingleOptionalTailMisassignsBytes sweeps the optional tail of
|
|
// DecodeExactOutputSingleParams one byte at a time and finds it POSITIONALLY
|
|
// AMBIGUOUS.
|
|
//
|
|
// Each optional field is guarded independently, and the running offset advances only
|
|
// when a field is taken. So when a field is skipped for want of room, a LATER field
|
|
// whose own guard happens to pass reads the skipped field's bytes instead of its
|
|
// own. There is no error and no way for the caller to tell.
|
|
//
|
|
// Measured, on a body carrying tickSpacing and a truncated hooks address:
|
|
//
|
|
// 118..129 bytes -> Deadline is read from input[110:118], the first eight bytes of
|
|
// the HOOKS address. A hooks address of 0xdeadbeef... becomes a
|
|
// deadline of 0xdeadbeefdeadbeef.
|
|
// 138..161 bytes -> Deadline is read from input[130:138], the first eight bytes of
|
|
// the SQRT PRICE LIMIT.
|
|
//
|
|
// The same shape is in DecodeExactInputSingleParams, byte for byte.
|
|
//
|
|
// Two things this is NOT: it is not a read past len (the sweep runs every length
|
|
// against a 0xA5-poisoned buffer and the verdicts match exactly, so the length
|
|
// guards themselves are sound), and it is not reachable through 0x9012's dispatch
|
|
// today, because the exact-output selectors revert PRECOMPILE_MOVED before any
|
|
// decoder runs. It is an exported decoder that a downstream client calls directly,
|
|
// and it is what the retired value path would parse if it ever came back.
|
|
func TestBlueBExactOutputSingleOptionalTailMisassignsBytes(t *testing.T) {
|
|
tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222")
|
|
hooks := common.HexToAddress("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
|
|
amountOut := big.NewInt(1_000)
|
|
amountInMax := big.NewInt(2_000)
|
|
limit := new(big.Int).Lsh(big.NewInt(1), 160) // wider than a word's low half, so truncation would show
|
|
const deadline uint64 = 0xFEDCBA9876543210
|
|
// A NEGATIVE tick spacing: the sign must survive the 3-byte encoding.
|
|
const spacing int24 = -60
|
|
|
|
full := make([]byte, 0, 170)
|
|
full = append(full, tokenIn.Bytes()...)
|
|
full = append(full, tokenOut.Bytes()...)
|
|
full = append(full, common.LeftPadBytes(amountOut.Bytes(), 32)...)
|
|
full = append(full, common.LeftPadBytes(amountInMax.Bytes(), 32)...)
|
|
var feeBytes [4]byte
|
|
binary.BigEndian.PutUint32(feeBytes[:], Fee030)
|
|
full = append(full, feeBytes[1:4]...)
|
|
full = append(full, int24ToBytes(spacing)...)
|
|
full = append(full, hooks.Bytes()...)
|
|
full = append(full, common.LeftPadBytes(limit.Bytes(), 32)...)
|
|
var dl [8]byte
|
|
binary.BigEndian.PutUint64(dl[:], deadline)
|
|
full = append(full, dl[:]...)
|
|
require.Len(t, full, 170, "the full ExactOutputSingle body is 107 fixed + 63 optional")
|
|
|
|
// The decoder's ACTUAL rule, offsets and all. Field k is taken only if the bytes
|
|
// remaining from the CURRENT offset cover it; the offset advances only on a take.
|
|
want := func(n int) SwapExactOutputSingleParams {
|
|
p := SwapExactOutputSingleParams{
|
|
TokenIn: tokenIn, TokenOut: tokenOut,
|
|
AmountOut: amountOut, AmountInMaximum: amountInMax, Fee: Fee030,
|
|
}
|
|
off := 107
|
|
if n >= off+3 {
|
|
p.TickSpacing = spacing
|
|
off += 3
|
|
}
|
|
if n >= off+20 {
|
|
p.Hooks = hooks
|
|
off += 20
|
|
}
|
|
if n >= off+32 {
|
|
p.SqrtPriceLimitX96 = limit
|
|
off += 32
|
|
}
|
|
if n >= off+8 {
|
|
// Whatever eight bytes sit at `off` — the caller's deadline only when
|
|
// every earlier field was taken.
|
|
p.Deadline = binary.BigEndian.Uint64(full[off : off+8])
|
|
}
|
|
return p
|
|
}
|
|
|
|
for n := 107; n <= len(full); n++ {
|
|
clean, err := DecodeExactOutputSingleParams(full[:n])
|
|
require.NoErrorf(t, err, "%d bytes is at or above the 107-byte floor", n)
|
|
blueBOutSingle(t, fmt.Sprintf("clean n=%d", n), clean, want(n))
|
|
|
|
// The length guards themselves are sound: poisoned spare capacity behind the
|
|
// same length decodes identically, so nothing is read past len.
|
|
dirty, err := DecodeExactOutputSingleParams(blueBPoisoned(full[:n], 256))
|
|
require.NoErrorf(t, err, "poisoned %d bytes", n)
|
|
blueBOutSingle(t, fmt.Sprintf("poisoned n=%d", n), dirty, want(n))
|
|
}
|
|
|
|
// The misassignment, named and pinned at both windows where it occurs.
|
|
hooksHead := binary.BigEndian.Uint64(hooks.Bytes()[:8])
|
|
for n := 118; n < 130; n++ {
|
|
p, err := DecodeExactOutputSingleParams(full[:n])
|
|
require.NoErrorf(t, err, "n=%d", n)
|
|
require.Equalf(t, common.Address{}, p.Hooks, "n=%d: hooks does not fit and is left unset", n)
|
|
require.Equalf(t, hooksHead, p.Deadline,
|
|
"n=%d: the deadline is the HEAD OF THE HOOKS ADDRESS, accepted without error", n)
|
|
require.NotEqualf(t, deadline, p.Deadline, "n=%d: and it is not the caller's deadline", n)
|
|
}
|
|
limitHead := binary.BigEndian.Uint64(common.LeftPadBytes(limit.Bytes(), 32)[:8])
|
|
for n := 138; n < 162; n++ {
|
|
p, err := DecodeExactOutputSingleParams(full[:n])
|
|
require.NoErrorf(t, err, "n=%d", n)
|
|
require.Nilf(t, p.SqrtPriceLimitX96, "n=%d: the price limit does not fit and is left unset", n)
|
|
require.Equalf(t, limitHead, p.Deadline,
|
|
"n=%d: the deadline is the HEAD OF THE PRICE LIMIT", n)
|
|
}
|
|
// Only the full body yields the caller's own deadline.
|
|
full170, err := DecodeExactOutputSingleParams(full)
|
|
require.NoError(t, err)
|
|
require.Equal(t, deadline, full170.Deadline, "a complete body decodes the deadline the caller sent")
|
|
require.Equal(t, hooks, full170.Hooks)
|
|
require.Equal(t, spacing, full170.TickSpacing, "a negative tick spacing must survive the 3-byte encoding")
|
|
require.Equal(t, 0, full170.SqrtPriceLimitX96.Cmp(limit))
|
|
|
|
// The exact-INPUT twin has the identical shape, so it carries the identical bug.
|
|
twin, err := DecodeExactInputSingleParams(full[:118])
|
|
require.NoError(t, err)
|
|
require.Equal(t, common.Address{}, twin.Hooks)
|
|
require.Equal(t, hooksHead, twin.Deadline,
|
|
"DecodeExactInputSingleParams misassigns the same bytes at the same length")
|
|
|
|
// Below the floor: refused at every length, clean or poisoned, never a panic.
|
|
for n := range 107 {
|
|
require.NotPanicsf(t, func() {
|
|
_, err := DecodeExactOutputSingleParams(full[:n])
|
|
require.Errorf(t, err, "%d bytes is below the floor", n)
|
|
_, err = DecodeExactOutputSingleParams(blueBPoisoned(full[:n], 256))
|
|
require.Errorf(t, err, "poisoned %d bytes is below the floor", n)
|
|
}, "%d bytes must not panic", n)
|
|
}
|
|
}
|
|
|
|
// TestBlueBExactOutputV4PathDecodes covers the V4 branch of DecodeExactOutputParams
|
|
// end to end, and pins the layout PARITY with its exact-input twin: both decoders
|
|
// read the same windows out of the same bytes, differing only in what they name the
|
|
// two amounts. A drift between them would mean one of the two reads a different
|
|
// field than the encoder wrote.
|
|
func TestBlueBExactOutputV4PathDecodes(t *testing.T) {
|
|
currency := blueBAddr(7)
|
|
keys := []PathKey{
|
|
{IntermediateCurrency: blueBAddr(8), Fee: Fee030, TickSpacing: TickSpacing030,
|
|
Hooks: common.HexToAddress("0x00000000000000000000000000000000000000AA")},
|
|
{IntermediateCurrency: blueBAddr(9), Fee: Fee005, TickSpacing: -TickSpacing005},
|
|
}
|
|
amountOut := big.NewInt(123_456)
|
|
amountInMax := new(big.Int).Lsh(big.NewInt(1), 200)
|
|
const deadline uint64 = 0x0102030405060708
|
|
|
|
build := func(withDeadline bool) []byte {
|
|
path := EncodePath(currency, keys)
|
|
out := []byte{0xFF, 0, 0}
|
|
binary.BigEndian.PutUint16(out[1:3], uint16(len(path)))
|
|
out = append(out, path...)
|
|
out = append(out, common.LeftPadBytes(amountOut.Bytes(), 32)...)
|
|
out = append(out, common.LeftPadBytes(amountInMax.Bytes(), 32)...)
|
|
if withDeadline {
|
|
var dl [8]byte
|
|
binary.BigEndian.PutUint64(dl[:], deadline)
|
|
out = append(out, dl[:]...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
for _, withDeadline := range []bool{true, false} {
|
|
in := build(withDeadline)
|
|
wantDeadline := uint64(0)
|
|
if withDeadline {
|
|
wantDeadline = deadline
|
|
}
|
|
for _, buf := range [][]byte{in, blueBPoisoned(in, 256)} {
|
|
p, err := DecodeExactOutputParams(buf)
|
|
require.NoErrorf(t, err, "deadline=%v", withDeadline)
|
|
require.Equal(t, currency, p.CurrencyOut, "the V4 branch names the path's leading address CurrencyOut")
|
|
require.Nil(t, p.Path, "the V4 branch must not also populate the simple path")
|
|
require.Len(t, p.PathKeys, len(keys))
|
|
for i := range keys {
|
|
require.Equalf(t, keys[i], p.PathKeys[i], "hop %d must survive the wire exactly", i)
|
|
}
|
|
require.Equal(t, 0, p.AmountOut.Cmp(amountOut))
|
|
require.Equal(t, 0, p.AmountInMaximum.Cmp(amountInMax))
|
|
require.Equalf(t, wantDeadline, p.Deadline,
|
|
"an absent deadline must stay zero, never be read from beyond the end")
|
|
}
|
|
|
|
// Layout parity with the exact-input decoder over the SAME bytes.
|
|
out, err := DecodeExactOutputParams(in)
|
|
require.NoError(t, err)
|
|
inp, err := DecodeExactInputParams(in)
|
|
require.NoError(t, err)
|
|
require.Equal(t, out.CurrencyOut, inp.CurrencyIn, "both decoders read the path's address from the same window")
|
|
require.Equal(t, out.PathKeys, inp.PathKeys)
|
|
require.Equal(t, 0, out.AmountOut.Cmp(inp.AmountIn), "the first amount word is the same window in both")
|
|
require.Equal(t, 0, out.AmountInMaximum.Cmp(inp.AmountOutMinimum))
|
|
require.Equal(t, out.Deadline, inp.Deadline)
|
|
|
|
// A pathLen that overruns the buffer is refused, not read past.
|
|
for _, claim := range []uint16{uint16(len(in)), 60_000, 0xFFFF} {
|
|
bad := append([]byte(nil), in...)
|
|
binary.BigEndian.PutUint16(bad[1:3], claim)
|
|
require.NotPanicsf(t, func() {
|
|
_, err := DecodeExactOutputParams(blueBPoisoned(bad, 4096))
|
|
require.Errorf(t, err, "a declared path length of %d must be refused", claim)
|
|
}, "declared path length %d must not read past the end", claim)
|
|
}
|
|
|
|
// A pathLen that FITS but describes no whole number of hops is a different
|
|
// refusal, and the one worth separating: the buffer is long enough, so the
|
|
// length guard passes and the PATH STRUCTURE is what refuses. A caller must
|
|
// not be able to shorten the declared path and have the leftover bytes read
|
|
// as amounts.
|
|
for _, claim := range []uint16{0, 1, 19, 20, 21, 45, 65, 67} {
|
|
bad := append([]byte(nil), in...)
|
|
binary.BigEndian.PutUint16(bad[1:3], claim)
|
|
require.GreaterOrEqual(t, len(bad), 3+int(claim)+64,
|
|
"claim %d must FIT, so the length guard is not what refuses it", claim)
|
|
_, err := DecodeExactOutputParams(blueBPoisoned(bad, 1024))
|
|
require.Errorf(t, err, "a %d-byte path is not a whole number of hops", claim)
|
|
require.Containsf(t, err.Error(), "decode V4 path",
|
|
"claim %d must be refused by the path decoder, not by the length check", claim)
|
|
|
|
// The exact-input twin refuses the same bytes the same way.
|
|
_, err = DecodeExactInputParams(blueBPoisoned(bad, 1024))
|
|
require.Errorf(t, err, "the input decoder must refuse claim %d too", claim)
|
|
require.Containsf(t, err.Error(), "decode V4 path", "claim %d", claim)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBlueBExactOutputSimplePathRejectsDegenerateCounts: the simple format's leading
|
|
// byte is a token count, and a path of fewer than two tokens is not a path. Both
|
|
// degenerate counts must be refused BY COUNT — before any length arithmetic — so a
|
|
// caller cannot get an empty or single-element Path back.
|
|
func TestBlueBExactOutputSimplePathRejectsDegenerateCounts(t *testing.T) {
|
|
tail := make([]byte, 72) // amountOut(32) + amountInMax(32) + deadline(8)
|
|
for _, n := range []byte{0, 1} {
|
|
body := append([]byte{n}, make([]byte, int(n)*20)...)
|
|
body = append(body, tail...)
|
|
for _, buf := range [][]byte{body, blueBPoisoned(body, 512)} {
|
|
_, err := DecodeExactOutputParams(buf)
|
|
require.Errorf(t, err, "a %d-token path must be refused", n)
|
|
require.Containsf(t, err.Error(), "at least 2 tokens", "a %d-token path is refused by COUNT", n)
|
|
_, err = DecodeExactInputParams(buf)
|
|
require.Errorf(t, err, "a %d-token path must be refused by the input decoder too", n)
|
|
}
|
|
}
|
|
// Two tokens is the first accepted count, and it yields exactly two.
|
|
body := append([]byte{2}, make([]byte, 40)...)
|
|
body = append(body, tail...)
|
|
p, err := DecodeExactOutputParams(body)
|
|
require.NoError(t, err, "two tokens is the smallest real path")
|
|
require.Len(t, p.Path, 2)
|
|
}
|
|
|
|
// =========================================================================
|
|
// 9. Deadline and slippage: what is actually enforced
|
|
// =========================================================================
|
|
|
|
// TestBlueBDeadlineWindowsAreExclusiveAndTotal sweeps checkDeadline across its
|
|
// boundary instead of sampling one point. For every non-zero deadline, exactly one
|
|
// of {allowed, expired} must hold, and the split must be at `blockNumber > deadline`
|
|
// — so a deadline EQUAL to the current height is still live. Two windows that both
|
|
// admitted the boundary would let one caller's transaction be simultaneously in time
|
|
// and out of time.
|
|
//
|
|
// It also pins the top of the uint64 domain: nothing is ever added to the deadline,
|
|
// so MaxUint64 cannot wrap into the past.
|
|
//
|
|
// And it records the two facts that matter more than the arithmetic: the comparison
|
|
// reads the BLOCK NUMBER (the only clock the state exposes here) while the field it
|
|
// compares is called a deadline and the sibling error documents it as
|
|
// block.timestamp; and NOTHING on the router calls this function.
|
|
func TestBlueBDeadlineWindowsAreExclusiveAndTotal(t *testing.T) {
|
|
sdb := NewMockStateDB()
|
|
|
|
// A zero deadline is "no deadline" at every height, including the extremes.
|
|
for _, height := range []uint64{0, 1, 1 << 32, ^uint64(0)} {
|
|
sdb.SetBlockNumber(height)
|
|
require.NoErrorf(t, checkDeadline(sdb, 0), "height %d: a zero deadline never expires", height)
|
|
}
|
|
|
|
for _, height := range []uint64{0, 1, 2, 1_000, 1 << 32, ^uint64(0) - 1, ^uint64(0)} {
|
|
sdb.SetBlockNumber(height)
|
|
for _, delta := range []int64{-2, -1, 0, 1, 2} {
|
|
// Skip deadlines that fall outside the domain or land on the "no deadline" value.
|
|
if delta < 0 && height < uint64(-delta) {
|
|
continue
|
|
}
|
|
if delta > 0 && height > ^uint64(0)-uint64(delta) {
|
|
continue
|
|
}
|
|
deadline := height
|
|
if delta < 0 {
|
|
deadline = height - uint64(-delta)
|
|
} else {
|
|
deadline = height + uint64(delta)
|
|
}
|
|
if deadline == 0 {
|
|
continue
|
|
}
|
|
err := checkDeadline(sdb, deadline)
|
|
expired := err != nil
|
|
require.Equalf(t, height > deadline, expired,
|
|
"height=%d deadline=%d: the split must be strictly `height > deadline`", height, deadline)
|
|
if expired {
|
|
require.ErrorIsf(t, err, ErrDeadlineExpired, "height=%d deadline=%d", height, deadline)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The boundary itself, stated once more on its own: equal is IN time, one past is out.
|
|
sdb.SetBlockNumber(1_000)
|
|
require.NoError(t, checkDeadline(sdb, 1_000), "a deadline equal to the current height is still live")
|
|
require.Error(t, checkDeadline(sdb, 999), "one block earlier is expired")
|
|
require.NoError(t, checkDeadline(sdb, 1_001), "one block later is live")
|
|
|
|
// Top of the domain: no overflow, because nothing is added to the deadline.
|
|
sdb.SetBlockNumber(^uint64(0))
|
|
require.NoError(t, checkDeadline(sdb, ^uint64(0)), "MaxUint64 must not wrap into the past")
|
|
require.Error(t, checkDeadline(sdb, ^uint64(0)-1))
|
|
|
|
// The comparison is against the BLOCK NUMBER: the only quantity varied here is
|
|
// the height, and the verdict follows it.
|
|
sdb.SetBlockNumber(500)
|
|
require.NoError(t, checkDeadline(sdb, 500))
|
|
sdb.SetBlockNumber(501)
|
|
require.Error(t, checkDeadline(sdb, 500), "advancing only the block NUMBER expires the deadline")
|
|
}
|
|
|
|
// TestBlueBRouterEnforcesNoSlippageOrDeadline states the surface honestly: the
|
|
// decoders parse AmountOutMinimum, AmountInMaximum and Deadline, and no reachable
|
|
// router path consults any of them. The value-moving selectors that once did are
|
|
// retired, so there is no minimum-output comparison left on 0x9012 to have a
|
|
// boundary at all.
|
|
//
|
|
// Asserted behaviourally: a quote whose declared minimum output is the largest
|
|
// number expressible, and whose deadline is long past, returns the SAME answer as
|
|
// one with both fields zeroed. If either were ever enforced here, these diverge.
|
|
func TestBlueBRouterEnforcesNoSlippageOrDeadline(t *testing.T) {
|
|
h := newSettleHarness(t)
|
|
withV2Configured(t)
|
|
a, b := blueBAddr(1), blueBAddr(2)
|
|
blueBBindV2(t, h.state.stateDB, a, b, 1_000_000, 2_000_000, 30)
|
|
require.Equal(t, uint64(1), h.state.stateDB.GetBlockNumber(), "the harness is at height 1, so any past deadline is expired")
|
|
|
|
// Same path, same amount; only the minimum-output and deadline words differ.
|
|
base := blueBSimple([]common.Address{a, b}, big.NewInt(10_000))
|
|
extreme := append([]byte(nil), base...)
|
|
for i := len(extreme) - 40; i < len(extreme)-8; i++ {
|
|
extreme[i] = 0xFF // amountOutMinimum = 2^256-1
|
|
}
|
|
// deadline = 1, i.e. the current height; and then a deadline in the past.
|
|
past := append([]byte(nil), extreme...)
|
|
binary.BigEndian.PutUint64(past[len(past)-8:], 0)
|
|
expired := append([]byte(nil), extreme...)
|
|
binary.BigEndian.PutUint64(expired[len(expired)-8:], 1)
|
|
|
|
// The declared fields really are decoded — this is not a claim that they vanish.
|
|
p, err := DecodeExactInputParams(extreme)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0, p.AmountOutMinimum.Cmp(new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))),
|
|
"the decoder does read a maximal minimum-output")
|
|
|
|
want := blueBRun(RouterPrecompile, h.state, blueBCall(SelectorQuoteExactInput, base), 1_000_000, true)
|
|
require.True(t, want.ok, "the bound pair must quote: %s", want.err)
|
|
for name, in := range map[string][]byte{
|
|
"maximal amountOutMinimum": extreme,
|
|
"zero deadline": past,
|
|
"deadline at height 1": expired,
|
|
} {
|
|
got := blueBRun(RouterPrecompile, h.state, blueBCall(SelectorQuoteExactInput, in), 1_000_000, true)
|
|
require.Truef(t, got.ok, "%s: %s", name, got.err)
|
|
require.Equalf(t, want.out, got.out, "%s: no router path consults this field", name)
|
|
require.Equalf(t, want.gas, got.gas, "%s", name)
|
|
}
|
|
require.Less(t, new(big.Int).SetBytes(want.out).Cmp(p.AmountOutMinimum), 0,
|
|
"the quoted output is far below the declared minimum, and is returned anyway")
|
|
}
|
|
|
|
// =========================================================================
|
|
// 10. The quote encoder
|
|
// =========================================================================
|
|
|
|
// TestBlueBEncodeQuoteResultsCountByteTruncates pins two silent truncations in
|
|
// EncodeQuoteResults, the exported wire format every quote answer is read from.
|
|
//
|
|
// - The result COUNT is written into a single byte. At 256 results the byte reads
|
|
// 0 while the buffer is 18689 bytes, so a decoder that trusts the count — which
|
|
// is the only thing telling it how many to read — sees an EMPTY answer from a
|
|
// full one. 256 is not reachable from calldata (a chain would have to register
|
|
// 253 external venue quoters), but the encoder is exported and offers no refusal.
|
|
// - An AmountOut wider than 32 bytes is not refused either: LeftPadBytes returns
|
|
// an over-long slice unchanged and the copy keeps its LEADING bytes, so the wire
|
|
// carries x>>8 for a 33-byte x — a different number, silently.
|
|
func TestBlueBEncodeQuoteResultsCountByteTruncates(t *testing.T) {
|
|
mk := func(n int) []QuoteResult {
|
|
rs := make([]QuoteResult, n)
|
|
for i := range rs {
|
|
rs[i] = QuoteResult{AmountOut: big.NewInt(int64(i + 1)), Venue: VenueV2, GasEstimate: uint64(i)}
|
|
}
|
|
return rs
|
|
}
|
|
|
|
// Below the byte's range the encoding is exact and self-describing.
|
|
for _, n := range []int{0, 1, 2, 8, 254, 255} {
|
|
enc := EncodeQuoteResults(mk(n))
|
|
require.Lenf(t, enc, 1+n*73, "%d results", n)
|
|
require.Equalf(t, byte(n), enc[0], "%d results must be declared as %d", n, n)
|
|
}
|
|
|
|
// At 256 the declared count and the delivered payload disagree.
|
|
enc := EncodeQuoteResults(mk(256))
|
|
require.Len(t, enc, 1+256*73, "the payload carries all 256 results")
|
|
require.Equal(t, byte(0), enc[0],
|
|
"the count byte wraps to 0: a decoder reads an empty answer out of a 18689-byte buffer")
|
|
require.Equal(t, byte(1), EncodeQuoteResults(mk(257))[0], "and 257 declares itself as 1")
|
|
|
|
// An amount wider than a word is truncated to its HIGH bytes, not refused.
|
|
wide := new(big.Int).Lsh(big.NewInt(1), 256) // 33 bytes
|
|
require.Len(t, wide.Bytes(), 33)
|
|
one := EncodeQuoteResults([]QuoteResult{{AmountOut: wide, Venue: VenueV4Native}})
|
|
require.Len(t, one, 74)
|
|
got := new(big.Int).SetBytes(one[2:34]) // count(1) | venue(1) | amountOut(32)
|
|
require.Equal(t, 0, got.Cmp(new(big.Int).Rsh(wide, 8)),
|
|
"a 33-byte amount is encoded as amount>>8 — a different number, with no error")
|
|
require.NotEqual(t, 0, got.Cmp(wide), "the encoded amount is not the amount")
|
|
|
|
// Amounts that DO fit round-trip exactly, including the largest one that fits.
|
|
maxWord := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
|
|
for _, v := range []*big.Int{big.NewInt(0), big.NewInt(1), maxWord} {
|
|
e := EncodeQuoteResults([]QuoteResult{{AmountOut: v, Venue: VenueExternal, GasEstimate: 7}})
|
|
require.Equal(t, 0, new(big.Int).SetBytes(e[2:34]).Cmp(v), "%s must round-trip", v)
|
|
require.Equal(t, byte(VenueExternal), e[1], "venue is the first byte of a result")
|
|
require.Equal(t, uint64(7), binary.BigEndian.Uint64(e[66:74]))
|
|
}
|
|
}
|