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>
902 lines
27 KiB
Go
902 lines
27 KiB
Go
//go:build chaos
|
|
|
|
package tests
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"fmt"
|
|
"math/big"
|
|
"os"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/luxfi/geth/common"
|
|
"github.com/luxfi/geth/core/types"
|
|
luxcrypto "github.com/luxfi/crypto"
|
|
"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"
|
|
)
|
|
|
|
// consensusTestEnv holds a running 5-node local network for consensus testing.
|
|
type consensusTestEnv struct {
|
|
network network.Network
|
|
clients []*ethclient.Client
|
|
nodes []node.Node
|
|
names []string
|
|
chainID *big.Int
|
|
fundKey *ecdsa.PrivateKey
|
|
fundAddr common.Address
|
|
}
|
|
|
|
func newConsensusTestEnv(t *testing.T) *consensusTestEnv {
|
|
t.Helper()
|
|
require := require.New(t)
|
|
|
|
logger := log.New("component", "consensus-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 names []string
|
|
var clients []*ethclient.Client
|
|
for _, n := range allNodes {
|
|
nodes = append(nodes, n)
|
|
names = append(names, n.GetName())
|
|
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)
|
|
|
|
fundKey, err := luxcrypto.HexToECDSA("56289e99c94b6912bfc12adc093c9b51124f0dc54ac7a766b2bc5ccf558d8027")
|
|
require.NoError(err)
|
|
fundAddr := common.PubkeyToAddress(fundKey.PublicKey)
|
|
|
|
return &consensusTestEnv{
|
|
network: net,
|
|
clients: clients,
|
|
nodes: nodes,
|
|
names: names,
|
|
chainID: chainID,
|
|
fundKey: fundKey,
|
|
fundAddr: fundAddr,
|
|
}
|
|
}
|
|
|
|
func (env *consensusTestEnv) 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)
|
|
}
|
|
|
|
// sendAndWait sends a value transfer and waits for the receipt.
|
|
func (env *consensusTestEnv) sendAndWait(t *testing.T, client *ethclient.Client, to common.Address, value *big.Int) *types.Receipt {
|
|
t.Helper()
|
|
require := require.New(t)
|
|
ctx := context.Background()
|
|
|
|
from := env.fundAddr
|
|
nonce, err := client.PendingNonceAt(ctx, from)
|
|
require.NoError(err)
|
|
gasPrice, err := client.SuggestGasPrice(ctx)
|
|
require.NoError(err)
|
|
|
|
tx := types.NewTx(&types.LegacyTx{
|
|
Nonce: nonce,
|
|
GasPrice: gasPrice,
|
|
Gas: 21000,
|
|
To: &to,
|
|
Value: value,
|
|
})
|
|
signed, err := types.SignTx(tx, types.LatestSignerForChainID(env.chainID), env.fundKey)
|
|
require.NoError(err)
|
|
require.NoError(client.SendTransaction(ctx, signed))
|
|
|
|
deadline, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
for {
|
|
receipt, err := client.TransactionReceipt(deadline, signed.Hash())
|
|
if err == nil {
|
|
return receipt
|
|
}
|
|
select {
|
|
case <-deadline.Done():
|
|
t.Fatalf("timeout waiting for tx %s", signed.Hash().Hex())
|
|
return nil
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
|
|
// waitHealthy waits for the network to become healthy with a timeout.
|
|
func (env *consensusTestEnv) waitHealthy(t *testing.T, timeout time.Duration) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
require.NoError(t, env.network.Healthy(ctx), "network did not become healthy within %s", timeout)
|
|
}
|
|
|
|
// blockNumbersFromAll queries block number from all available clients.
|
|
func (env *consensusTestEnv) blockNumbersFromAll(t *testing.T) map[int]uint64 {
|
|
t.Helper()
|
|
result := make(map[int]uint64)
|
|
ctx := context.Background()
|
|
for i, c := range env.clients {
|
|
bn, err := c.BlockNumber(ctx)
|
|
if err == nil {
|
|
result[i] = bn
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Test 1: Partition Recovery
|
|
// Partition 2 of 5 validators. Verify chain continues with 3.
|
|
// Heal partition. Verify all 5 converge to same state.
|
|
// -------------------------------------------------------------------
|
|
func TestConsensus_PartitionRecovery(t *testing.T) {
|
|
if os.Getenv("RUN_CHAOS") == "" {
|
|
t.Skip("RUN_CHAOS not set")
|
|
}
|
|
require := require.New(t)
|
|
env := newConsensusTestEnv(t)
|
|
defer env.cleanup(t)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Record initial block number.
|
|
bnBefore := env.blockNumbersFromAll(t)
|
|
t.Logf("block numbers before partition: %v", bnBefore)
|
|
|
|
// Partition: pause 2 nodes.
|
|
partitioned := env.names[:2]
|
|
for _, name := range partitioned {
|
|
pCtx, pCancel := context.WithTimeout(ctx, 10*time.Second)
|
|
err := env.network.PauseNode(pCtx, name)
|
|
pCancel()
|
|
require.NoError(err, "failed to pause %s", name)
|
|
}
|
|
t.Logf("partitioned nodes: %v", partitioned)
|
|
|
|
// Find a client for a non-partitioned node.
|
|
activeIdx := 2 // nodes[2] should still be active
|
|
activeClient := env.clients[activeIdx]
|
|
|
|
// Verify chain continues: send a tx and confirm it gets mined.
|
|
recipient := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
|
|
receipt := env.sendAndWait(t, activeClient, recipient, big.NewInt(1e15))
|
|
require.Equal(types.ReceiptStatusSuccessful, receipt.Status,
|
|
"tx should succeed with 3/5 validators")
|
|
t.Logf("tx mined at block %s during partition", receipt.BlockNumber)
|
|
|
|
// Heal partition.
|
|
for _, name := range partitioned {
|
|
rCtx, rCancel := context.WithTimeout(ctx, 10*time.Second)
|
|
err := env.network.ResumeNode(rCtx, name)
|
|
rCancel()
|
|
require.NoError(err, "failed to resume %s", name)
|
|
}
|
|
|
|
// Wait for convergence.
|
|
env.waitHealthy(t, 90*time.Second)
|
|
|
|
// Verify all 5 nodes converge to the same state.
|
|
time.Sleep(5 * time.Second) // allow propagation
|
|
bnAfter := env.blockNumbersFromAll(t)
|
|
t.Logf("block numbers after heal: %v", bnAfter)
|
|
|
|
// All nodes should be at or near the same height.
|
|
var heights []uint64
|
|
for _, h := range bnAfter {
|
|
heights = append(heights, h)
|
|
}
|
|
require.NotEmpty(heights)
|
|
maxH, minH := heights[0], heights[0]
|
|
for _, h := range heights {
|
|
if h > maxH {
|
|
maxH = h
|
|
}
|
|
if h < minH {
|
|
minH = h
|
|
}
|
|
}
|
|
require.True(maxH-minH <= 2, "nodes diverged after heal: min=%d max=%d", minH, maxH)
|
|
|
|
// Verify recipient balance is consistent across all nodes.
|
|
for i, c := range env.clients {
|
|
bal, err := c.BalanceAt(ctx, recipient, nil)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
require.Equal(big.NewInt(1e15).String(), bal.String(),
|
|
"node %d (%s) has inconsistent balance", i, env.names[i])
|
|
}
|
|
|
|
t.Log("PASS: partition recovery verified. all 5 nodes converged")
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Test 2: Byzantine Fault
|
|
// 1 of 5 validators sends conflicting transactions from the same
|
|
// nonce. Verify honest nodes finalize one and reject the other.
|
|
// -------------------------------------------------------------------
|
|
func TestConsensus_ByzantineFault(t *testing.T) {
|
|
if os.Getenv("RUN_CHAOS") == "" {
|
|
t.Skip("RUN_CHAOS not set")
|
|
}
|
|
require := require.New(t)
|
|
env := newConsensusTestEnv(t)
|
|
defer env.cleanup(t)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create two conflicting transactions with the same nonce (double-spend attempt).
|
|
nonce, err := env.clients[0].PendingNonceAt(ctx, env.fundAddr)
|
|
require.NoError(err)
|
|
gasPrice, err := env.clients[0].SuggestGasPrice(ctx)
|
|
require.NoError(err)
|
|
|
|
recipientA := common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
|
|
recipientB := common.HexToAddress("0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC")
|
|
|
|
txA := types.NewTx(&types.LegacyTx{
|
|
Nonce: nonce, GasPrice: gasPrice, Gas: 21000,
|
|
To: &recipientA, Value: big.NewInt(1e18),
|
|
})
|
|
txB := types.NewTx(&types.LegacyTx{
|
|
Nonce: nonce, GasPrice: gasPrice, Gas: 21000,
|
|
To: &recipientB, Value: big.NewInt(1e18),
|
|
})
|
|
|
|
signedA, err := types.SignTx(txA, types.LatestSignerForChainID(env.chainID), env.fundKey)
|
|
require.NoError(err)
|
|
signedB, err := types.SignTx(txB, types.LatestSignerForChainID(env.chainID), env.fundKey)
|
|
require.NoError(err)
|
|
|
|
// Send conflicting txs to different nodes (simulates byzantine behavior).
|
|
_ = env.clients[0].SendTransaction(ctx, signedA)
|
|
_ = env.clients[len(env.clients)-1].SendTransaction(ctx, signedB)
|
|
|
|
// Wait for one to be mined.
|
|
time.Sleep(10 * time.Second)
|
|
|
|
// Check which one won.
|
|
balA, errA := env.clients[0].BalanceAt(ctx, recipientA, nil)
|
|
balB, errB := env.clients[0].BalanceAt(ctx, recipientB, nil)
|
|
|
|
aWon := errA == nil && balA.Cmp(big.NewInt(0)) > 0
|
|
bWon := errB == nil && balB.Cmp(big.NewInt(0)) > 0
|
|
|
|
// Exactly one should have been finalized.
|
|
require.True(aWon || bWon, "neither conflicting tx was finalized")
|
|
require.False(aWon && bWon, "BOTH conflicting txs finalized -- double spend!")
|
|
|
|
winner := "A"
|
|
if bWon {
|
|
winner = "B"
|
|
}
|
|
t.Logf("byzantine resolution: tx %s won", winner)
|
|
|
|
// Verify all nodes agree on the winner.
|
|
for i, c := range env.clients {
|
|
balAi, _ := c.BalanceAt(ctx, recipientA, nil)
|
|
balBi, _ := c.BalanceAt(ctx, recipientB, nil)
|
|
|
|
nodeAWon := balAi != nil && balAi.Cmp(big.NewInt(0)) > 0
|
|
nodeBWon := balBi != nil && balBi.Cmp(big.NewInt(0)) > 0
|
|
|
|
if aWon {
|
|
require.True(nodeAWon, "node %d disagrees: A should have won", i)
|
|
require.False(nodeBWon, "node %d double-spent: B also funded", i)
|
|
} else {
|
|
require.True(nodeBWon, "node %d disagrees: B should have won", i)
|
|
require.False(nodeAWon, "node %d double-spent: A also funded", i)
|
|
}
|
|
}
|
|
|
|
t.Log("PASS: byzantine fault handled correctly. exactly one tx finalized, all nodes agree")
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Test 3: Finality Under Load
|
|
// Submit 1000 txs as fast as possible. Verify finality latency
|
|
// stays under 2 seconds even at peak load.
|
|
// -------------------------------------------------------------------
|
|
func TestConsensus_FinalityUnderLoad(t *testing.T) {
|
|
if os.Getenv("RUN_CHAOS") == "" {
|
|
t.Skip("RUN_CHAOS not set")
|
|
}
|
|
require := require.New(t)
|
|
env := newConsensusTestEnv(t)
|
|
defer env.cleanup(t)
|
|
|
|
ctx := context.Background()
|
|
client := env.clients[0]
|
|
|
|
const totalTxs = 1000
|
|
const maxFinalityMs = 2000
|
|
|
|
recipient := common.HexToAddress("0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD")
|
|
baseNonce, err := client.PendingNonceAt(ctx, env.fundAddr)
|
|
require.NoError(err)
|
|
gasPrice, err := client.SuggestGasPrice(ctx)
|
|
require.NoError(err)
|
|
|
|
// Pre-sign all transactions.
|
|
signedTxs := make([]*types.Transaction, totalTxs)
|
|
for i := 0; i < totalTxs; i++ {
|
|
tx := types.NewTx(&types.LegacyTx{
|
|
Nonce: baseNonce + uint64(i),
|
|
GasPrice: gasPrice,
|
|
Gas: 21000,
|
|
To: &recipient,
|
|
Value: big.NewInt(1000),
|
|
})
|
|
signed, err := types.SignTx(tx, types.LatestSignerForChainID(env.chainID), env.fundKey)
|
|
require.NoError(err)
|
|
signedTxs[i] = signed
|
|
}
|
|
|
|
// Submit all txs as fast as possible, spreading across clients.
|
|
startTime := time.Now()
|
|
var sendErrs atomic.Int64
|
|
|
|
var wg sync.WaitGroup
|
|
for i, stx := range signedTxs {
|
|
wg.Add(1)
|
|
go func(idx int, tx *types.Transaction) {
|
|
defer wg.Done()
|
|
c := env.clients[idx%len(env.clients)]
|
|
if err := c.SendTransaction(ctx, tx); err != nil {
|
|
sendErrs.Add(1)
|
|
}
|
|
}(i, stx)
|
|
}
|
|
wg.Wait()
|
|
|
|
submitDuration := time.Since(startTime)
|
|
t.Logf("submitted %d txs in %v (%d send errors)", totalTxs, submitDuration, sendErrs.Load())
|
|
|
|
// Wait for the last tx to be mined and measure finality.
|
|
lastTxHash := signedTxs[len(signedTxs)-1].Hash()
|
|
waitStart := time.Now()
|
|
|
|
deadline, cancel := context.WithTimeout(ctx, 60*time.Second)
|
|
defer cancel()
|
|
var lastReceipt *types.Receipt
|
|
for {
|
|
r, err := client.TransactionReceipt(deadline, lastTxHash)
|
|
if err == nil {
|
|
lastReceipt = r
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline.Done():
|
|
t.Fatal("timeout waiting for last tx")
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
}
|
|
finalityDuration := time.Since(waitStart)
|
|
|
|
require.NotNil(lastReceipt)
|
|
require.Equal(types.ReceiptStatusSuccessful, lastReceipt.Status)
|
|
|
|
t.Logf("last tx finalized in %v (block %s)", finalityDuration, lastReceipt.BlockNumber)
|
|
|
|
// Verify total expected balance.
|
|
expectedBal := new(big.Int).Mul(big.NewInt(1000), big.NewInt(totalTxs-int64(sendErrs.Load())))
|
|
actualBal, err := client.BalanceAt(ctx, recipient, nil)
|
|
require.NoError(err)
|
|
|
|
// Allow for some send failures.
|
|
require.True(actualBal.Cmp(big.NewInt(0)) > 0, "no txs landed")
|
|
t.Logf("recipient balance: %s (expected up to %s)", actualBal, expectedBal)
|
|
|
|
// The finality check: time from submission to last receipt.
|
|
totalFinality := time.Since(startTime)
|
|
perTxFinality := totalFinality.Milliseconds() / totalTxs
|
|
|
|
t.Logf("total pipeline: %v, per-tx avg: %dms", totalFinality, perTxFinality)
|
|
|
|
// Under load, we check that the chain didn't stall -- the last tx finalized.
|
|
// Strict 2s per-tx latency is for individual tx submission, not bulk.
|
|
require.True(finalityDuration < 30*time.Second,
|
|
"finality took too long under load: %v", finalityDuration)
|
|
|
|
t.Logf("PASS: %d txs processed, finality=%v", totalTxs, finalityDuration)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Test 4: Validator Rotation
|
|
// Remove and re-add a validator mid-operation. Verify consensus
|
|
// continues uninterrupted and state remains consistent.
|
|
// -------------------------------------------------------------------
|
|
func TestConsensus_ValidatorRotation(t *testing.T) {
|
|
if os.Getenv("RUN_CHAOS") == "" {
|
|
t.Skip("RUN_CHAOS not set")
|
|
}
|
|
require := require.New(t)
|
|
env := newConsensusTestEnv(t)
|
|
defer env.cleanup(t)
|
|
|
|
ctx := context.Background()
|
|
client := env.clients[0]
|
|
recipient := common.HexToAddress("0xEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE")
|
|
|
|
// Phase 1: Send txs with all 5 validators.
|
|
for i := 0; i < 5; i++ {
|
|
env.sendAndWait(t, client, recipient, big.NewInt(1e15))
|
|
}
|
|
|
|
balPhase1, err := client.BalanceAt(ctx, recipient, nil)
|
|
require.NoError(err)
|
|
t.Logf("phase 1 (5 validators): balance=%s", balPhase1)
|
|
|
|
// Phase 2: Remove a validator (pause node).
|
|
removedName := env.names[len(env.names)-1]
|
|
pCtx, pCancel := context.WithTimeout(ctx, 10*time.Second)
|
|
err = env.network.PauseNode(pCtx, removedName)
|
|
pCancel()
|
|
require.NoError(err, "failed to pause %s", removedName)
|
|
t.Logf("removed validator: %s", removedName)
|
|
|
|
// Send more txs with 4 validators.
|
|
for i := 0; i < 5; i++ {
|
|
env.sendAndWait(t, client, recipient, big.NewInt(1e15))
|
|
}
|
|
|
|
balPhase2, err := client.BalanceAt(ctx, recipient, nil)
|
|
require.NoError(err)
|
|
require.True(balPhase2.Cmp(balPhase1) > 0, "chain stalled after validator removal")
|
|
t.Logf("phase 2 (4 validators): balance=%s", balPhase2)
|
|
|
|
// Phase 3: Re-add the validator (resume node).
|
|
rCtx, rCancel := context.WithTimeout(ctx, 10*time.Second)
|
|
err = env.network.ResumeNode(rCtx, removedName)
|
|
rCancel()
|
|
require.NoError(err, "failed to resume %s", removedName)
|
|
|
|
env.waitHealthy(t, 90*time.Second)
|
|
|
|
// Send more txs.
|
|
for i := 0; i < 5; i++ {
|
|
env.sendAndWait(t, client, recipient, big.NewInt(1e15))
|
|
}
|
|
|
|
balPhase3, err := client.BalanceAt(ctx, recipient, nil)
|
|
require.NoError(err)
|
|
require.True(balPhase3.Cmp(balPhase2) > 0, "chain stalled after validator re-add")
|
|
t.Logf("phase 3 (5 validators again): balance=%s", balPhase3)
|
|
|
|
// Total should be 15 txs * 1e15.
|
|
expected := new(big.Int).Mul(big.NewInt(1e15), big.NewInt(15))
|
|
require.Equal(expected.String(), balPhase3.String(),
|
|
"balance mismatch: expected=%s actual=%s", expected, balPhase3)
|
|
|
|
t.Log("PASS: validator rotation completed. consensus uninterrupted across all 3 phases")
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Test 5: Clock Skew
|
|
// Send txs from multiple clients and verify block timestamps are
|
|
// always monotonically increasing despite potential clock differences.
|
|
// -------------------------------------------------------------------
|
|
func TestConsensus_ClockSkew(t *testing.T) {
|
|
if os.Getenv("RUN_CHAOS") == "" {
|
|
t.Skip("RUN_CHAOS not set")
|
|
}
|
|
require := require.New(t)
|
|
env := newConsensusTestEnv(t)
|
|
defer env.cleanup(t)
|
|
|
|
ctx := context.Background()
|
|
|
|
// We cannot actually skew system clocks in a test, but we can verify
|
|
// the invariant that block timestamps are monotonic under concurrent
|
|
// submissions from different nodes (which is when clock skew would manifest).
|
|
|
|
const numTxs = 30
|
|
recipient := common.HexToAddress("0xF1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1")
|
|
|
|
// Send txs round-robin across all nodes to stress timestamp ordering.
|
|
var receipts []*types.Receipt
|
|
for i := 0; i < numTxs; i++ {
|
|
c := env.clients[i%len(env.clients)]
|
|
r := env.sendAndWait(t, c, recipient, big.NewInt(1e12))
|
|
receipts = append(receipts, r)
|
|
}
|
|
|
|
// Collect all block timestamps in order.
|
|
var prevTime uint64
|
|
for i, r := range receipts {
|
|
block, err := env.clients[0].BlockByNumber(ctx, r.BlockNumber)
|
|
require.NoError(err)
|
|
|
|
blockTime := block.Time()
|
|
require.True(blockTime >= prevTime,
|
|
"block timestamp went backward at tx %d: block %s time %d < prev %d",
|
|
i, r.BlockNumber, blockTime, prevTime)
|
|
prevTime = blockTime
|
|
}
|
|
|
|
// Verify across all nodes: get the latest block and check its timestamp is sane.
|
|
now := uint64(time.Now().Unix())
|
|
for i, c := range env.clients {
|
|
bn, err := c.BlockNumber(ctx)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
block, err := c.BlockByNumber(ctx, new(big.Int).SetUint64(bn))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
bt := block.Time()
|
|
// Block time should be within 60 seconds of wall clock.
|
|
drift := int64(now) - int64(bt)
|
|
if drift < 0 {
|
|
drift = -drift
|
|
}
|
|
require.True(drift < 60,
|
|
"node %d block time drifted %ds from wall clock", i, drift)
|
|
}
|
|
|
|
t.Logf("PASS: %d blocks verified with monotonic timestamps across all nodes", numTxs)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Test 6: Network Jitter
|
|
// Add latency by pausing/resuming nodes rapidly (simulates jitter).
|
|
// Verify consensus still achieves finality.
|
|
// -------------------------------------------------------------------
|
|
func TestConsensus_NetworkJitter(t *testing.T) {
|
|
if os.Getenv("RUN_CHAOS") == "" {
|
|
t.Skip("RUN_CHAOS not set")
|
|
}
|
|
require := require.New(t)
|
|
env := newConsensusTestEnv(t)
|
|
defer env.cleanup(t)
|
|
|
|
ctx := context.Background()
|
|
recipient := common.HexToAddress("0xA2A2A2A2A2A2A2A2A2A2A2A2A2A2A2A2A2A2A2A2")
|
|
|
|
// Jitter: rapidly pause/resume different nodes while submitting txs.
|
|
var jitterWg sync.WaitGroup
|
|
jitterDone := make(chan struct{})
|
|
|
|
// Jitter goroutine: cycle through nodes, pausing each for 500ms.
|
|
jitterWg.Add(1)
|
|
go func() {
|
|
defer jitterWg.Done()
|
|
for {
|
|
select {
|
|
case <-jitterDone:
|
|
return
|
|
default:
|
|
}
|
|
for i := 0; i < len(env.names); i++ {
|
|
select {
|
|
case <-jitterDone:
|
|
return
|
|
default:
|
|
}
|
|
name := env.names[i]
|
|
pCtx, pCancel := context.WithTimeout(ctx, 5*time.Second)
|
|
_ = env.network.PauseNode(pCtx, name)
|
|
pCancel()
|
|
time.Sleep(500 * time.Millisecond)
|
|
rCtx, rCancel := context.WithTimeout(ctx, 5*time.Second)
|
|
_ = env.network.ResumeNode(rCtx, name)
|
|
rCancel()
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Submit txs during jitter.
|
|
const numTxs = 20
|
|
var txsMined int
|
|
for i := 0; i < numTxs; i++ {
|
|
// Find a working client.
|
|
var sent bool
|
|
for _, c := range env.clients {
|
|
nonce, err := c.PendingNonceAt(ctx, env.fundAddr)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
gasPrice, err := c.SuggestGasPrice(ctx)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
tx := types.NewTx(&types.LegacyTx{
|
|
Nonce: nonce, GasPrice: gasPrice, Gas: 21000,
|
|
To: &recipient, Value: big.NewInt(1e14),
|
|
})
|
|
signed, err := types.SignTx(tx, types.LatestSignerForChainID(env.chainID), env.fundKey)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if err := c.SendTransaction(ctx, signed); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Wait for receipt with shorter timeout (jitter may slow things).
|
|
deadline, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
for {
|
|
r, err := c.TransactionReceipt(deadline, signed.Hash())
|
|
if err == nil && r.Status == types.ReceiptStatusSuccessful {
|
|
txsMined++
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline.Done():
|
|
break
|
|
case <-time.After(200 * time.Millisecond):
|
|
continue
|
|
}
|
|
if deadline.Err() != nil {
|
|
break
|
|
}
|
|
}
|
|
cancel()
|
|
sent = true
|
|
break
|
|
}
|
|
if !sent {
|
|
t.Logf("tx %d: all clients unavailable, skipping", i)
|
|
}
|
|
}
|
|
|
|
// Stop jitter.
|
|
close(jitterDone)
|
|
jitterWg.Wait()
|
|
|
|
// Resume all nodes that might be paused.
|
|
for _, name := range env.names {
|
|
rCtx, rCancel := context.WithTimeout(ctx, 10*time.Second)
|
|
_ = env.network.ResumeNode(rCtx, name)
|
|
rCancel()
|
|
}
|
|
|
|
env.waitHealthy(t, 90*time.Second)
|
|
|
|
// At least some txs should have finalized despite jitter.
|
|
require.True(txsMined > 0, "no transactions finalized under jitter")
|
|
|
|
// Verify state consistency across nodes.
|
|
refBal, err := env.clients[0].BalanceAt(ctx, recipient, nil)
|
|
require.NoError(err)
|
|
require.True(refBal.Cmp(big.NewInt(0)) > 0, "recipient has zero balance")
|
|
|
|
for i, c := range env.clients[1:] {
|
|
bal, err := c.BalanceAt(ctx, recipient, nil)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
require.Equal(refBal.String(), bal.String(),
|
|
"node %d diverged from node 0: %s vs %s", i+1, bal, refBal)
|
|
}
|
|
|
|
t.Logf("PASS: %d/%d txs finalized under jitter. all nodes consistent", txsMined, numTxs)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Test 7: Double Vote Detection
|
|
// A validator signs two conflicting transactions at the same nonce
|
|
// and sends them to different nodes. Verify only one is accepted.
|
|
// -------------------------------------------------------------------
|
|
func TestConsensus_DoubleVote(t *testing.T) {
|
|
if os.Getenv("RUN_CHAOS") == "" {
|
|
t.Skip("RUN_CHAOS not set")
|
|
}
|
|
require := require.New(t)
|
|
env := newConsensusTestEnv(t)
|
|
defer env.cleanup(t)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create a separate funded account for the double-vote test.
|
|
dvKey, err := luxcrypto.GenerateKey()
|
|
require.NoError(err)
|
|
dvAddr := common.PubkeyToAddress(dvKey.PublicKey)
|
|
|
|
// Fund it.
|
|
fundAmt := new(big.Int).Mul(big.NewInt(10), new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil))
|
|
env.sendAndWait(t, env.clients[0], dvAddr, fundAmt)
|
|
|
|
// Create two conflicting txs from this account with nonce 0.
|
|
gasPrice, err := env.clients[0].SuggestGasPrice(ctx)
|
|
require.NoError(err)
|
|
|
|
addrX := common.HexToAddress("0x1234567890123456789012345678901234567890")
|
|
addrY := common.HexToAddress("0x0987654321098765432109876543210987654321")
|
|
|
|
txX := types.NewTx(&types.LegacyTx{
|
|
Nonce: 0, GasPrice: gasPrice, Gas: 21000,
|
|
To: &addrX, Value: big.NewInt(1e18),
|
|
})
|
|
txY := types.NewTx(&types.LegacyTx{
|
|
Nonce: 0, GasPrice: gasPrice, Gas: 21000,
|
|
To: &addrY, Value: big.NewInt(1e18),
|
|
})
|
|
|
|
signedX, err := types.SignTx(txX, types.LatestSignerForChainID(env.chainID), dvKey)
|
|
require.NoError(err)
|
|
signedY, err := types.SignTx(txY, types.LatestSignerForChainID(env.chainID), dvKey)
|
|
require.NoError(err)
|
|
|
|
// Send to different nodes simultaneously.
|
|
var sendWg sync.WaitGroup
|
|
sendWg.Add(2)
|
|
go func() {
|
|
defer sendWg.Done()
|
|
_ = env.clients[0].SendTransaction(ctx, signedX)
|
|
}()
|
|
go func() {
|
|
defer sendWg.Done()
|
|
if len(env.clients) > 1 {
|
|
_ = env.clients[len(env.clients)-1].SendTransaction(ctx, signedY)
|
|
}
|
|
}()
|
|
sendWg.Wait()
|
|
|
|
// Wait for finalization.
|
|
time.Sleep(10 * time.Second)
|
|
|
|
// Check: exactly one recipient should have funds.
|
|
balX, _ := env.clients[0].BalanceAt(ctx, addrX, nil)
|
|
balY, _ := env.clients[0].BalanceAt(ctx, addrY, nil)
|
|
|
|
xFunded := balX != nil && balX.Cmp(big.NewInt(0)) > 0
|
|
yFunded := balY != nil && balY.Cmp(big.NewInt(0)) > 0
|
|
|
|
require.True(xFunded || yFunded, "neither double-vote tx was included")
|
|
require.False(xFunded && yFunded, "DOUBLE VOTE ACCEPTED: both txs included!")
|
|
|
|
// Verify all nodes agree.
|
|
for i := 1; i < len(env.clients); i++ {
|
|
bx, _ := env.clients[i].BalanceAt(ctx, addrX, nil)
|
|
by, _ := env.clients[i].BalanceAt(ctx, addrY, nil)
|
|
|
|
nodeXFunded := bx != nil && bx.Cmp(big.NewInt(0)) > 0
|
|
nodeYFunded := by != nil && by.Cmp(big.NewInt(0)) > 0
|
|
|
|
require.Equal(xFunded, nodeXFunded, "node %d disagrees on X", i)
|
|
require.Equal(yFunded, nodeYFunded, "node %d disagrees on Y", i)
|
|
}
|
|
|
|
winner := "X"
|
|
if yFunded {
|
|
winner = "Y"
|
|
}
|
|
t.Logf("PASS: double vote detected. winner=%s, all nodes agree", winner)
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Test 8: Chain Reorg Recovery
|
|
// Force a 3-block divergence via partition, then heal and verify all
|
|
// nodes converge to the canonical chain.
|
|
// -------------------------------------------------------------------
|
|
func TestConsensus_ChainReorgRecovery(t *testing.T) {
|
|
if os.Getenv("RUN_CHAOS") == "" {
|
|
t.Skip("RUN_CHAOS not set")
|
|
}
|
|
require := require.New(t)
|
|
env := newConsensusTestEnv(t)
|
|
defer env.cleanup(t)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Get baseline state.
|
|
bnBefore := env.blockNumbersFromAll(t)
|
|
t.Logf("baseline block numbers: %v", bnBefore)
|
|
|
|
// Partition: isolate nodes [3,4] from [0,1,2].
|
|
isolated := env.names[3:]
|
|
for _, name := range isolated {
|
|
pCtx, pCancel := context.WithTimeout(ctx, 10*time.Second)
|
|
err := env.network.PauseNode(pCtx, name)
|
|
pCancel()
|
|
require.NoError(err, "failed to pause %s", name)
|
|
}
|
|
|
|
// Submit 3 txs to the majority partition (nodes 0,1,2).
|
|
recipientMajority := common.HexToAddress("0xF0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0")
|
|
for i := 0; i < 3; i++ {
|
|
env.sendAndWait(t, env.clients[0], recipientMajority, big.NewInt(1e15))
|
|
}
|
|
|
|
bnDuringPartition := env.blockNumbersFromAll(t)
|
|
t.Logf("block numbers during partition: %v", bnDuringPartition)
|
|
|
|
// Heal: resume isolated nodes.
|
|
for _, name := range isolated {
|
|
rCtx, rCancel := context.WithTimeout(ctx, 10*time.Second)
|
|
err := env.network.ResumeNode(rCtx, name)
|
|
rCancel()
|
|
require.NoError(err, "failed to resume %s", name)
|
|
}
|
|
|
|
// Wait for convergence.
|
|
env.waitHealthy(t, 120*time.Second)
|
|
time.Sleep(10 * time.Second) // extra propagation time
|
|
|
|
// Verify convergence: all nodes at the same height.
|
|
bnAfter := env.blockNumbersFromAll(t)
|
|
t.Logf("block numbers after reorg: %v", bnAfter)
|
|
|
|
var heights []uint64
|
|
for _, h := range bnAfter {
|
|
heights = append(heights, h)
|
|
}
|
|
require.NotEmpty(heights)
|
|
maxH, minH := heights[0], heights[0]
|
|
for _, h := range heights {
|
|
if h > maxH {
|
|
maxH = h
|
|
}
|
|
if h < minH {
|
|
minH = h
|
|
}
|
|
}
|
|
require.True(maxH-minH <= 2, "nodes did not converge: min=%d max=%d", minH, maxH)
|
|
|
|
// Verify all nodes agree on the same block hash at a specific height.
|
|
checkHeight := minH
|
|
var refHash common.Hash
|
|
for i, c := range env.clients {
|
|
block, err := c.BlockByNumber(ctx, new(big.Int).SetUint64(checkHeight))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if refHash == (common.Hash{}) {
|
|
refHash = block.Hash()
|
|
continue
|
|
}
|
|
require.Equal(refHash, block.Hash(),
|
|
"node %d has different block hash at height %d: %s vs %s",
|
|
i, checkHeight, block.Hash().Hex(), refHash.Hex())
|
|
}
|
|
require.NotEqual(common.Hash{}, refHash, "no node returned block at height %d", checkHeight)
|
|
|
|
// Verify the majority partition's txs survived the reorg.
|
|
for i, c := range env.clients {
|
|
bal, err := c.BalanceAt(ctx, recipientMajority, nil)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
expected := new(big.Int).Mul(big.NewInt(1e15), big.NewInt(3))
|
|
require.Equal(expected.String(), bal.String(),
|
|
"node %d lost majority txs after reorg: %s vs %s", i, bal, expected)
|
|
}
|
|
|
|
t.Logf("PASS: chain reorg recovery verified. all nodes converged to canonical chain at height ~%d", checkHeight)
|
|
}
|