Skip to content
Merged
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
20 changes: 17 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,23 @@ are byte-identical to pre-change behaviour.
lotus.

Not in this PR: `ChainHead` head-poll lag on devnet ([#123](https://github.com/Reiers/lantern/issues/123)
findings 8+9) and the `eth_getCode` proxy-address slowness (finding 7).
Both are latency issues, not correctness issues; folding them into a
separate `--devnet-head-poll-interval` knob PR after this batch lands.
findings 8+9). Folding it into a separate `--devnet-head-poll-interval`
knob PR.

### Devnet: bridge-first read ordering for eth_* (closes [#123](https://github.com/Reiers/lantern/issues/123) finding 7)

- **`eth_getCode`, `eth_getStorageAt`, and `eth_call` on devnet now bypass
the local hamt walk and go straight to the auto-wired VMBridge.** On a
single-node docker devnet the local walk always burns its full retry
budget (no bitswap peers to fetch cold storage-trie blocks from), then
falls through to the bridge anyway. The devnet lotus IS the source of
truth for the devnet chain, so short-circuiting is faster (verified
live: 34 ms vs 15 s timeout for `eth_getCode`) and semantically identical.
Guarded on `NetworkName == "devnet" && Bridge != nil` so mainnet /
calibration paths are unchanged, and so an operator who explicitly
drops the bridge on devnet still gets a clear `errBridgeUnconfigured`
instead of a silent hang. 5 new unit tests in
`rpc/handlers/devnet_bridge_first_test.go` lock in the routing table.

### Added

Expand Down
161 changes: 161 additions & 0 deletions rpc/handlers/devnet_bridge_first_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package handlers

// Tests for the lantern#123 finding-7 devnet bridge-first short-circuit.
// On a single-node docker devnet the local hamt walk always burns its
// full retry budget (no bitswap peers to fetch cold storage-trie blocks
// from) before falling through to the bridge. The devnet lotus IS the
// source of truth for the devnet chain, so short-circuiting straight to
// it is both faster (verified live: 34ms vs 15s timeout) and semantically
// identical. These tests lock in the routing decision.
//
// Assertions here focus on the ROUTING (which path was taken), not on
// end-to-end correctness of the bridge's answer (that lives in
// vm/bridge tests).

import (
"context"
"encoding/json"
"testing"
)

// Devnet: EthGetCode always forwards to the bridge, even for the "0x"-EOA
// case where mainnet/calibration would have served locally without a
// bridge round-trip.
func TestEthGetCode_DevnetGoesStraightToBridge(t *testing.T) {
c := newCAPI()
c.NetworkName = "devnet"
rb := &recordingBridge{reply: map[string]json.RawMessage{
"eth_getCode": json.RawMessage(`"0xdeadbeef"`),
}}
c.Bridge = rb

out, err := c.EthGetCode(context.Background(),
"0x0000000000000000000000000000000000000042", "latest")
if err != nil {
t.Fatalf("EthGetCode: %v", err)
}
if out != "0xdeadbeef" {
t.Fatalf("expected bridge answer 0xdeadbeef, got %q", out)
}
if len(rb.methods) != 1 || rb.methods[0] != "eth_getCode" {
t.Fatalf("expected exactly one bridge forward of eth_getCode, got %v", rb.methods)
}
}

// Mainnet with no local accessor wired: the local resolver can't serve,
// falls back to the bridge. Same OBSERVABLE result as devnet, but proves
// the mainnet path still tries local first (accessor==nil is a serve=false
// miss, not a definitive short-circuit).
func TestEthGetCode_MainnetTriesLocalFirst(t *testing.T) {
c := newCAPI() // NetworkName = "mainnet" via newCAPI
rb := &recordingBridge{reply: map[string]json.RawMessage{
"eth_getCode": json.RawMessage(`"0xdeadbeef"`),
}}
c.Bridge = rb

out, err := c.EthGetCode(context.Background(),
"0x0000000000000000000000000000000000000042", "latest")
if err != nil {
t.Fatalf("EthGetCode: %v", err)
}
if out != "0xdeadbeef" {
t.Fatalf("expected bridge answer 0xdeadbeef, got %q", out)
}
// The bridge is still called (accessor==nil = local miss), but the
// path is different: on devnet we bypass localEthGetCode entirely.
// We can't easily observe the code path from the test, only its
// pre-conditions. Guarding against a regression here means asserting
// that at least the routing table looks right (bridge called once).
if len(rb.methods) != 1 {
t.Fatalf("expected one bridge forward on mainnet fallback, got %v", rb.methods)
}
}

// Devnet without a bridge: no auto-wired lotus, no local. The read
// returns errBridgeUnconfigured (no crash, no bogus value). This is the
// operator-explicitly-drops-the-bridge escape hatch.
func TestEthGetCode_DevnetNoBridgeErrorsOut(t *testing.T) {
c := newCAPI()
c.NetworkName = "devnet"
// No bridge assigned.
_, err := c.EthGetCode(context.Background(),
"0x0000000000000000000000000000000000000042", "latest")
if err == nil {
t.Fatal("expected error with no bridge on devnet")
}
}

// Devnet: EthGetStorageAt bypasses local read, goes straight to bridge.
func TestEthGetStorageAt_DevnetGoesStraightToBridge(t *testing.T) {
c := newCAPI()
c.NetworkName = "devnet"
slotHex := `"0x0000000000000000000000000000000000000000000000000000000000000005"`
rb := &recordingBridge{reply: map[string]json.RawMessage{
"eth_getStorageAt": json.RawMessage(slotHex),
}}
c.Bridge = rb

out, err := c.EthGetStorageAt(context.Background(),
"0x0000000000000000000000000000000000000042",
"0x0",
"latest")
if err != nil {
t.Fatalf("EthGetStorageAt: %v", err)
}
want := "0x0000000000000000000000000000000000000000000000000000000000000005"
if out != want {
t.Fatalf("expected %q, got %q", want, out)
}
if len(rb.methods) != 1 || rb.methods[0] != "eth_getStorageAt" {
t.Fatalf("expected exactly one bridge forward of eth_getStorageAt, got %v", rb.methods)
}
}

