Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ release/
.DS_Store
build/
cache/
./evm_stress
*.iml

# Local .terraform directories
Expand Down
125 changes: 125 additions & 0 deletions scripts/evm_stress.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# Spin up a local seid node, flood it with EVM transfers from N accounts to one
# recipient, and print only branch-specific logs + block time.
#
# Usage: ./scripts/evm_stress.sh
# Run from the repo root.
set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SEID="$HOME/go/bin/seid"
LOG_FILE="/tmp/seid_stress.log"

# Genesis balance per sender account. 10^12 usei ≈ unlimited for any test run.
SENDER_GENESIS_FUNDS="1000000000000"

cd "$REPO_ROOT"

cleanup() {
echo ""
echo "==> shutting down..."
[ -n "${SEID_PID:-}" ] && kill "$SEID_PID" 2>/dev/null || true
# Kill the entire process group so tail and grep children are also terminated.
[ -n "${LOG_PID:-}" ] && kill -- -"$LOG_PID" 2>/dev/null || true
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup fails to kill log-tailing child processes

Medium Severity

kill -- -"$LOG_PID" attempts to send a signal to the process group identified by $LOG_PID, but in a non-interactive shell script (no set -m), background subshells do not become process group leaders — they inherit the parent's PGID. So there is no process group with PGID equal to $LOG_PID, the kill silently fails (masked by 2>/dev/null || true), and the tail/grep children are left orphaned. The comment explicitly states the intent to "kill the entire process group so tail and grep children are also terminated," but this doesn't happen.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 93fda38. Configure here.

}
trap cleanup EXIT INT TERM

# Kill any tail processes from previous runs that are still watching this log
# file. tail -F re-opens the file by name when it is truncated, so a stale
# tail would re-read the new run's log and emit duplicate lines.
pkill -f "tail.*${LOG_FILE}" 2>/dev/null || true

# ---------------------------------------------------------------------------
# 1. Init chain (no start)
# ---------------------------------------------------------------------------
echo "==> initializing chain..."
NO_RUN=1 ./scripts/initialize_local_chain.sh

# ---------------------------------------------------------------------------
# 2. Bulk-add sender accounts to genesis via direct JSON patch.
# go run -dump-sei-addrs generates all bech32 addresses; Python patches
# genesis.json in one pass (same technique as populate_genesis_accounts.py).
# ---------------------------------------------------------------------------
cat > /tmp/evm_stress_patch_genesis.py << 'PYEOF'
import json, sys

genesis_path = sys.argv[1]
amount = sys.argv[2]
denom = "usei"

addrs = [l.strip() for l in sys.stdin if l.strip()]

with open(genesis_path) as f:
g = json.load(f)

for addr in addrs:
g["app_state"]["auth"]["accounts"].append({
"@type": "/cosmos.auth.v1beta1.BaseAccount",
"address": addr,
"pub_key": None,
"account_number": "0",
"sequence": "0",
})
g["app_state"]["bank"]["balances"].append({
"address": addr,
"coins": [{"denom": denom, "amount": amount}],
})

with open(genesis_path, "w") as f:
json.dump(g, f, separators=(",", ":"))

print(f"Added {len(addrs)} accounts to genesis", file=sys.stderr)
PYEOF

echo "==> patching genesis with sender accounts..."
go run "$REPO_ROOT/scripts/evm_stress/main.go" -dump-sei-addrs \
| python3 /tmp/evm_stress_patch_genesis.py \
"$HOME/.sei/config/genesis.json" "$SENDER_GENESIS_FUNDS"
echo "==> genesis patched"

# ---------------------------------------------------------------------------
# 3. Start seid, capturing all output to log file
# ---------------------------------------------------------------------------
echo "==> starting seid (logs -> $LOG_FILE)..."
mkdir -p /tmp/race
GORACE="log_path=/tmp/race/seid_race" \
"$SEID" start --trace --chain-id sei-chain > "$LOG_FILE" 2>&1 &
SEID_PID=$!
echo "==> seid PID: $SEID_PID"

# ---------------------------------------------------------------------------
# 4. Tail log file, printing only branch-specific messages
# - "occ scheduler key conflicts" from sei-cosmos/tasks/scheduler.go
# - "execution block time" from x/evm/keeper/abci.go
# ---------------------------------------------------------------------------
(
tail -F "$LOG_FILE" 2>/dev/null \
| grep --line-buffered -E \
'"occ scheduler key conflicts"|"execution block time"'
) &
LOG_PID=$!

# ---------------------------------------------------------------------------
# 5. Wait for the EVM RPC to accept connections
# ---------------------------------------------------------------------------
echo "==> waiting for EVM RPC at http://127.0.0.1:8545..."
for i in $(seq 1 60); do
if curl -sf -X POST http://127.0.0.1:8545 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \
> /dev/null 2>&1; then
echo "==> EVM RPC ready (after ${i}s)"
break
fi
if [ "$i" -eq 60 ]; then
echo "ERROR: EVM RPC not ready after 60s" >&2
exit 1
fi
sleep 1
done

