From 68247f81b30c6641a9c95d936f239b9d35c7e276 Mon Sep 17 00:00:00 2001 From: Jacek Sieka Date: Mon, 17 Jan 2022 10:27:08 +0100 Subject: [PATCH] Trusted node sync (#3209) * Trusted node sync Trusted node sync, aka checkpoint sync, allows syncing tyhe chain from a trusted node instead of relying on a full sync from genesis. Features include: * sync from any slot, including the latest finalized slot * backfill blocks either from the REST api (default) or p2p (#3263) Future improvements: * top up blocks between head in database and some other node - this makes for an efficient backup tool * recreate historical state to enable historical queries * fixes * load genesis from network metadata * check checkpoint block root against state * fix invalid block root in rest json decoding * odds and ends * retry looking for epoch-boundary checkpoint blocks --- beacon_chain/conf.nim | 36 +- beacon_chain/nimbus_beacon_node.nim | 35 +- .../eth2_apis/eth2_rest_serialization.nim | 2 + beacon_chain/sync/sync_manager.nim | 2 +- beacon_chain/trusted_node_sync.nim | 411 ++++++++++++++++++ docs/the_nimbus_book/src/trusted-node-sync.md | 75 ++++ 6 files changed, 542 insertions(+), 19 deletions(-) create mode 100644 beacon_chain/trusted_node_sync.nim create mode 100644 docs/the_nimbus_book/src/trusted-node-sync.md diff --git a/beacon_chain/conf.nim b/beacon_chain/conf.nim index 73e6e4ec5..c3217998b 100644 --- a/beacon_chain/conf.nim +++ b/beacon_chain/conf.nim @@ -37,7 +37,7 @@ const defaultSigningNodeRequestTimeout* = 60 type - BNStartUpCmd* = enum + BNStartUpCmd* {.pure.} = enum noCommand createTestnet deposits @@ -45,6 +45,7 @@ type record web3 slashingdb + trustedNodeSync WalletsCmd* {.pure.} = enum create = "Creates a new EIP-2386 wallet" @@ -177,9 +178,9 @@ type case cmd* {. command - defaultValue: noCommand }: BNStartUpCmd + defaultValue: BNStartUpCmd.noCommand }: BNStartUpCmd - of noCommand: + of BNStartUpCmd.noCommand: bootstrapNodes* {. desc: "Specifies one or more bootstrap nodes to use when connecting to the network" abbr: "b" @@ -417,7 +418,7 @@ type defaultValue: false name: "validator-monitor-totals" }: bool - of createTestnet: + of BNStartUpCmd.createTestnet: testnetDepositsFile* {. desc: "A LaunchPad deposits file for the genesis state validators" name: "deposits-file" }: InputFile @@ -451,7 +452,7 @@ type desc: "Output file with list of bootstrap nodes for the network" name: "output-bootstrap-file" }: OutFile - of wallets: + of BNStartUpCmd.wallets: case walletsCmd* {.command.}: WalletsCmd of WalletsCmd.create: nextAccount* {. @@ -484,7 +485,7 @@ type of WalletsCmd.list: discard - of deposits: + of BNStartUpCmd.deposits: case depositsCmd* {.command.}: DepositsCmd of DepositsCmd.createTestnetDeposits: totalDeposits* {. @@ -543,7 +544,7 @@ type name: "epoch" desc: "The desired exit epoch" }: Option[uint64] - of record: + of BNStartUpCmd.record: case recordCmd* {.command.}: RecordCmd of RecordCmd.create: ipExt* {. @@ -573,7 +574,7 @@ type desc: "ENR URI of the record to print" name: "enr" .}: Record - of web3: + of BNStartUpCmd.web3: case web3Cmd* {.command.}: Web3Cmd of Web3Cmd.test: web3TestUrl* {. @@ -581,7 +582,7 @@ type desc: "The web3 provider URL to test" name: "url" }: Uri - of slashingdb: + of BNStartUpCmd.slashingdb: case slashingdbCmd* {.command.}: SlashProtCmd of SlashProtCmd.`import`: importedInterchangeFile* {. @@ -597,6 +598,23 @@ type desc: "EIP-3076 slashing protection interchange file to export" argument }: OutFile + of BNStartUpCmd.trustedNodeSync: + trustedNodeUrl* {. + desc: "URL of the REST API to sync from" + defaultValue: "http://localhost:5052" + name: "trusted-node-url" + .}: string + + blockId* {. + desc: "Block id to sync to - this can be a block root, slot number, \"finalized\" or \"head\"" + defaultValue: "finalized" + .}: string + + backfillBlocks* {. + desc: "Backfill blocks directly from REST server instead of fetching via API" + defaultValue: true + name: "backfill"}: bool + ValidatorClientConf* = object logLevel* {. desc: "Sets the log level" diff --git a/beacon_chain/nimbus_beacon_node.nim b/beacon_chain/nimbus_beacon_node.nim index c77ebb9ce..f3f95233f 100644 --- a/beacon_chain/nimbus_beacon_node.nim +++ b/beacon_chain/nimbus_beacon_node.nim @@ -26,7 +26,7 @@ import # Local modules "."/[ beacon_clock, beacon_chain_db, beacon_node, beacon_node_status, - conf, filepath, interop, nimbus_binary_common, statusbar, + conf, filepath, interop, nimbus_binary_common, statusbar, trusted_node_sync, version], ./networking/[eth2_discovery, eth2_network, network_metadata], ./gossip_processing/[eth2_processor, block_processor, consensus_manager], @@ -1874,7 +1874,6 @@ proc doSlashingImport(conf: BeaconNodeConf) {.raises: [SerializationError, IOErr echo "Import finished: '", interchange, "' into '", dir/filetrunc & ".sqlite3", "'" proc doSlashingInterchange(conf: BeaconNodeConf) {.raises: [Defect, CatchableError].} = - doAssert conf.cmd == slashingdb case conf.slashingdbCmd of SlashProtCmd.`export`: conf.doSlashingExport() @@ -1921,10 +1920,28 @@ programMain: let rng = keys.newRng() case config.cmd - of createTestnet: doCreateTestnet(config, rng[]) - of noCommand: doRunBeaconNode(config, rng) - of deposits: doDeposits(config, rng[]) - of wallets: doWallets(config, rng[]) - of record: doRecord(config, rng[]) - of web3: doWeb3Cmd(config) - of slashingdb: doSlashingInterchange(config) + of BNStartUpCmd.createTestnet: doCreateTestnet(config, rng[]) + of BNStartUpCmd.noCommand: doRunBeaconNode(config, rng) + of BNStartUpCmd.deposits: doDeposits(config, rng[]) + of BNStartUpCmd.wallets: doWallets(config, rng[]) + of BNStartUpCmd.record: doRecord(config, rng[]) + of BNStartUpCmd.web3: doWeb3Cmd(config) + of BNStartUpCmd.slashingdb: doSlashingInterchange(config) + of BNStartupCmd.trustedNodeSync: + let + network = loadEth2Network(config) + cfg = network.cfg + genesis = + if network.genesisData.len > 0: + newClone(readSszForkedHashedBeaconState( + cfg, + network.genesisData.toOpenArrayByte(0, network.genesisData.high()))) + else: nil + + waitFor doTrustedNodeSync( + cfg, + config.databaseDir, + config.trustedNodeUrl, + config.blockId, + config.backfillBlocks, + genesis) diff --git a/beacon_chain/spec/eth2_apis/eth2_rest_serialization.nim b/beacon_chain/spec/eth2_apis/eth2_rest_serialization.nim index 9cba78c98..edc2bc7e2 100644 --- a/beacon_chain/spec/eth2_apis/eth2_rest_serialization.nim +++ b/beacon_chain/spec/eth2_apis/eth2_rest_serialization.nim @@ -795,6 +795,8 @@ proc readValue*(reader: var JsonReader[RestJson], if res.isNone(): reader.raiseUnexpectedValue("Incorrect merge block format") value = ForkedSignedBeaconBlock.init(res.get()) + withBlck(value): + blck.root = hash_tree_root(blck.message) proc writeValue*(writer: var JsonWriter[RestJson], value: ForkedSignedBeaconBlock) {. diff --git a/beacon_chain/sync/sync_manager.nim b/beacon_chain/sync/sync_manager.nim index b92c11bb4..113575103 100644 --- a/beacon_chain/sync/sync_manager.nim +++ b/beacon_chain/sync/sync_manager.nim @@ -471,7 +471,7 @@ proc guardTask[A, B](man: SyncManager[A, B]) {.async.} = man.workers[index].future = future pending[index] = future -proc toTimeLeftString(d: Duration): string = +proc toTimeLeftString*(d: Duration): string = if d == InfiniteDuration: "--h--m" else: diff --git a/beacon_chain/trusted_node_sync.nim b/beacon_chain/trusted_node_sync.nim new file mode 100644 index 000000000..febb94d56 --- /dev/null +++ b/beacon_chain/trusted_node_sync.nim @@ -0,0 +1,411 @@ +# Copyright (c) 2018-2022 Status Research & Development GmbH +# Licensed and distributed under either of +# * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT). +# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0). +# at your option. This file may not be copied, modified, or distributed except according to those terms. + +{.push raises: [Defect].} + +import + std/[os], + stew/[assign2, base10], + chronicles, chronos, + ./sync/sync_manager, + ./consensus_object_pools/blockchain_dag, + ./spec/eth2_apis/rest_beacon_client, + ./spec/[beaconstate, eth2_merkleization, forks, presets, state_transition], + "."/[beacon_clock, beacon_chain_db] + +type + DbCache = object + summaries: Table[Eth2Digest, BeaconBlockSummary] + slots: seq[Option[Eth2Digest]] + +const + emptyHash = Eth2Digest() + +proc updateSlots(cache: var DbCache, root: Eth2Digest, slot: Slot) = + # The slots mapping stores one linear block history - we construct it by + # starting from a given root/slot and walking the known parents as far back + # as possible which ensures that all blocks belong to the same history + + if cache.slots.len() < slot.int + 1: + cache.slots.setLen(slot.int + 1) + + var + root = root + lastSlot = slot + + while true: + cache.summaries.withValue(root, v) do: + let slot = v[].slot + + for i in slot.int + 1.. headSlot: + # When the checkpoint is newer than the head, we run into trouble: the + # current backfill in ChainDAG does not support filling in arbitrary gaps. + # If we were to update the backfill pointer in this case, the ChainDAG + # backfiller would re-download the entire backfill history. + # For now, we'll abort and let the user choose what to do. + error "Checkpoint block is newer than head slot - start with a new database or use a checkpoint no more recent than the head", + checkpointSlot, checkpointRoot = shortLog(checkpointBlock.root), headSlot + quit 1 + + if checkpointSlot.is_epoch(): + found = true + break + + id = BlockIdent.init((checkpointSlot.epoch() - 1).start_slot) + + info "Downloaded checkpoint block does not fall on epoch boundary, trying an earlier epoch", + checkpointSlot, id + + if not found: + # The ChainDAG requires that the tail falls on an epoch boundary, or it + # will be unable to load the corresponding state - this could be fixed, but + # for now, we ask the user to fix it instead + error "A checkpoint block from the first slot of an epoch could not be found with the given block id - pass an epoch slot with a block using the --block-id parameter", + blockId + quit 1 + checkpointBlock + + let checkpointSlot = getForkedBlockField(checkpointBlock, slot) + if checkpointBlock.root in dbCache.summaries: + notice "Checkpoint block is already known, skipping checkpoint state download" + + withBlck(checkpointBlock): + dbCache.updateSlots(blck.root, blck.message.slot) + + else: + notice "Downloading checkpoint state", restUrl, checkpointSlot + + let + state = try: + await client.getStateV2(StateIdent.init(checkpointSlot), cfg) + except CatchableError as exc: + error "Unable to download checkpoint state", + error = exc.msg, restUrl, checkpointSlot + quit 1 + + if isNil(state): + notice "No state found at given checkpoint", checkpointSlot + quit 1 + + withState(state[]): + let latest_block_root = state.latest_block_root + + if latest_block_root != checkpointBlock.root: + error "Checkpoint state does not match checkpoint block, server error?", + blockRoot = shortLog(checkpointBlock.root), + blck = shortLog(checkpointBlock), + stateBlockRoot = shortLog(latest_block_root) + quit 1 + + info "Writing checkpoint state", + stateRoot = shortLog(state.root) + db.putStateRoot(state.latest_block_root(), state.data.slot, state.root) + db.putState(state.root, state.data) + + withBlck(checkpointBlock): + info "Writing checkpoint block", + blockRoot = shortLog(blck.root), + blck = shortLog(blck.message) + + db.putBlock(blck.asTrusted()) + db.putHeadBlock(blck.root) + db.putTailBlock(blck.root) + + dbCache.update(blck) + + # Coming this far, we've done what ChainDAGRef.preInit would normally do - + # Let's do a sanity check and start backfilling blocks from the trusted node + if (let v = ChainDAGRef.isInitialized(db); v.isErr()): + error "Database not initialized after checkpoint sync, report bug", + err = v.error() + quit 1 + + let missingSlots = block: + var total = 0 + for i in 0..= dbCache.slots.lenu64(): + error "Downloaded block does not match checkpoint history" + quit 1 + + if not dbCache.slots[childSlot.int].isSome(): + # Should never happen - we download slots backwards + error "Downloaded block does not match checkpoint history" + quit 1 + + let knownRoot = dbCache.slots[childSlot.int].get() + if knownRoot == emptyHash: + childSlot += 1 + continue + + dbCache.summaries.withValue(knownRoot, summary): + if summary[].parent_root != blck.root: + error "Downloaded block does not match checkpoint history", + blockRoot = shortLog(blck.root), + expectedRoot = shortLog(summary[].parent_root) + quit 1 + + break + + # This shouldn't happen - we should have downloaded the child and + # updated knownBlocks before here + error "Expected child block not found in checkpoint history" + quit 1 + + if blck.root notin dbCache.summaries: + db.putBlock(blck.asTrusted()) + + dbCache.update(blck) + + let newStamp = SyncMoment.now(processed) + if newStamp.stamp - stamp.stamp > 12.seconds: + syncCount += 1 + + let + remaining = blck.message.slot.int.float + slotsPerSec = speed(stamp, newStamp) + avgSyncSpeed = avgSyncSpeed + (slotsPerSec - avgSyncSpeed) / float(syncCount) + + info "Backfilling", + timeleft = toTimeLeftString( + if avgSyncSpeed >= 0.001: + Duration.fromFloatSeconds(remaining / avgSyncSpeed) + else: InfiniteDuration), + avgSyncSpeed, + remaining + stamp = newStamp + + # Download blocks backwards from the checkpoint slot, skipping the ones we + # already have in the database. We'll do a few downloads in parallel which + # risks having some redundant downloads going on, but speeds things up + for i in 0'u64..<(checkpointSlot.uint64 + gets.lenu64()): + if not isNil(gets[int(i mod gets.lenu64)]): + await processBlock( + gets[int(i mod gets.lenu64)], + checkpointSlot + gets.lenu64() - uint64(i)) + gets[int(i mod gets.lenu64)] = nil + + if i < checkpointSlot: + let slot = checkpointSlot - i + if dbCache.isKnown(slot): + continue + + gets[int(i mod gets.lenu64)] = downloadBlock(slot) + else: + notice "Database initialized, historical blocks will be backfilled when starting the node", + missingSlots + + notice "Done, your beacon node is ready to serve you! Don't forget to check that you're on the canoncial chain by comparing the checkpoint root with other online sources. See https://nimbus.guide/trusted-node-sync.html for more information.", + checkpointRoot = checkpointBlock.root + +when isMainModule: + let backfill = os.paramCount() > 3 and os.paramStr(4) == "true" + + waitFor doTrustedNodeSync( + defaultRuntimeConfig, os.paramStr(1), os.paramStr(2), os.paramStr(3), + backfill) diff --git a/docs/the_nimbus_book/src/trusted-node-sync.md b/docs/the_nimbus_book/src/trusted-node-sync.md new file mode 100644 index 000000000..bfede8d8b --- /dev/null +++ b/docs/the_nimbus_book/src/trusted-node-sync.md @@ -0,0 +1,75 @@ +# Trusted node sync + +When you start the beacon node for the first time, it will connect to the beacon chain network and start syncing automatically, a process that can take several days. + +Trusted node sync allows getting started more quickly by syncing with a single trusted node. + +To use trusted node sync, you must have access to a node that you trust completely that exposes the REST HTTP API. Should this node or your connection to it be compromised, your node will not be able to detect an attack, thus it is important that you use a node and a connection that you trust, for example a locally running node or an SSH tunnel. + +## Verifying that you synced the correct chain + +When performing a trusted node sync, you can manually verify that the correct chain was synced by comparing the head hash with other sources, such as friends, forums, chats and web sites. You can retrieve the current head from the node using: + +``` +# Make sure to enabled the `--rest` option when running your node: + +curl http://localhost:5052/eth/v1/beacon/blocks/head/root +``` + +The `head` root is also printed in the log output at regular intervals. + +## Performing a trusted node sync + +**Prater (testnet)** + +```bash +build/nimbus_beacon_node trustedNodeSync --network:prater \ + --data-dir=build/data/shared_prater_0 \ + --trusted-node-url=http://localhost:5052 +``` + +**Mainnet** + +```bash +build/nimbus_beacon_node trustedNodeSync --network:mainnet \ + --data-dir=build/data/shared_mainnet_0 \ + --trusted-node-url=http://localhost:5052 +``` + +**NOTE** + +Because trusted node sync by default copies all blocks via REST, if you use a service such as Infura, you might hit API limits - see the `--backfill` option. + +## Block history + +By default, both the state and the full block history will be downloaded from the trusted node. + +It is possible to get started more quickly by delaying the backfill of the block history using the `--backfill=false` parameter. In this case, the beacon node will first sync to the current head so that it can start performing its duties, then backfill the blocks from the network. + +While backfilling blocks from the network, the node will not be conforming to the protocol and may be disconnected or lose reputation with other nodes. + +## Sync point + +By default, the node will sync up to the latest finalized checkpoint of the node that you're syncing with. You can choose a different sync point using a block hash or a slot number - this block must fall on an epoch boundary: + +``` +build/nimbus_beacon_node trustedNodeSync --blockId:0x239940f2537f5bbee1a3829f9058f4c04f49897e4d325145153ca89838dfc9e2 ... + +``` + +## Sync from checkpoint files + +If you have a state and a block file available, you can instead start the node using the finalized checkpoint options: + +``` +# Obtain a state and a block from a REST API - these must be in SSZ format: + +wget -o state.32000.ssz http://localhost:5052/eth/v2/debug/beacon/states/32000 +wget -o block.32000.ssz http://localhost:5052/eth/v2/beacon/blocks/32000 + +build/nimbus_beacon_node --data-dir:trusted --finalized-checkpoint-block=block.32000.ssz --finalized-checkpoint-state=state.32000.ssz +``` + +## Caveats + +A node synced using trusted node sync will not be able to serve historical requests from before the checkpoint. Future versions will resolve this issue.