Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .github/workflows/integration-test-matrix.json
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,13 @@
"./integration_test/evm_module/scripts/evm_rpc_tests.sh"
]
},
{
"name": "Frozen RPC Router",
"cluster": "frozen-rpc-router",
"scripts": [
"go test -tags frozen_rpc_integration -v -count=1 -timeout 5m ./integration_test/frozen_rpc_router/..."
]
},
{
"name": "EVM RPC Parity (geth reference)",
"env": "GIGA_STORAGE=true",
Expand Down
17 changes: 13 additions & 4 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ jobs:
- uses: actions/setup-go@v6
with:
go-version: '1.25.6'
- name: Build seid binary in localnode container
- name: Build seid and frozen RPC router binaries in localnode container
env:
DOCKER_PLATFORM: linux/amd64
run: make build-seid-in-localnode-ci
Expand All @@ -142,7 +142,7 @@ jobs:
docker tag sei-chain/localnode "${GHCR_LOCALNODE}:${{ github.run_id }}"
push_with_retry "${GHCR_LOCALNODE}:${{ github.run_id }}"
- name: Package CI artifacts
run: tar -czf integration-build.tar.gz build/seid
run: tar -czf integration-build.tar.gz build/seid build/frozen-rpc-router
- name: Upload integration CI artifacts
uses: actions/upload-artifact@v4
with:
Expand Down Expand Up @@ -206,7 +206,7 @@ jobs:
- name: Load prebuilt seid and pull Docker images
run: |
tar -xzf integration-build.tar.gz
# The seid binary is now extracted to build/seid; drop the
# The binaries are now extracted under build/; drop the
# tarball so its copy does not sit on disk for the rest of the job.
rm -f integration-build.tar.gz
source .github/scripts/docker-registry-retry.sh
Expand All @@ -218,8 +218,13 @@ jobs:
# retagged as sei-chain/*; keeps a single tag per image.
docker image rm "${GHCR_LOCALNODE}:${{ github.run_id }}" "${GHCR_RPCNODE}:${{ github.run_id }}" || true
- name: Start 4 node docker cluster
if: ${{ matrix.test.cluster != 'frozen-rpc-router' }}
run: DOCKER_DETACH=true INVARIANT_CHECK_INTERVAL=10 ${{matrix.test.env}} make docker-cluster-start-ci

- name: Start frozen RPC router docker cluster
if: ${{ matrix.test.cluster == 'frozen-rpc-router' }}
run: DOCKER_DETACH=true INVARIANT_CHECK_INTERVAL=10 make docker-frozen-rpc-cluster-start-ci

- name: Wait for docker cluster to start
run: |
until [ $(cat build/generated/launch.complete |wc -l) = 4 ]
Expand All @@ -229,9 +234,11 @@ jobs:
sleep 10

- name: Start rpc node
if: ${{ matrix.test.cluster != 'frozen-rpc-router' }}
run: ${{matrix.test.env}} make run-rpc-node-integration-ci &

- name: Verify Sei Chain is running
if: ${{ matrix.test.cluster != 'frozen-rpc-router' }}
run: go test -tags yaml_integration -v -timeout 5m ./integration_test/runner/... -run TestStartup

- name: ${{ matrix.test.name }}
Expand Down Expand Up @@ -259,6 +266,8 @@ jobs:
tail -200 "build/generated/logs/seid-${NODE_ID}.log" || true
fi
done
echo "==================== sei-frozen-rpc-router ===================="
docker logs --tail 200 sei-frozen-rpc-router || true

- name: Prepare log artifact name
if: ${{ always() }}
Expand Down Expand Up @@ -356,7 +365,7 @@ jobs:
- name: Load prebuilt seid and pull Docker images
run: |
tar -xzf integration-build.tar.gz
# The seid binary is now extracted to build/seid; drop the
# The binaries are now extracted under build/; drop the
# tarball so its copy does not sit on disk for the rest of the job.
rm -f integration-build.tar.gz
source .github/scripts/docker-registry-retry.sh
Expand Down
55 changes: 52 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ build:
go build $(BUILD_FLAGS) -o ./build/seid ./cmd/seid
.PHONY: build

build-frozen-rpc-router:
mkdir -p ./build
go build -o ./build/frozen-rpc-router ./cmd/frozen-rpc-router
.PHONY: build-frozen-rpc-router

build-verbose:
mkdir -p ./build
go build -x -v $(BUILD_FLAGS) -o ./build/seid ./cmd/seid
Expand Down Expand Up @@ -268,7 +273,7 @@ build-seid-in-localnode: build-docker-node
-w /sei-protocol/sei-chain \
-e LEDGER_ENABLED=false \
sei-chain/localnode \
bash -c 'export PATH=/usr/local/go/bin:$$PATH && make clean && make build-linux && mkdir -p build/generated && echo DONE > build/generated/build.complete'
bash -c 'export PATH=/usr/local/go/bin:$$PATH && make clean && make build-linux && make build-frozen-rpc-router && mkdir -p build/generated && echo DONE > build/generated/build.complete'
.PHONY: build-seid-in-localnode

# CI variant: assumes localnode image already built by Buildx in prepare-cluster (skips docker build).
Expand All @@ -283,10 +288,10 @@ build-seid-in-localnode-ci: ensure-integration-ci-images
-w /sei-protocol/sei-chain \
-e LEDGER_ENABLED=false \
sei-chain/localnode \
bash -c 'export PATH=/usr/local/go/bin:$$PATH && make clean && make build-linux && mkdir -p build/generated && echo DONE > build/generated/build.complete'
bash -c 'export PATH=/usr/local/go/bin:$$PATH && make clean && make build-linux && make build-frozen-rpc-router && mkdir -p build/generated && echo DONE > build/generated/build.complete'
.PHONY: build-seid-in-localnode-ci

# Images + seid binary for integration-test CI (see .github/workflows/integration-test.yml).
# Images plus seid and frozen-rpc-router binaries for integration-test CI.
# build-seid-in-localnode already depends on build-docker-node, so omit it here to avoid building localnode twice.
build-integration-ci-artifacts: build-rpc-node build-seid-in-localnode
.PHONY: build-integration-ci-artifacts
Expand Down Expand Up @@ -398,6 +403,8 @@ CLUSTER_ENV_VARS = DOCKER_PLATFORM=$(DOCKER_PLATFORM) USERID=$(shell id -u) GROU
GIGA_MIGRATE_FROM_MEMIAVL=$(GIGA_MIGRATE_FROM_MEMIAVL) \
GIGA_FLATKV_ONLY=$(GIGA_FLATKV_ONLY)

FROZEN_RPC_COMPOSE_FILES = -f docker-compose.yml -f docker-compose.frozen-rpc-router.yml

# Run a 4-node docker containers
docker-cluster-start: docker-cluster-stop build-docker-node
@rm -rf $(PROJECT_HOME)/build/generated
Expand Down Expand Up @@ -445,6 +452,48 @@ docker-cluster-stop:
@cd docker && DOCKER_PLATFORM=$(DOCKER_PLATFORM) USERID=$(shell id -u) GROUPID=$(shell id -g) GOCACHE=$(shell go env GOCACHE) docker compose down
.PHONY: localnet-stop

# Run the localnet with two non-validating archival nodes frozen before heights
# 10 and 20, two live validators, and frozen-rpc-router published on port 8553.
docker-frozen-rpc-cluster-start: docker-frozen-rpc-cluster-stop build-docker-node
@rm -rf $(PROJECT_HOME)/build/generated
@mkdir -p $(shell go env GOMODCACHE)
@mkdir -p $(shell go env GOCACHE)
@cd docker && \
if [ "$${DOCKER_DETACH:-}" = "true" ]; then \
DETACH_FLAG="-d"; \
else \
DETACH_FLAG=""; \
fi; \
$(CLUSTER_ENV_VARS) docker compose $(FROZEN_RPC_COMPOSE_FILES) up $$DETACH_FLAG
.PHONY: docker-frozen-rpc-cluster-start

# CI variant: use the prebuilt image and binaries from integration-build.tar.gz.
docker-frozen-rpc-cluster-start-ci: docker-frozen-rpc-cluster-stop ensure-integration-ci-images
@rm -rf $(PROJECT_HOME)/build/generated
@test -f $(PROJECT_HOME)/build/seid || (echo "build/seid missing; download integration-build.tar.gz from prepare-cluster" && exit 1)
@test -f $(PROJECT_HOME)/build/frozen-rpc-router || (echo "build/frozen-rpc-router missing; download integration-build.tar.gz from prepare-cluster" && exit 1)
@mkdir -p $(shell go env GOMODCACHE)
@mkdir -p $(shell go env GOCACHE)
@cd docker && \
if [ "$${DOCKER_DETACH:-}" = "true" ]; then \
DETACH_FLAG="-d"; \
else \
DETACH_FLAG=""; \
fi; \
$(CLUSTER_ENV_VARS) SKIP_BUILD=true docker compose $(FROZEN_RPC_COMPOSE_FILES) up $$DETACH_FLAG
.PHONY: docker-frozen-rpc-cluster-start-ci

docker-frozen-rpc-cluster-stop:
@cd docker && DOCKER_PLATFORM=$(DOCKER_PLATFORM) USERID=$(shell id -u) GROUPID=$(shell id -g) GOCACHE=$(shell go env GOCACHE) docker compose $(FROZEN_RPC_COMPOSE_FILES) down --remove-orphans
.PHONY: docker-frozen-rpc-cluster-stop

frozen-rpc-router-integration-test:
@set -e; \
trap '$(MAKE) docker-frozen-rpc-cluster-stop' EXIT; \
DOCKER_DETACH=true $(MAKE) docker-frozen-rpc-cluster-start; \
go test -tags frozen_rpc_integration -v -count=1 -timeout 5m ./integration_test/frozen_rpc_router/...
.PHONY: frozen-rpc-router-integration-test

# Start 4-node cluster with Prometheus and Grafana monitoring
docker-cluster-start-monitoring: docker-cluster-stop-monitoring build-docker-node
@rm -rf $(PROJECT_HOME)/build/generated
Expand Down
32 changes: 32 additions & 0 deletions cmd/frozen-rpc-router/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Frozen RPC router

`frozen-rpc-router` exposes an HTTP EVM JSON-RPC endpoint backed by a live node
and any number of nodes running with `freeze-height`.

A freeze height is an exclusive boundary. A node started with
`freeze-height = 100` serves blocks through height 99, so the router sends
height 99 to that node and height 100 to the next configured interval (or the
live node).

```sh
go run ./cmd/frozen-rpc-router \
--listen-address 0.0.0.0:8545 \
--live-node localhost:9545 \
--frozen-node 1000000=localhost:9546 \
--frozen-node 2000000=10.0.0.12:8545
```

Repeat `--frozen-node` for every `freeze-height=ip:port` pair. HTTP and HTTPS
URLs are also accepted. Frozen nodes may be listed in any order, but freeze
heights must be unique.

Methods with explicit numeric block parameters are routed to the matching
interval. `eth_getLogs` and `eth_feeHistory` are routed only when their entire
explicit range belongs to one interval; ranges crossing an interval boundary
return JSON-RPC error `-32000`. Latest-style block tags, methods without block
numbers, stateful filter methods, subscriptions, and WebSocket connections use
the live node.

Single-backend HTTP responses include `Sei-RPC-Route: frozen:<height>` or
`Sei-RPC-Route: live`. A batch split across backends returns
`Sei-RPC-Route: mixed`.
88 changes: 88 additions & 0 deletions cmd/frozen-rpc-router/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package main

import (
"errors"
"flag"
"fmt"
"io"
"math"
"strconv"
"strings"
"time"
)

const (
defaultListenAddress = "127.0.0.1:8545"
defaultMaxRequestBodySize = int64(5 << 20)
defaultShutdownTimeout = 10 * time.Second
)

type config struct {
listenAddress string
liveNode string
frozenNodes frozenNodeFlags
maxRequestBodySize int64
shutdownTimeout time.Duration
}

type frozenNodeConfig struct {
freezeHeight uint64
address string
}

type frozenNodeFlags []string

func (f *frozenNodeFlags) String() string {
return strings.Join(*f, ",")
}

func (f *frozenNodeFlags) Set(value string) error {
*f = append(*f, value)
return nil
}

func parseConfig(args []string, output io.Writer) (config, error) {
cfg := config{}
flags := flag.NewFlagSet("frozen-rpc-router", flag.ContinueOnError)
flags.SetOutput(output)
flags.StringVar(&cfg.listenAddress, "listen-address", defaultListenAddress, "address on which the router listens")
flags.StringVar(&cfg.liveNode, "live-node", "", "HTTP RPC address of the live node (required)")
flags.Var(&cfg.frozenNodes, "frozen-node", "freeze-height=ip:port pair; repeat once per frozen node")
flags.Int64Var(&cfg.maxRequestBodySize, "max-request-body-bytes", defaultMaxRequestBodySize, "maximum JSON-RPC request body size")
flags.DurationVar(&cfg.shutdownTimeout, "shutdown-timeout", defaultShutdownTimeout, "graceful shutdown timeout")
if err := flags.Parse(args); err != nil {
return config{}, err
}
if flags.NArg() != 0 {
return config{}, fmt.Errorf("unexpected positional arguments: %s", strings.Join(flags.Args(), " "))
}
if strings.TrimSpace(cfg.liveNode) == "" {
return config{}, errors.New("--live-node is required")
}
if cfg.maxRequestBodySize <= 0 {
return config{}, errors.New("--max-request-body-bytes must be positive")
}
if cfg.shutdownTimeout <= 0 {
return config{}, errors.New("--shutdown-timeout must be positive")
}
return cfg, nil
}

func parseFrozenNodes(values frozenNodeFlags) ([]frozenNodeConfig, error) {
nodes := make([]frozenNodeConfig, 0, len(values))
for _, value := range values {
heightText, address, ok := strings.Cut(value, "=")
if !ok || strings.TrimSpace(heightText) == "" || strings.TrimSpace(address) == "" {
return nil, fmt.Errorf("invalid frozen node %q: expected freeze-height=ip:port", value)
}
freezeHeight, err := strconv.ParseUint(strings.TrimSpace(heightText), 10, 64)
if err != nil || freezeHeight == 0 || freezeHeight > math.MaxInt64 {
return nil, fmt.Errorf("invalid freeze height %q", heightText)
}
nodes = append(nodes, frozenNodeConfig{
freezeHeight: freezeHeight,
address: strings.TrimSpace(address),
})
}
return nodes, nil
}
41 changes: 41 additions & 0 deletions cmd/frozen-rpc-router/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package main

import (
"io"
"testing"

"github.com/stretchr/testify/require"
)

func TestParseConfig(t *testing.T) {
cfg, err := parseConfig([]string{
"--listen-address", "0.0.0.0:9000",
"--live-node", "localhost:8545",
"--frozen-node", "200=localhost:8547",
"--frozen-node", "100=localhost:8546",
}, io.Discard)
require.NoError(t, err)
require.Equal(t, "0.0.0.0:9000", cfg.listenAddress)
require.Equal(t, "localhost:8545", cfg.liveNode)

nodes, err := parseFrozenNodes(cfg.frozenNodes)
require.NoError(t, err)
require.Equal(t, []frozenNodeConfig{
{freezeHeight: 200, address: "localhost:8547"},
{freezeHeight: 100, address: "localhost:8546"},
}, nodes)
}

func TestParseConfigRejectsMissingLiveNode(t *testing.T) {
_, err := parseConfig(nil, io.Discard)
require.EqualError(t, err, "--live-node is required")
}

func TestParseFrozenNodesRejectsInvalidPairs(t *testing.T) {
for _, value := range []string{"100", "=localhost:8545", "0=localhost:8545", "abc=localhost:8545", "9223372036854775808=localhost:8545", "100="} {
t.Run(value, func(t *testing.T) {
_, err := parseFrozenNodes(frozenNodeFlags{value})
require.Error(t, err)
})
}
}
Loading
Loading