mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
A precompile panic aborts the block rather than the call, so both of these stopped every validator on any chain with stableswap enabled. An amplification coefficient of zero divides by zero in all three Newton ops -- computeY's c/(A*n*n) and D/(A*n), and computeD's denominator (A*n-1)*D + (n+1)*dP, which crosses exactly zero. A 40,000-input sweep hit it 665 times. A is now read and refused at the boundary, which is also the honest answer: the invariant is undefined at A=0. addLiquidity divides by D0, and computeD returns zero for an empty pool. Balances and totalSupply arrive as independent calldata, so "no assets, LP tokens outstanding" is reachable. Post-fix sweep: zero panics in 40,000 inputs. computeY used floor division, so its iterates drifted BELOW the true root in 55% of solves, by up to 2 wei, which getDy's single -1 margin did not cover. The invariant therefore DECREASED across a swap -- 27 losses in 19,996 random swaps, a wei at a time out of the pool. Newton converges from above on this convex function, so ceiling division restores the one-sidedness the proof depends on. After: zero losses, 19,847 gains. math returned every result through padTo32, which keeps only the low 32 bytes. MulDiv(2^255, 4, 1) returned 0. MulDiv(max, max, 1) returned 1. And dex.MulDiv PANICS on the same input -- two behaviours for one operation, one of them a plausible-looking wrong number in the primitive that computes fees and share ratios. Now refuses with ErrOverflow, matching FullMath.sol. Consensus-visible; it needs a coordinated upgrade. Coverage: dex 57.1 -> 99.1, stableswap 80.6 -> 98.6, swap 87.6 -> 99.6, v3 78.1 -> 98.7, math 78.5 -> 97.6. 50 of 52 mutations caught, both survivors proven unreachable rather than assumed. dex is now order-independent: -count=2 and -shuffle=on both pass, 843 tests run individually and 260 shuffle seeds clean. The leak was a package-level resolver latch that installed on top of whatever was live; t.Cleanup had fixed the exit and not the entry. Also corrected five comments describing the CLOB price limit as IEEE-754 float64 bits. It is exact big.Int on an integer grid, and a reviewer would have believed consensus compared float bit patterns.
335 lines
11 KiB
Go
335 lines
11 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package dex
|
|
|
|
import (
|
|
"math/big"
|
|
|
|
"github.com/luxfi/geth/common"
|
|
)
|
|
|
|
// zzpool_harness_test.go holds the doubles the pool_manager.go / erc20_vault.go
|
|
// coverage lane shares. Everything here composes the EXISTING MockStateDB
|
|
// (pool_manager_test.go) rather than reimplementing a StateDB: the only thing the
|
|
// custody path needs on top of it is the OPTIONAL txIdentified capability (a tx
|
|
// hash) and the OPTIONAL erc20Vault capability (a per-token ledger keyed on the
|
|
// 0x9010 vault address, NOT the 0x9999 one contractStateDBWrapper models).
|
|
|
|
// zzpTxDB is MockStateDB + the txIdentified capability. Deposit/Withdraw/Swap/
|
|
// ModifyLiquidity all derive their DURABLE idempotency key through this seam, so
|
|
// a StateDB without it is the "unbound" refusal path and one with a settable hash
|
|
// is every bound path.
|
|
type zzpTxDB struct {
|
|
*MockStateDB
|
|
tx common.Hash
|
|
}
|
|
|
|
func zzpNewTxDB(tx common.Hash) *zzpTxDB {
|
|
return &zzpTxDB{MockStateDB: NewMockStateDB(), tx: tx}
|
|
}
|
|
|
|
func (d *zzpTxDB) TxHash() common.Hash { return d.tx }
|
|
|
|
// zzpVaultDB is zzpTxDB + the erc20Vault capability, with an in-memory
|
|
// (token -> holder -> balance) ledger. The vault side is poolManagerAddr (0x9010),
|
|
// which is what erc20_vault.go moves value in and out of.
|
|
//
|
|
// failFrom / failTo make the token's transferFrom / transfer REVERT, which is how
|
|
// a real OZ-unsafe token surfaces (ErrERC20TransferFailed). shortBps models a
|
|
// fee-on-transfer token: the recipient receives amount*(1-shortBps/1e4), which is
|
|
// the observed-delta edge the deposit leg must credit instead of the request.
|
|
type zzpVaultDB struct {
|
|
*zzpTxDB
|
|
bal map[common.Address]map[common.Address]*big.Int
|
|
failFrom bool
|
|
failTo bool
|
|
shortBps int64
|
|
// swallow makes transferFrom succeed while delivering NOTHING — a token whose
|
|
// transfer is a silent no-op. The observed-delta measurement is the only thing
|
|
// that catches it (ErrERC20ZeroDelta).
|
|
swallow bool
|
|
// onTransferFrom / onTransferTo run INSIDE the token sub-call, which is where a
|
|
// malicious token mounts its reentrancy. This is the real attack shape the
|
|
// global custody guard exists to refuse.
|
|
onTransferFrom func()
|
|
onTransferTo func()
|
|
}
|
|
|
|
func zzpNewVaultDB(tx common.Hash) *zzpVaultDB {
|
|
return &zzpVaultDB{
|
|
zzpTxDB: zzpNewTxDB(tx),
|
|
bal: make(map[common.Address]map[common.Address]*big.Int),
|
|
}
|
|
}
|
|
|
|
func (d *zzpVaultDB) zzpSlot(token, holder common.Address) *big.Int {
|
|
if d.bal[token] == nil {
|
|
d.bal[token] = make(map[common.Address]*big.Int)
|
|
}
|
|
if d.bal[token][holder] == nil {
|
|
d.bal[token][holder] = big.NewInt(0)
|
|
}
|
|
return d.bal[token][holder]
|
|
}
|
|
|
|
// zzpMint seeds a token balance (test setup only).
|
|
func (d *zzpVaultDB) zzpMint(token, holder common.Address, amount int64) {
|
|
cur := d.zzpSlot(token, holder)
|
|
d.bal[token][holder] = new(big.Int).Add(cur, big.NewInt(amount))
|
|
}
|
|
|
|
func (d *zzpVaultDB) TokenBalanceOf(token, owner common.Address) *big.Int {
|
|
return new(big.Int).Set(d.zzpSlot(token, owner))
|
|
}
|
|
|
|
func (d *zzpVaultDB) TransferTokenFrom(token, from, to common.Address, amount *big.Int) error {
|
|
if d.onTransferFrom != nil {
|
|
d.onTransferFrom()
|
|
}
|
|
if d.failFrom {
|
|
return ErrERC20TransferFailed
|
|
}
|
|
bal := d.zzpSlot(token, from)
|
|
if bal.Cmp(amount) < 0 {
|
|
return ErrERC20TransferFailed
|
|
}
|
|
d.bal[token][from] = new(big.Int).Sub(bal, amount)
|
|
if d.swallow {
|
|
return nil // debited the sender, delivered nothing: observed delta 0
|
|
}
|
|
received := new(big.Int).Set(amount)
|
|
if d.shortBps > 0 {
|
|
fee := new(big.Int).Div(new(big.Int).Mul(amount, big.NewInt(d.shortBps)), big.NewInt(10000))
|
|
received.Sub(received, fee)
|
|
}
|
|
d.bal[token][to] = new(big.Int).Add(d.zzpSlot(token, to), received)
|
|
return nil
|
|
}
|
|
|
|
func (d *zzpVaultDB) TransferTokenTo(token, to common.Address, amount *big.Int) error {
|
|
if d.onTransferTo != nil {
|
|
d.onTransferTo()
|
|
}
|
|
if d.failTo {
|
|
return ErrERC20TransferFailed
|
|
}
|
|
bal := d.zzpSlot(token, poolManagerAddr)
|
|
if bal.Cmp(amount) < 0 {
|
|
return ErrERC20TransferFailed
|
|
}
|
|
d.bal[token][poolManagerAddr] = new(big.Int).Sub(bal, amount)
|
|
d.bal[token][to] = new(big.Int).Add(d.zzpSlot(token, to), amount)
|
|
return nil
|
|
}
|
|
|
|
// zzpLedger is the D-Chain side of custody as the PoolManager sees it: a per
|
|
// (account, asset) available balance plus a per-ref dedup index, which is exactly
|
|
// the shape custodyEngine documents. Deposits credit, withdraws debit CLAMPED to
|
|
// availability and return the realized amount — the clamp is what makes "a
|
|
// withdrawal can never exceed the deposited balance" a ledger property rather
|
|
// than a caller courtesy.
|
|
type zzpLedger struct {
|
|
mockEngine
|
|
avail map[common.Address]map[common.Address]uint64
|
|
opened map[[32]byte]bool
|
|
refs []([32]byte)
|
|
// depositErr / withdrawErr / balanceErr force the backend failure branches.
|
|
depositErr error
|
|
withdrawErr error
|
|
balanceErr error
|
|
// overpay makes Withdraw return MORE than it debited — a backend mint. The
|
|
// vault-underflow guard is the last line that stops it reaching the caller.
|
|
overpay uint64
|
|
// deposits / withdraws count backend calls so a replay that must NOT reach the
|
|
// backend is observable, not merely "returned nil".
|
|
deposits int
|
|
withdraws int
|
|
// onDeposit / onWithdraw run INSIDE the backend leg — the second place a
|
|
// reentrant custody call can originate.
|
|
onDeposit func()
|
|
onWithdraw func()
|
|
openErr error
|
|
}
|
|
|
|
// zzpBadEngine is an Engine whose every operation fails, so the PoolManager's
|
|
// "the backend refused" branches are reachable without a live D-Chain.
|
|
type zzpBadEngine struct {
|
|
mockEngine
|
|
err error
|
|
}
|
|
|
|
func (e *zzpBadEngine) Initialize(*big.Int) (int24, error) { return 0, e.err }
|
|
|
|
func (e *zzpBadEngine) ModifyLiquidity(*PoolState, common.Address, ModifyLiquidityParams) (BalanceDelta, BalanceDelta, error) {
|
|
return ZeroBalanceDelta(), ZeroBalanceDelta(), e.err
|
|
}
|
|
|
|
func (e *zzpBadEngine) Donate(*PoolState, *big.Int, *big.Int) (BalanceDelta, error) {
|
|
return ZeroBalanceDelta(), e.err
|
|
}
|
|
|
|
func zzpNewLedger() *zzpLedger {
|
|
return &zzpLedger{
|
|
avail: make(map[common.Address]map[common.Address]uint64),
|
|
opened: make(map[[32]byte]bool),
|
|
}
|
|
}
|
|
|
|
func (l *zzpLedger) zzpAvail(account common.Address, asset Currency) uint64 {
|
|
if l.avail[account] == nil {
|
|
return 0
|
|
}
|
|
return l.avail[account][asset.Address]
|
|
}
|
|
|
|
func (l *zzpLedger) zzpSet(account common.Address, asset Currency, v uint64) {
|
|
if l.avail[account] == nil {
|
|
l.avail[account] = make(map[common.Address]uint64)
|
|
}
|
|
l.avail[account][asset.Address] = v
|
|
}
|
|
|
|
// zzpTotal is the ledger-wide sum for one asset — the "value out" half of the
|
|
// custody conservation invariant.
|
|
func (l *zzpLedger) zzpTotal(asset Currency) *big.Int {
|
|
sum := new(big.Int)
|
|
for _, byAsset := range l.avail {
|
|
sum.Add(sum, new(big.Int).SetUint64(byAsset[asset.Address]))
|
|
}
|
|
return sum
|
|
}
|
|
|
|
func (l *zzpLedger) OpenMarket(poolID [32]byte, base, quote Currency) error {
|
|
if l.openErr != nil {
|
|
return l.openErr
|
|
}
|
|
l.opened[poolID] = true
|
|
return nil
|
|
}
|
|
|
|
func (l *zzpLedger) Deposit(account common.Address, asset Currency, amount uint64, ref [32]byte) error {
|
|
l.deposits++
|
|
if l.onDeposit != nil {
|
|
l.onDeposit()
|
|
}
|
|
if l.depositErr != nil {
|
|
return l.depositErr
|
|
}
|
|
l.refs = append(l.refs, ref)
|
|
l.zzpSet(account, asset, l.zzpAvail(account, asset)+amount)
|
|
return nil
|
|
}
|
|
|
|
func (l *zzpLedger) Withdraw(account common.Address, asset Currency, want uint64, ref [32]byte) (uint64, error) {
|
|
l.withdraws++
|
|
if l.onWithdraw != nil {
|
|
l.onWithdraw()
|
|
}
|
|
if l.withdrawErr != nil {
|
|
return 0, l.withdrawErr
|
|
}
|
|
l.refs = append(l.refs, ref)
|
|
have := l.zzpAvail(account, asset)
|
|
realized := want
|
|
if realized > have {
|
|
realized = have // CLAMP: the ledger can never pay out more than it holds
|
|
}
|
|
l.zzpSet(account, asset, have-realized)
|
|
return realized + l.overpay, nil
|
|
}
|
|
|
|
func (l *zzpLedger) Balance(account common.Address, asset Currency) (uint64, error) {
|
|
if l.balanceErr != nil {
|
|
return 0, l.balanceErr
|
|
}
|
|
return l.zzpAvail(account, asset), nil
|
|
}
|
|
|
|
// zzpRouter is an Engine that ALSO implements poolRouter, so routePool's
|
|
// delegating branch and Initialize's server-tick override are reachable.
|
|
type zzpRouter struct {
|
|
mockEngine
|
|
routed map[*PoolState][32]byte
|
|
inits int
|
|
initTick int24
|
|
initErr error
|
|
}
|
|
|
|
func zzpNewRouter() *zzpRouter {
|
|
return &zzpRouter{routed: make(map[*PoolState][32]byte)}
|
|
}
|
|
|
|
func (r *zzpRouter) InitializePool(ps *PoolState, poolID [32]byte, sqrtPriceX96 *big.Int, tickSpacing int32, lpFee uint32) (int24, error) {
|
|
r.inits++
|
|
if r.initErr != nil {
|
|
return 0, r.initErr
|
|
}
|
|
r.routed[ps] = poolID
|
|
return r.initTick, nil
|
|
}
|
|
|
|
func (r *zzpRouter) SetPoolID(ps *PoolState, poolID [32]byte) { r.routed[ps] = poolID }
|
|
|
|
// zzpAuth is an Engine that ALSO implements cancelAuthority, the seam
|
|
// ModifyLiquidity drives the durable cancel binding through.
|
|
type zzpAuth struct {
|
|
mockEngine
|
|
handles map[string]uint64
|
|
seeded map[string]uint64
|
|
// nextID is the order id a successful place binds; bindPlace=false models a
|
|
// backend that placed without exposing a handle.
|
|
nextID uint64
|
|
bindPlace bool
|
|
}
|
|
|
|
func zzpNewAuth() *zzpAuth {
|
|
return &zzpAuth{handles: make(map[string]uint64), seeded: make(map[string]uint64), nextID: 7, bindPlace: true}
|
|
}
|
|
|
|
func zzpHandleKey(maker common.Address, poolID [32]byte, salt [32]byte) string {
|
|
return string(maker.Bytes()) + string(poolID[:]) + string(salt[:])
|
|
}
|
|
|
|
func (a *zzpAuth) OrderHandle(maker common.Address, poolID [32]byte, salt [32]byte) (uint64, bool) {
|
|
if !a.bindPlace {
|
|
return 0, false
|
|
}
|
|
return a.nextID, true
|
|
}
|
|
|
|
func (a *zzpAuth) SeedOrderHandle(maker common.Address, poolID [32]byte, salt [32]byte, orderID uint64) {
|
|
a.seeded[zzpHandleKey(maker, poolID, salt)] = orderID
|
|
}
|
|
|
|
// zzpKey is the lane's standard pool key: native currency0 (so the custody path
|
|
// can move native value) and one ERC-20 currency1.
|
|
func zzpKey() PoolKey {
|
|
return PoolKey{
|
|
Currency0: NativeCurrency,
|
|
Currency1: Currency{Address: common.HexToAddress("0x00000000000000000000000000000000000000C1")},
|
|
Fee: Fee030,
|
|
TickSpacing: TickSpacing030,
|
|
Hooks: common.Address{},
|
|
}
|
|
}
|
|
|
|
// zzpTokenAddr is the ERC-20 the vault tests move; zzpToken is its Currency.
|
|
var (
|
|
zzpTokenAddr = common.HexToAddress("0x0000000000000000000000000000000000000C0C")
|
|
zzpToken = Currency{Address: zzpTokenAddr}
|
|
)
|
|
|
|
// zzpAlice / zzpBob are two distinct callers — every authorization assertion needs
|
|
// a second identity to prove a binding does not carry across principals.
|
|
var (
|
|
zzpAlice = common.HexToAddress("0x00000000000000000000000000000000000A11CE")
|
|
zzpBob = common.HexToAddress("0x0000000000000000000000000000000000000B0B")
|
|
)
|
|
|
|
// zzpTx1 / zzpTx2 are two distinct EVM tx identities.
|
|
var (
|
|
zzpTx1 = common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111")
|
|
zzpTx2 = common.HexToHash("0x2222222222222222222222222222222222222222222222222222222222222222")
|
|
)
|