# ---------------------------------------------------------------------------
# 6. Run the Go load tester
# ---------------------------------------------------------------------------
echo "==> starting EVM transfer stress test (target 500 TPS, 50k unique senders)..."
go run "$REPO_ROOT/scripts/evm_stress/main.go"
154 changes: 154 additions & 0 deletions scripts/evm_stress/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package main

import (
"context"
"crypto/ecdsa"
"flag"
"fmt"
"math/big"
"sync"
"time"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"

seibech32 "github.com/sei-protocol/sei-chain/sei-cosmos/types/bech32"
)

const (
evmRPC = "http://127.0.0.1:8545"
chainID = 713714 // default EVM chain ID for "sei-chain"
targetTPS = 500
numWorkers = 250

// Total unique sender accounts pre-funded in genesis. Every tx sent by
// the stress test has a distinct sender with nonce=0. At targetTPS, the
// pool lasts totalAccounts/targetTPS seconds.
totalAccounts = 50_000
)

var (
bigChainID = big.NewInt(chainID)
signer = types.NewLondonSigner(bigChainID)
maxFee = big.NewInt(1_000_000_000_000) // 1000 gwei
priorityFee = big.NewInt(1_000_000_000) // 1 gwei
txValue = big.NewInt(1_000_000_000_001) // 10^12+1 wei: touches both usei balance and wei remainder
)

// nextKey returns a unique deterministic private key for the given index.
func nextKey(idx uint64) *ecdsa.PrivateKey {
seed := make([]byte, 32)
// use upper 8 bytes for the index so seed is never all-zero
seed[0] = 0x01
for i := 0; i < 8; i++ {
seed[1+i] = byte(idx >> (56 - 8*i))
}
key, err := crypto.ToECDSA(seed)
if err != nil {
panic(fmt.Sprintf("bad key seed %d: %v", idx, err))
}
return key
}

func keyAddr(key *ecdsa.PrivateKey) common.Address {
return crypto.PubkeyToAddress(key.PublicKey)
}

func evmToSei(addr common.Address) string {
s, err := seibech32.ConvertAndEncode("sei", addr.Bytes())
if err != nil {
panic(fmt.Sprintf("bech32 encode: %v", err))
}
return s
}

func signTx(tx *types.Transaction, key *ecdsa.PrivateKey) *types.Transaction {
signed, err := types.SignTx(tx, signer, key)
if err != nil {
panic(err)
}
return signed
}

func transfer(nonce uint64, to common.Address, key *ecdsa.PrivateKey) *types.Transaction {
return signTx(types.NewTx(&types.DynamicFeeTx{
ChainID: bigChainID,
Nonce: nonce,
GasTipCap: priorityFee,
GasFeeCap: maxFee,
Gas: 21_000,
To: &to,
Value: txValue,
}), key)
}

func waitForBalance(ctx context.Context, client *ethclient.Client, addr common.Address) {
fmt.Printf("waiting for %s to have balance...\n", addr.Hex())
for {
bal, err := client.BalanceAt(ctx, addr, nil)
if err == nil && bal.Sign() > 0 {
fmt.Printf(" %s: %s wei\n", addr.Hex(), bal.String())
return
}
time.Sleep(300 * time.Millisecond)
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Infinite loop in waitForBalance lacks timeout

Low Severity

waitForBalance spins in an infinite for loop polling for a positive balance, with no timeout or context cancellation check. If the genesis patching fails or the node doesn't produce a block, this hangs forever with no diagnostic output. Since ctx is context.Background(), there's no external cancellation path either.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 66f51e7. Configure here.


func main() {
dumpSeiAddrs := flag.Bool("dump-sei-addrs", false, "print sender sei bech32 addresses for genesis funding and exit")
flag.Parse()

// Key 0 = recipient; keys 1..totalAccounts = one-time genesis-funded senders.
recipient := keyAddr(nextKey(0))

if *dumpSeiAddrs {
for i := uint64(1); i <= totalAccounts; i++ {
fmt.Println(evmToSei(keyAddr(nextKey(i))))
}
return
}

ctx := context.Background()
client, err := ethclient.Dial(evmRPC)
if err != nil {
panic(fmt.Sprintf("dial %s: %v", evmRPC, err))
}
defer client.Close()

fmt.Printf("recipient: %s\n", recipient.Hex())

// Wait for genesis accounts to have balance — confirms the node is live.
waitForBalance(ctx, client, keyAddr(nextKey(1)))

// Pre-fill the work queue. Each key is used for exactly one tx (nonce=0).
funded := make(chan *ecdsa.PrivateKey, totalAccounts)
for i := uint64(1); i <= totalAccounts; i++ {
funded <- nextKey(i)
}
close(funded)

// Shared rate limiter across all workers: one tick per tx slot.
ticker := time.NewTicker(time.Second / time.Duration(targetTPS))
defer ticker.Stop()

fmt.Printf("starting %d workers, %d unique senders, target %d TPS\n",
numWorkers, totalAccounts, targetTPS)

var wg sync.WaitGroup
for range numWorkers {
wg.Add(1)
go func() {
defer wg.Done()
for key := range funded {
<-ticker.C
tx := transfer(0, recipient, key)
_ = client.SendTransaction(ctx, tx)
}
}()
}

wg.Wait()
fmt.Printf("all %d accounts exhausted\n", totalAccounts)
}
Loading