Lux mainnet and testnet are served from their archives on AWS

The canonical C-Chains are the RLP archives, 1,082,781 blocks on mainnet and
218 on testnet. Each runs as luxd-archive on the chains node and answers
publicly through the rpc door, which passes only eth_/net_/web3_ methods and
answers admin_, debug_, personal_ and txpool_ itself.

Testnet is frozen: its database is an emptyDir rebuilt from the archive
inside VM initialization on every boot, so its admin API is never on and no
restart depends on state surviving one. Mainnet is too large for that and
is imported once with admin_importChain; its admin API is behind the door,
and the pod is OnDelete and must not be deleted until luxfi/evm#152 is fixed
(after an import, a restart leaves the C-Chain unable to initialize).

Genesis is declared. The luxd-genesis ConfigMaps now hold the documents the
archives chain to: mainnet block 0 0x3f4fa2a0…, testnet 0x1c5fe377…,
each verified by booting it. The testnet document here before was a
different one (gasLimit 0x3b9aca00) that refuses every archived block, and
the mainnet one carried a C-Chain genesis that hashes to 0x43befc4e….

api.lux.network and api.lux.cloud route /v1/chain and / to the door at
priority 300, above the unified api-* router, and no longer point at
validators on the LAN.

The five-validator luxd sets are 0. They start from a genesis the archive
does not chain to and import nothing, so any block they made would fork
it. They come back on the canonical genesis once #152 is fixed.
This commit is contained in:
2026-09-22 23:53:53 -07:00
parent 1aedb563ec
commit 8a65db078a
13 changed files with 540 additions and 94 deletions
+14 -46
View File
@@ -1,46 +1,19 @@
# api.lux.network and api.lux.cloud, answering the chain.
#
# Both hosts otherwise reach cloud, which knows nothing about a chain and
# answers 404 to every RPC — which is what every Lux interface has been drawing
# since the old cluster went away.
# Both hosts otherwise reach cloud through the unified api-* router, which knows
# nothing about a chain and claims these hosts by rule length, so these routes
# state priority 300 (the carve-out api.hanzo.ai/v1/packages takes).
#
# The validators are on the LAN and not in this cluster, so the Service carries
# no selector and its endpoints are written here. Five of them: a node that is
# down costs a retry rather than the host.
# They go to the rpc door in lux-mainnet (k8s/rpc), which passes only
# eth_/net_/web3_ methods to the archive node there (k8s/lux-mainnet/luxd-archive.yaml).
#
# The path is `/v1/chain`. `/v1/bc` was the older spelling of the same segment
# and is gone from every node as of luxd 1.36.181 — it is not translated here,
# because a rename kept alive at the edge is a rename that never finishes.
# The path is `/v1/chain`. `/v1/bc` was the older spelling and is gone from every
# node; it is not translated here, because a rename kept alive at the edge is a
# rename that never finishes.
#
# The root is a different question and not a second spelling: a wallet is handed
# a host, so `https://api.lux.network` IS the C-chain endpoint. That is a route
# to an endpoint, stated once, here.
apiVersion: v1
kind: Service
metadata:
name: lux-chain
namespace: lux-mainnet
spec:
ports:
- name: rpc
port: 80
targetPort: 9730
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: lux-chain
namespace: lux-mainnet
labels:
kubernetes.io/service-name: lux-chain
addressType: IPv4
ports:
- name: rpc
port: 9730
protocol: TCP
endpoints:
- addresses: ["10.0.0.21"]
---
# a host, so `https://api.lux.network` IS the C-chain endpoint. That is a route to
# an endpoint, stated once, here.
apiVersion: hanzo.ai/v1alpha1
kind: Middleware
metadata:
@@ -61,15 +34,10 @@ spec:
- match: (Host(`api.lux.network`) || Host(`api.lux.cloud`)) && PathPrefix(`/v1/chain`)
kind: Rule
priority: 300
services:
- name: lux-chain
port: 80
services: [{name: rpc, port: 80}]
- match: (Host(`api.lux.network`) || Host(`api.lux.cloud`)) && Path(`/`)
kind: Rule
priority: 310
middlewares:
- name: c-chain
services:
- name: lux-chain
port: 80
priority: 300
middlewares: [{name: c-chain}]
services: [{name: rpc, port: 80}]
tls: {}
+7 -1
View File
@@ -651,7 +651,13 @@ readinessProbe:
# or a block-level snapshot of a quiesced donor — luxd-3/4, never luxd-1, and
# never copy /data/staking with it: two pods on one identity is equivocation,
# not a peering bug). Then set this back to 6.
replicas: 5
# 0, not 5. The canonical lux-mainnet C-Chain is the RLP archive, served by
# k8s/lux-mainnet/luxd-archive.yaml. These validators start from a genesis the
# archive does not chain to and import nothing, so any block they make is a
# fork of it. They return once luxfi/evm#152 is fixed (a validator that
# imports and then restarts cannot initialize its C-Chain) and they run the
# canonical genesis with the archive imported.
replicas: 0
resources:
limits:
cpu: '4'
+7 -1
View File
@@ -2,7 +2,13 @@
# OnDelete and Retain/Retain preserve quorum and ledger data during releases.
nameOverride: luxd
containerName: luxd
replicas: 5
# 0, not 5. The canonical lux-testnet C-Chain is the RLP archive, served by
# k8s/lux-testnet/luxd-archive.yaml. These validators start from a genesis the
# archive does not chain to and import nothing, so any block they make is a
# fork of it. They return once luxfi/evm#152 is fixed (a validator that
# imports and then restarts cannot initialize its C-Chain) and they run the
# canonical genesis with the archive imported.
replicas: 0
workload:
kind: StatefulSet
serviceName: luxd-headless
+2
View File
@@ -21,6 +21,8 @@ resources:
- api-gateway-gateway-config.yaml
- luxd-rpc.yaml
- luxd-genesis.yaml
- luxd-archive.yaml
- ../rpc
# luxd is intentionally absent here. Its only deployment source is
# deploy/lux-mainnet/luxd.yaml through the lux-mainnet-luxd CD Application.
# Ships the hourly C-Chain RLP archive off luxd-0's non-retained PVC to
+79
View File
@@ -0,0 +1,79 @@
# The canonical mainnet archive, imported once by admin_importChain (too large for
# import-chain-data inside the 10-minute VM start). The admin API is on, so the
# only public way in is the rpc door. updateStrategy is OnDelete and the running
# pod must not be deleted: until luxfi/evm#152 is fixed, a restart after import
# leaves the C-Chain unable to initialize.
#
# The archives are provisioned on the chains node from the canonical copies:
# aws s3 sync s3://hanzo-data-prod-532217001883/chain-archives/rlp/ /var/lib/rancher/k3s/storage/archives/rlp/
# (the same files as luxfi/state rlp/, mainnet reassembled from its .part.* chunks).
apiVersion: v1
kind: ConfigMap
metadata: {name: luxd-chainconfig, namespace: lux-mainnet}
data:
config.json: |
{
"pruning-enabled": false,
"admin-api-enabled": true,
"eth-apis": [
"eth",
"eth-filter",
"net",
"web3",
"internal-eth",
"internal-blockchain",
"internal-transaction"
],
"state-scheme": "hash",
"commit-interval": 1
}
---
apiVersion: v1
kind: Service
metadata: {name: luxd-archive, namespace: lux-mainnet}
spec:
selector: {app: luxd-archive}
ports: [{name: rpc, port: 9630, targetPort: 9630}]
---
apiVersion: apps/v1
kind: StatefulSet
metadata: {name: luxd-archive, namespace: lux-mainnet}
spec:
serviceName: luxd-archive
replicas: 1
updateStrategy: {type: OnDelete}
selector: {matchLabels: {app: luxd-archive}}
template:
metadata: {labels: {app: luxd-archive}}
spec:
terminationGracePeriodSeconds: 300
nodeSelector: {hanzo.ai/role: chains}
tolerations: [{key: hanzo.ai/visor, operator: Exists, effect: NoSchedule}]
containers:
- name: luxd
image: ghcr.io/luxfi/node:1.37.7
command: ["/luxd/build/luxd"]
args:
- --genesis-file=/genesis/genesis.json
- --network-id=1
- --data-dir=/data
- --chain-config-dir=/chainconfig
- --http-host=0.0.0.0
- --http-port=9630
- --http-allowed-hosts=*
- --staking-port=9631
- --bootstrap-nodes=
- --public-ip=127.0.0.1
- --api-admin-enabled
ports: [{containerPort: 9630, name: rpc}]
volumeMounts:
- {name: genesis, mountPath: /genesis, readOnly: true}
- {name: chainconfig, mountPath: /chainconfig/C, readOnly: true}
- {name: archives, mountPath: /archives, readOnly: true}
- {name: data, mountPath: /data}
resources: {requests: {cpu: 300m, memory: 2Gi}}
volumes:
- {name: genesis, configMap: {name: luxd-genesis}}
- {name: chainconfig, configMap: {name: luxd-chainconfig}}
- {name: archives, hostPath: {path: /var/lib/rancher/k3s/storage/archives/rlp}}
- {name: data, hostPath: {path: /var/lib/rancher/k3s/storage/chains/lux-mainnet/data}}
+5 -31
View File
@@ -1,35 +1,9 @@
---
# ⚠ NOTHING MOUNTS THIS ON MAINNET. Corrected 2026-08-07.
#
# lux-mainnet/luxd does not reference this ConfigMap; only lux-devnet/luxd
# does. Mainnet's genesis is written by startup.sh, from a base64+gzip blob
# embedded in that script, then rewritten in place:
# echo "H4sIA..." | base64 -d | gunzip > /data/genesis.json
# sed -i 's/"ethAddr":/"evmAddr":/g; s/"luxAddr":/"utxoAddr":/g' ...
# The running pod's /data/genesis.json is 523,815 bytes and matches NEITHER
# this file nor the source below — they differ in cChainGenesis.
#
# So this object is a mirror nobody reads on this network, and a diff against
# it says nothing about what the chain is running. I aligned it with the
# declared source below and described that as removing a risk of bricking a
# validator; that was wrong, and the claim is withdrawn. Establish that
# something MOUNTS a ConfigMap before ranking its drift.
#
# luxd-genesis canonical ConfigMap — generated 2026-06-01
# Source of truth: ~/work/lux/genesis/configs/mainnet/genesis.json (canonical)
# Network ID: from genesis.networkID (canonical per luxfi/constants).
# Image: ghcr.io/luxfi/node:1.27.4
# The luxd network genesis whose C-Chain block 0 is 0x3f4fa2a0b0ce089f52bf0ae9199c75ffdd76ecafc987794050cb0d286f1ec61e,
# the genesis the lux-mainnet-96369.rlp archive is chained to. Verified by booting it and
# comparing eth_getBlockByNumber("0x0"). Large: apply with --server-side.
apiVersion: v1
kind: ConfigMap
metadata:
name: luxd-genesis
namespace: lux-mainnet
labels:
app.kubernetes.io/part-of: lux-network
lux.network/network: mainnet
annotations:
lux.network/source: "~/work/lux/genesis/configs/mainnet/genesis.json (canonical)"
lux.network/image: "ghcr.io/luxfi/node:1.27.4"
metadata: {name: luxd-genesis, namespace: lux-mainnet}
data:
genesis.json: |
{
@@ -40737,7 +40711,7 @@ data:
}
],
"bChainGenesis": "{\n \"description\": \"Cross-chain bridge + MPC signing\",\n \"initialSigners\": [],\n \"message\": \"Lux B-Chain Genesis\",\n \"name\": \"B-Chain\",\n \"networkId\": 1,\n \"signerThreshold\": 0,\n \"timestamp\": 1730446786,\n \"version\": 1,\n \"vm\": \"BridgeVM\"\n}\n",
"cChainGenesis": "{\n \"config\": {\n \"chainId\": 96369,\n \"homesteadBlock\": 0,\n \"eip150Block\": 0,\n \"eip155Block\": 0,\n \"eip158Block\": 0,\n \"byzantiumBlock\": 0,\n \"constantinopleBlock\": 0,\n \"petersburgBlock\": 0,\n \"istanbulBlock\": 0,\n \"muirGlacierBlock\": 0,\n \"berlinBlock\": 0,\n \"londonBlock\": 0,\n \"feeConfig\": {\n \"gasLimit\": 12000000,\n \"targetBlockRate\": 2,\n \"minBaseFee\": 25000000000,\n \"targetGas\": 60000000,\n \"baseFeeChangeDenominator\": 36,\n \"minBlockGasCost\": 0,\n \"maxBlockGasCost\": 1000000,\n \"blockGasCostStep\": 200000\n },\n \"warpConfig\": {\n \"blockTimestamp\": 1750805381,\n \"quorumNumerator\": 67\n }\n },\n \"nonce\": \"0x0\",\n \"timestamp\": \"0x685b2b85\",\n \"extraData\": \"0x\",\n \"gasLimit\": \"0xb71b00\",\n \"difficulty\": \"0x0\",\n \"mixHash\": \"0x0000000000000000000000000000000000000000000000000000000000000000\",\n \"coinbase\": \"0x0000000000000000000000000000000000000000\",\n \"alloc\": {\n \"0x9011E888251AB053B7bD1cdB598Db4f9DEd94714\": {\n \"balance\": \"0x193e5939a08ce9dbd480000000\"\n }\n },\n \"airdropHash\": \"0x0000000000000000000000000000000000000000000000000000000000000000\",\n \"airdropAmount\": null,\n \"number\": \"0x0\",\n \"gasUsed\": \"0x0\",\n \"parentHash\": \"0x0000000000000000000000000000000000000000000000000000000000000000\",\n \"baseFeePerGas\": null,\n \"excessBlobGas\": null,\n \"blobGasUsed\": null\n}",
"cChainGenesis": "{\n \"config\": {\n \"chainId\": 96369,\n \"homesteadBlock\": 0,\n \"eip150Block\": 0,\n \"eip155Block\": 0,\n \"eip158Block\": 0,\n \"byzantiumBlock\": 0,\n \"constantinopleBlock\": 0,\n \"petersburgBlock\": 0,\n \"istanbulBlock\": 0,\n \"muirGlacierBlock\": 0,\n \"berlinBlock\": 0,\n \"londonBlock\": 0,\n \"arrowGlacierBlock\": 0,\n \"grayGlacierBlock\": 0,\n \"mergeNetsplitBlock\": 0,\n \"shanghaiTime\": 0,\n \"cancunTime\": 0,\n \"terminalTotalDifficulty\": 0,\n \"banffBlockTimestamp\": 0,\n \"cortinaBlockTimestamp\": 0,\n \"durangoBlockTimestamp\": 0,\n \"quasarTimestamp\": 0,\n \"fortunaTimestamp\": 0,\n \"graniteTimestamp\": 0,\n \"blobSchedule\": {\n \"cancun\": {\n \"max\": 6,\n \"target\": 3,\n \"baseFeeUpdateFraction\": 3338477\n }\n },\n \"feeConfig\": {\n \"gasLimit\": 12000000,\n \"targetBlockRate\": 2,\n \"minBaseFee\": 25000000000,\n \"targetGas\": 60000000,\n \"baseFeeChangeDenominator\": 36,\n \"minBlockGasCost\": 0,\n \"maxBlockGasCost\": 1000000,\n \"blockGasCostStep\": 200000\n },\n \"evmTimestamp\": 0,\n \"warpConfig\": {\n \"blockTimestamp\": 1730446786,\n \"quorumNumerator\": 67,\n \"requirePrimaryNetworkSigners\": false\n }\n },\n \"nonce\": \"0x0\",\n \"timestamp\": \"0x672485c2\",\n \"extraData\": \"0x\",\n \"gasLimit\": \"0xb71b00\",\n \"difficulty\": \"0x0\",\n \"mixHash\": \"0x0000000000000000000000000000000000000000000000000000000000000000\",\n \"coinbase\": \"0x0000000000000000000000000000000000000000\",\n \"alloc\": {\n \"0200000000000000000000000000000000000005\": {\n \"balance\": \"0x0\",\n \"nonce\": \"0x1\",\n \"code\": \"0x01\"\n },\n \"0x9011E888251AB053B7bD1cdB598Db4f9DEd94714\": {\n \"balance\": \"0x193e5939a08ce9dbd480000000\"\n }\n },\n \"stateRoot\": \"0x2d1cedac263020c5c56ef962f6abe0da1f5217bdc6468f8c9258a0ea23699e80\",\n \"number\": \"0x0\",\n \"gasUsed\": \"0x0\",\n \"parentHash\": \"0x0000000000000000000000000000000000000000000000000000000000000000\",\n \"baseFeePerGas\": \"0x5d21dba00\",\n \"skipPostMergeFields\": true,\n \"genesisHash\": \"0x3f4fa2a0b0ce089f52bf0ae9199c75ffdd76ecafc987794050cb0d286f1ec61e\"\n}",
"dChainGenesis": "{\n \"description\": \"Decentralized Exchange \\u2014 native CLOB + AMM + perpetuals\",\n \"feeConfig\": {\n \"makerFeeBps\": 2,\n \"takerFeeBps\": 5\n },\n \"liquidityPools\": [],\n \"message\": \"Lux D-Chain Genesis\",\n \"name\": \"D-Chain\",\n \"networkId\": 1,\n \"perpetualMarkets\": [],\n \"timestamp\": 1730446786,\n \"tradingPairs\": [],\n \"version\": 1,\n \"vm\": \"DexVM\"\n}\n",
"gChainGenesis": "{\n \"description\": \"Graph database (GraphQL/Dgraph)\",\n \"message\": \"Lux G-Chain Genesis\",\n \"name\": \"G-Chain\",\n \"networkId\": 1,\n \"timestamp\": 1730446786,\n \"version\": 1,\n \"vm\": \"GraphVM\"\n}\n",
"initialStakeDuration": 31536000,
+14
View File
@@ -0,0 +1,14 @@
# The chain's public RPC: one route to the rpc door, no rewrites. The priority
# puts it above the unified api-* router, which claims some chain hosts by rule
# length (the same carve-out api.hanzo.ai/v1/packages takes at 300).
apiVersion: hanzo.ai/v1alpha1
kind: IngressRoute
metadata: {name: chain-api, namespace: lux-testnet}
spec:
entryPoints: [websecure]
routes:
- match: Host(`api.lux-test.network`) && PathPrefix(`/v1/chain`)
kind: Rule
priority: 300
services: [{name: rpc, port: 80}]
tls: {}
+3
View File
@@ -45,6 +45,9 @@ resources:
# deploy/lux-testnet/luxd.yaml through the lux-testnet-luxd CD Application.
- configmap.yaml
- luxd-genesis.yaml
- luxd-archive.yaml
- chain-api.yaml
- ../rpc
- chain-aliases.yaml
- dead-chains-cm.yaml
# heartbeat-cronjob.yaml was deleted in 96a7f61 (chain-heartbeat spoke the
+76
View File
@@ -0,0 +1,76 @@
# A frozen chain: nothing produces blocks, so the RLP archive is the record and
# the database is a cache of it. Each boot rebuilds the chain from the archive
# inside VM initialization (import-chain-data), so the admin API is never on and
# no restart depends on state surviving one (luxfi/evm#152).
#
# The archives are provisioned on the chains node from the canonical copies:
# aws s3 sync s3://hanzo-data-prod-532217001883/chain-archives/rlp/ /var/lib/rancher/k3s/storage/archives/rlp/
# (the same files as luxfi/state rlp/, mainnet reassembled from its .part.* chunks).
apiVersion: v1
kind: ConfigMap
metadata: {name: luxd-chainconfig, namespace: lux-testnet}
data:
config.json: |
{
"pruning-enabled": false,
"admin-api-enabled": false,
"eth-apis": [
"eth",
"eth-filter",
"net",
"web3",
"internal-eth",
"internal-blockchain",
"internal-transaction"
],
"import-chain-data": "/archives/lux-testnet/lux-testnet-96368.rlp"
}
---
apiVersion: v1
kind: Service
metadata: {name: luxd-archive, namespace: lux-testnet}
spec:
selector: {app: luxd-archive}
ports: [{name: rpc, port: 9630, targetPort: 9630}]
---
apiVersion: apps/v1
kind: StatefulSet
metadata: {name: luxd-archive, namespace: lux-testnet}
spec:
serviceName: luxd-archive
replicas: 1
updateStrategy: {type: OnDelete}
selector: {matchLabels: {app: luxd-archive}}
template:
metadata: {labels: {app: luxd-archive}}
spec:
terminationGracePeriodSeconds: 60
nodeSelector: {hanzo.ai/role: chains}
tolerations: [{key: hanzo.ai/visor, operator: Exists, effect: NoSchedule}]
containers:
- name: luxd
image: ghcr.io/luxfi/node:1.37.7
command: ["/luxd/build/luxd"]
args:
- --genesis-file=/genesis/genesis.json
- --network-id=2
- --data-dir=/data
- --chain-config-dir=/chainconfig
- --http-host=0.0.0.0
- --http-port=9630
- --http-allowed-hosts=*
- --staking-port=9631
- --bootstrap-nodes=
- --public-ip=127.0.0.1
ports: [{containerPort: 9630, name: rpc}]
volumeMounts:
- {name: genesis, mountPath: /genesis, readOnly: true}
- {name: chainconfig, mountPath: /chainconfig/C, readOnly: true}
- {name: archives, mountPath: /archives, readOnly: true}
- {name: data, mountPath: /data}
resources: {requests: {cpu: 200m, memory: 1Gi}}
volumes:
- {name: genesis, configMap: {name: luxd-genesis}}
- {name: chainconfig, configMap: {name: luxd-chainconfig}}
- {name: archives, hostPath: {path: /var/lib/rancher/k3s/storage/archives/rlp}}
- {name: data, emptyDir: {}}
File diff suppressed because one or more lines are too long
+135
View File
@@ -0,0 +1,135 @@
# The public JSON-RPC door in front of a chain's luxd. Only eth_/net_/web3_
# methods reach the node; admin_, debug_, personal_, txpool_ are answered here
# with -32601. The node is always addressed as localhost. rpc_test.py is the check.
# Namespace comes from the kustomization that includes this directory.
apiVersion: v1
kind: ConfigMap
metadata: {name: rpc}
data:
rpc.py: |
"""Public JSON-RPC door for one C-Chain node.
Forwards POST /v1/chain/{c,C}/rpc to UPSTREAM when every call in the body
(single or batch) names a method in the eth, net or web3 namespace. Any other
method -- admin_*, debug_*, personal_*, txpool_* -- is answered here with
-32601 and never reaches the node. Upstream is always addressed as localhost,
so the node needs no allowed-hosts entry for the public name.
"""
import http.server, json, os, re, socketserver, urllib.request, urllib.error
UPSTREAM = os.environ["UPSTREAM"] # e.g. http://luxd-archive:9630
PORT = int(os.environ.get("PORT", "8080"))
PATH = re.compile(r"^/v1/chain/[cC]/rpc/?$")
ALLOW = ("eth_", "net_", "web3_")
MAX_BODY = 1 << 20
MAX_BATCH = 100
def refused(call):
return {"jsonrpc": "2.0", "id": call.get("id") if isinstance(call, dict) else None,
"error": {"code": -32601, "message": "method not available on the public endpoint"}}
def allowed(call):
return isinstance(call, dict) and isinstance(call.get("method"), str) and call["method"].startswith(ALLOW)
class Door(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def reply(self, code, body, ctype="application/json"):
data = body if isinstance(body, bytes) else json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(data)
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Max-Age", "86400")
self.send_header("Content-Length", "0")
self.end_headers()
def do_GET(self):
if self.path == "/health":
return self.reply(200, {"ok": True})
self.reply(404, {"error": "not found"})
def do_POST(self):
if not PATH.match(self.path.split("?")[0]):
return self.reply(404, {"error": "not found"})
n = int(self.headers.get("Content-Length") or 0)
if n <= 0 or n > MAX_BODY:
return self.reply(413, {"error": "body size"})
raw = self.rfile.read(n)
try:
body = json.loads(raw)
except ValueError:
return self.reply(400, {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}})
calls = body if isinstance(body, list) else [body]
if not calls or len(calls) > MAX_BATCH:
return self.reply(400, {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "batch size"}})
if not all(allowed(c) for c in calls):
out = [refused(c) if not allowed(c) else
{"jsonrpc": "2.0", "id": c.get("id"), "error": {"code": -32600, "message": "batch contains a refused method"}}
for c in calls]
return self.reply(200, out if isinstance(body, list) else out[0])
req = urllib.request.Request(UPSTREAM + "/v1/chain/C/rpc", data=raw, method="POST",
headers={"Content-Type": "application/json", "Host": "localhost"})
try:
with urllib.request.urlopen(req, timeout=60) as r:
self.reply(r.status, r.read())
except urllib.error.HTTPError as e:
self.reply(e.code, e.read())
except Exception:
self.reply(502, {"jsonrpc": "2.0", "id": None, "error": {"code": -32603, "message": "upstream unavailable"}})
def log_message(self, fmt, *args):
pass
class Server(socketserver.ThreadingMixIn, http.server.HTTPServer):
daemon_threads = True
if __name__ == "__main__":
Server(("0.0.0.0", PORT), Door).serve_forever()
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: rpc}
spec:
replicas: 1
selector: {matchLabels: {app: rpc}}
template:
metadata:
labels: {app: rpc}
annotations: {hanzo.ai/script-sha: "fff2344061e2996f"}
spec:
nodeSelector: {hanzo.ai/role: platform}
containers:
- name: rpc
image: python:3.12.12-alpine3.22
command: ["python3", "/app/rpc.py"]
env:
- {name: UPSTREAM, value: "http://luxd-archive:9630"}
- {name: PORT, value: "8080"}
ports: [{containerPort: 8080, name: http}]
readinessProbe: {httpGet: {path: /health, port: 8080}, periodSeconds: 10}
livenessProbe: {httpGet: {path: /health, port: 8080}, periodSeconds: 20}
resources: {requests: {cpu: 50m, memory: 64Mi}, limits: {memory: 256Mi}}
securityContext: {runAsNonRoot: true, runAsUser: 65534, readOnlyRootFilesystem: true, allowPrivilegeEscalation: false}
volumeMounts: [{name: app, mountPath: /app, readOnly: true}]
volumes: [{name: app, configMap: {name: rpc}}]
---
apiVersion: v1
kind: Service
metadata: {name: rpc}
spec:
selector: {app: rpc}
ports: [{name: http, port: 80, targetPort: 8080}]
+4
View File
@@ -0,0 +1,4 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- door.yaml
+60
View File
@@ -0,0 +1,60 @@
"""Check the rpc door in door.yaml against a fake node: python3 rpc_test.py"""
import http.server, json, os, sys, threading, time, types, urllib.error, urllib.request
import yaml
here = os.path.dirname(os.path.abspath(__file__))
cm = next(d for d in yaml.safe_load_all(open(os.path.join(here, "door.yaml"))) if d and d["kind"] == "ConfigMap")
src = cm["data"]["rpc.py"]
class Node(http.server.BaseHTTPRequestHandler):
def do_POST(self):
self.rfile.read(int(self.headers["Content-Length"]))
out = json.dumps({"jsonrpc": "2.0", "id": 1, "result": "0x1", "host": self.headers["Host"]}).encode()
self.send_response(200); self.send_header("Content-Length", str(len(out))); self.end_headers(); self.wfile.write(out)
def log_message(self, *a):
pass
node = http.server.HTTPServer(("127.0.0.1", 0), Node)
threading.Thread(target=node.serve_forever, daemon=True).start()
os.environ["UPSTREAM"] = f"http://127.0.0.1:{node.server_port}"
rpc = types.ModuleType("rpc"); exec(compile(src, "rpc.py", "exec"), rpc.__dict__)
door = rpc.Server(("127.0.0.1", 0), rpc.Door)
threading.Thread(target=door.serve_forever, daemon=True).start()
time.sleep(0.2)
def call(body, path="/v1/chain/c/rpc"):
r = urllib.request.Request(f"http://127.0.0.1:{door.server_port}{path}", data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(r) as x:
return x.status, json.loads(x.read())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read())
cases = [
("eth_ passes, node sees Host localhost", {"jsonrpc": "2.0", "id": 1, "method": "eth_blockNumber"},
"/v1/chain/c/rpc", lambda s, b: s == 200 and b["host"] == "localhost"),
("admin_ refused", {"jsonrpc": "2.0", "id": 2, "method": "admin_importChain", "params": ["/x"]},
"/v1/chain/c/rpc", lambda s, b: b["error"]["code"] == -32601),
("debug_ refused", {"jsonrpc": "2.0", "id": 3, "method": "debug_traceBlockByNumber"},
"/v1/chain/c/rpc", lambda s, b: b["error"]["code"] == -32601),
("batch holding admin_ refused whole", [{"jsonrpc": "2.0", "id": 4, "method": "eth_chainId"},
{"jsonrpc": "2.0", "id": 5, "method": "admin_nodeInfo"}],
"/v1/chain/c/rpc", lambda s, b: all("error" in x for x in b)),
("clean batch passes", [{"jsonrpc": "2.0", "id": 6, "method": "eth_chainId"},
{"jsonrpc": "2.0", "id": 7, "method": "net_version"}], "/v1/chain/c/rpc", lambda s, b: s == 200),
("call without method refused", {"jsonrpc": "2.0", "id": 8}, "/v1/chain/c/rpc", lambda s, b: b["error"]["code"] == -32601),
("other path is 404", {"jsonrpc": "2.0", "id": 9, "method": "eth_chainId"}, "/v1/admin", lambda s, b: s == 404),
("uppercase C path passes", {"jsonrpc": "2.0", "id": 10, "method": "eth_chainId"}, "/v1/chain/C/rpc", lambda s, b: s == 200),
]
failed = 0
for name, body, path, ok in cases:
s, b = call(body, path)
good = ok(s, b); failed += not good
print(("PASS " if good else "FAIL ") + name)
sys.exit(1 if failed else 0)