Skip to content

Commit 3d34be4

Browse files
committed
Unified Nimbus node
This change brings Nimbus full circle to where it started all these years ago and allows running Ethereum in a single node / process, both as a wallet/web3 backend and as a validator. Among interesting properies are: * Easy to set up and run - one binary, one process, no JWT and messy setup, no cross-client communication issues, timing issues etc * Excellent performance, of course * Shared database = small database * Can run without the legacy devp2p as long as you're reasonably synced and not using it for block production - up to 5 months of history are instead sourced from the consensus network - block production requires devp2p since that's where the public mempool comes from Running ethereum and syncing mainnet is now as easy as: ```sh ./nimbus trustedNodeSync \ --trusted-node-url=http://testing.mainnet.beacon-api.nimbus.team/ \ --backfill=false ./nimbus ``` The consensus chain will start from a checkpoint while the execution chain will still be synced via P2P. You need about 500GB of space in total, but if you're buying a drive today, get 2 or 4 TB anyway. Testnets like `hoodi` can reasonably be synced from P2P all the way (takes a bit more than a day at the time of writing), without the checkpoint sync: ```nim ./nimbus --network:hoodi ``` That's it! The node can now be used both for validators and as a web3 provider. `--rpc` gives you the web3 backend which allows connecting wallets while `--rest` gives the beacon api that validator clients use. Of course, you can run your validators [in the node](https://nimbus.guide/run-a-validator.html#2-import-your-validator-keys) as well. Here's a true maxi configuration that turns on (almost) everything: ```nim ./nimbus --rpc --rest --metrics ``` The execution chain can also be imported from era files, downloading the history from https://mainnet.era.nimbus.team/ and https://mainnet.era1.nimbus.team/ and placing them in `era` and `era1` in the data directory as the [manual](https://nimbus.guide/execution-client.html#syncing-using-era-files) suggests, then running an `import` - it takes a few days: ```sh ./nimbus import ``` If you were already running nimbus, you can reuse your existing data directory - use `--data-dir:/some/path` as usual with all the commands to specify where you want your data stored - if you had both eth1 and eth2 directories, just merge their contents. To get up and running more quickly, snapshots of the mainnet execution database are maintained here: https://eth1-db.nimbus.team/ Together with checkpoint sync, you'll have a fully synced node in no time! In future versions, this will be replaced by snap sync or an equivalent state sync mechanism. To build the protoype: ```sh make update make -j8 nimbus ``` In a single process binary, the beacon and execution chain are each running in their own thread, sharing data directory and common services, similar to running the two pieces separately with the same data dir. One way to think about it is that the execution client and beacon nodes are stand-alone libraries that are being used together - this is not far from the truth and in fact, you can use either (or both!) as a library. The binary supports the union of all functionality that `nimbus_execution_client` and `nimbus_beacon_node` offers, including all the subcommands like [checkpoint sync](https://nimbus.guide/trusted-node-sync.html) and [execution history import](https://nimbus.guide/execution-client.html#import-era-files), simply using the `nimbus` command instead. Prototype notes: * cross-thread communication is done using a local instance of web3 / JSON - this is nuts of course: it should simply pass objects around and convert to directly to RLP on demand without going via JSON * the thread pool is not shared but should be - nim-taskpools needs to learn to accept tasks from threads other than the one that created it * discovery is not shared - instead, each of eth1/2 runs its own discovery protocols and consequently the node has two "identities" * there are many efficiency opportunities to exploit, in particular on the memory usage front * next up is light client and portal to be added as options, to support a wide range of feature vs performance tradeoffs
1 parent a627026 commit 3d34be4

File tree

13 files changed

+713
-129
lines changed

13 files changed

+713
-129
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,5 @@ tests/fixtures/eest_static
5151
tests/fixtures/eest_stable
5252
tests/fixtures/eest_develop
5353
tests/fixtures/eest_devnet
54+
55+
execution_chain/nimbus

Makefile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,9 @@ nimbus_execution_client: | build deps rocksdb
216216
check_revision: nimbus_execution_client
217217
scripts/check_revision.sh
218218

219-
nimbus: nimbus_execution_client
220-
echo "The nimbus target is deprecated and will soon change meaning, use 'nimbus_execution_client' instead"
219+
nimbus: | build deps rocksdb
220+
echo -e $(BUILD_MSG) "build/nimbus" && \
221+
$(ENV_SCRIPT) nim c $(NIM_PARAMS) -d:chronicles_log_level=TRACE -o:build/nimbus "execution_chain/nimbus.nim"
221222

222223
# symlink
223224
nimbus.nims:

execution_chain/beacon/api_handler/api_newpayload.nim

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ proc newPayload*(ben: BeaconEngineRef,
178178
# If we already have the block locally, ignore the entire execution and just
179179
# return a fake success.
180180
if chain.haveBlockAndState(blockHash):
181-
notice "Ignoring already known beacon payload",
181+
debug "Ignoring already known beacon payload",
182182
number = header.number, hash = blockHash.short
183183
return validStatus(blockHash)
184184

execution_chain/config.nim

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ func getLogLevels(): string =
6666
join(logLevels, ", ")
6767

6868
const
69-
defaultPort = 30303
69+
defaultExecutionPort* = 30303
7070
defaultMetricsServerPort = 9093
7171
defaultHttpPort = 8545
7272
# https://github.com/ethereum/execution-apis/blob/v1.0.0-beta.4/src/engine/authentication.md#jwt-specifications
@@ -267,8 +267,8 @@ type
267267

268268
tcpPort* {.
269269
desc: "Ethereum P2P network listening TCP port"
270-
defaultValue: defaultPort
271-
defaultValueDesc: $defaultPort
270+
defaultValue: defaultExecutionPort
271+
defaultValueDesc: $defaultExecutionPort
272272
name: "tcp-port" }: Port
273273

274274
udpPort* {.
@@ -489,6 +489,12 @@ type
489489
defaultValueDesc: "\"jwt.hex\" in the data directory (see --data-dir)"
490490
name: "jwt-secret" .}: Option[InputFile]
491491

492+
jwtSecretValue* {.
493+
hidden
494+
desc: "Hex string with jwt secret"
495+
defaultValueDesc: "\"jwt.hex\" in the data directory (see --data-dir)"
496+
name: "debug-jwt-secret-value" .}: Option[string]
497+
492498
beaconSyncTarget* {.
493499
hidden
494500
desc: "Manually set the initial sync target specified by its 32 byte" &
@@ -810,6 +816,7 @@ proc makeConfig*(cmdLine = commandLineParams()): NimbusConf =
810816
cmdLine,
811817
version = NimbusBuild,
812818
copyrightBanner = NimbusHeader,
819+
ignoreUnknown = true,
813820
secondarySources = proc (
814821
conf: NimbusConf, sources: ref SecondarySources
815822
) {.raises: [ConfigurationError].} =

execution_chain/core/chain/forked_chain.nim

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,16 @@ proc updateBase(c: ForkedChainRef, base: BlockRef): uint =
311311
# No update, return
312312
return
313313

314+
block:
315+
# Write block contents to txFrame at the last moment - otherwise, they would
316+
# stay both in BlockRef and TxFrame memory
317+
# TODO probably makes sense to do it the other way around, removing blk
318+
# from BlockRef
319+
var blk = base
320+
while blk.isOk:
321+
c.writeBaggage(blk.blk, blk.hash, blk.txFrame, blk.receipts)
322+
blk = blk.parent
323+
314324
# State root sanity check is performed to verify, before writing to disk,
315325
# that optimistically checked blocks indeed end up being stored with a
316326
# consistent state root.
@@ -327,17 +337,6 @@ something else needs attention! Shutting down to preserve the database - restart
327337
with --debug-eager-state-root."""
328338

329339
c.com.db.persist(base.txFrame)
330-
block:
331-
# Write block contents to txFrame at the last moment - otherwise, they would
332-
# stay both in BlockRef and TxFrame memory
333-
# TODO probably makes sense to do it the other way around, removing blk
334-
# from BlockRef
335-
var blk = base
336-
while blk.isOk:
337-
c.writeBaggage(blk.blk, blk.hash, blk.txFrame, blk.receipts)
338-
blk = blk.parent
339-
340-
c.com.db.persist(base.txFrame, Opt.some(base.stateRoot))
341340

342341
# Update baseTxFrame when we about to yield to the event loop
343342
# and prevent other modules accessing expired baseTxFrame.

execution_chain/db/aristo/aristo_init/rocks_db/rdb_init.nim

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,13 @@ proc init*(rdb: var RdbInst, opts: DbOptions, baseDb: RocksDbInstanceRef) =
108108
# TODO instead of dumping at exit, these stats could be logged or written
109109
# to a file for better tracking over time - that said, this is mainly
110110
# a debug utility at this point
111-
addExitProc(
112-
proc() =
113-
dumpCacheStats(ks, vs, bs)
114-
)
111+
# TODO hack to make this work in single-nimbus-executable - it's not gcsafe
112+
# at all!
113+
{.gcsafe.}:
114+
addExitProc(
115+
proc() =
116+
dumpCacheStats(ks, vs, bs)
117+
)
115118

116119
# Initialise column handlers (this stores implicitely `baseDb`)
117120
rdb.admCol = baseDb.db.getColFamily($AdmCF).valueOr(default(ColFamilyReadWrite))

execution_chain/el_sync.nim

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# Nimbus
2+
# Copyright (c) 2024-2025 Status Research & Development GmbH
3+
# Licensed under either of
4+
# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE))
5+
# * MIT license ([LICENSE-MIT](LICENSE-MIT))
6+
# at your option.
7+
# This file may not be copied, modified, or distributed except according to
8+
# those terms.
9+
10+
## Consensus to execution syncer prototype based on nrpc
11+
12+
{.push raises: [].}
13+
14+
import
15+
chronos,
16+
chronicles,
17+
web3,
18+
web3/[engine_api, primitives, conversions],
19+
beacon_chain/consensus_object_pools/blockchain_dag,
20+
beacon_chain/el/[el_manager, engine_api_conversions],
21+
beacon_chain/spec/[forks, presets, state_transition_block]
22+
23+
logScope:
24+
topics = "elsync"
25+
26+
proc getForkedBlock(dag: ChainDAGRef, slot: Slot): Opt[ForkedTrustedSignedBeaconBlock] =
27+
let bsi = ?dag.getBlockIdAtSlot(slot)
28+
if bsi.isProposed():
29+
dag.getForkedBlock(bsi.bid)
30+
else:
31+
Opt.none(ForkedTrustedSignedBeaconBlock)
32+
33+
proc blockNumber(blck: ForkedTrustedSignedBeaconBlock): uint64 =
34+
withBlck(blck):
35+
when consensusFork >= ConsensusFork.Bellatrix and
36+
consensusFork < ConsensusFork.Gloas:
37+
forkyBlck.message.body.execution_payload.block_number
38+
else:
39+
0'u64
40+
41+
# Load the network configuration based on the network id
42+
proc loadNetworkConfig(cfg: RuntimeConfig): (uint64, uint64) =
43+
case cfg.CONFIG_NAME
44+
of "mainnet":
45+
(15537393'u64, 4700013'u64)
46+
of "sepolia":
47+
(1450408'u64, 115193'u64)
48+
of "holesky", "hoodi":
49+
(0'u64, 0'u64)
50+
else:
51+
notice "Loading custom network, assuming post-merge"
52+
(0'u64, 0'u64)
53+
54+
# Slot Finding Mechanism
55+
# First it sets the initial lower bound to `firstSlotAfterMerge` + number of blocks after Era1
56+
# Then it iterates over the slots to find the current slot number, along with reducing the
57+
# search space by calculating the difference between the `blockNumber` and the `block_number` from the executionPayload
58+
# of the slot, then adding the difference to the importedSlot. This pushes the lower bound more,
59+
# making the search way smaller
60+
proc findSlot(
61+
dag: ChainDAGRef,
62+
elBlockNumber: uint64,
63+
lastEra1Block: uint64,
64+
firstSlotAfterMerge: uint64,
65+
): Opt[uint64] =
66+
var importedSlot = (elBlockNumber - lastEra1Block) + firstSlotAfterMerge + 1
67+
debug "Finding slot number corresponding to block", elBlockNumber, importedSlot
68+
69+
var clNum = 0'u64
70+
while clNum < elBlockNumber:
71+
# Check if we can get the block id - if not, this part of the chain is not
72+
# available from the CL
73+
let bsi = ?dag.getBlockIdAtSlot(Slot(importedSlot))
74+
75+
if not bsi.isProposed:
76+
importedSlot += 1
77+
continue # Empty slot
78+
79+
let blck = dag.getForkedBlock(bsi.bid).valueOr:
80+
return # Block unavailable
81+
82+
clNum = blck.blockNumber
83+
# on the first iteration, the arithmetic helps skip the gap that has built
84+
# up due to empty slots - for all subsequent iterations, except the last,
85+
# we'll go one step at a time
86+
# iteration so that we don't start at "one slot early"
87+
importedSlot += max(elBlockNumber - clNum, 1)
88+
89+
Opt.some importedSlot
90+
91+
proc syncToEngineApi*(dag: ChainDAGRef, url: EngineApiUrl) {.async.} =
92+
# Takes blocks from the CL and sends them to the EL - the attempt is made
93+
# optimistically until something unexpected happens (reorg etc) at which point
94+
# the process ends
95+
96+
let
97+
# Create the client for the engine api
98+
# And exchange the capabilities for a test communication
99+
web3 = await url.newWeb3()
100+
rpcClient = web3.provider
101+
(lastEra1Block, firstSlotAfterMerge) = dag.cfg.loadNetworkConfig()
102+
103+
defer:
104+
try:
105+
await web3.close()
106+
except:
107+
discard
108+
109+
# Load the EL state detials and create the beaconAPI client
110+
var elBlockNumber = uint64(await rpcClient.eth_blockNumber())
111+
112+
# Check for pre-merge situation
113+
if elBlockNumber <= lastEra1Block:
114+
debug "EL still pre-merge, no EL sync",
115+
blocknumber = elBlockNumber, lastPoWBlock = lastEra1Block
116+
return
117+
118+
# Load the latest state from the CL
119+
var clBlockNumber = dag.getForkedBlock(dag.head.slot).expect("head block").blockNumber
120+
121+
# Check if the EL is already in sync or about to become so (ie processing a
122+
# payload already, most likely)
123+
if clBlockNumber in [elBlockNumber, elBlockNumber + 1]:
124+
debug "EL in sync (or almost)", clBlockNumber, elBlockNumber
125+
return
126+
127+
if clBlockNumber < elBlockNumber:
128+
# This happens often during initial sync when the light client information
129+
# allows the EL to sync ahead of the CL head - it can also happen during
130+
# reorgs
131+
debug "CL is behind EL, not activating", clBlockNumber, elBlockNumber
132+
return
133+
134+
var importedSlot = findSlot(dag, elBlockNumber, lastEra1Block, firstSlotAfterMerge).valueOr:
135+
debug "Missing slot information for sync", elBlockNumber
136+
return
137+
138+
notice "Found initial slot for EL sync", importedSlot, elBlockNumber, clBlockNumber
139+
140+
while elBlockNumber < clBlockNumber:
141+
var isAvailable = false
142+
let curBlck = dag.getForkedBlock(Slot(importedSlot)).valueOr:
143+
importedSlot += 1
144+
continue
145+
importedSlot += 1
146+
let payloadResponse = withBlck(curBlck):
147+
# Don't include blocks before bellatrix, as it doesn't have payload
148+
when consensusFork >= ConsensusFork.Gloas:
149+
break
150+
elif consensusFork >= ConsensusFork.Bellatrix:
151+
# Load the execution payload for all blocks after the bellatrix upgrade
152+
let payload =
153+
forkyBlck.message.body.execution_payload.asEngineExecutionPayload()
154+
155+
debug "Sending payload", payload
156+
157+
when consensusFork >= ConsensusFork.Electra:
158+
let
159+
# Calculate the versioned hashes from the kzg commitments
160+
versioned_hashes =
161+
forkyBlck.message.body.blob_kzg_commitments.asEngineVersionedHashes()
162+
# Execution Requests for Electra
163+
execution_requests =
164+
forkyBlck.message.body.execution_requests.asEngineExecutionRequests()
165+
166+
await rpcClient.engine_newPayloadV4(
167+
payload,
168+
versioned_hashes,
169+
forkyBlck.message.parent_root.to(Hash32),
170+
execution_requests,
171+
)
172+
elif consensusFork >= ConsensusFork.Deneb:
173+
# Calculate the versioned hashes from the kzg commitments
174+
let versioned_hashes =
175+
forkyBlck.message.body.blob_kzg_commitments.asEngineVersionedHashes()
176+
await rpcClient.engine_newPayloadV3(
177+
payload, versioned_hashes, forkyBlck.message.parent_root.to(Hash32)
178+
)
179+
elif consensusFork >= ConsensusFork.Capella:
180+
await rpcClient.engine_newPayloadV2(payload)
181+
else:
182+
await rpcClient.engine_newPayloadV1(payload)
183+
else:
184+
return
185+
186+
if payloadResponse.status != PayloadExecutionStatus.valid:
187+
if payloadResponse.status notin
188+
[PayloadExecutionStatus.syncing, PayloadExecutionStatus.accepted]:
189+
# This would be highly unusual since it would imply a CL-valid but
190+
# EL-invalid block..
191+
warn "Payload invalid",
192+
elBlockNumber, status = payloadResponse.status, curBlck = shortLog(curBlck)
193+
return
194+
195+
debug "newPayload accepted", elBlockNumber, response = payloadResponse.status
196+
197+
elBlockNumber += 1
198+
199+
if elBlockNumber mod 1024 == 0:
200+
let curElBlock = uint64(await rpcClient.eth_blockNumber())
201+
if curElBlock != elBlockNumber:
202+
# If the EL starts syncing on its own, faster than we can feed it blocks
203+
# from here, it'll run ahead and we can stop this remote-drive attempt
204+
# TODO this happens because el-sync competes with the regular devp2p sync
205+
# when in fact it could be collaborating such that we don't do
206+
# redundant work
207+
debug "EL out of sync with EL syncer", curElBlock, elBlockNumber
208+
return

0 commit comments

Comments
 (0)