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:
+14
-46
@@ -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: {}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}]
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- door.yaml
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user