From dfe6f913f30f3870907d85a45c24da3f650a1c51 Mon Sep 17 00:00:00 2001 From: paologalligit Date: Tue, 7 Apr 2026 13:05:39 +0200 Subject: [PATCH 1/2] add tests for eip-7823 ModExp upper bound --- .github/README.md | 2 + tests/eip7823/main_test.go | 14 +++ tests/eip7823/modexp_test.go | 216 +++++++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 tests/eip7823/main_test.go create mode 100644 tests/eip7823/modexp_test.go diff --git a/.github/README.md b/.github/README.md index 73dec62..db5d344 100644 --- a/.github/README.md +++ b/.github/README.md @@ -8,6 +8,7 @@ End-to-end tests for the VeChain **INTERSTELLAR** fork, which activates at block |--------|-----|-------------| | `tests/eip5656` | [EIP-5656](https://eips.ethereum.org/EIPS/eip-5656) | `MCOPY` opcode (0x5e) for in-memory copying | | `tests/eip7825` | [EIP-7825](https://eips.ethereum.org/EIPS/eip-7825) | Per-transaction gas limit cap (`MaxTxGasLimit = 1 << 24`) | +| `tests/eip7823` | [EIP-7823](https://eips.ethereum.org/EIPS/eip-7823) | ModExp upper bound (1024-byte limit on base/exp/mod) | | `tests/eip7883` | [EIP-7883](https://eips.ethereum.org/EIPS/eip-7883) | ModExp precompile repricing | ## Repository layout @@ -22,6 +23,7 @@ interstellar-e2e/ └── tests/ ├── helper/ # shared test utilities (client, network lifecycle) ├── eip5656/ + ├── eip7823/ ├── eip7825/ └── eip7883/ ``` diff --git a/tests/eip7823/main_test.go b/tests/eip7823/main_test.go new file mode 100644 index 0000000..6cd2bdc --- /dev/null +++ b/tests/eip7823/main_test.go @@ -0,0 +1,14 @@ +package eip7823 + +import ( + "os" + "testing" + + "github.com/vechain/interstellar-e2e/tests/helper" +) + +var nodeURL string + +func TestMain(m *testing.M) { + os.Exit(helper.RunTestMain(m, &nodeURL)) +} diff --git a/tests/eip7823/modexp_test.go b/tests/eip7823/modexp_test.go new file mode 100644 index 0000000..489ebb5 --- /dev/null +++ b/tests/eip7823/modexp_test.go @@ -0,0 +1,216 @@ +package eip7823 + +// EIP-7823 introduces an upper bound of 1024 bytes for the base, exponent, and +// modulus length parameters of the MODEXP precompile (address 0x05). +// +// Pre-fork: no upper-bound check; the same inputs succeed as long as enough gas +// is supplied. +// +// Post-fork (INTERSTELLAR): any declared length > 1024 — or a length field that +// overflows uint64 — causes the precompile to revert with +// "one or more of base/exponent/modulus length exceeded 1024 bytes" + +import ( + "encoding/hex" + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vechain/thor/v2/api" + "github.com/vechain/thor/v2/thor" + "github.com/vechain/thor/v2/thorclient" + + "github.com/vechain/interstellar-e2e/tests/helper" +) + +var modExpAddr = thor.BytesToAddress([]byte{5}) + +// encodeModExpInput encodes inputs for the MODEXP precompile. +// Wire format: [baseLen (32 B)][expLen (32 B)][modLen (32 B)][base][exp][mod] +func encodeModExpInput(base, exp, mod []byte) []byte { + buf := make([]byte, 96+len(base)+len(exp)+len(mod)) + new(big.Int).SetUint64(uint64(len(base))).FillBytes(buf[0:32]) + new(big.Int).SetUint64(uint64(len(exp))).FillBytes(buf[32:64]) + new(big.Int).SetUint64(uint64(len(mod))).FillBytes(buf[64:96]) + copy(buf[96:], base) + copy(buf[96+len(base):], exp) + copy(buf[96+len(base)+len(exp):], mod) + return buf +} + +// encodeModExpOverflowInput builds a raw MODEXP input whose baseLen field +// exceeds uint64 (2^64 + 1), with expLen=1 and modLen=1. +func encodeModExpOverflowInput() []byte { + buf := make([]byte, 96+3) + // baseLen = 2^64 + 1 → 32-byte big-endian with bit 64 set plus 1 + buf[23] = 0x01 + buf[31] = 0x01 + // expLen = 1 + buf[63] = 0x01 + // modLen = 1 + buf[95] = 0x01 + // minimal data: base=0x01, exp=0x01, mod=0x03 + buf[96] = 0x01 + buf[97] = 0x01 + buf[98] = 0x03 + return buf +} + +func TestEIP7823_WithinBound(t *testing.T) { + client := helper.NewClient(nodeURL) + + base := make([]byte, 32) + base[31] = 0x02 // 2 + exp := []byte{0x0a} // 10 + mod := make([]byte, 32) + mod[30] = 0x03 + mod[31] = 0xe8 // 1000 + + input := encodeModExpInput(base, exp, mod) + callData := &api.BatchCallData{ + Clauses: api.Clauses{{To: &modExpAddr, Data: "0x" + hex.EncodeToString(input)}}, + Gas: 100_000, + } + + t.Run("pre-fork", func(t *testing.T) { + results, err := client.InspectClauses(callData, thorclient.Revision(helper.PreForkRevision)) + require.NoError(t, err) + require.Len(t, results, 1) + assert.False(t, results[0].Reverted, + "small modexp must not revert pre-fork: %s", results[0].VMError) + }) + + t.Run("post-fork", func(t *testing.T) { + results, err := client.InspectClauses(callData, thorclient.Revision(helper.PostForkRevision)) + require.NoError(t, err) + require.Len(t, results, 1) + assert.False(t, results[0].Reverted, + "small modexp must not revert post-fork: %s", results[0].VMError) + }) +} + +func TestEIP7823_ExactBoundary(t *testing.T) { + client := helper.NewClient(nodeURL) + + base := make([]byte, 1024) + base[0] = 0x01 + exp := []byte{0x01} + mod := []byte{0x03} + + input := encodeModExpInput(base, exp, mod) + callData := &api.BatchCallData{ + Clauses: api.Clauses{{To: &modExpAddr, Data: "0x" + hex.EncodeToString(input)}}, + Gas: 100_000, + } + + results, err := client.InspectClauses(callData, thorclient.Revision(helper.PostForkRevision)) + require.NoError(t, err) + require.Len(t, results, 1) + assert.False(t, results[0].Reverted, + "1024-byte base is at the limit and must not revert post-fork: %s", results[0].VMError) +} + +func TestEIP7823_ExceedsBound_PostFork(t *testing.T) { + client := helper.NewClient(nodeURL) + + tests := []struct { + name string + baseLen int + expLen int + modLen int + gas uint64 + }{ + {"base_exceeds_1024", 1025, 1, 1, 100_000}, + {"exp_exceeds_1024", 1, 1025, 1, 500_000}, + {"mod_exceeds_1024", 1, 1, 1025, 100_000}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + base := make([]byte, tc.baseLen) + base[0] = 0x01 + exp := make([]byte, tc.expLen) + exp[0] = 0x01 + mod := make([]byte, tc.modLen) + mod[0] = 0x03 + + input := encodeModExpInput(base, exp, mod) + callData := &api.BatchCallData{ + Clauses: api.Clauses{{To: &modExpAddr, Data: "0x" + hex.EncodeToString(input)}}, + Gas: tc.gas, + } + + results, err := client.InspectClauses(callData, thorclient.Revision(helper.PostForkRevision)) + require.NoError(t, err) + require.Len(t, results, 1) + assert.True(t, results[0].Reverted, + "modexp with lengths exceeding 1024 must revert post-fork") + assert.Contains(t, results[0].VMError, "exceeded 1024", + "vmError should mention the 1024-byte limit") + }) + } + + // When all three lengths exceed 1024, RequiredGas (~273M) exceeds the + // node's gas limit so Run is never reached — the call still reverts. + t.Run("all_exceed_1024", func(t *testing.T) { + base := make([]byte, 1025) + base[0] = 0x01 + exp := make([]byte, 1025) + exp[0] = 0x01 + mod := make([]byte, 1025) + mod[0] = 0x03 + + input := encodeModExpInput(base, exp, mod) + callData := &api.BatchCallData{ + Clauses: api.Clauses{{To: &modExpAddr, Data: "0x" + hex.EncodeToString(input)}}, + Gas: 10_000_000, + } + + results, err := client.InspectClauses(callData, thorclient.Revision(helper.PostForkRevision)) + require.NoError(t, err) + require.Len(t, results, 1) + assert.True(t, results[0].Reverted, + "modexp with all lengths exceeding 1024 must revert post-fork") + }) +} + +func TestEIP7823_ExceedsBound_PreFork(t *testing.T) { + client := helper.NewClient(nodeURL) + + base := make([]byte, 1025) + base[0] = 0x01 + exp := []byte{0x01} + mod := []byte{0x03} + + input := encodeModExpInput(base, exp, mod) + callData := &api.BatchCallData{ + Clauses: api.Clauses{{To: &modExpAddr, Data: "0x" + hex.EncodeToString(input)}}, + Gas: 100_000, + } + + results, err := client.InspectClauses(callData, thorclient.Revision(helper.PreForkRevision)) + require.NoError(t, err) + require.Len(t, results, 1) + assert.False(t, results[0].Reverted, + "modexp with baseLen=1025 must succeed pre-fork (no upper-bound check): %s", results[0].VMError) +} + +func TestEIP7823_LengthOverflow(t *testing.T) { + client := helper.NewClient(nodeURL) + + input := encodeModExpOverflowInput() + callData := &api.BatchCallData{ + Clauses: api.Clauses{{To: &modExpAddr, Data: "0x" + hex.EncodeToString(input)}}, + Gas: 100_000, + } + + // RequiredGas returns math.MaxUint64 for overflowing length fields, so the + // EVM always reverts with "out of gas" before Run (and the EIP-7823 check) + // is reached. We only assert that the call is rejected. + results, err := client.InspectClauses(callData, thorclient.Revision(helper.PostForkRevision)) + require.NoError(t, err) + require.Len(t, results, 1) + assert.True(t, results[0].Reverted, + "modexp with baseLen overflowing uint64 must revert post-fork") +} From 4e9f68b3c49963865d2da9f0104189fa41fc8c4e Mon Sep 17 00:00:00 2001 From: paologalligit Date: Fri, 10 Apr 2026 10:15:21 +0200 Subject: [PATCH 2/2] merge main --- .github/README.md | 14 ++- Makefile | 9 +- network/cmd/nodeurl.go | 33 ++++-- network/cmd/start.go | 14 ++- network/cmd/types.go | 5 +- network/main.go | 4 +- tests/eip5656/main_test.go | 2 +- tests/eip7823/main_test.go | 2 +- tests/eip7825/main_test.go | 2 +- tests/eip7883/main_test.go | 2 +- tests/eip7934/eip7934_test.go | 190 ++++++++++++++++++++++++++++++++ tests/eip7934/main_test.go | 17 +++ tests/eip7939/clz_test.go | 107 +++++++++++++++++++ tests/eip7939/main_test.go | 14 +++ tests/helper/client.go | 17 ++- tests/helper/network.go | 5 +- tests/helper/p2p.go | 196 ++++++++++++++++++++++++++++++++++ tests/helper/testmain.go | 36 ++++++- 18 files changed, 637 insertions(+), 32 deletions(-) create mode 100644 tests/eip7934/eip7934_test.go create mode 100644 tests/eip7934/main_test.go create mode 100644 tests/eip7939/clz_test.go create mode 100644 tests/eip7939/main_test.go create mode 100644 tests/helper/p2p.go diff --git a/.github/README.md b/.github/README.md index db5d344..e2efafb 100644 --- a/.github/README.md +++ b/.github/README.md @@ -7,9 +7,11 @@ End-to-end tests for the VeChain **INTERSTELLAR** fork, which activates at block | Folder | EIP | Description | |--------|-----|-------------| | `tests/eip5656` | [EIP-5656](https://eips.ethereum.org/EIPS/eip-5656) | `MCOPY` opcode (0x5e) for in-memory copying | -| `tests/eip7825` | [EIP-7825](https://eips.ethereum.org/EIPS/eip-7825) | Per-transaction gas limit cap (`MaxTxGasLimit = 1 << 24`) | | `tests/eip7823` | [EIP-7823](https://eips.ethereum.org/EIPS/eip-7823) | ModExp upper bound (1024-byte limit on base/exp/mod) | +| `tests/eip7825` | [EIP-7825](https://eips.ethereum.org/EIPS/eip-7825) | Per-transaction gas limit cap (`MaxTxGasLimit = 1 << 24`) | | `tests/eip7883` | [EIP-7883](https://eips.ethereum.org/EIPS/eip-7883) | ModExp precompile repricing | +| `tests/eip7934` | [EIP-7934](https://eips.ethereum.org/EIPS/eip-7934) | Max RLP-encoded block size (`MaxRLPBlockSize = 8_388_608`); packer-level split test + P2P consensus-level rejection of oversized blocks | +| `tests/eip7939` | [EIP-7939](https://eips.ethereum.org/EIPS/eip-7939) | `CLZ` opcode (0x1e) — count leading zeros | ## Repository layout @@ -17,7 +19,7 @@ End-to-end tests for the VeChain **INTERSTELLAR** fork, which activates at block interstellar-e2e/ ├── go.work # workspace linking this repo + local thor + networkhub ├── Makefile -├── network/ # network binary (start/stop/status/node-url) +├── network/ # network binary (start/stop/status/node-url/node-p2p-port) │ ├── cmd/ │ └── setup/ # 3-node genesis config with INTERSTELLAR at block 1 └── tests/ @@ -25,7 +27,9 @@ interstellar-e2e/ ├── eip5656/ ├── eip7823/ ├── eip7825/ - └── eip7883/ + ├── eip7883/ + ├── eip7934/ + └── eip7939/ ``` ## Prerequisites @@ -51,6 +55,7 @@ To run a single EIP package during development: ```bash go test -v ./tests/eip7883/... +go test -v ./tests/eip7934/... ``` This starts its own network automatically (no `make` needed). @@ -70,6 +75,7 @@ This starts its own network automatically (no `make` needed). | Variable | Description | |----------|-------------| | `NODE_URL` | Skip network start and use this node URL directly | +| `NODE_P2P_PORT` | Passed automatically by `make test`; set it manually only for P2P-based tests such as `tests/eip7934` when `NODE_URL` points to an already-running external node | | `THOR_EXISTING_PATH` | Use a pre-built thor binary instead of building from source | | `THOR_REPO` | Override the thor Git repo URL (default: `https://github.com/vechain/thor`) | | `THOR_BRANCH` | Override the thor branch (default: `pedro/eip-7883`) | @@ -81,4 +87,4 @@ Each EIP test uses `InspectClauses` with a block revision to test behaviour on b - `Revision("0")` — genesis block, INTERSTELLAR not yet active - `Revision("1")` — block 1, INTERSTELLAR active -EIP-7825 is an exception: its gas cap is enforced by the txpool and `PrepareTransaction`, not by `InspectClauses`, so those tests send real transactions and wait for inclusion. +EIP-7825 and EIP-7934 are exceptions: their checks are enforced on transaction submission/packing paths, not by `InspectClauses`, so those tests send real transactions and assert submission/inclusion behaviour. EIP-7934 additionally tests the **consensus-layer** rejection path by connecting to a node via devp2p and sending a validly-signed block whose RLP size exceeds the limit. diff --git a/Makefile b/Makefile index d63ccf2..caf8c1a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build-network test clean stop status +.PHONY: build-network test clean stop status lint build-network: cd network && go build -o /tmp/interstellar-network github.com/vechain/interstellar-e2e/network && cd .. @@ -6,7 +6,8 @@ build-network: test: build-network @/tmp/interstellar-network start & \ NODE_URL=$$(/tmp/interstellar-network node-url) && \ - cd tests && NODE_URL=$$NODE_URL go test -v -count=1 -timeout 20m ./... ; \ + NODE_P2P_PORT=$$(/tmp/interstellar-network node-p2p-port) && \ + cd tests && NODE_URL=$$NODE_URL NODE_P2P_PORT=$$NODE_P2P_PORT go test -v -count=1 -timeout 20m ./... ; \ CODE=$$? ; \ /tmp/interstellar-network stop 2>/dev/null || true ; \ exit $$CODE @@ -19,3 +20,7 @@ status: clean: stop rm -f /tmp/interstellar-network /tmp/interstellar-network.json + +lint: + cd network && golangci-lint run --timeout=10m --config=../.golangci.yml + cd tests && golangci-lint run --timeout=10m --config=../.golangci.yml diff --git a/network/cmd/nodeurl.go b/network/cmd/nodeurl.go index 96f2b37..cd49562 100644 --- a/network/cmd/nodeurl.go +++ b/network/cmd/nodeurl.go @@ -16,7 +16,7 @@ const nodeURLTimeout = 20 * time.Minute func NodeURL() error { deadline := time.Now().Add(nodeURLTimeout) for time.Now().Before(deadline) { - if url, ok := tryNodeURL(); ok { + if url, _, ok := tryNodeInfo(); ok { fmt.Println(url) return nil } @@ -25,19 +25,40 @@ func NodeURL() error { return fmt.Errorf("timed out after %s waiting for network to become ready", nodeURLTimeout) } -func tryNodeURL() (string, bool) { +// NodeP2PPort blocks until the network is ready and prints the first node's +// P2P listen port to stdout. +func NodeP2PPort() error { + deadline := time.Now().Add(nodeURLTimeout) + for time.Now().Before(deadline) { + _, p2pPort, ok := tryNodeInfo() + if ok && p2pPort != 0 { + fmt.Println(p2pPort) + return nil + } + time.Sleep(2 * time.Second) + } + return fmt.Errorf("timed out after %s waiting for network to become ready", nodeURLTimeout) +} + +func tryNodeInfo() (string, int, bool) { data, err := os.ReadFile(stateFilePath) if err != nil { - return "", false + return "", 0, false } var state networkState if err := json.Unmarshal(data, &state); err != nil || len(state.Nodes) == 0 { - return "", false + return "", 0, false } resp, err := http.Get(state.Nodes[0] + "/blocks/best") //nolint:noctx if err != nil || resp.StatusCode != http.StatusOK { - return "", false + return "", 0, false } resp.Body.Close() - return state.Nodes[0], true + + var p2pPort int + if len(state.P2PPorts) > 0 { + p2pPort = state.P2PPorts[0] + } + + return state.Nodes[0], p2pPort, true } diff --git a/network/cmd/start.go b/network/cmd/start.go index d1a06c7..5cd044a 100644 --- a/network/cmd/start.go +++ b/network/cmd/start.go @@ -34,18 +34,26 @@ func Start() error { } urls := make([]string, len(net.Nodes)) + p2pPorts := make([]int, len(net.Nodes)) for i, n := range net.Nodes { urls[i] = n.GetHTTPAddr() + p2pPorts[i] = n.GetP2PListenPort() } // Write state file so that stop/status commands can find the process. - state := networkState{PID: os.Getpid(), Nodes: urls} + state := networkState{PID: os.Getpid(), Nodes: urls, P2PPorts: p2pPorts} if data, err := json.Marshal(state); err == nil { _ = os.WriteFile(stateFilePath, data, 0o600) } - // Emit a single JSON line to stdout — TestMain reads this to get node URLs. - ready, _ := json.Marshal(map[string][]string{"nodes": urls}) + // Emit a single JSON line to stdout — TestMain reads this to get node connection details. + ready, _ := json.Marshal(struct { + Nodes []string `json:"nodes"` + P2PPorts []int `json:"p2pPorts"` + }{ + Nodes: urls, + P2PPorts: p2pPorts, + }) fmt.Println(string(ready)) slog.Info("Network ready", "nodes", urls) diff --git a/network/cmd/types.go b/network/cmd/types.go index 2424ab9..ee2100a 100644 --- a/network/cmd/types.go +++ b/network/cmd/types.go @@ -3,6 +3,7 @@ package cmd const stateFilePath = "/tmp/interstellar-network.json" type networkState struct { - PID int `json:"pid"` - Nodes []string `json:"nodes"` + PID int `json:"pid"` + Nodes []string `json:"nodes"` + P2PPorts []int `json:"p2pPorts"` } diff --git a/network/main.go b/network/main.go index 45e48c3..bd113ff 100644 --- a/network/main.go +++ b/network/main.go @@ -9,7 +9,7 @@ import ( func main() { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: interstellar-network ") + fmt.Fprintln(os.Stderr, "usage: interstellar-network ") os.Exit(1) } @@ -23,6 +23,8 @@ func main() { err = cmd.Status() case "node-url": err = cmd.NodeURL() + case "node-p2p-port": + err = cmd.NodeP2PPort() default: fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) os.Exit(1) diff --git a/tests/eip5656/main_test.go b/tests/eip5656/main_test.go index 52221e4..c3991b9 100644 --- a/tests/eip5656/main_test.go +++ b/tests/eip5656/main_test.go @@ -10,5 +10,5 @@ import ( var nodeURL string func TestMain(m *testing.M) { - os.Exit(helper.RunTestMain(m, &nodeURL)) + os.Exit(helper.RunTestMain(m, &nodeURL, nil)) } diff --git a/tests/eip7823/main_test.go b/tests/eip7823/main_test.go index 6cd2bdc..ac56fbc 100644 --- a/tests/eip7823/main_test.go +++ b/tests/eip7823/main_test.go @@ -10,5 +10,5 @@ import ( var nodeURL string func TestMain(m *testing.M) { - os.Exit(helper.RunTestMain(m, &nodeURL)) + os.Exit(helper.RunTestMain(m, &nodeURL, nil)) } diff --git a/tests/eip7825/main_test.go b/tests/eip7825/main_test.go index 3b4ca2e..c5f8e67 100644 --- a/tests/eip7825/main_test.go +++ b/tests/eip7825/main_test.go @@ -10,5 +10,5 @@ import ( var nodeURL string func TestMain(m *testing.M) { - os.Exit(helper.RunTestMain(m, &nodeURL)) + os.Exit(helper.RunTestMain(m, &nodeURL, nil)) } diff --git a/tests/eip7883/main_test.go b/tests/eip7883/main_test.go index 0e2901c..abeee5c 100644 --- a/tests/eip7883/main_test.go +++ b/tests/eip7883/main_test.go @@ -10,5 +10,5 @@ import ( var nodeURL string func TestMain(m *testing.M) { - os.Exit(helper.RunTestMain(m, &nodeURL)) + os.Exit(helper.RunTestMain(m, &nodeURL, nil)) } diff --git a/tests/eip7934/eip7934_test.go b/tests/eip7934/eip7934_test.go new file mode 100644 index 0000000..84d12f1 --- /dev/null +++ b/tests/eip7934/eip7934_test.go @@ -0,0 +1,190 @@ +package eip7934 + +// EIP-7934 caps the RLP-encoded block size at MaxRLPBlockSize = 8,388,608 bytes (8 MiB). +// After the INTERSTELLAR fork the packer tracks accumulated transaction size and stops +// adding transactions once the next one would push the block past the limit. The +// consensus layer independently rejects any block whose RLP encoding exceeds the cap. +// +// Why a burst of many transactions? +// +// Individual transactions are bounded by the txpool's MaxTxSize (64 KB). A single +// transaction can therefore never exceed the 8 MiB block limit on its own. The block- +// size constraint only becomes the binding limit when many large transactions compete +// for space in the same block. +// +// With the test network's 40 M block gas limit and ~64 KB txs (each costing ~277 K +// intrinsic gas for 64,000 zero-byte data), gas alone would allow ~144 txs per block, +// but the 8 MiB block-size cap is reached at ~130. This makes block size the binding +// constraint — exactly what EIP-7934 is designed to enforce. + +import ( + "crypto/ecdsa" + "fmt" + "testing" + "time" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vechain/thor/v2/api" + "github.com/vechain/thor/v2/block" + "github.com/vechain/thor/v2/thor" + "github.com/vechain/thor/v2/tx" + "github.com/vechain/thor/v2/vrf" + + "github.com/vechain/interstellar-e2e/tests/helper" +) + +const ( + maxRLPBlockSize uint64 = 8_388_608 + + // txDataSize is chosen so that (data + ~200 B of RLP/signature overhead) + // stays under the txpool's MaxTxSize (64 KB = 65,536 B). + txDataSize = 64_000 + + targetSize = maxRLPBlockSize + 1 + baseTxCount = 130 + estimatedPadding = 30_000 +) + +// signers are the three pre-funded node accounts from LocalThreeNodesNetwork. +// We distribute transactions round-robin to stay within the txpool's per-account +// quota (LimitPerAccount = 128). +var signers = []*ecdsa.PrivateKey{ + helper.TestSenderKey, + helper.Node2Key, + helper.Node3Key, +} + +// TestEIP7934 constructs a validly-signed block whose RLP +// encoding exceeds MaxRLPBlockSize and disseminates it to a running Thor node +// via the devp2p MsgNewBlock message. The consensus validator (validateBlockBody) +// must reject it at the size check, so the block must NOT appear in the chain. +func TestEIP7934(t *testing.T) { + client := helper.NewClient(nodeURL) + + genesis, err := client.Block("0") + require.NoError(t, err) + + // Wait for a fresh block so we can copy its already-validated scheduling. + initialBest, err := client.Block("best") + require.NoError(t, err) + + var observed *api.JSONCollapsedBlock + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + b, err := client.Block("best") + require.NoError(t, err) + if b.Number > initialBest.Number { + observed = b + break + } + time.Sleep(time.Second) + } + require.NotNil(t, observed, "must observe a new block within timeout") + + // Identify which of our keys signed the observed block. + var proposerKey *ecdsa.PrivateKey + for _, key := range signers { + addr := thor.Address(crypto.PubkeyToAddress(key.PublicKey)) + if addr == observed.Signer { + proposerKey = key + break + } + } + require.NotNil(t, proposerKey, + "observed block signer %s must match one of our known keys", observed.Signer) + + // Fetch the observed block's full header for Alpha and BaseFee. + observedHeader, err := helper.FetchRawBlockHeader(nodeURL, fmt.Sprintf("%d", observed.Number)) + require.NoError(t, err) + + alpha := observedHeader.Alpha() + require.NotEmpty(t, alpha, "post-VIP214 block must carry Alpha") + + // Build a block that is exactly MaxRLPBlockSize + 1 bytes. We use + // baseTxCount full-size transactions plus one "padding" transaction + // whose data length is calibrated to land on the exact byte target. + // Within the 56–65535 data-length range each extra byte of tx data + // adds exactly one byte to the block's RLP size, so a single + // probe-and-adjust pass is enough. + buildBlock := func(paddingDataLen int) *block.Block { + b := new(block.Builder). + ParentID(observed.ParentID). + Timestamp(observed.Timestamp). + GasLimit(observed.GasLimit). + TotalScore(observed.TotalScore). + GasUsed(0). + Beneficiary(observed.Beneficiary). + StateRoot(thor.Bytes32{}). + ReceiptsRoot(thor.Bytes32{}). + TransactionFeatures(tx.DelegationFeature). + Alpha(alpha). + BaseFee(observedHeader.BaseFee()) + + for i := range baseTxCount { + clause := tx.NewClause(nil).WithData(make([]byte, txDataSize)) + trx := tx.NewBuilder(tx.TypeLegacy). + Clause(clause). + Gas(21_000). + Nonce(uint64(i)). + Build() + b.Transaction(trx) + } + + clause := tx.NewClause(nil).WithData(make([]byte, paddingDataLen)) + trx := tx.NewBuilder(tx.TypeLegacy). + Clause(clause). + Gas(21_000). + Nonce(uint64(baseTxCount)). + Build() + b.Transaction(trx) + + return b.Build() + } + + signBlock := func(blk *block.Block) *block.Block { + ecSig, err := crypto.Sign(blk.Header().SigningHash().Bytes(), proposerKey) + require.NoError(t, err) + _, proof, err := vrf.Prove(proposerKey, alpha) + require.NoError(t, err) + sig, err := block.NewComplexSignature(ecSig, proof) + require.NoError(t, err) + return blk.WithSignature(sig) + } + + probe := signBlock(buildBlock(estimatedPadding)) + probeSize := uint64(probe.Size()) + + adjustedPadding := int(estimatedPadding) + int(targetSize) - int(probeSize) + require.Greater(t, adjustedPadding, 0, "padding calculation must yield positive data size") + + oversized := signBlock(buildBlock(adjustedPadding)) + + require.Equal(t, targetSize, uint64(oversized.Size()), + "block must be exactly MaxRLPBlockSize + 1") + require.NotEqual(t, thor.Bytes32{}, oversized.Header().ID(), + "block ID must be non-zero (valid signature)") + + // Connect via P2P and send the oversized block. + p2pClient := helper.NewThorP2PClient(genesis.ID, observed.ParentID, observed.TotalScore-1) + err = p2pClient.Connect(helper.TestSenderKey, nodeP2PPort) + require.NoError(t, err, "P2P connection to node1 must succeed") + defer p2pClient.Stop() + + err = p2pClient.SendBlock(oversized) + require.NoError(t, err, "sending oversized block via P2P must not error at the transport level") + + // Give the node time to process the block and continue producing. + time.Sleep(30 * time.Second) + + blockID := oversized.Header().ID() + found, _ := client.Block(blockID.String()) + assert.Nil(t, found, + "oversized block (ID %s) must NOT be accepted into the chain", blockID) + + newBest, err := client.Block("best") + require.NoError(t, err) + assert.Greater(t, newBest.Number, observed.Number, + "node must continue producing blocks after rejecting the oversized P2P block") +} diff --git a/tests/eip7934/main_test.go b/tests/eip7934/main_test.go new file mode 100644 index 0000000..759911b --- /dev/null +++ b/tests/eip7934/main_test.go @@ -0,0 +1,17 @@ +package eip7934 + +import ( + "os" + "testing" + + "github.com/vechain/interstellar-e2e/tests/helper" +) + +var ( + nodeURL string + nodeP2PPort int +) + +func TestMain(m *testing.M) { + os.Exit(helper.RunTestMain(m, &nodeURL, &nodeP2PPort)) +} diff --git a/tests/eip7939/clz_test.go b/tests/eip7939/clz_test.go new file mode 100644 index 0000000..549e909 --- /dev/null +++ b/tests/eip7939/clz_test.go @@ -0,0 +1,107 @@ +package eip7939 + +// EIP-7939 (CLZ) adds opcode 0x1e for counting leading zero bits in a 256-bit word, +// active at the INTERSTELLAR fork. +// +// The bytecode generated by clzBytecode: +// 1. PUSH32 — push the 256-bit value under test +// 2. CLZ (0x1e) — replace it with the number of leading zero bits (0–256) +// 3. MSTORE at offset 0 +// 4. RETURN 32 bytes from offset 0 +// +// Pre-fork: 0x1e is an invalid opcode; the EVM reverts. + +import ( + "encoding/hex" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vechain/thor/v2/api" + "github.com/vechain/thor/v2/thorclient" + + "github.com/vechain/interstellar-e2e/tests/helper" +) + +// clzBytecode builds init-code that pushes a 32-byte value, executes CLZ, and +// returns the result. Running via InspectClauses (To=nil) simulates contract +// creation so RETURN data appears in CallResult.Data. +func clzBytecode(input [32]byte) []byte { + code := make([]byte, 0, 42) + code = append(code, 0x7f) // PUSH32 + code = append(code, input[:]...) // <32-byte value> + code = append(code, 0x1e) // CLZ + code = append(code, 0x60, 0x00) // PUSH1 0x00 (MSTORE offset) + code = append(code, 0x52) // MSTORE + code = append(code, 0x60, 0x20) // PUSH1 0x20 (RETURN size) + code = append(code, 0x60, 0x00) // PUSH1 0x00 (RETURN offset) + code = append(code, 0xf3) // RETURN + return code +} + +func TestCLZ_PreFork(t *testing.T) { + client := helper.NewClient(nodeURL) + + var input [32]byte + input[31] = 0x01 // arbitrary non-zero value + + callData := &api.BatchCallData{ + Clauses: api.Clauses{ + {Data: "0x" + hex.EncodeToString(clzBytecode(input))}, + }, + Gas: 100_000, + } + + // 0x1e is not in the pre-Osaka instruction set; the EVM treats it as an + // invalid opcode and halts with a revert. + results, err := client.InspectClauses(callData, thorclient.Revision(helper.PreForkRevision)) + require.NoError(t, err) + require.Len(t, results, 1) + assert.True(t, results[0].Reverted, + "CLZ must revert before INTERSTELLAR (invalid opcode)") +} + +func TestCLZ_PostFork(t *testing.T) { + client := helper.NewClient(nodeURL) + + tests := []struct { + name string + input string + expected uint64 + }{ + {"zero", "0000000000000000000000000000000000000000000000000000000000000000", 256}, + {"one", "0000000000000000000000000000000000000000000000000000000000000001", 255}, + {"11_bits", "00000000000000000000000000000000000000000000000000000000000006ff", 245}, + {"40_bits", "000000000000000000000000000000000000000000000000000000ffffffffff", 216}, + {"second_highest_bit", "4000000000000000000000000000000000000000000000000000000000000000", 1}, + {"all_but_msb", "7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", 1}, + {"msb_set", "8000000000000000000000000000000000000000000000000000000000000000", 0}, + {"all_ones", "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", 0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var input [32]byte + copy(input[:], common.FromHex(tc.input)) + + callData := &api.BatchCallData{ + Clauses: api.Clauses{ + {Data: "0x" + hex.EncodeToString(clzBytecode(input))}, + }, + Gas: 100_000, + } + + results, err := client.InspectClauses(callData, thorclient.Revision(helper.PostForkRevision)) + require.NoError(t, err) + require.Len(t, results, 1) + assert.False(t, results[0].Reverted, + "CLZ must not revert after INTERSTELLAR (vmError: %s)", results[0].VMError) + + got := new(big.Int).SetBytes(common.FromHex(results[0].Data)) + assert.Equal(t, tc.expected, got.Uint64(), + "clz(0x%s) = %d; want %d", tc.input, got.Uint64(), tc.expected) + }) + } +} diff --git a/tests/eip7939/main_test.go b/tests/eip7939/main_test.go new file mode 100644 index 0000000..0e1c108 --- /dev/null +++ b/tests/eip7939/main_test.go @@ -0,0 +1,14 @@ +package eip7939 + +import ( + "os" + "testing" + + "github.com/vechain/interstellar-e2e/tests/helper" +) + +var nodeURL string + +func TestMain(m *testing.M) { + os.Exit(helper.RunTestMain(m, &nodeURL, nil)) +} diff --git a/tests/helper/client.go b/tests/helper/client.go index 4b6a688..55d3a67 100644 --- a/tests/helper/client.go +++ b/tests/helper/client.go @@ -13,9 +13,12 @@ import ( "github.com/vechain/thor/v2/tx" ) -// node1Key is the private key for Node 1's master address (0x61fF580B63D3845934610222245C116E013717ec). -// This account is pre-funded with a large balance in LocalThreeNodesNetwork genesis. -const node1Key = "01a4107bfb7d5141ec519e75788c34295741a1eefbfe460320efd2ada944071e" +// Private keys for the three master accounts pre-funded in LocalThreeNodesNetwork genesis. +const ( + node1Key = "01a4107bfb7d5141ec519e75788c34295741a1eefbfe460320efd2ada944071e" // 0x61fF580B63D3845934610222245C116E013717ec + node2Key = "7072249b800ddac1d29a3cd06468cc1a917cbcd110dde358a905d03dad51748d" // 0x327931085B4cCbCE0baABb5a5E1C678707C51d90 + node3Key = "c55455943bf026dc44fcf189e8765eb0587c94e66029d580bae795386c0b737a" // 0x084E48c8AE79656D7e27368AE5317b5c2D6a7497 +) // PreForkRevision targets block 0 (genesis), which is before INTERSTELLAR activates. // PostForkRevision targets block 1, the block at which INTERSTELLAR activates. @@ -25,8 +28,12 @@ const ( PostForkRevision = "1" ) -// TestSenderKey is the signing key used across all tests. -var TestSenderKey, _ = crypto.HexToECDSA(node1Key) +// Signing keys for all three pre-funded node accounts. +var ( + TestSenderKey, _ = crypto.HexToECDSA(node1Key) + Node2Key, _ = crypto.HexToECDSA(node2Key) + Node3Key, _ = crypto.HexToECDSA(node3Key) +) // NewClient returns a thorclient pointed at the given node URL. func NewClient(nodeURL string) *thorclient.Client { diff --git a/tests/helper/network.go b/tests/helper/network.go index f45fac2..892bc79 100644 --- a/tests/helper/network.go +++ b/tests/helper/network.go @@ -16,9 +16,10 @@ const ( startupTimeout = 15 * time.Minute // allows for ThorBuilder clone + compile on first run ) -// NetworkInfo holds node URLs emitted by the network binary on startup. +// NetworkInfo holds node connection details emitted by the network binary on startup. type NetworkInfo struct { - Nodes []string `json:"nodes"` + Nodes []string `json:"nodes"` + P2PPorts []int `json:"p2pPorts"` } // BuildNetworkBinary compiles the network/ module to a binary. diff --git a/tests/helper/p2p.go b/tests/helper/p2p.go new file mode 100644 index 0000000..e3e7fe8 --- /dev/null +++ b/tests/helper/p2p.go @@ -0,0 +1,196 @@ +package helper + +import ( + "context" + "crypto/ecdsa" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "time" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rlp" + "github.com/vechain/thor/v2/block" + "github.com/vechain/thor/v2/comm/proto" + "github.com/vechain/thor/v2/p2p" + "github.com/vechain/thor/v2/p2p/discover" + "github.com/vechain/thor/v2/p2psrv/rpc" + "github.com/vechain/thor/v2/thor" + "github.com/vechain/thor/v2/tx" +) + +// ThorP2PClient connects to a running Thor node via devp2p (RLPx) +// using the thor/1 sub-protocol and allows sending raw blocks. +type ThorP2PClient struct { + server *p2p.Server + genesisID thor.Bytes32 + bestID thor.Bytes32 + bestScore uint64 + + mu sync.Mutex + peerRPC *rpc.RPC + peerReady chan struct{} +} + +// NewThorP2PClient creates a client configured for the status handshake. +// genesisID, bestBlockID, and bestTotalScore are used when responding +// to the remote node's MsgGetStatus call. +func NewThorP2PClient(genesisID, bestBlockID thor.Bytes32, bestTotalScore uint64) *ThorP2PClient { + return &ThorP2PClient{ + genesisID: genesisID, + bestID: bestBlockID, + bestScore: bestTotalScore, + peerReady: make(chan struct{}), + } +} + +// Connect dials the target Thor node's P2P port, performs the RLPx +// handshake, negotiates the thor/1 protocol, and exchanges status. +func (c *ThorP2PClient) Connect(targetNodeKey *ecdsa.PrivateKey, targetP2PPort int) error { + ourKey, err := crypto.GenerateKey() + if err != nil { + return fmt.Errorf("generate key: %w", err) + } + + c.server = &p2p.Server{ + Config: p2p.Config{ + PrivateKey: ourKey, + MaxPeers: 1, + NoDiscovery: true, + ListenAddr: "127.0.0.1:0", + Name: "e2e-test-peer", + Protocols: []p2p.Protocol{ + { + Name: proto.Name, + Version: proto.Version, + Length: proto.Length, + Run: c.protocolHandler, + }, + }, + }, + } + + if err := c.server.Start(); err != nil { + return fmt.Errorf("start p2p server: %w", err) + } + + targetNodeID := discover.PubkeyID(&targetNodeKey.PublicKey) + targetNode := &discover.Node{ + ID: targetNodeID, + IP: net.ParseIP("127.0.0.1"), + TCP: uint16(targetP2PPort), + } + c.server.AddPeer(targetNode) + + select { + case <-c.peerReady: + return nil + case <-time.After(15 * time.Second): + c.server.Stop() + return fmt.Errorf("timeout waiting for P2P handshake") + } +} + +// SendBlock sends a block to the connected peer via MsgNewBlock (Notify). +func (c *ThorP2PClient) SendBlock(blk *block.Block) error { + c.mu.Lock() + r := c.peerRPC + c.mu.Unlock() + + if r == nil { + return fmt.Errorf("no peer connection") + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return proto.NotifyNewBlock(ctx, r, blk) +} + +// Stop tears down the P2P server and all connections. +func (c *ThorP2PClient) Stop() { + if c.server != nil { + c.server.Stop() + } +} + +// protocolHandler runs for the thor/1 sub-protocol once the RLPx and +// capability negotiation complete. It responds to the remote node's +// status query and keeps the connection alive for block sending. +func (c *ThorP2PClient) protocolHandler(peer *p2p.Peer, rw p2p.MsgReadWriter) error { + r := rpc.New(peer, rw) + + c.mu.Lock() + c.peerRPC = r + c.mu.Unlock() + + return r.Serve(func(msg *p2p.Msg, write func(any)) error { + switch msg.Code { + case proto.MsgGetStatus: + if err := msg.Decode(&struct{}{}); err != nil { + return err + } + write(&proto.Status{ + GenesisBlockID: c.genesisID, + SysTimestamp: uint64(time.Now().Unix()), + BestBlockID: c.bestID, + TotalScore: c.bestScore, + }) + select { + case <-c.peerReady: + default: + close(c.peerReady) + } + case proto.MsgGetTxs: + if err := msg.Decode(&struct{}{}); err != nil { + return err + } + write(tx.Transactions(nil)) + default: + msg.Discard() + } + return nil + }, proto.MaxMsgSize) +} + +// FetchRawBlockHeader retrieves a block header in raw RLP form from +// the Thor REST API and decodes it. This exposes fields (Alpha, Beta, +// Signature) that are not available in the standard JSON response. +func FetchRawBlockHeader(nodeURL, revision string) (*block.Header, error) { + url := fmt.Sprintf("%s/blocks/%s?raw=true", strings.TrimRight(nodeURL, "/"), revision) + + resp, err := http.Get(url) //nolint:gosec + if err != nil { + return nil, fmt.Errorf("fetch raw block header: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("fetch raw block header: HTTP %d: %s", resp.StatusCode, body) + } + + var result struct { + Raw string `json:"raw"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + rawHex := strings.TrimPrefix(result.Raw, "0x") + rawBytes, err := hex.DecodeString(rawHex) + if err != nil { + return nil, fmt.Errorf("hex decode: %w", err) + } + + var header block.Header + if err := rlp.DecodeBytes(rawBytes, &header); err != nil { + return nil, fmt.Errorf("RLP decode: %w", err) + } + + return &header, nil +} diff --git a/tests/helper/testmain.go b/tests/helper/testmain.go index 570bd64..99827f5 100644 --- a/tests/helper/testmain.go +++ b/tests/helper/testmain.go @@ -1,8 +1,10 @@ package helper import ( + "fmt" "log" "os" + "strconv" "testing" ) @@ -14,13 +16,21 @@ import ( // Usage: // // var nodeURL string +// var nodeP2PPort int // // func TestMain(m *testing.M) { -// os.Exit(helper.RunTestMain(m, &nodeURL)) +// os.Exit(helper.RunTestMain(m, &nodeURL, &nodeP2PPort)) // } -func RunTestMain(m *testing.M, nodeURL *string) int { +func RunTestMain(m *testing.M, nodeURL *string, nodeP2PPort *int) int { if url := os.Getenv("NODE_URL"); url != "" { *nodeURL = url + if nodeP2PPort != nil { + port, err := resolveNodeP2PPort(url) + if err != nil { + log.Fatal(err) + } + *nodeP2PPort = port + } return m.Run() } @@ -35,7 +45,27 @@ func RunTestMain(m *testing.M, nodeURL *string) int { defer stop() *nodeURL = info.Nodes[0] - log.Printf("network ready — node: %s", *nodeURL) + if nodeP2PPort != nil { + if len(info.P2PPorts) == 0 { + log.Fatal("network did not expose any P2P ports") + } + *nodeP2PPort = info.P2PPorts[0] + log.Printf("network ready — node: %s, p2p: %d", *nodeURL, *nodeP2PPort) + } else { + log.Printf("network ready — node: %s", *nodeURL) + } return m.Run() } + +func resolveNodeP2PPort(nodeURL string) (int, error) { + if port := os.Getenv("NODE_P2P_PORT"); port != "" { + parsedPort, err := strconv.Atoi(port) + if err != nil { + return 0, fmt.Errorf("parse NODE_P2P_PORT: %w", err) + } + return parsedPort, nil + } + + return 0, fmt.Errorf("could not determine P2P port for %s; set NODE_P2P_PORT or start the network via the helper", nodeURL) +}