Files
netrunner/tests/consensus_chaos_test.go
zeekayandHanzo Dev 26e21d0a8e chore: migrate luxd HTTP routes /ext -> /v1
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>
2026-07-01 11:40:27 -07:00

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)
}