// Devnet: EthCall bypasses local FEVM execution, goes straight to bridge.
// Uses a well-formed selector so the bridge is asked (recordingBridge
// returns the pre-canned reply below).
func TestEthCall_DevnetGoesStraightToBridge(t *testing.T) {
c := newCAPI()
c.NetworkName = "devnet"
// Even though LocalFEVMDisabled is false (default), the devnet path
// must bypass local FEVM and go straight to bridge.
rb := &recordingBridge{reply: map[string]json.RawMessage{
"eth_call": json.RawMessage(`"0x2a"`),
}}
c.Bridge = rb

out, err := c.EthCall(context.Background(),
map[string]any{
"to": "0x0000000000000000000000000000000000000042",
"data": "0x9a99b4f0",
},
"latest",
)
if err != nil {
t.Fatalf("EthCall: %v", err)
}
if out != "0x2a" {
t.Fatalf("expected bridge answer 0x2a, got %q", out)
}
if len(rb.methods) != 1 || rb.methods[0] != "eth_call" {
t.Fatalf("expected exactly one bridge forward of eth_call, got %v", rb.methods)
}
}

// Devnet: EthCall with no bridge returns errBridgeUnconfigured.
// (Sanity check for the escape-hatch case.)
func TestEthCall_DevnetNoBridgeErrorsOut(t *testing.T) {
c := newCAPI()
c.NetworkName = "devnet"
// No bridge.
_, err := c.EthCall(context.Background(),
map[string]any{
"to": "0x0000000000000000000000000000000000000042",
"data": "0x9a99b4f0",
},
"latest",
)
if err == nil {
t.Fatal("expected error with no bridge on devnet")
}
}
41 changes: 41 additions & 0 deletions rpc/handlers/extra.go
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,16 @@ var errBridgeUnconfigured = xerrors.New("FEVM method requires --vm-bridge-rpc po
// trust-anchored chain head (we don't speak the FEVM ourselves, but
// we know which block to ask about).
func (c *ChainAPI) EthCall(ctx context.Context, callObj any, blockParam any) (string, error) {
// Devnet bridge-first (lantern#123 finding 7): on a single-node docker
// devnet the local hamt walk always burns its full retry budget (no
// bitswap peers to fetch cold storage-trie blocks from), then falls
// through to the bridge anyway. The devnet lotus IS the source of
// truth for the devnet chain, so short-circuiting straight to it is
// both faster and semantically identical. Guarded on Bridge != nil so
// we still attempt local if the operator dropped the auto-wired bridge.
if c.NetworkName == "devnet" && c.Bridge != nil {
return c.bridgeEthCall(ctx, callObj, blockParam)
}
// Local FEVM execution first (lantern#43 Part B): run the call against
// our own verified state tree with the pure-Go EVM. A clean local
// result (including a definitive revert) is returned directly; a local
Expand Down Expand Up @@ -606,6 +616,13 @@ func (c *ChainAPI) EthCall(ctx context.Context, callObj any, blockParam any) (st
}
}

return c.bridgeEthCall(ctx, callObj, blockParam)
}

// bridgeEthCall forwards eth_call to the auto-wired VM bridge. Extracted
// so the devnet bridge-first path and the mainnet/calibration local-miss
// fallback share the same code.
func (c *ChainAPI) bridgeEthCall(ctx context.Context, callObj any, blockParam any) (string, error) {
if c.Bridge == nil {
return "", errBridgeUnconfigured
}
Expand Down Expand Up @@ -893,6 +910,18 @@ func (c *ChainAPI) EthGetTransactionByBlockNumberAndIndex(ctx context.Context, b
// without a VMBridge. Falls back to the bridge only when the address
// can't be resolved locally (cold blocks during rollout).
func (c *ChainAPI) EthGetCode(ctx context.Context, addr string, blockParam any) (string, error) {
// Devnet bridge-first (lantern#123 finding 7): see EthCall.
if c.NetworkName == "devnet" && c.Bridge != nil {
raw, err := c.forwardEth(ctx, "eth_getCode", []any{addr, blockParam})
if err != nil {
return "", err
}
s, ok := raw.(string)
if !ok {
return "", xerrors.Errorf("eth_getCode: unexpected result type %T", raw)
}
return s, nil
}
if out, served, err := c.localEthGetCode(ctx, addr); served {
return out, err
}
Expand All @@ -910,6 +939,18 @@ func (c *ChainAPI) EthGetCode(ctx context.Context, addr string, blockParam any)
// EthGetStorageAt returns the raw 32-byte storage slot value at the
// given key on the given contract.
func (c *ChainAPI) EthGetStorageAt(ctx context.Context, addr string, key string, blockParam any) (string, error) {
// Devnet bridge-first (lantern#123 finding 7): see EthCall.
if c.NetworkName == "devnet" && c.Bridge != nil {
raw, err := c.forwardEth(ctx, "eth_getStorageAt", []any{addr, key, blockParam})
if err != nil {
return "", err
}
s, ok := raw.(string)
if !ok {
return "", xerrors.Errorf("eth_getStorageAt: unexpected result type %T", raw)
}
return s, nil
}
// Local-first (lantern#75): read the storage slot from local state so a
// bridge-off node serves it without a VMBridge; fall back otherwise.
if out, served, err := c.localEthGetStorageAt(ctx, addr, key); served {
Expand Down
Loading