feat: checkpoint fallbacks (#120)

* 🏗️ checkpoint fallback initial impl

* 🚧 checkpoint fallbacks

*  checkpoint fallbacks

* ⚙️ fix result types

* ♻️ checkpoints refactoring

* 🔨 import nits

* 🚀 graceful checkpoint fallbacks

*  parallel checkpoint fallback service fetching using async tokio tasks

* 📝 readme touchups
This commit is contained in:
andreas 2022-12-01 17:18:23 -08:00 committed by GitHub
parent 4d721e86c3
commit 161e0fbfb9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 802 additions and 225 deletions

29
Cargo.lock generated
View File

@ -597,10 +597,16 @@ dependencies = [
"ethers", "ethers",
"eyre", "eyre",
"figment", "figment",
"futures",
"hex", "hex",
"log",
"reqwest",
"serde", "serde",
"serde_yaml",
"ssz-rs", "ssz-rs",
"strum",
"thiserror", "thiserror",
"tokio",
] ]
[[package]] [[package]]
@ -2900,9 +2906,9 @@ dependencies = [
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.11.12" version = "0.11.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "431949c384f4e2ae07605ccaa56d1d9d2ecdb5cadd4f9577ccfab29f2e5149fc" checksum = "68cc60575865c7831548863cc02356512e3f1dc2f3f82cb837d7fc4cc8f3c97c"
dependencies = [ dependencies = [
"base64 0.13.1", "base64 0.13.1",
"bytes", "bytes",
@ -3380,6 +3386,19 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_yaml"
version = "0.9.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d232d893b10de3eb7258ff01974d6ee20663d8e833263c99409d4b13a0209da"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]] [[package]]
name = "sha-1" name = "sha-1"
version = "0.9.8" version = "0.9.8"
@ -3980,6 +3999,12 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"
[[package]]
name = "unsafe-libyaml"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1e5fa573d8ac5f1a856f8d7be41d390ee973daf97c806b2c1a465e4e1406e68"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.7.1" version = "0.7.1"

View File

@ -1,4 +1,5 @@
## Helios ## Helios
[![build](https://github.com/a16z/helios/actions/workflows/test.yml/badge.svg)](https://github.com/a16z/helios/actions/workflows/test.yml) [![license: MIT](https://img.shields.io/badge/license-MIT-brightgreen.svg)](https://opensource.org/licenses/MIT) [![chat](https://img.shields.io/badge/chat-telegram-blue)](https://t.me/+IntDY_gZJSRkNTJj) [![build](https://github.com/a16z/helios/actions/workflows/test.yml/badge.svg)](https://github.com/a16z/helios/actions/workflows/test.yml) [![license: MIT](https://img.shields.io/badge/license-MIT-brightgreen.svg)](https://opensource.org/licenses/MIT) [![chat](https://img.shields.io/badge/chat-telegram-blue)](https://t.me/+IntDY_gZJSRkNTJj)
Helios is a fully trustless, efficient, and portable Ethereum light client written in Rust. Helios is a fully trustless, efficient, and portable Ethereum light client written in Rust.
@ -8,14 +9,19 @@ Helios converts an untrusted centralized RPC endpoint into a safe unmanipulable
The entire size of Helios's binary is 13Mb and should be easy to compile into WebAssembly. This makes it a perfect target to embed directly inside wallets and dapps. The entire size of Helios's binary is 13Mb and should be easy to compile into WebAssembly. This makes it a perfect target to embed directly inside wallets and dapps.
## Installing ## Installing
First install `heliosup`, Helios's installer: First install `heliosup`, Helios's installer:
``` ```
curl https://raw.githubusercontent.com/a16z/helios/master/heliosup/install | bash curl https://raw.githubusercontent.com/a16z/helios/master/heliosup/install | bash
``` ```
To install Helios, run `heliosup`. To install Helios, run `heliosup`.
## Usage ## Usage
To run Helios, run the below command, replacing `$ETH_RPC_URL` with an RPC provider URL such as Alchemy or Infura: To run Helios, run the below command, replacing `$ETH_RPC_URL` with an RPC provider URL such as Alchemy or Infura:
``` ```
helios --execution-rpc $ETH_RPC_URL helios --execution-rpc $ETH_RPC_URL
``` ```
@ -25,9 +31,11 @@ helios --execution-rpc $ETH_RPC_URL
Helios will now run a local RPC server at `http://127.0.0.1:8545`. Helios will now run a local RPC server at `http://127.0.0.1:8545`.
### Warning ### Warning
Helios is still experimental software. While we hope you try it out, we do not suggest adding it as your main RPC in wallets yet. Sending high-value transactions from a wallet connected to Helios is discouraged. Helios is still experimental software. While we hope you try it out, we do not suggest adding it as your main RPC in wallets yet. Sending high-value transactions from a wallet connected to Helios is discouraged.
### Additional Options ### Additional Options
`--consensus-rpc` or `-c` can be used to set a custom consensus layer rpc endpoint. This must be a consenus node that supports the light client beaconchain api. We recommend using Nimbus for this. If no consensus rpc is supplied, it defaults to `https://www.lightclientdata.org` which is run by us. `--consensus-rpc` or `-c` can be used to set a custom consensus layer rpc endpoint. This must be a consenus node that supports the light client beaconchain api. We recommend using Nimbus for this. If no consensus rpc is supplied, it defaults to `https://www.lightclientdata.org` which is run by us.
`--checkpoint` or `-w` can be used to set a custom weak subjectivity checkpoint. This must be equal the first beacon blockhash of an epoch. Weak subjectivity checkpoints are the root of trust in the system. If this is set to a malicious value, an attacker can cause the client to sync to the wrong chain. Helios sets a default value initially, then caches the most recent finalized block it has seen for later use. `--checkpoint` or `-w` can be used to set a custom weak subjectivity checkpoint. This must be equal the first beacon blockhash of an epoch. Weak subjectivity checkpoints are the root of trust in the system. If this is set to a malicious value, an attacker can cause the client to sync to the wrong chain. Helios sets a default value initially, then caches the most recent finalized block it has seen for later use.
@ -38,8 +46,25 @@ Helios is still experimental software. While we hope you try it out, we do not s
`--data-dir` or `-d` sets the directory that Helios should use to store cached weak subjectivity checkpoints in. Each network only stores the latest checkpoint, which is just 32 bytes. `--data-dir` or `-d` sets the directory that Helios should use to store cached weak subjectivity checkpoints in. Each network only stores the latest checkpoint, which is just 32 bytes.
`--fallback` or `-f` sets the checkpoint fallback url (a string). This is only used if the checkpoint provided by the `--checkpoint` flag is too outdated for Helios to use to sync.
If none is provided and the `--load-external-fallback` flag is not set, Helios will error.
For example, you can specify the fallback like so: `helios --fallback "https://sync-mainnet.beaconcha.in"` (or using shorthand like so: `helios -f "https://sync-mainnet.beaconcha.in"`)
`--load-external-fallback` or `-l` enables weak subjectivity checkpoint fallback (no value needed).
For example, say you set a checkpoint value that is too outdated and Helios cannot sync to it.
If this flag is set, Helios will query all network apis in the community-maintained list
at [ethpandaops/checkpoint-synz-health-checks](https://github.com/ethpandaops/checkpoint-sync-health-checks/blob/master/_data/endpoints.yaml) for their latest slots.
The list of slots is filtered for healthy apis and the most frequent checkpoint occuring in the latest epoch will be returned.
Note: this is a community-maintained list and thus no security guarantees are provided. Use this is a last resort if your checkpoint passed into `--checkpoint` fails.
This is not recommened as malicious checkpoints can be returned from the listed apis, even if they are considered _healthy_.
This can be run like so: `helios --load-external-fallback` (or `helios -l` with the shorthand).
`--help` or `-h` prints the help message.
### Configuration Files ### Configuration Files
All configuration options can be set on a per-network level in `~/.helios/helios.toml`. Here is an example config file: All configuration options can be set on a per-network level in `~/.helios/helios.toml`. Here is an example config file:
```toml ```toml
[mainnet] [mainnet]
consensus_rpc = "https://www.lightclientdata.org" consensus_rpc = "https://www.lightclientdata.org"
@ -53,6 +78,7 @@ checkpoint = "0xb5c375696913865d7c0e166d87bc7c772b6210dc9edf149f4c7ddc6da0dd4495
``` ```
### Using Helios as a Library ### Using Helios as a Library
Helios can be imported into any Rust project. Helios requires the Rust nightly toolchain to compile. Helios can be imported into any Rust project. Helios requires the Rust nightly toolchain to compile.
```rust ```rust
@ -86,11 +112,39 @@ async fn main() -> Result<()> {
} }
``` ```
Below we demonstrate fetching checkpoints from the community-maintained list of checkpoint sync apis maintained by [ethPandaOps](https://github.com/ethpandaops/checkpoint-sync-health-checks/blob/master/_data/endpoints.yaml).
> **Warning**
>
> This is a community-maintained list and thus no security guarantees are provided. Attacks on your light client can occur if malicious checkpoints are set in the list. Please use the explicit `checkpoint` flag, environment variable, or config setting with an updated, and verified checkpoint.
```rust
use eyre::Result;
use helios::config::{checkpoints, networks};
#[tokio::main]
async fn main() -> Result<()> {
// Construct the checkpoint fallback services
let cf = checkpoints::CheckpointFallback::new().build().await.unwrap();
// Fetch the latest goerli checkpoint
let goerli_checkpoint = cf.fetch_latest_checkpoint(&networks::Network::GOERLI).await.unwrap();
println!("Fetched latest goerli checkpoint: {}", goerli_checkpoint);
// Fetch the latest mainnet checkpoint
let mainnet_checkpoint = cf.fetch_latest_checkpoint(&networks::Network::MAINNET).await.unwrap();
println!("Fetched latest mainnet checkpoint: {}", mainnet_checkpoint);
}
```
## Contributing ## Contributing
All contributions to Helios are welcome. Before opening a PR, please submit an issue detailing the bug or feature. When opening a PR, please ensure that your contribution builds on the nightly rust toolchain, has been linted with `cargo fmt`, and contains tests when applicable. All contributions to Helios are welcome. Before opening a PR, please submit an issue detailing the bug or feature. When opening a PR, please ensure that your contribution builds on the nightly rust toolchain, has been linted with `cargo fmt`, and contains tests when applicable.
## Telegram ## Telegram
If you are having trouble with Helios or are considering contributing, feel free to join our telegram [here](https://t.me/+IntDY_gZJSRkNTJj). If you are having trouble with Helios or are considering contributing, feel free to join our telegram [here](https://t.me/+IntDY_gZJSRkNTJj).
## Disclaimer ## Disclaimer
_This code is being provided as is. No guarantee, representation or warranty is being made, express or implied, as to the safety or correctness of the code. It has not been audited and as such there can be no assurance it will work as intended, and users may experience delays, failures, errors, omissions or loss of transmitted information. Nothing in this repo should be construed as investment advice or legal advice for any particular facts or circumstances and is not meant to replace competent counsel. It is strongly advised for you to contact a reputable attorney in your jurisdiction for any questions or concerns with respect thereto. a16z is not liable for any use of the foregoing, and users should proceed with caution and use at their own risk. See a16z.com/disclosures for more info._ _This code is being provided as is. No guarantee, representation or warranty is being made, express or implied, as to the safety or correctness of the code. It has not been audited and as such there can be no assurance it will work as intended, and users may experience delays, failures, errors, omissions or loss of transmitted information. Nothing in this repo should be construed as investment advice or legal advice for any particular facts or circumstances and is not meant to replace competent counsel. It is strongly advised for you to contact a reputable attorney in your jurisdiction for any questions or concerns with respect thereto. a16z is not liable for any use of the foregoing, and users should proceed with caution and use at their own risk. See a16z.com/disclosures for more info._

View File

@ -85,6 +85,10 @@ struct Cli {
consensus_rpc: Option<String>, consensus_rpc: Option<String>,
#[clap(short, long, env)] #[clap(short, long, env)]
data_dir: Option<String>, data_dir: Option<String>,
#[clap(short = 'f', long, env)]
fallback: Option<String>,
#[clap(short = 'l', long, env)]
load_external_fallback: bool,
} }
impl Cli { impl Cli {
@ -100,6 +104,8 @@ impl Cli {
consensus_rpc: self.consensus_rpc.clone(), consensus_rpc: self.consensus_rpc.clone(),
data_dir: self.get_data_dir(), data_dir: self.get_data_dir(),
rpc_port: self.rpc_port, rpc_port: self.rpc_port,
fallback: self.fallback.clone(),
load_external_fallback: self.load_external_fallback,
} }
} }

View File

@ -7,8 +7,8 @@ use ethers::types::{Filter, Log, Transaction, TransactionReceipt, H256};
use eyre::{eyre, Result}; use eyre::{eyre, Result};
use common::types::BlockTag; use common::types::BlockTag;
use config::Config; use config::{CheckpointFallback, Config};
use consensus::types::Header; use consensus::{types::Header, ConsensusClient};
use execution::types::{CallOpts, ExecutionBlock}; use execution::types::{CallOpts, ExecutionBlock};
use log::{info, warn}; use log::{info, warn};
use tokio::spawn; use tokio::spawn;
@ -19,27 +19,6 @@ use crate::database::{Database, FileDB};
use crate::node::Node; use crate::node::Node;
use crate::rpc::Rpc; use crate::rpc::Rpc;
pub struct Client<DB: Database> {
node: Arc<RwLock<Node>>,
rpc: Option<Rpc>,
db: Option<DB>,
}
impl Client<FileDB> {
fn new(config: Config) -> Result<Self> {
let config = Arc::new(config);
let node = Node::new(config.clone())?;
let node = Arc::new(RwLock::new(node));
let rpc = config.rpc_port.map(|port| Rpc::new(node.clone(), port));
let data_dir = config.data_dir.clone();
let db = data_dir.map(FileDB::new);
Ok(Client { node, rpc, db })
}
}
#[derive(Default)] #[derive(Default)]
pub struct ClientBuilder { pub struct ClientBuilder {
network: Option<Network>, network: Option<Network>,
@ -49,6 +28,8 @@ pub struct ClientBuilder {
rpc_port: Option<u16>, rpc_port: Option<u16>,
data_dir: Option<PathBuf>, data_dir: Option<PathBuf>,
config: Option<Config>, config: Option<Config>,
fallback: Option<String>,
load_external_fallback: bool,
} }
impl ClientBuilder { impl ClientBuilder {
@ -92,6 +73,16 @@ impl ClientBuilder {
self self
} }
pub fn fallback(mut self, fallback: &str) -> Self {
self.fallback = Some(fallback.to_string());
self
}
pub fn load_external_fallback(mut self) -> Self {
self.load_external_fallback = true;
self
}
pub fn build(self) -> Result<Client<FileDB>> { pub fn build(self) -> Result<Client<FileDB>> {
let base_config = if let Some(network) = self.network { let base_config = if let Some(network) = self.network {
network.to_base_config() network.to_base_config()
@ -143,6 +134,20 @@ impl ClientBuilder {
None None
}; };
let fallback = if self.fallback.is_some() {
self.fallback
} else if let Some(config) = &self.config {
config.fallback.clone()
} else {
None
};
let load_external_fallback = if let Some(config) = &self.config {
self.load_external_fallback || config.load_external_fallback
} else {
self.load_external_fallback
};
let config = Config { let config = Config {
consensus_rpc, consensus_rpc,
execution_rpc, execution_rpc,
@ -152,21 +157,60 @@ impl ClientBuilder {
chain: base_config.chain, chain: base_config.chain,
forks: base_config.forks, forks: base_config.forks,
max_checkpoint_age: base_config.max_checkpoint_age, max_checkpoint_age: base_config.max_checkpoint_age,
fallback,
load_external_fallback,
}; };
Client::new(config) Client::new(config)
} }
} }
pub struct Client<DB: Database> {
node: Arc<RwLock<Node>>,
rpc: Option<Rpc>,
db: Option<DB>,
fallback: Option<String>,
load_external_fallback: bool,
}
impl Client<FileDB> {
fn new(config: Config) -> Result<Self> {
let config = Arc::new(config);
let node = Node::new(config.clone())?;
let node = Arc::new(RwLock::new(node));
let rpc = config.rpc_port.map(|port| Rpc::new(node.clone(), port));
let data_dir = config.data_dir.clone();
let db = data_dir.map(FileDB::new);
Ok(Client {
node,
rpc,
db,
fallback: config.fallback.clone(),
load_external_fallback: config.load_external_fallback,
})
}
}
impl<DB: Database> Client<DB> { impl<DB: Database> Client<DB> {
pub async fn start(&mut self) -> Result<()> { pub async fn start(&mut self) -> Result<()> {
if let Some(rpc) = &mut self.rpc { if let Some(rpc) = &mut self.rpc {
rpc.start().await?; rpc.start().await?;
} }
let res = self.node.write().await.sync().await; if self.node.write().await.sync().await.is_err() {
if let Err(err) = res { warn!(
warn!("consensus error: {}", err); "failed to sync consensus node with checkpoint: 0x{}",
hex::encode(&self.node.read().await.config.checkpoint),
);
let fallback = self.boot_from_fallback().await;
if fallback.is_err() && self.load_external_fallback {
self.boot_from_external_fallbacks().await?
} else if fallback.is_err() {
return Err(eyre::eyre!("Checkpoint is too old. Please update your checkpoint. Alternatively, set an explicit checkpoint fallback service url with the `-f` flag or use the configured external fallback services with `-l` (NOT RECOMMENED). See https://github.com/a16z/helios#additional-options for more information."));
}
} }
let node = self.node.clone(); let node = self.node.clone();
@ -185,6 +229,75 @@ impl<DB: Database> Client<DB> {
Ok(()) Ok(())
} }
async fn boot_from_fallback(&self) -> eyre::Result<()> {
if let Some(fallback) = &self.fallback {
info!(
"attempting to load checkpoint from fallback \"{}\"",
fallback
);
let checkpoint = CheckpointFallback::fetch_checkpoint_from_api(fallback)
.await
.map_err(|_| {
eyre::eyre!("Failed to fetch checkpoint from fallback \"{}\"", fallback)
})?;
info!(
"external fallbacks responded with checkpoint 0x{:?}",
checkpoint
);
// Try to sync again with the new checkpoint by reconstructing the consensus client
// We fail fast here since the node is unrecoverable at this point
let config = self.node.read().await.config.clone();
let consensus =
ConsensusClient::new(&config.consensus_rpc, checkpoint.as_bytes(), config.clone())?;
self.node.write().await.consensus = consensus;
self.node.write().await.sync().await?;
Ok(())
} else {
Err(eyre::eyre!("no explicit fallback specified"))
}
}
async fn boot_from_external_fallbacks(&self) -> eyre::Result<()> {
info!("attempting to fetch checkpoint from external fallbacks...");
// Build the list of external checkpoint fallback services
let list = CheckpointFallback::new()
.build()
.await
.map_err(|_| eyre::eyre!("Failed to construct external checkpoint sync fallbacks"))?;
let checkpoint = if self.node.read().await.config.chain.chain_id == 5 {
list.fetch_latest_checkpoint(&Network::GOERLI)
.await
.map_err(|_| {
eyre::eyre!("Failed to fetch latest goerli checkpoint from external fallbacks")
})?
} else {
list.fetch_latest_checkpoint(&Network::MAINNET)
.await
.map_err(|_| {
eyre::eyre!("Failed to fetch latest mainnet checkpoint from external fallbacks")
})?
};
info!(
"external fallbacks responded with checkpoint {:?}",
checkpoint
);
// Try to sync again with the new checkpoint by reconstructing the consensus client
// We fail fast here since the node is unrecoverable at this point
let config = self.node.read().await.config.clone();
let consensus =
ConsensusClient::new(&config.consensus_rpc, checkpoint.as_bytes(), config.clone())?;
self.node.write().await.consensus = consensus;
self.node.write().await.sync().await?;
Ok(())
}
pub async fn shutdown(&self) { pub async fn shutdown(&self) {
let node = self.node.read().await; let node = self.node.read().await;
let checkpoint = if let Some(checkpoint) = node.get_last_checkpoint() { let checkpoint = if let Some(checkpoint) = node.get_last_checkpoint() {

View File

@ -20,12 +20,12 @@ use execution::ExecutionClient;
use crate::errors::NodeError; use crate::errors::NodeError;
pub struct Node { pub struct Node {
consensus: ConsensusClient<NimbusRpc>, pub consensus: ConsensusClient<NimbusRpc>,
execution: Arc<ExecutionClient<HttpRpc>>, pub execution: Arc<ExecutionClient<HttpRpc>>,
config: Arc<Config>, pub config: Arc<Config>,
payloads: BTreeMap<u64, ExecutionPayload>, payloads: BTreeMap<u64, ExecutionPayload>,
finalized_payloads: BTreeMap<u64, ExecutionPayload>, finalized_payloads: BTreeMap<u64, ExecutionPayload>,
history_size: usize, pub history_size: usize,
} }
impl Node { impl Node {

View File

@ -5,6 +5,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
tokio = { version = "1", features = ["full"] }
eyre = "0.6.8" eyre = "0.6.8"
serde = { version = "1.0.143", features = ["derive"] } serde = { version = "1.0.143", features = ["derive"] }
hex = "0.4.3" hex = "0.4.3"
@ -12,5 +13,12 @@ ssz-rs = { git = "https://github.com/ralexstokes/ssz-rs", rev = "cb08f18ca919cc1
ethers = "1.0.0" ethers = "1.0.0"
figment = { version = "0.10.7", features = ["toml", "env"] } figment = { version = "0.10.7", features = ["toml", "env"] }
thiserror = "1.0.37" thiserror = "1.0.37"
log = "0.4.17"
common = { path = "../common" } common = { path = "../common" }
reqwest = "0.11.13"
serde_yaml = "0.9.14"
strum = "0.24.1"
futures = "0.3.25"

19
config/src/base.rs Normal file
View File

@ -0,0 +1,19 @@
use serde::Serialize;
use crate::types::{ChainConfig, Forks};
use crate::utils::bytes_serialize;
/// The base configuration for a network.
#[derive(Serialize, Default)]
pub struct BaseConfig {
pub rpc_port: u16,
pub consensus_rpc: Option<String>,
#[serde(
deserialize_with = "bytes_deserialize",
serialize_with = "bytes_serialize"
)]
pub checkpoint: Vec<u8>,
pub chain: ChainConfig,
pub forks: Forks,
pub max_checkpoint_age: u64,
}

232
config/src/checkpoints.rs Normal file
View File

@ -0,0 +1,232 @@
use std::collections::HashMap;
use ethers::types::H256;
use serde::{Deserialize, Serialize};
use crate::networks;
/// The location where the list of checkpoint services are stored.
pub const CHECKPOINT_SYNC_SERVICES_LIST: &str = "https://raw.githubusercontent.com/ethpandaops/checkpoint-sync-health-checks/master/_data/endpoints.yaml";
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RawSlotResponse {
pub data: RawSlotResponseData,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RawSlotResponseData {
pub slots: Vec<Slot>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Slot {
pub slot: u64,
pub block_root: Option<H256>,
pub state_root: Option<H256>,
pub epoch: u64,
pub time: StartEndTime,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StartEndTime {
/// An ISO 8601 formatted UTC timestamp.
pub start_time: String,
/// An ISO 8601 formatted UTC timestamp.
pub end_time: String,
}
/// A health check for the checkpoint sync service.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Health {
/// If the node is healthy.
pub result: bool,
/// An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) UTC timestamp.
pub date: String,
}
/// A checkpoint fallback service.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckpointFallbackService {
/// The endpoint for the checkpoint sync service.
pub endpoint: String,
/// The checkpoint sync service name.
pub name: String,
/// The service state.
pub state: bool,
/// If the service is verified.
pub verification: bool,
/// Contact information for the service maintainers.
pub contacts: Option<serde_yaml::Value>,
/// Service Notes
pub notes: Option<serde_yaml::Value>,
/// The service health check.
pub health: Vec<Health>,
}
/// The CheckpointFallback manages checkpoint fallback services.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckpointFallback {
/// Services Map
pub services: HashMap<networks::Network, Vec<CheckpointFallbackService>>,
/// A list of supported networks to build.
/// Default: [mainnet, goerli]
pub networks: Vec<networks::Network>,
}
impl CheckpointFallback {
/// Constructs a new checkpoint fallback service.
pub fn new() -> Self {
Self {
services: Default::default(),
networks: [networks::Network::MAINNET, networks::Network::GOERLI].to_vec(),
}
}
/// Build the checkpoint fallback service from the community-maintained list by [ethPandaOps](https://github.com/ethpandaops).
///
/// The list is defined in [ethPandaOps/checkpoint-fallback-service](https://github.com/ethpandaops/checkpoint-sync-health-checks/blob/master/_data/endpoints.yaml).
pub async fn build(mut self) -> eyre::Result<Self> {
// Fetch the services
let client = reqwest::Client::new();
let res = client.get(CHECKPOINT_SYNC_SERVICES_LIST).send().await?;
let yaml = res.text().await?;
// Parse the yaml content results.
let list: serde_yaml::Value = serde_yaml::from_str(&yaml)?;
// Construct the services mapping from network <> list of services
let mut services = HashMap::new();
for network in &self.networks {
// Try to parse list of checkpoint fallback services
let service_list = list
.get(network.to_string().to_lowercase())
.ok_or_else(|| {
eyre::eyre!(format!("missing {} fallback checkpoint services", network))
})?;
let parsed: Vec<CheckpointFallbackService> =
serde_yaml::from_value(service_list.clone())?;
services.insert(*network, parsed);
}
self.services = services;
Ok(self)
}
/// Fetch the latest checkpoint from the checkpoint fallback service.
pub async fn fetch_latest_checkpoint(
&self,
network: &crate::networks::Network,
) -> eyre::Result<H256> {
let services = &self.services[network];
Self::fetch_latest_checkpoint_from_services(&services[..]).await
}
/// Fetch the latest checkpoint from a list of checkpoint fallback services.
pub async fn fetch_latest_checkpoint_from_services(
services: &[CheckpointFallbackService],
) -> eyre::Result<H256> {
// Iterate over all mainnet checkpoint sync services and get the latest checkpoint slot for each.
let tasks: Vec<_> = services
.iter()
.map(|service| {
let service = service.clone();
tokio::spawn(async move {
let client = reqwest::Client::new();
let constructed_url = Self::construct_url(&service.endpoint);
let res = client.get(&constructed_url).send().await?;
let raw: RawSlotResponse = res.json().await?;
if raw.data.slots.is_empty() {
return Err(eyre::eyre!("no slots"));
}
Ok(raw.data.slots[0].clone())
})
})
.collect();
let slots = futures::future::join_all(tasks)
.await
.iter()
.filter_map(|slot| match &slot {
Ok(Ok(s)) => Some(s.clone()),
_ => None,
})
.collect::<Vec<_>>();
// Get the max epoch
let max_epoch_slot = slots.iter().max_by_key(|x| x.epoch).ok_or(eyre::eyre!(
"Failed to find max epoch from checkpoint slots"
))?;
let max_epoch = max_epoch_slot.epoch;
// Filter out all the slots that are not the max epoch.
let slots = slots
.into_iter()
.filter(|x| x.epoch == max_epoch)
.collect::<Vec<_>>();
// Return the most commonly verified checkpoint.
let checkpoints = slots
.iter()
.filter_map(|x| x.block_root)
.collect::<Vec<_>>();
let mut m: HashMap<H256, usize> = HashMap::new();
for c in checkpoints {
*m.entry(c).or_default() += 1;
}
let most_common = m.into_iter().max_by_key(|(_, v)| *v).map(|(k, _)| k);
// Return the most commonly verified checkpoint for the latest epoch.
most_common.ok_or_else(|| eyre::eyre!("No checkpoint found"))
}
/// Associated function to fetch the latest checkpoint from a specific checkpoint sync fallback
/// service api url.
pub async fn fetch_checkpoint_from_api(url: &str) -> eyre::Result<H256> {
// Fetch the url
let client = reqwest::Client::new();
let constructed_url = Self::construct_url(url);
let res = client.get(constructed_url).send().await?;
let raw: RawSlotResponse = res.json().await?;
let slot = raw.data.slots[0].clone();
slot.block_root
.ok_or_else(|| eyre::eyre!("Checkpoint not in returned slot"))
}
/// Constructs the checkpoint fallback service url for fetching a slot.
///
/// This is an associated function and can be used like so:
///
/// ```rust
/// use config::CheckpointFallback;
///
/// let url = CheckpointFallback::construct_url("https://sync-mainnet.beaconcha.in");
/// assert_eq!("https://sync-mainnet.beaconcha.in/checkpointz/v1/beacon/slots", url);
/// ```
pub fn construct_url(endpoint: &str) -> String {
format!("{}/checkpointz/v1/beacon/slots", endpoint)
}
/// Returns a list of all checkpoint fallback endpoints.
///
/// ### Warning
///
/// These services are not healthchecked **nor** trustworthy and may act with malice by returning invalid checkpoints.
pub fn get_all_fallback_endpoints(&self, network: &networks::Network) -> Vec<String> {
self.services[network]
.iter()
.map(|service| service.endpoint.clone())
.collect()
}
/// Returns a list of healthchecked checkpoint fallback endpoints.
///
/// ### Warning
///
/// These services are not trustworthy and may act with malice by returning invalid checkpoints.
pub fn get_healthy_fallback_endpoints(&self, network: &networks::Network) -> Vec<String> {
self.services[network]
.iter()
.filter(|service| service.state)
.map(|service| service.endpoint.clone())
.collect()
}
}

51
config/src/cli.rs Normal file
View File

@ -0,0 +1,51 @@
use std::{collections::HashMap, path::PathBuf};
use figment::{providers::Serialized, value::Value};
use serde::Serialize;
/// Cli Config
#[derive(Serialize)]
pub struct CliConfig {
pub execution_rpc: Option<String>,
pub consensus_rpc: Option<String>,
pub checkpoint: Option<Vec<u8>>,
pub rpc_port: Option<u16>,
pub data_dir: PathBuf,
pub fallback: Option<String>,
pub load_external_fallback: bool,
}
impl CliConfig {
pub fn as_provider(&self, network: &str) -> Serialized<HashMap<&str, Value>> {
let mut user_dict = HashMap::new();
if let Some(rpc) = &self.execution_rpc {
user_dict.insert("execution_rpc", Value::from(rpc.clone()));
}
if let Some(rpc) = &self.consensus_rpc {
user_dict.insert("consensus_rpc", Value::from(rpc.clone()));
}
if let Some(checkpoint) = &self.checkpoint {
user_dict.insert("checkpoint", Value::from(hex::encode(checkpoint)));
}
if let Some(port) = self.rpc_port {
user_dict.insert("rpc_port", Value::from(port));
}
user_dict.insert("data_dir", Value::from(self.data_dir.to_str().unwrap()));
if let Some(fallback) = &self.fallback {
user_dict.insert("fallback", Value::from(fallback.clone()));
}
user_dict.insert(
"load_external_fallback",
Value::from(self.load_external_fallback),
);
Serialized::from(user_dict, network)
}
}

100
config/src/config.rs Normal file
View File

@ -0,0 +1,100 @@
use figment::{
providers::{Format, Serialized, Toml},
Figment,
};
use serde::{Deserialize, Serialize};
use std::{path::PathBuf, process::exit};
use crate::base::BaseConfig;
use crate::cli::CliConfig;
use crate::networks;
use crate::types::{ChainConfig, Forks};
use crate::utils::{bytes_deserialize, bytes_serialize};
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct Config {
pub consensus_rpc: String,
pub execution_rpc: String,
pub rpc_port: Option<u16>,
#[serde(
deserialize_with = "bytes_deserialize",
serialize_with = "bytes_serialize"
)]
pub checkpoint: Vec<u8>,
pub data_dir: Option<PathBuf>,
pub chain: ChainConfig,
pub forks: Forks,
pub max_checkpoint_age: u64,
pub fallback: Option<String>,
pub load_external_fallback: bool,
}
impl Config {
pub fn from_file(config_path: &PathBuf, network: &str, cli_config: &CliConfig) -> Self {
let base_config = match network {
"mainnet" => networks::mainnet(),
"goerli" => networks::goerli(),
_ => BaseConfig::default(),
};
let base_provider = Serialized::from(base_config, network);
let toml_provider = Toml::file(config_path).nested();
let cli_provider = cli_config.as_provider(network);
let config_res = Figment::new()
.merge(base_provider)
.merge(toml_provider)
.merge(cli_provider)
.select(network)
.extract();
match config_res {
Ok(config) => config,
Err(err) => {
match err.kind {
figment::error::Kind::MissingField(field) => {
let field = field.replace('_', "-");
println!(
"\x1b[91merror\x1b[0m: missing configuration field: {}",
field
);
println!(
"\n\ttry supplying the propoper command line argument: --{}",
field
);
println!("\talternatively, you can add the field to your helios.toml file or as an environment variable");
println!("\nfor more information, check the github README");
}
_ => println!("cannot parse configuration: {}", err),
}
exit(1);
}
}
}
pub fn fork_version(&self, slot: u64) -> Vec<u8> {
let epoch = slot / 32;
if epoch >= self.forks.bellatrix.epoch {
self.forks.bellatrix.fork_version.clone()
} else if epoch >= self.forks.altair.epoch {
self.forks.altair.fork_version.clone()
} else {
self.forks.genesis.fork_version.clone()
}
}
pub fn to_base_config(&self) -> BaseConfig {
BaseConfig {
rpc_port: self.rpc_port.unwrap_or(8545),
consensus_rpc: Some(self.consensus_rpc.clone()),
checkpoint: self.checkpoint.clone(),
chain: self.chain.clone(),
forks: self.forks.clone(),
max_checkpoint_age: self.max_checkpoint_age,
}
}
}

View File

@ -1,179 +1,26 @@
/// Base Config
pub mod base;
pub use base::*;
/// Core Config
pub mod config;
pub use crate::config::*;
/// Checkpoint Config
pub mod checkpoints;
pub use checkpoints::*;
/// Cli Config
pub mod cli;
pub use cli::*;
/// Network Configuration
pub mod networks; pub mod networks;
pub use networks::*;
use std::{collections::HashMap, path::PathBuf, process::exit}; /// Generic Config Types
pub mod types;
pub use types::*;
use eyre::Result; /// Generic Utilities
use figment::{ pub mod utils;
providers::{Format, Serialized, Toml},
value::Value,
Figment,
};
use networks::BaseConfig;
use serde::{Deserialize, Serialize};
use common::utils::hex_str_to_bytes;
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct Config {
pub consensus_rpc: String,
pub execution_rpc: String,
pub rpc_port: Option<u16>,
#[serde(
deserialize_with = "bytes_deserialize",
serialize_with = "bytes_serialize"
)]
pub checkpoint: Vec<u8>,
pub data_dir: Option<PathBuf>,
pub chain: ChainConfig,
pub forks: Forks,
pub max_checkpoint_age: u64,
}
impl Config {
pub fn from_file(config_path: &PathBuf, network: &str, cli_config: &CliConfig) -> Self {
let base_config = match network {
"mainnet" => networks::mainnet(),
"goerli" => networks::goerli(),
_ => BaseConfig::default(),
};
let base_provider = Serialized::from(base_config, network);
let toml_provider = Toml::file(config_path).nested();
let cli_provider = cli_config.as_provider(network);
let config_res = Figment::new()
.merge(base_provider)
.merge(toml_provider)
.merge(cli_provider)
.select(network)
.extract();
match config_res {
Ok(config) => config,
Err(err) => {
match err.kind {
figment::error::Kind::MissingField(field) => {
let field = field.replace('_', "-");
println!(
"\x1b[91merror\x1b[0m: missing configuration field: {}",
field
);
println!(
"\n\ttry supplying the propoper command line argument: --{}",
field
);
println!("\talternatively, you can add the field to your helios.toml file or as an environment variable");
println!("\nfor more information, check the github README");
}
_ => println!("cannot parse configuration: {}", err),
}
exit(1);
}
}
}
pub fn fork_version(&self, slot: u64) -> Vec<u8> {
let epoch = slot / 32;
if epoch >= self.forks.bellatrix.epoch {
self.forks.bellatrix.fork_version.clone()
} else if epoch >= self.forks.altair.epoch {
self.forks.altair.fork_version.clone()
} else {
self.forks.genesis.fork_version.clone()
}
}
pub fn to_base_config(&self) -> BaseConfig {
BaseConfig {
rpc_port: self.rpc_port.unwrap_or(8545),
consensus_rpc: Some(self.consensus_rpc.clone()),
checkpoint: self.checkpoint.clone(),
chain: self.chain.clone(),
forks: self.forks.clone(),
max_checkpoint_age: self.max_checkpoint_age,
}
}
}
#[derive(Serialize)]
pub struct CliConfig {
pub execution_rpc: Option<String>,
pub consensus_rpc: Option<String>,
pub checkpoint: Option<Vec<u8>>,
pub rpc_port: Option<u16>,
pub data_dir: PathBuf,
}
impl CliConfig {
fn as_provider(&self, network: &str) -> Serialized<HashMap<&str, Value>> {
let mut user_dict = HashMap::new();
if let Some(rpc) = &self.execution_rpc {
user_dict.insert("execution_rpc", Value::from(rpc.clone()));
}
if let Some(rpc) = &self.consensus_rpc {
user_dict.insert("consensus_rpc", Value::from(rpc.clone()));
}
if let Some(checkpoint) = &self.checkpoint {
user_dict.insert("checkpoint", Value::from(hex::encode(checkpoint)));
}
if let Some(port) = self.rpc_port {
user_dict.insert("rpc_port", Value::from(port));
}
user_dict.insert("data_dir", Value::from(self.data_dir.to_str().unwrap()));
Serialized::from(user_dict, network)
}
}
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct ChainConfig {
pub chain_id: u64,
pub genesis_time: u64,
#[serde(
deserialize_with = "bytes_deserialize",
serialize_with = "bytes_serialize"
)]
pub genesis_root: Vec<u8>,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct Forks {
pub genesis: Fork,
pub altair: Fork,
pub bellatrix: Fork,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct Fork {
pub epoch: u64,
#[serde(
deserialize_with = "bytes_deserialize",
serialize_with = "bytes_serialize"
)]
pub fork_version: Vec<u8>,
}
fn bytes_deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: serde::Deserializer<'de>,
{
let bytes: String = serde::Deserialize::deserialize(deserializer)?;
Ok(hex_str_to_bytes(&bytes).unwrap())
}
fn bytes_serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let bytes_string = hex::encode(bytes);
serializer.serialize_str(&bytes_string)
}

View File

@ -1,8 +1,24 @@
use serde::Serialize;
use crate::{bytes_serialize, ChainConfig, Fork, Forks};
use common::utils::hex_str_to_bytes; use common::utils::hex_str_to_bytes;
use serde::{Deserialize, Serialize};
use strum::{Display, EnumIter};
use crate::base::BaseConfig;
use crate::types::{ChainConfig, Fork, Forks};
#[derive(
Debug,
Clone,
Copy,
Serialize,
Deserialize,
EnumIter,
Display,
Hash,
Eq,
PartialEq,
PartialOrd,
Ord,
)]
pub enum Network { pub enum Network {
MAINNET, MAINNET,
GOERLI, GOERLI,
@ -17,20 +33,6 @@ impl Network {
} }
} }
#[derive(Serialize, Default)]
pub struct BaseConfig {
pub rpc_port: u16,
pub consensus_rpc: Option<String>,
#[serde(
deserialize_with = "bytes_deserialize",
serialize_with = "bytes_serialize"
)]
pub checkpoint: Vec<u8>,
pub chain: ChainConfig,
pub forks: Forks,
pub max_checkpoint_age: u64,
}
pub fn mainnet() -> BaseConfig { pub fn mainnet() -> BaseConfig {
BaseConfig { BaseConfig {
checkpoint: hex_str_to_bytes( checkpoint: hex_str_to_bytes(

31
config/src/types.rs Normal file
View File

@ -0,0 +1,31 @@
use serde::{Deserialize, Serialize};
use crate::utils::{bytes_deserialize, bytes_serialize};
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct ChainConfig {
pub chain_id: u64,
pub genesis_time: u64,
#[serde(
deserialize_with = "bytes_deserialize",
serialize_with = "bytes_serialize"
)]
pub genesis_root: Vec<u8>,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct Forks {
pub genesis: Fork,
pub altair: Fork,
pub bellatrix: Fork,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct Fork {
pub epoch: u64,
#[serde(
deserialize_with = "bytes_deserialize",
serialize_with = "bytes_serialize"
)]
pub fork_version: Vec<u8>,
}

17
config/src/utils.rs Normal file
View File

@ -0,0 +1,17 @@
use common::utils::hex_str_to_bytes;
pub fn bytes_deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: serde::Deserializer<'de>,
{
let bytes: String = serde::Deserialize::deserialize(deserializer)?;
Ok(hex_str_to_bytes(&bytes).unwrap())
}
pub fn bytes_serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let bytes_string = hex::encode(bytes);
serializer.serialize_str(&bytes_string)
}

View File

@ -0,0 +1,68 @@
use config::networks;
use ethers::types::H256;
#[tokio::test]
async fn test_checkpoint_fallback() {
let cf = config::checkpoints::CheckpointFallback::new();
assert_eq!(cf.services.get(&networks::Network::MAINNET), None);
assert_eq!(cf.services.get(&networks::Network::GOERLI), None);
assert_eq!(
cf.networks,
[networks::Network::MAINNET, networks::Network::GOERLI].to_vec()
);
}
#[tokio::test]
async fn test_construct_checkpoints() {
let cf = config::checkpoints::CheckpointFallback::new()
.build()
.await
.unwrap();
assert!(cf.services[&networks::Network::MAINNET].len() > 1);
assert!(cf.services[&networks::Network::GOERLI].len() > 1);
}
#[tokio::test]
async fn test_fetch_latest_checkpoints() {
let cf = config::checkpoints::CheckpointFallback::new()
.build()
.await
.unwrap();
let checkpoint = cf
.fetch_latest_checkpoint(&networks::Network::GOERLI)
.await
.unwrap();
assert!(checkpoint != H256::zero());
let checkpoint = cf
.fetch_latest_checkpoint(&networks::Network::MAINNET)
.await
.unwrap();
assert!(checkpoint != H256::zero());
}
#[tokio::test]
async fn test_get_all_fallback_endpoints() {
let cf = config::checkpoints::CheckpointFallback::new()
.build()
.await
.unwrap();
let urls = cf.get_all_fallback_endpoints(&networks::Network::MAINNET);
assert!(urls.len() > 0);
let urls = cf.get_all_fallback_endpoints(&networks::Network::GOERLI);
assert!(urls.len() > 0);
}
#[tokio::test]
async fn test_get_healthy_fallback_endpoints() {
let cf = config::checkpoints::CheckpointFallback::new()
.build()
.await
.unwrap();
let urls = cf.get_healthy_fallback_endpoints(&networks::Network::MAINNET);
assert!(urls.len() > 0);
let urls = cf.get_healthy_fallback_endpoints(&networks::Network::GOERLI);
assert!(urls.len() > 0);
}

View File

@ -94,6 +94,10 @@ impl<R: ConsensusRpc> ConsensusClient<R> {
} }
pub async fn sync(&mut self) -> Result<()> { pub async fn sync(&mut self) -> Result<()> {
info!(
"Consensus client in sync with checkpoint: 0x{}",
hex::encode(&self.initial_checkpoint)
);
self.bootstrap().await?; self.bootstrap().await?;
let current_period = calc_sync_period(self.store.finalized_header.slot); let current_period = calc_sync_period(self.store.finalized_header.slot);