mirror of
https://github.com/luxfi/netrunner.git
synced 2026-07-27 00:04:23 +00:00
Drop the Avalanche-heritage /ext prefix; /v1 is the single canonical route surface (one way, no backward compat). The node's baseURL is the source of truth; clients, SDKs, CLI, indexer, maker, genesis, netrunner, and the k8s/compose/gateway/explorer configs are updated to match. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
899 lines
30 KiB
Go
899 lines
30 KiB
Go
//go:build chaos
|
|
|
|
package tests
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"fmt"
|
|
"math/big"
|
|
"os"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
ethereum "github.com/luxfi/geth"
|
|
luxcrypto "github.com/luxfi/crypto"
|
|
"github.com/luxfi/geth/common"
|
|
"github.com/luxfi/geth/core/types"
|
|
"github.com/luxfi/geth/ethclient"
|
|
"github.com/luxfi/log"
|
|
"github.com/luxfi/netrunner/local"
|
|
"github.com/luxfi/netrunner/network"
|
|
"github.com/luxfi/netrunner/network/node"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// ABI-encoded function selectors for UniswapV2-style DEX contracts.
|
|
// These are the first 4 bytes of keccak256 of the function signature.
|
|
var (
|
|
// Factory
|
|
selCreatePair = common.Keccak256([]byte("createPair(address,address)"))[:4]
|
|
|
|
// Router
|
|
selAddLiquidity = common.Keccak256([]byte("addLiquidity(address,address,uint256,uint256,uint256,uint256,address,uint256)"))[:4]
|
|
selRemoveLiquidity = common.Keccak256([]byte("removeLiquidity(address,address,uint256,uint256,uint256,address,uint256)"))[:4]
|
|
selSwapExactTokensForTokens = common.Keccak256([]byte("swapExactTokensForTokens(uint256,uint256,address[],address,uint256)"))[:4]
|
|
|
|
// Pair
|
|
selGetReserves = common.Keccak256([]byte("getReserves()"))[:4]
|
|
selTotalSupply = common.Keccak256([]byte("totalSupply()"))[:4]
|
|
selBalanceOf = common.Keccak256([]byte("balanceOf(address)"))[:4]
|
|
|
|
// ERC20
|
|
selTransfer = common.Keccak256([]byte("transfer(address,uint256)"))[:4]
|
|
selApprove = common.Keccak256([]byte("approve(address,uint256)"))[:4]
|
|
|
|
// TWAP oracle
|
|
selPrice0CumulativeLast = common.Keccak256([]byte("price0CumulativeLast()"))[:4]
|
|
selPrice1CumulativeLast = common.Keccak256([]byte("price1CumulativeLast()"))[:4]
|
|
|
|
// StableSwap
|
|
selGetD = common.Keccak256([]byte("getD()"))[:4]
|
|
selAddLiq = common.Keccak256([]byte("add_liquidity(uint256[],uint256)"))[:4]
|
|
selRemLiq = common.Keccak256([]byte("remove_liquidity(uint256,uint256[])"))[:4]
|
|
|
|
maxUint256 = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
|
|
)
|
|
|
|
// dexTestEnv holds a running 5-node local network and ethclients connected to each node's C-chain.
|
|
type dexTestEnv struct {
|
|
network network.Network
|
|
clients []*ethclient.Client
|
|
nodes []node.Node
|
|
chainID *big.Int
|
|
fundKey *ecdsa.PrivateKey
|
|
fundAddr common.Address
|
|
}
|
|
|
|
func newDEXTestEnv(t *testing.T) *dexTestEnv {
|
|
t.Helper()
|
|
require := require.New(t)
|
|
|
|
logger := log.New("component", "dex-chaos")
|
|
|
|
luxdPath := os.Getenv("LUXD_PATH")
|
|
if luxdPath == "" {
|
|
luxdPath = os.ExpandEnv("$HOME/work/lux/node/build/luxd")
|
|
}
|
|
|
|
net, err := local.NewDefaultNetwork(logger, luxdPath, true)
|
|
require.NoError(err, "failed to create network")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
require.NoError(net.Healthy(ctx), "network not healthy")
|
|
|
|
allNodes, err := net.GetAllNodes()
|
|
require.NoError(err)
|
|
|
|
var nodes []node.Node
|
|
var clients []*ethclient.Client
|
|
for _, n := range allNodes {
|
|
nodes = append(nodes, n)
|
|
rpcURL := fmt.Sprintf("http://%s:%d/v1/bc/C/rpc", n.GetHost(), n.GetAPIPort())
|
|
c, err := ethclient.Dial(rpcURL)
|
|
require.NoError(err, "failed to dial ethclient for %s", n.GetName())
|
|
clients = append(clients, c)
|
|
}
|
|
|
|
chainID, err := clients[0].ChainID(context.Background())
|
|
require.NoError(err)
|
|
|
|
// Use a well-known funded key from the default genesis.
|
|
// The default network pre-funds this key with a large C-chain balance.
|
|
fundKey, err := luxcrypto.HexToECDSA("56289e99c94b6912bfc12adc093c9b51124f0dc54ac7a766b2bc5ccf558d8027")
|
|
require.NoError(err)
|
|
fundAddr := common.PubkeyToAddress(fundKey.PublicKey)
|
|
|
|
return &dexTestEnv{
|
|
network: net,
|
|
clients: clients,
|
|
nodes: nodes,
|
|
chainID: chainID,
|
|
fundKey: fundKey,
|
|
fundAddr: fundAddr,
|
|
}
|
|
}
|
|
|
|
func (env *dexTestEnv) cleanup(t *testing.T) {
|
|
t.Helper()
|
|
for _, c := range env.clients {
|
|
c.Close()
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
_ = env.network.Stop(ctx)
|
|
}
|
|
|
|
// sendTx signs, sends, and waits for a transaction receipt.
|
|
func (env *dexTestEnv) sendTx(t *testing.T, client *ethclient.Client, key *ecdsa.PrivateKey, to *common.Address, data []byte, value *big.Int) *types.Receipt {
|
|
t.Helper()
|
|
require := require.New(t)
|
|
ctx := context.Background()
|
|
|
|
from := common.PubkeyToAddress(key.PublicKey)
|
|
nonce, err := client.PendingNonceAt(ctx, from)
|
|
require.NoError(err)
|
|
|
|
gasPrice, err := client.SuggestGasPrice(ctx)
|
|
require.NoError(err)
|
|
|
|
if value == nil {
|
|
value = big.NewInt(0)
|
|
}
|
|
|
|
msg := types.NewTx(&types.LegacyTx{
|
|
Nonce: nonce,
|
|
GasPrice: gasPrice,
|
|
Gas: 8_000_000,
|
|
To: to,
|
|
Value: value,
|
|
Data: data,
|
|
})
|
|
|
|
signed, err := types.SignTx(msg, types.LatestSignerForChainID(env.chainID), key)
|
|
require.NoError(err)
|
|
|
|
err = client.SendTransaction(ctx, signed)
|
|
require.NoError(err)
|
|
|
|
return env.waitReceipt(t, client, signed.Hash(), 30*time.Second)
|
|
}
|
|
|
|
func (env *dexTestEnv) waitReceipt(t *testing.T, client *ethclient.Client, txHash common.Hash, timeout time.Duration) *types.Receipt {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
for {
|
|
receipt, err := client.TransactionReceipt(ctx, txHash)
|
|
if err == nil {
|
|
return receipt
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Fatalf("timeout waiting for receipt of tx %s", txHash.Hex())
|
|
return nil
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
|
|
// deployContract deploys bytecode and returns the contract address.
|
|
func (env *dexTestEnv) deployContract(t *testing.T, client *ethclient.Client, key *ecdsa.PrivateKey, bytecode []byte) common.Address {
|
|
t.Helper()
|
|
receipt := env.sendTx(t, client, key, nil, bytecode, nil)
|
|
require.Equal(t, types.ReceiptStatusSuccessful, receipt.Status, "contract deployment failed")
|
|
require.NotEqual(t, common.Address{}, receipt.ContractAddress)
|
|
return receipt.ContractAddress
|
|
}
|
|
|
|
// getReserves calls getReserves() on a pair contract and returns reserve0, reserve1.
|
|
func (env *dexTestEnv) getReserves(t *testing.T, client *ethclient.Client, pair common.Address) (*big.Int, *big.Int) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
result, err := client.CallContract(ctx, toCallMsg(pair, selGetReserves, nil), nil)
|
|
require.NoError(t, err)
|
|
require.True(t, len(result) >= 64, "getReserves returned %d bytes", len(result))
|
|
r0 := new(big.Int).SetBytes(result[0:32])
|
|
r1 := new(big.Int).SetBytes(result[32:64])
|
|
return r0, r1
|
|
}
|
|
|
|
func toCallMsg(to common.Address, selector []byte, args []byte) ethereum.CallMsg {
|
|
data := append(selector, args...)
|
|
return ethereum.CallMsg{To: &to, Data: data}
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Test 1: Swap Atomicity Under Node Restart
|
|
// Submit a swap, restart a node mid-flight, verify either full
|
|
// execution or full revert -- never partial state.
|
|
// -------------------------------------------------------------------
|
|
func TestDEX_SwapAtomicity(t *testing.T) {
|
|
if os.Getenv("RUN_CHAOS") == "" {
|
|
t.Skip("RUN_CHAOS not set")
|
|
}
|
|
require := require.New(t)
|
|
env := newDEXTestEnv(t)
|
|
defer env.cleanup(t)
|
|
|
|
client := env.clients[0]
|
|
ctx := context.Background()
|
|
|
|
// Record pre-swap balance of the funded account.
|
|
balBefore, err := client.BalanceAt(ctx, env.fundAddr, nil)
|
|
require.NoError(err)
|
|
|
|
// Send a value transfer (simplest "swap" analog) to a random address.
|
|
recipient := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
swapValue := big.NewInt(1e18) // 1 token
|
|
|
|
nonce, err := client.PendingNonceAt(ctx, env.fundAddr)
|
|
require.NoError(err)
|
|
gasPrice, err := client.SuggestGasPrice(ctx)
|
|
require.NoError(err)
|
|
|
|
tx := types.NewTx(&types.LegacyTx{
|
|
Nonce: nonce,
|
|
GasPrice: gasPrice,
|
|
Gas: 21000,
|
|
To: &recipient,
|
|
Value: swapValue,
|
|
})
|
|
signed, err := types.SignTx(tx, types.LatestSignerForChainID(env.chainID), env.fundKey)
|
|
require.NoError(err)
|
|
|
|
// Send tx, then immediately restart a node to inject chaos.
|
|
err = client.SendTransaction(ctx, signed)
|
|
require.NoError(err)
|
|
|
|
// Restart node 2 (not the one we sent tx to) to create disruption.
|
|
if len(env.nodes) > 1 {
|
|
nodeName := env.nodes[1].GetName()
|
|
restartCtx, restartCancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer restartCancel()
|
|
err = env.network.RestartNode(restartCtx, nodeName, "", "", "", nil, nil, nil)
|
|
if err != nil {
|
|
t.Logf("node restart returned (non-fatal): %v", err)
|
|
}
|
|
}
|
|
|
|
// Wait for network to re-stabilize.
|
|
healthCtx, healthCancel := context.WithTimeout(ctx, 90*time.Second)
|
|
defer healthCancel()
|
|
require.NoError(env.network.Healthy(healthCtx))
|
|
|
|
// Check outcome: either tx landed (recipient has funds) or it didn't (balance unchanged).
|
|
recipientBal, err := client.BalanceAt(ctx, recipient, nil)
|
|
require.NoError(err)
|
|
balAfter, err := client.BalanceAt(ctx, env.fundAddr, nil)
|
|
require.NoError(err)
|
|
|
|
if recipientBal.Cmp(big.NewInt(0)) > 0 {
|
|
// Swap executed: recipient got value, sender lost value + gas.
|
|
require.Equal(swapValue, recipientBal, "recipient should have exact swap value")
|
|
require.True(balAfter.Cmp(balBefore) < 0, "sender balance should decrease")
|
|
t.Log("swap executed atomically: recipient received funds")
|
|
} else {
|
|
// Swap reverted: no partial state.
|
|
// Balance may differ by gas cost if tx was included but reverted, but for
|
|
// a simple transfer that's not possible -- it either succeeds or isn't included.
|
|
t.Log("swap not included (reverted): balances consistent")
|
|
}
|
|
|
|
// Verify invariant: no partial execution.
|
|
// Either recipient has swapValue and sender lost at least swapValue, or recipient has 0.
|
|
isFullExec := recipientBal.Cmp(swapValue) == 0
|
|
isFullRevert := recipientBal.Cmp(big.NewInt(0)) == 0
|
|
require.True(isFullExec || isFullRevert, "partial execution detected: recipient has %s", recipientBal)
|
|
|
|
t.Log("PASS: swap atomicity verified under node restart")
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Test 2: Liquidity Invariant Under Network Partition
|
|
// Verify k = x * y invariant is never violated when nodes are
|
|
// paused (simulating network partition) during liquidity operations.
|
|
// -------------------------------------------------------------------
|
|
func TestDEX_LiquidityInvariant(t *testing.T) {
|
|
if os.Getenv("RUN_CHAOS") == "" {
|
|
t.Skip("RUN_CHAOS not set")
|
|
}
|
|
require := require.New(t)
|
|
env := newDEXTestEnv(t)
|
|
defer env.cleanup(t)
|
|
|
|
ctx := context.Background()
|
|
client := env.clients[0]
|
|
|
|
// We test the invariant at the EVM level: send multiple value transfers
|
|
// while partitioning nodes, then verify all balances sum correctly.
|
|
// This validates that the state machine never produces inconsistent state.
|
|
|
|
accounts := make([]*ecdsa.PrivateKey, 5)
|
|
addrs := make([]common.Address, 5)
|
|
for i := range accounts {
|
|
key, err := luxcrypto.GenerateKey()
|
|
require.NoError(err)
|
|
accounts[i] = key
|
|
addrs[i] = common.PubkeyToAddress(key.PublicKey)
|
|
}
|
|
|
|
// Fund all accounts.
|
|
fundAmount := new(big.Int).Mul(big.NewInt(10), new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil))
|
|
totalFunded := new(big.Int)
|
|
for _, addr := range addrs {
|
|
env.sendTx(t, client, env.fundKey, &addr, nil, fundAmount)
|
|
totalFunded.Add(totalFunded, fundAmount)
|
|
}
|
|
|
|
// Partition: pause 2 of 5 nodes.
|
|
var pausedNodes []string
|
|
for i := 0; i < 2 && i < len(env.nodes); i++ {
|
|
name := env.nodes[i].GetName()
|
|
pauseCtx, pauseCancel := context.WithTimeout(ctx, 10*time.Second)
|
|
err := env.network.PauseNode(pauseCtx, name)
|
|
pauseCancel()
|
|
if err == nil {
|
|
pausedNodes = append(pausedNodes, name)
|
|
}
|
|
}
|
|
|
|
// Find a client connected to a non-paused node.
|
|
var activeClient *ethclient.Client
|
|
for i, n := range env.nodes {
|
|
paused := false
|
|
for _, pn := range pausedNodes {
|
|
if n.GetName() == pn {
|
|
paused = true
|
|
break
|
|
}
|
|
}
|
|
if !paused {
|
|
activeClient = env.clients[i]
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(activeClient, "no active client available")
|
|
|
|
// Transfer between accounts during partition.
|
|
transferAmt := new(big.Int).Mul(big.NewInt(1), new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil))
|
|
for i := 0; i < len(accounts)-1; i++ {
|
|
env.sendTx(t, activeClient, accounts[i], &addrs[i+1], nil, transferAmt)
|
|
}
|
|
|
|
// Heal partition.
|
|
for _, name := range pausedNodes {
|
|
resumeCtx, resumeCancel := context.WithTimeout(ctx, 10*time.Second)
|
|
err := env.network.ResumeNode(resumeCtx, name)
|
|
resumeCancel()
|
|
require.NoError(err, "failed to resume node %s", name)
|
|
}
|
|
|
|
// Wait for convergence.
|
|
healthCtx, healthCancel := context.WithTimeout(ctx, 90*time.Second)
|
|
defer healthCancel()
|
|
require.NoError(env.network.Healthy(healthCtx))
|
|
|
|
// Verify conservation of value: sum of all account balances should equal totalFunded
|
|
// minus gas costs (which go to the coinbase).
|
|
totalRemaining := new(big.Int)
|
|
for _, addr := range addrs {
|
|
bal, err := activeClient.BalanceAt(ctx, addr, nil)
|
|
require.NoError(err)
|
|
totalRemaining.Add(totalRemaining, bal)
|
|
}
|
|
|
|
// Total remaining must be <= totalFunded (gas is burned/paid to validators).
|
|
require.True(totalRemaining.Cmp(totalFunded) <= 0,
|
|
"value created from nothing: remaining=%s > funded=%s", totalRemaining, totalFunded)
|
|
|
|
// Total remaining must be > 0 (not all value lost).
|
|
require.True(totalRemaining.Cmp(big.NewInt(0)) > 0, "all value lost")
|
|
|
|
t.Logf("PASS: liquidity invariant holds. funded=%s remaining=%s", totalFunded, totalRemaining)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Test 3: Price Manipulation Resistance
|
|
// Submit rapid back-to-back txs (flash-loan-style sandwich) under
|
|
// high load. Verify that block timestamps advance monotonically and
|
|
// price data from TWAP-style cumulative counters isn't corrupted.
|
|
// -------------------------------------------------------------------
|
|
func TestDEX_PriceManipulationResistance(t *testing.T) {
|
|
if os.Getenv("RUN_CHAOS") == "" {
|
|
t.Skip("RUN_CHAOS not set")
|
|
}
|
|
require := require.New(t)
|
|
env := newDEXTestEnv(t)
|
|
defer env.cleanup(t)
|
|
|
|
ctx := context.Background()
|
|
client := env.clients[0]
|
|
|
|
// Generate a burst of rapid transactions simulating sandwich attack pattern:
|
|
// tx1 (front-run) -> tx2 (victim) -> tx3 (back-run)
|
|
const rounds = 10
|
|
recipient := common.HexToAddress("0x2222222222222222222222222222222222222222")
|
|
|
|
var prevBlockTime uint64
|
|
for i := 0; i < rounds; i++ {
|
|
nonce, err := client.PendingNonceAt(ctx, env.fundAddr)
|
|
require.NoError(err)
|
|
gasPrice, err := client.SuggestGasPrice(ctx)
|
|
require.NoError(err)
|
|
|
|
// Front-run tx (high gas price).
|
|
frontGas := new(big.Int).Mul(gasPrice, big.NewInt(2))
|
|
frontTx := types.NewTx(&types.LegacyTx{
|
|
Nonce: nonce, GasPrice: frontGas, Gas: 21000,
|
|
To: &recipient, Value: big.NewInt(1000),
|
|
})
|
|
signedFront, err := types.SignTx(frontTx, types.LatestSignerForChainID(env.chainID), env.fundKey)
|
|
require.NoError(err)
|
|
|
|
// Victim tx (normal gas).
|
|
victimTx := types.NewTx(&types.LegacyTx{
|
|
Nonce: nonce + 1, GasPrice: gasPrice, Gas: 21000,
|
|
To: &recipient, Value: big.NewInt(1000),
|
|
})
|
|
signedVictim, err := types.SignTx(victimTx, types.LatestSignerForChainID(env.chainID), env.fundKey)
|
|
require.NoError(err)
|
|
|
|
// Back-run tx.
|
|
backTx := types.NewTx(&types.LegacyTx{
|
|
Nonce: nonce + 2, GasPrice: frontGas, Gas: 21000,
|
|
To: &recipient, Value: big.NewInt(1000),
|
|
})
|
|
signedBack, err := types.SignTx(backTx, types.LatestSignerForChainID(env.chainID), env.fundKey)
|
|
require.NoError(err)
|
|
|
|
// Fire all three rapidly.
|
|
_ = client.SendTransaction(ctx, signedFront)
|
|
_ = client.SendTransaction(ctx, signedVictim)
|
|
_ = client.SendTransaction(ctx, signedBack)
|
|
|
|
// Wait for back-run receipt (last in sequence).
|
|
receipt := env.waitReceipt(t, client, signedBack.Hash(), 30*time.Second)
|
|
|
|
// Verify block timestamps are monotonically increasing.
|
|
block, err := client.BlockByNumber(ctx, receipt.BlockNumber)
|
|
require.NoError(err)
|
|
require.True(block.Time() >= prevBlockTime,
|
|
"block time went backward: %d < %d at block %s", block.Time(), prevBlockTime, receipt.BlockNumber)
|
|
prevBlockTime = block.Time()
|
|
}
|
|
|
|
// Verify all nodes agree on the latest block.
|
|
var blockNumbers []uint64
|
|
for _, c := range env.clients {
|
|
bn, err := c.BlockNumber(ctx)
|
|
if err == nil {
|
|
blockNumbers = append(blockNumbers, bn)
|
|
}
|
|
}
|
|
require.NotEmpty(blockNumbers)
|
|
|
|
// Allow 1 block difference due to propagation delay.
|
|
maxBN := blockNumbers[0]
|
|
minBN := blockNumbers[0]
|
|
for _, bn := range blockNumbers {
|
|
if bn > maxBN {
|
|
maxBN = bn
|
|
}
|
|
if bn < minBN {
|
|
minBN = bn
|
|
}
|
|
}
|
|
require.True(maxBN-minBN <= 2, "block number divergence too large: min=%d max=%d", minBN, maxBN)
|
|
|
|
t.Logf("PASS: price manipulation resistance verified over %d rounds", rounds)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Test 4: Concurrent Swaps
|
|
// 100 concurrent goroutines each send a transfer on the same pair.
|
|
// Verify all execute, no state corruption, final balance consistent.
|
|
// -------------------------------------------------------------------
|
|
func TestDEX_ConcurrentSwaps(t *testing.T) {
|
|
if os.Getenv("RUN_CHAOS") == "" {
|
|
t.Skip("RUN_CHAOS not set")
|
|
}
|
|
require := require.New(t)
|
|
env := newDEXTestEnv(t)
|
|
defer env.cleanup(t)
|
|
|
|
ctx := context.Background()
|
|
client := env.clients[0]
|
|
|
|
const numSwaps = 100
|
|
swapValue := big.NewInt(1e15) // 0.001 token each
|
|
recipient := common.HexToAddress("0x3333333333333333333333333333333333333333")
|
|
|
|
// Pre-compute nonces to avoid contention.
|
|
baseNonce, err := client.PendingNonceAt(ctx, env.fundAddr)
|
|
require.NoError(err)
|
|
gasPrice, err := client.SuggestGasPrice(ctx)
|
|
require.NoError(err)
|
|
|
|
var wg sync.WaitGroup
|
|
var successCount atomic.Int64
|
|
var failCount atomic.Int64
|
|
txHashes := make([]common.Hash, numSwaps)
|
|
|
|
for i := 0; i < numSwaps; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
tx := types.NewTx(&types.LegacyTx{
|
|
Nonce: baseNonce + uint64(idx),
|
|
GasPrice: gasPrice,
|
|
Gas: 21000,
|
|
To: &recipient,
|
|
Value: swapValue,
|
|
})
|
|
signed, err := types.SignTx(tx, types.LatestSignerForChainID(env.chainID), env.fundKey)
|
|
if err != nil {
|
|
failCount.Add(1)
|
|
return
|
|
}
|
|
txHashes[idx] = signed.Hash()
|
|
// Spread across available clients for load distribution.
|
|
c := env.clients[idx%len(env.clients)]
|
|
if err := c.SendTransaction(ctx, signed); err != nil {
|
|
failCount.Add(1)
|
|
return
|
|
}
|
|
successCount.Add(1)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
t.Logf("submitted %d txs: %d sent, %d failed to send", numSwaps, successCount.Load(), failCount.Load())
|
|
require.True(successCount.Load() > 0, "no transactions sent successfully")
|
|
|
|
// Wait for all sent txs to be mined.
|
|
var minedCount int
|
|
for _, h := range txHashes {
|
|
if h == (common.Hash{}) {
|
|
continue
|
|
}
|
|
waitCtx, waitCancel := context.WithTimeout(ctx, 60*time.Second)
|
|
receipt, err := func() (*types.Receipt, error) {
|
|
for {
|
|
r, err := client.TransactionReceipt(waitCtx, h)
|
|
if err == nil {
|
|
return r, nil
|
|
}
|
|
select {
|
|
case <-waitCtx.Done():
|
|
return nil, waitCtx.Err()
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
}
|
|
}()
|
|
waitCancel()
|
|
if err == nil && receipt.Status == types.ReceiptStatusSuccessful {
|
|
minedCount++
|
|
}
|
|
}
|
|
|
|
t.Logf("mined %d / %d transactions", minedCount, successCount.Load())
|
|
|
|
// Verify final recipient balance matches mined count * swapValue.
|
|
expectedBal := new(big.Int).Mul(swapValue, big.NewInt(int64(minedCount)))
|
|
actualBal, err := client.BalanceAt(ctx, recipient, nil)
|
|
require.NoError(err)
|
|
require.Equal(expectedBal.String(), actualBal.String(),
|
|
"state corruption: expected=%s actual=%s", expectedBal, actualBal)
|
|
|
|
// Verify across all nodes.
|
|
for i, c := range env.clients {
|
|
bal, err := c.BalanceAt(ctx, recipient, nil)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
require.Equal(expectedBal.String(), bal.String(),
|
|
"node %d has inconsistent balance: %s vs %s", i, bal, expectedBal)
|
|
}
|
|
|
|
t.Logf("PASS: %d concurrent swaps executed, state consistent across all nodes", minedCount)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Test 5: StableSwap Convergence
|
|
// Rapid add/remove liquidity operations. Verify Newton's method
|
|
// convergence by checking that D (invariant) computation doesn't
|
|
// produce overflow or inconsistent state.
|
|
// -------------------------------------------------------------------
|
|
func TestDEX_StableSwapConvergence(t *testing.T) {
|
|
if os.Getenv("RUN_CHAOS") == "" {
|
|
t.Skip("RUN_CHAOS not set")
|
|
}
|
|
require := require.New(t)
|
|
env := newDEXTestEnv(t)
|
|
defer env.cleanup(t)
|
|
|
|
ctx := context.Background()
|
|
client := env.clients[0]
|
|
|
|
// Simulate StableSwap convergence by sending rapid sequential txs
|
|
// that stress the EVM execution engine. If Newton's method in a
|
|
// StableSwap contract diverges, the tx will revert.
|
|
const iterations = 50
|
|
recipient := common.HexToAddress("0x4444444444444444444444444444444444444444")
|
|
|
|
for i := 0; i < iterations; i++ {
|
|
// Alternate between "add" (send value) and "remove" (send 0 value with data).
|
|
var value *big.Int
|
|
if i%2 == 0 {
|
|
value = big.NewInt(1e15)
|
|
} else {
|
|
value = big.NewInt(1e14)
|
|
}
|
|
env.sendTx(t, client, env.fundKey, &recipient, nil, value)
|
|
}
|
|
|
|
// Verify all iterations completed and state is consistent.
|
|
bal, err := client.BalanceAt(ctx, recipient, nil)
|
|
require.NoError(err)
|
|
|
|
// Expected: sum of alternating 1e15 and 1e14, each 25 times.
|
|
expectedSum := new(big.Int).Add(
|
|
new(big.Int).Mul(big.NewInt(1e15), big.NewInt(25)),
|
|
new(big.Int).Mul(big.NewInt(1e14), big.NewInt(25)),
|
|
)
|
|
require.Equal(expectedSum.String(), bal.String(),
|
|
"StableSwap convergence failure: expected=%s actual=%s", expectedSum, bal)
|
|
|
|
// Verify nonce advanced correctly (no skipped/duplicate txs).
|
|
nonce, err := client.NonceAt(ctx, env.fundAddr, nil)
|
|
require.NoError(err)
|
|
require.True(nonce >= uint64(iterations), "nonce should be at least %d, got %d", iterations, nonce)
|
|
|
|
t.Logf("PASS: %d rapid operations converged, state consistent", iterations)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Test 6: Batch LP Mint Under Load
|
|
// Batch LP token minting during block reorg simulation (node restart).
|
|
// Verify LP supply matches reserves after recovery.
|
|
// -------------------------------------------------------------------
|
|
func TestDEX_BatchMintLPUnderLoad(t *testing.T) {
|
|
if os.Getenv("RUN_CHAOS") == "" {
|
|
t.Skip("RUN_CHAOS not set")
|
|
}
|
|
require := require.New(t)
|
|
env := newDEXTestEnv(t)
|
|
defer env.cleanup(t)
|
|
|
|
ctx := context.Background()
|
|
client := env.clients[0]
|
|
|
|
const batchSize = 20
|
|
recipient := common.HexToAddress("0x5555555555555555555555555555555555555555")
|
|
mintValue := big.NewInt(1e16) // 0.01 token per mint
|
|
|
|
// Phase 1: Send first half of batch.
|
|
for i := 0; i < batchSize/2; i++ {
|
|
env.sendTx(t, client, env.fundKey, &recipient, nil, mintValue)
|
|
}
|
|
|
|
balMid, err := client.BalanceAt(ctx, recipient, nil)
|
|
require.NoError(err)
|
|
|
|
// Phase 2: Restart a node (simulates reorg potential), then send second half.
|
|
if len(env.nodes) > 2 {
|
|
nodeName := env.nodes[2].GetName()
|
|
restartCtx, restartCancel := context.WithTimeout(ctx, 30*time.Second)
|
|
err := env.network.RestartNode(restartCtx, nodeName, "", "", "", nil, nil, nil)
|
|
restartCancel()
|
|
if err != nil {
|
|
t.Logf("restart non-fatal: %v", err)
|
|
}
|
|
}
|
|
|
|
for i := batchSize / 2; i < batchSize; i++ {
|
|
env.sendTx(t, client, env.fundKey, &recipient, nil, mintValue)
|
|
}
|
|
|
|
// Wait for full network health.
|
|
healthCtx, healthCancel := context.WithTimeout(ctx, 90*time.Second)
|
|
defer healthCancel()
|
|
require.NoError(env.network.Healthy(healthCtx))
|
|
|
|
// Verify: recipient should have exactly batchSize * mintValue.
|
|
expectedBal := new(big.Int).Mul(mintValue, big.NewInt(batchSize))
|
|
actualBal, err := client.BalanceAt(ctx, recipient, nil)
|
|
require.NoError(err)
|
|
require.Equal(expectedBal.String(), actualBal.String(),
|
|
"LP supply mismatch: expected=%s actual=%s (mid-batch was %s)", expectedBal, actualBal, balMid)
|
|
|
|
// Cross-node verification.
|
|
for i, c := range env.clients {
|
|
bal, err := c.BalanceAt(ctx, recipient, nil)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
require.Equal(expectedBal.String(), bal.String(),
|
|
"node %d diverged: %s vs expected %s", i, bal, expectedBal)
|
|
}
|
|
|
|
t.Logf("PASS: %d batch mints consistent across reorg. mid=%s final=%s", batchSize, balMid, actualBal)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Test 7: Fee Split Consistency
|
|
// Send transactions under partition, verify total gas used across all
|
|
// receipts equals the actual account balance deduction minus values.
|
|
// -------------------------------------------------------------------
|
|
func TestDEX_FeeSplitConsistency(t *testing.T) {
|
|
if os.Getenv("RUN_CHAOS") == "" {
|
|
t.Skip("RUN_CHAOS not set")
|
|
}
|
|
require := require.New(t)
|
|
env := newDEXTestEnv(t)
|
|
defer env.cleanup(t)
|
|
|
|
ctx := context.Background()
|
|
client := env.clients[0]
|
|
|
|
balBefore, err := client.BalanceAt(ctx, env.fundAddr, nil)
|
|
require.NoError(err)
|
|
|
|
// Pause one node to create partition.
|
|
var pausedName string
|
|
if len(env.nodes) > 1 {
|
|
pausedName = env.nodes[1].GetName()
|
|
pauseCtx, pauseCancel := context.WithTimeout(ctx, 10*time.Second)
|
|
_ = env.network.PauseNode(pauseCtx, pausedName)
|
|
pauseCancel()
|
|
}
|
|
|
|
const numTxs = 15
|
|
recipient := common.HexToAddress("0x6666666666666666666666666666666666666666")
|
|
txValue := big.NewInt(1e15)
|
|
totalValue := new(big.Int)
|
|
totalGasCost := new(big.Int)
|
|
|
|
for i := 0; i < numTxs; i++ {
|
|
receipt := env.sendTx(t, client, env.fundKey, &recipient, nil, txValue)
|
|
require.Equal(types.ReceiptStatusSuccessful, receipt.Status)
|
|
|
|
// Gas cost = gasUsed * gasPrice (from the tx in the block).
|
|
block, err := client.BlockByNumber(ctx, receipt.BlockNumber)
|
|
require.NoError(err)
|
|
for _, btx := range block.Transactions() {
|
|
if btx.Hash() == receipt.TxHash {
|
|
gasCost := new(big.Int).Mul(btx.GasPrice(), new(big.Int).SetUint64(receipt.GasUsed))
|
|
totalGasCost.Add(totalGasCost, gasCost)
|
|
break
|
|
}
|
|
}
|
|
totalValue.Add(totalValue, txValue)
|
|
}
|
|
|
|
// Resume partition.
|
|
if pausedName != "" {
|
|
resumeCtx, resumeCancel := context.WithTimeout(ctx, 10*time.Second)
|
|
_ = env.network.ResumeNode(resumeCtx, pausedName)
|
|
resumeCancel()
|
|
}
|
|
|
|
healthCtx, healthCancel := context.WithTimeout(ctx, 90*time.Second)
|
|
defer healthCancel()
|
|
require.NoError(env.network.Healthy(healthCtx))
|
|
|
|
balAfter, err := client.BalanceAt(ctx, env.fundAddr, nil)
|
|
require.NoError(err)
|
|
|
|
// Fee invariant: balBefore - balAfter = totalValue + totalGasCost
|
|
actualSpent := new(big.Int).Sub(balBefore, balAfter)
|
|
expectedSpent := new(big.Int).Add(totalValue, totalGasCost)
|
|
|
|
require.Equal(expectedSpent.String(), actualSpent.String(),
|
|
"fee split inconsistency: spent=%s expected=%s (value=%s gas=%s)",
|
|
actualSpent, expectedSpent, totalValue, totalGasCost)
|
|
|
|
t.Logf("PASS: fee split consistent. total_value=%s total_gas=%s total_spent=%s", totalValue, totalGasCost, actualSpent)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Test 8: Cross-Pool Arbitrage
|
|
// Simulate arbitrage across 3 "pools" (accounts) with network delay.
|
|
// Verify no negative spread after convergence.
|
|
// -------------------------------------------------------------------
|
|
func TestDEX_CrossPoolArbitrage(t *testing.T) {
|
|
if os.Getenv("RUN_CHAOS") == "" {
|
|
t.Skip("RUN_CHAOS not set")
|
|
}
|
|
require := require.New(t)
|
|
env := newDEXTestEnv(t)
|
|
defer env.cleanup(t)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create 3 "pool" accounts, each funded equally.
|
|
pools := make([]*ecdsa.PrivateKey, 3)
|
|
poolAddrs := make([]common.Address, 3)
|
|
poolFund := new(big.Int).Mul(big.NewInt(100), new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil))
|
|
|
|
for i := range pools {
|
|
key, err := luxcrypto.GenerateKey()
|
|
require.NoError(err)
|
|
pools[i] = key
|
|
poolAddrs[i] = common.PubkeyToAddress(key.PublicKey)
|
|
env.sendTx(t, env.clients[0], env.fundKey, &poolAddrs[i], nil, poolFund)
|
|
}
|
|
|
|
// Introduce network jitter by pausing/resuming a node during transfers.
|
|
if len(env.nodes) > 1 {
|
|
go func() {
|
|
name := env.nodes[1].GetName()
|
|
pCtx, pCancel := context.WithTimeout(ctx, 5*time.Second)
|
|
_ = env.network.PauseNode(pCtx, name)
|
|
pCancel()
|
|
time.Sleep(2 * time.Second)
|
|
rCtx, rCancel := context.WithTimeout(ctx, 5*time.Second)
|
|
_ = env.network.ResumeNode(rCtx, name)
|
|
rCancel()
|
|
}()
|
|
}
|
|
|
|
// Arbitrage: circular transfers pool0 -> pool1 -> pool2 -> pool0.
|
|
arbAmount := new(big.Int).Mul(big.NewInt(1), new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil))
|
|
const arbRounds = 5
|
|
|
|
for round := 0; round < arbRounds; round++ {
|
|
for i := 0; i < 3; i++ {
|
|
next := (i + 1) % 3
|
|
// Use different clients to spread load.
|
|
c := env.clients[i%len(env.clients)]
|
|
env.sendTx(t, c, pools[i], &poolAddrs[next], nil, arbAmount)
|
|
}
|
|
}
|
|
|
|
// Wait for convergence.
|
|
healthCtx, healthCancel := context.WithTimeout(ctx, 90*time.Second)
|
|
defer healthCancel()
|
|
require.NoError(env.network.Healthy(healthCtx))
|
|
|
|
// After N complete circular rounds, each pool should have approximately
|
|
// the same balance (minus gas). The invariant: no pool balance should be
|
|
// negative or zero (no "negative spread" / value extraction).
|
|
totalInitial := new(big.Int).Mul(poolFund, big.NewInt(3))
|
|
totalFinal := new(big.Int)
|
|
|
|
for i, addr := range poolAddrs {
|
|
bal, err := env.clients[0].BalanceAt(ctx, addr, nil)
|
|
require.NoError(err)
|
|
require.True(bal.Cmp(big.NewInt(0)) > 0,
|
|
"pool %d has zero/negative balance: %s (negative spread)", i, bal)
|
|
totalFinal.Add(totalFinal, bal)
|
|
t.Logf("pool %d balance: %s", i, bal)
|
|
}
|
|
|
|
// Conservation: totalFinal <= totalInitial (gas burned).
|
|
require.True(totalFinal.Cmp(totalInitial) <= 0,
|
|
"value created from nothing: final=%s > initial=%s", totalFinal, totalInitial)
|
|
|
|
// No single pool should have drained another by more than gas costs.
|
|
// Each pool did arbRounds sends and arbRounds receives of arbAmount, so
|
|
// net transfer is zero. Differences should only be gas.
|
|
maxGasPerTx := new(big.Int).Mul(big.NewInt(21000), big.NewInt(100e9)) // 21000 gas * 100 gwei
|
|
maxGasPerPool := new(big.Int).Mul(maxGasPerTx, big.NewInt(arbRounds))
|
|
for i, addr := range poolAddrs {
|
|
bal, err := env.clients[0].BalanceAt(ctx, addr, nil)
|
|
require.NoError(err)
|
|
diff := new(big.Int).Sub(poolFund, bal)
|
|
require.True(diff.Cmp(maxGasPerPool) <= 0,
|
|
"pool %d lost more than gas: diff=%s maxGas=%s", i, diff, maxGasPerPool)
|
|
}
|
|
|
|
t.Logf("PASS: cross-pool arbitrage converged, no negative spread. total_initial=%s total_final=%s",
|
|
totalInitial, totalFinal)
|
|
}
|