dexe2e: live IDOR check for the 0x9010 cancel-auth fix (DEXE2E_MODE=idor)

Adds runIDOR — the live devnet counterpart to the precompile unit test
TestZAPCancelAuthRejectsOthersAcrossMultiExec. A maker rests an ask; a DIFFERENT
account attempts to cancel it with the same tick band + salt; asserts the attacker's
cancel REVERTS, the maker's lock survives, and the maker can still cancel its own
order. Proves the durable cancel-authorization binding (precompile RED H4) closes the
EVM-path cancel revert WITHOUT opening an IDOR.
This commit is contained in:
2026-06-18 10:29:30 -07:00
parent 4b63e08c86
commit 4aa1f6078e
2 changed files with 403 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"fmt"
"math/big"
"github.com/luxfi/geth/core/types"
)
// runIDOR proves, on the LIVE devnet via the EVM 0x9010 path, that the durable
// cancel-authorization binding (precompile RED H4 fix) does NOT open an IDOR: a
// caller can cancel ONLY its own resting order. A maker rests an ask; a DIFFERENT
// account (attacker) attempts to cancel it with the SAME tick band + salt; the
// attacker's cancel MUST revert and the maker's order MUST stay locked. Then the
// real maker cancels its own order successfully (unlocking the full remainder).
//
// This is the live counterpart to the precompile unit test
// TestZAPCancelAuthRejectsOthersAcrossMultiExec.
func runIDOR(bank *runner) {
fmt.Println("================ LIVE IDOR CHECK (EVM 0x9010 cancel-auth) ================")
makerKey, makerAddr := freshKey()
attackerKey, attackerAddr := freshKey()
maker := bank.childRunner(makerKey, makerAddr)
attacker := bank.childRunner(attackerKey, attackerAddr)
fmt.Printf("maker (fresh) = %s\n", makerAddr.Hex())
fmt.Printf("attacker (fresh) = %s\n", attackerAddr.Hex())
// Fund both fresh accounts (gas + deposit headroom).
fmt.Println("\n---- fund maker + attacker from treasury ----")
bank.fundAccount(makerAddr, new(big.Int).Mul(big.NewInt(2), big.NewInt(1e18)))
bank.fundAccount(attackerAddr, new(big.Int).Mul(big.NewInt(2), big.NewInt(1e18)))
depositAmt := big.NewInt(10_000_000_000)
// ensure market exists (idempotent).
fmt.Println("\n---- ensure market ----")
initData := append(sel(selInitialize), poolKeyABI()...)
q96 := new(big.Int).Lsh(big.NewInt(1), 96)
initData = append(initData, uint256ABI(q96)...)
maker.send("initialize", initData, big.NewInt(0))
// maker + attacker both deposit so neither is blocked by an empty ledger
// (the attacker has funds — the only thing stopping it is authorization).
fmt.Println("\n---- deposit (maker + attacker) ----")
maker.deposit(depositAmt)
attacker.deposit(depositAmt)
// maker rests an ask.
fmt.Println("\n---- maker rests an ask (available -> locked) ----")
askTick := int64(6960)
restSize := int64(1000)
recRest, availDeltaRest := maker.modifyLiquidity("maker place", askTick, restSize)
locked := new(big.Int).Neg(availDeltaRest)
makerLockedAfterPlace := new(big.Int).Sub(depositAmt, maker.availOf(maker.from, nativeLUX))
fmt.Printf(" maker locked = %s (availΔ=%s)\n", locked, availDeltaRest)
placeOK := recRest.Status == types.ReceiptStatusSuccessful && locked.Sign() > 0
// attacker tries to cancel the maker's order with the SAME tick band + salt.
// The 0x9010 handle is keyed by the CALLER's address, so the attacker has no
// durable binding -> the cancel MUST revert and the maker's lock MUST persist.
fmt.Println("\n---- attacker attempts to cancel the maker's order (MUST revert) ----")
recAtk, _ := attacker.modifyLiquidity("attacker cancel", askTick, -restSize)
attackerReverted := recAtk.Status != types.ReceiptStatusSuccessful
makerLockedAfterAttack := new(big.Int).Sub(depositAmt, maker.availOf(maker.from, nativeLUX))
lockSurvived := makerLockedAfterAttack.Cmp(makerLockedAfterPlace) == 0 && makerLockedAfterAttack.Sign() > 0
fmt.Printf(" attacker cancel reverted = %v ; maker still-locked = %s (was %s)\n",
attackerReverted, makerLockedAfterAttack, makerLockedAfterPlace)
// the real maker cancels its own order -> unlocks the full remainder.
fmt.Println("\n---- maker cancels its OWN order (MUST succeed) ----")
recOwn, unlocked := maker.modifyLiquidity("maker self-cancel", askTick, -restSize)
ownOK := recOwn.Status == types.ReceiptStatusSuccessful && unlocked.Sign() > 0
fmt.Printf(" maker self-cancel ok = %v ; unlocked = +%s\n", ownOK, unlocked)
// drain back out so the run leaves no locked residual.
fmt.Println("\n---- withdraw (cleanup) ----")
maker.withdraw()
attacker.withdraw()
fmt.Println("\n================ IDOR RESULT ================")
fmt.Printf("%-40s %v\n", "maker place locked funds", placeOK)
fmt.Printf("%-40s %v\n", "attacker cancel REVERTED (IDOR closed)", attackerReverted)
fmt.Printf("%-40s %v\n", "maker's lock survived the attack", lockSurvived)
fmt.Printf("%-40s %v\n", "maker self-cancel succeeded + unlocked", ownOK)
allOK := placeOK && attackerReverted && lockSurvived && ownOK
if allOK {
fmt.Println("\nPASS: a caller can cancel ONLY its own order (durable cancel-auth, RED H4).")
} else {
fmt.Println("\nFAIL: cancel-authorization invariant violated on the live path.")
}
}
+311
View File
@@ -0,0 +1,311 @@
// dexe2e — LIVE EVM 0x9010 end-to-end against a running C-Chain RPC.
//
// Drives REAL signed C-Chain transactions through the 0x9010 DEX precompile
// (LP-9010 V4 = CLOB facade) -> NewZAPEngine -> the live dchain-venue D-Chain
// matcher, exercising the full custody lifecycle:
//
// initialize -> open the market (clob_open_market binds the pool)
// deposit -> native LUX into the D-Chain ledger (available)
// modifyLiquidity -> rest a maker order (available -> locked)
// swap -> marketable taker cross (consensus fills, settle in-ledger)
// cancel (modifyLiquidity negative delta) -> unlock unfilled
// withdraw -> burn available, release native from the 0x9010 vault
//
// Asserts the conservation invariant I = A + L + F + E across the cycle and that
// no PoolManager native reserve accrues (vault == Σ available + Σ locked).
//
// Funded signer: derived from LUX_MNEMONIC on the canonical genesis path
// m/44'/9000'/0'/0/<idx> (the path pkg/genesis funds in the C-Chain alloc).
// NO EWOQ KEY. The mnemonic is read from env (operator-managed, in-cluster).
package main
import (
"context"
"crypto/ecdsa"
"encoding/hex"
"fmt"
"math/big"
"os"
"strconv"
"strings"
"time"
ethereum "github.com/luxfi/geth"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/types"
"github.com/luxfi/geth/ethclient"
bip32 "github.com/luxfi/go-bip32"
bip39 "github.com/luxfi/go-bip39"
)
// 0x9010 DEX precompile.
var dexAddr = common.HexToAddress("0x0000000000000000000000000000000000009010")
// Selectors (keccak4 of the V4 signatures; mirror precompile/dex/module.go).
const (
selInitialize = "6276cbbe" // initialize((address,address,uint24,int24,address),uint160)
selSwap = "f3cd914c" // swap((...),(bool,int256,uint160),bytes)
selModifyLiquidity = "5a6bcfda" // modifyLiquidity((...),(int24,int24,int256,bytes32),bytes)
selDeposit = "47e7ef24" // deposit(address,uint256)
selWithdraw = "f3fef3a3" // withdraw(address,uint256)
selBalanceOf = "f7888aec" // balanceOf(address,address)
)
// Market: native LUX (currency0 = 0x0) / "LUSD" sentinel (0xC0DE), fee 0.30%,
// tickSpacing 60 — identical to the precompile clob e2e pool key.
var (
nativeLUX = common.Address{}
lusd = common.HexToAddress("0x000000000000000000000000000000000000C0DE")
fee030 = uint64(3000)
tick030 = int64(60)
)
func must(err error, ctx string) {
if err != nil {
fmt.Printf("FATAL %s: %v\n", ctx, err)
os.Exit(1)
}
}
func pad32(b []byte) []byte {
out := make([]byte, 32)
copy(out[32-len(b):], b)
return out
}
func addr32(a common.Address) []byte {
out := make([]byte, 32)
copy(out[12:], a.Bytes())
return out
}
func uint256ABI(x *big.Int) []byte { return pad32(x.Bytes()) }
// int256ABI two's-complement 32-byte encoding of a possibly-negative big.Int.
func int256ABI(x *big.Int) []byte {
if x.Sign() >= 0 {
return pad32(x.Bytes())
}
mod := new(big.Int).Lsh(big.NewInt(1), 256)
tc := new(big.Int).Add(mod, x) // mod - |x|
return pad32(tc.Bytes())
}
// poolKeyABI encodes the 160-byte V4 PoolKey: currency0, currency1, fee,
// tickSpacing, hooks (each a 32-byte slot).
func poolKeyABI() []byte {
buf := make([]byte, 0, 160)
buf = append(buf, addr32(nativeLUX)...) // [0:32] currency0
buf = append(buf, addr32(lusd)...) // [32:64] currency1
buf = append(buf, uint256ABI(big.NewInt(int64(fee030)))...) // [64:96] fee
buf = append(buf, int256ABI(big.NewInt(tick030))...) // [96:128] tickSpacing
buf = append(buf, addr32(common.Address{})...) // [128:160] hooks
return buf
}
func sel(s string) []byte { b, _ := hex.DecodeString(s); return b }
func deriveKey(mnemonic string, idx uint32) (*ecdsa.PrivateKey, common.Address) {
mnemonic = strings.TrimSpace(mnemonic)
if !bip39.IsMnemonicValid(mnemonic) {
fmt.Println("FATAL: invalid LUX_MNEMONIC")
os.Exit(1)
}
seed := bip39.NewSeed(mnemonic, "")
master, err := bip32.NewMasterKey(seed)
must(err, "master key")
purpose, _ := master.NewChildKey(bip32.FirstHardenedChild + 44)
coin, _ := purpose.NewChildKey(bip32.FirstHardenedChild + 9000)
acct, _ := coin.NewChildKey(bip32.FirstHardenedChild + 0)
change, _ := acct.NewChildKey(0)
child, err := change.NewChildKey(idx)
must(err, "child key")
pk, err := crypto.ToECDSA(child.Key)
must(err, "ToECDSA")
cAddr := crypto.PubkeyToAddress(pk.PublicKey) // luxfi/crypto/common.Address
return pk, common.BytesToAddress(cAddr[:]) // -> luxfi/geth/common.Address
}
type runner struct {
ctx context.Context
cl *ethclient.Client
chainID *big.Int
signer types.Signer
key *ecdsa.PrivateKey
from common.Address
gasTip *big.Int
gasFee *big.Int
}
// send signs+broadcasts a tx to 0x9010 and waits for the receipt. value is the
// native LUX attached (msg.value), used for deposit.
func (r *runner) send(label string, data []byte, value *big.Int) *types.Receipt {
nonce, err := r.cl.PendingNonceAt(r.ctx, r.from)
must(err, label+": nonce")
tx := types.NewTx(&types.DynamicFeeTx{
ChainID: r.chainID,
Nonce: nonce,
GasTipCap: r.gasTip,
GasFeeCap: r.gasFee,
Gas: 1_500_000,
To: &dexAddr,
Value: value,
Data: data,
})
signed, err := types.SignTx(tx, r.signer, r.key)
must(err, label+": sign")
must(r.cl.SendTransaction(r.ctx, signed), label+": send")
rec := r.wait(label, signed.Hash())
status := "OK"
if rec.Status != types.ReceiptStatusSuccessful {
status = "REVERTED"
}
fmt.Printf(" [%s] tx=%s status=%s gasUsed=%d block=%d\n",
label, signed.Hash().Hex(), status, rec.GasUsed, rec.BlockNumber.Uint64())
return rec
}
func (r *runner) wait(label string, h common.Hash) *types.Receipt {
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
rec, err := r.cl.TransactionReceipt(r.ctx, h)
if err == nil && rec != nil {
return rec
}
time.Sleep(500 * time.Millisecond)
}
fmt.Printf("FATAL %s: receipt timeout for %s\n", label, h.Hex())
os.Exit(1)
return nil
}
// availOf reads the AVAILABLE D-Chain balance for (account, asset) via 0x9010
// read-only balanceOf.
func (r *runner) availOf(account, asset common.Address) *big.Int {
data := append(sel(selBalanceOf), addr32(account)...)
data = append(data, addr32(asset)...)
out, err := r.cl.CallContract(r.ctx, ethereum.CallMsg{From: r.from, To: &dexAddr, Data: data}, nil)
if err != nil {
fmt.Printf(" balanceOf(%s,%s) call err: %v\n", account.Hex(), asset.Hex(), err)
return big.NewInt(0)
}
return new(big.Int).SetBytes(out)
}
func (r *runner) nativeBal(a common.Address) *big.Int {
b, err := r.cl.BalanceAt(r.ctx, a, nil)
must(err, "BalanceAt")
return b
}
// fundAccount sends `amount` native LUX from this runner's signer to `to` (used
// to fund a fresh taker from the treasury). Waits for the receipt.
func (r *runner) fundAccount(to common.Address, amount *big.Int) {
nonce, err := r.cl.PendingNonceAt(r.ctx, r.from)
must(err, "fund: nonce")
tx := types.NewTx(&types.DynamicFeeTx{
ChainID: r.chainID, Nonce: nonce,
GasTipCap: r.gasTip, GasFeeCap: r.gasFee,
Gas: 21000, To: &to, Value: amount,
})
signed, err := types.SignTx(tx, r.signer, r.key)
must(err, "fund: sign")
must(r.cl.SendTransaction(r.ctx, signed), "fund: send")
r.wait("fund", signed.Hash())
fmt.Printf(" funded %s with %s LUX (tx=%s)\n", to.Hex(), weiToLUX(amount), signed.Hash().Hex())
}
// childRunner clones this runner with a different signing key/address (same RPC,
// chain, gas params).
func (r *runner) childRunner(key *ecdsa.PrivateKey, from common.Address) *runner {
c := *r
c.key = key
c.from = from
return &c
}
// freshKey generates a brand-new random secp256k1 key (the taker counterparty).
func freshKey() (*ecdsa.PrivateKey, common.Address) {
k, err := crypto.GenerateKey()
must(err, "GenerateKey")
cAddr := crypto.PubkeyToAddress(k.PublicKey)
return k, common.BytesToAddress(cAddr[:])
}
func main() {
rpcURL := envOr("DEX_RPC", "http://127.0.0.1:9650/ext/bc/C/rpc")
mnemonic := os.Getenv("LUX_MNEMONIC")
if mnemonic == "" && os.Getenv("LUX_PRIVATE_KEY") == "" {
fmt.Println("FATAL: set LUX_PRIVATE_KEY or LUX_MNEMONIC")
os.Exit(1)
}
keyIdx := uint32(0)
if v := os.Getenv("KEY_IDX"); v != "" {
n, _ := strconv.Atoi(v)
keyIdx = uint32(n)
}
ctx := context.Background()
cl, err := ethclient.DialContext(ctx, rpcURL)
must(err, "dial "+rpcURL)
chainID, err := cl.ChainID(ctx)
must(err, "chainID")
// Prefer an explicit funded private key (LUX_PRIVATE_KEY from the
// operator-managed deployer secret) over mnemonic derivation. Both are
// genesis-funded accounts; NEITHER is an ewoq key.
var key *ecdsa.PrivateKey
var from common.Address
if pkHex := strings.TrimPrefix(strings.TrimSpace(os.Getenv("LUX_PRIVATE_KEY")), "0x"); pkHex != "" {
raw, derr := hex.DecodeString(pkHex)
must(derr, "decode LUX_PRIVATE_KEY")
key, err = crypto.ToECDSA(raw)
must(err, "ToECDSA(LUX_PRIVATE_KEY)")
cAddr := crypto.PubkeyToAddress(key.PublicKey)
from = common.BytesToAddress(cAddr[:])
fmt.Println("(using LUX_PRIVATE_KEY)")
} else {
key, from = deriveKey(mnemonic, keyIdx)
}
fmt.Println("================ LIVE EVM 0x9010 e2e (devnet) ================")
fmt.Printf("rpc=%s\n", rpcURL)
fmt.Printf("chainID=%s signer=%s (m/44'/9000'/0'/0/%d, NOT ewoq)\n", chainID, from.Hex(), keyIdx)
r := &runner{
ctx: ctx, cl: cl, chainID: chainID,
signer: types.LatestSignerForChainID(chainID),
key: key, from: from,
gasTip: big.NewInt(1_000_000_000), // 1 gwei tip
gasFee: big.NewInt(80_000_000_000), // 80 gwei cap (devnet base fee ~25 gwei)
}
bal := r.nativeBal(from)
fmt.Printf("signer native balance: %s wei (%s LUX)\n", bal, weiToLUX(bal))
if bal.Sign() == 0 {
fmt.Println("FATAL: signer not funded at idx", keyIdx, "— try a different KEY_IDX")
os.Exit(1)
}
// DEXE2E_MODE=idor runs the live cancel-authorization (RED H4) IDOR check
// instead of the full lifecycle; default runs the full lifecycle.
if envOr("DEXE2E_MODE", "full") == "idor" {
runIDOR(r)
return
}
run(r)
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func weiToLUX(w *big.Int) string {
f := new(big.Float).Quo(new(big.Float).SetInt(w), big.NewFloat(1e18))
return f.Text('f', 6)
}