Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
3d7c7c9
feat(daemon): improt enriched snapshots
EclesioMeloJunior Oct 8, 2026
7af312d
chore: remove backfill from rpc-checks
EclesioMeloJunior Oct 8, 2026
f4452c3
Merge branch 'main' into forest-enrich-archive
EclesioMeloJunior Oct 8, 2026
eb1a0de
Merge branch 'main' into forest-enrich-archive
EclesioMeloJunior Oct 9, 2026
3f4494e
chore: changes to metadata url format
EclesioMeloJunior Oct 9, 2026
417dce5
Merge branch 'main' of github.com:ChainSafe/forest into forest-enrich…
EclesioMeloJunior Oct 9, 2026
78eaa9c
Merge branch 'forest-enrich-archive' of github.com:ChainSafe/forest i…
EclesioMeloJunior Oct 9, 2026
6c1be3a
Merge branch 'main' into forest-enrich-archive
EclesioMeloJunior Oct 13, 2026
0948434
Update CHANGELOG.md
EclesioMeloJunior Oct 15, 2026
25a2657
Merge branch 'main' into forest-enrich-archive
EclesioMeloJunior Oct 15, 2026
3720635
Merge branch 'main' of github.com:ChainSafe/forest into forest-enrich…
EclesioMeloJunior Oct 16, 2026
6c1c8ea
chore: batch writes to db to improve speed
EclesioMeloJunior Oct 16, 2026
9b6b5e6
Merge branch 'forest-enrich-archive' of github.com:ChainSafe/forest i…
EclesioMeloJunior Oct 16, 2026
2cf0536
Merge branch 'main' into forest-enrich-archive
EclesioMeloJunior Oct 16, 2026
c64f488
Merge branch 'main' into forest-enrich-archive
EclesioMeloJunior Oct 19, 2026
9713afe
chore: address comments
EclesioMeloJunior Oct 19, 2026
b65be4d
Merge branch 'main' into forest-enrich-archive
EclesioMeloJunior Oct 19, 2026
995fa7d
chore: share import_tipset_lookup_snapshot & backfill_receipts_events
EclesioMeloJunior Oct 19, 2026
108abc5
Merge branch 'forest-enrich-archive' of github.com:ChainSafe/forest i…
EclesioMeloJunior Oct 19, 2026
8f85fbf
chore: resolve redirect urls
EclesioMeloJunior Oct 19, 2026
d8766e4
chore: solve problems
EclesioMeloJunior Oct 19, 2026
d52b43d
Merge branch 'main' into forest-enrich-archive
EclesioMeloJunior Oct 19, 2026
9bab5cc
chore: fix backfill problems
EclesioMeloJunior Oct 19, 2026
ddceb1b
Merge branch 'main' of github.com:ChainSafe/forest into forest-enrich…
EclesioMeloJunior Oct 19, 2026
75ab9c8
Merge branch 'forest-enrich-archive' of github.com:ChainSafe/forest i…
EclesioMeloJunior Oct 19, 2026
b0756bd
Merge branch 'main' into forest-enrich-archive
EclesioMeloJunior Oct 20, 2026
5ca5642
chore: include few logs
EclesioMeloJunior Oct 20, 2026
1576430
Merge branch 'forest-enrich-archive' of github.com:ChainSafe/forest i…
EclesioMeloJunior Oct 20, 2026
6ae1502
use a simple regex on download_extended_snapshot
EclesioMeloJunior Oct 20, 2026
78d87aa
chore: introduce SnapshotImportPath
EclesioMeloJunior Oct 20, 2026
e6b59d1
chore: use `ensure!`
EclesioMeloJunior Oct 20, 2026
c77e9f6
chore: remove counter
EclesioMeloJunior Oct 20, 2026
40d6cce
chore: remove out-of-scope changes
EclesioMeloJunior Oct 21, 2026
64f8a79
Merge branch 'main' into forest-enrich-archive
EclesioMeloJunior Oct 21, 2026
4cbdcf3
backfill only after receipts_events and tipset lookup import
EclesioMeloJunior Oct 21, 2026
f122381
chore: avoid warn log by starting backfill at the heads parent
EclesioMeloJunior Oct 21, 2026
935fd25
chore: update documentation
EclesioMeloJunior Oct 21, 2026
5f6401d
implement `CarBlock::validate` the same check as `forest-tool db impo…
EclesioMeloJunior Oct 21, 2026
6a57c04
chore: remove unneded `resolve.rb` as we auto backfill on node startup
EclesioMeloJunior Oct 21, 2026
6bf80a1
`maybe_import_extended_snapshots` no longer takes a db parameter
EclesioMeloJunior Oct 21, 2026
ab6b83e
Merge branch 'forest-enrich-archive' of github.com:ChainSafe/forest i…
EclesioMeloJunior Oct 21, 2026
2c57904
chore: address doc lint
EclesioMeloJunior Oct 21, 2026
a093b5b
Merge branch 'main' into forest-enrich-archive
EclesioMeloJunior Oct 21, 2026
aeb5cc4
Merge branch 'main' into forest-enrich-archive
EclesioMeloJunior Oct 22, 2026
4a46a58
Merge branch 'main' into forest-enrich-archive
EclesioMeloJunior Oct 23, 2026
0353b94
chore: show download progress
EclesioMeloJunior Oct 23, 2026
7962684
chore: remove double loop while importing tipset_lookup
EclesioMeloJunior Oct 24, 2026
a510b22
Merge branch 'forest-enrich-archive' of github.com:ChainSafe/forest i…
EclesioMeloJunior Oct 24, 2026
56ada40
chore: addressing comments
EclesioMeloJunior Oct 27, 2026
6f13b85
Merge branch 'main' into forest-enrich-archive
EclesioMeloJunior Oct 27, 2026
a9bdaff
chore: show tipset import entries progress
EclesioMeloJunior Oct 28, 2026
5fe6b05
Merge branch 'forest-enrich-archive' of github.com:ChainSafe/forest i…
EclesioMeloJunior Oct 28, 2026
e4ec43b
Merge branch 'main' into forest-enrich-archive
EclesioMeloJunior Oct 28, 2026
535e214
Merge branch 'main' into forest-enrich-archive
EclesioMeloJunior Oct 29, 2026
22146db
chore: fallback to url when HEAD request cannot redirect successfully
EclesioMeloJunior Nov 3, 2026
ca6a40b
chore: url join + query so it dont drop any url info
EclesioMeloJunior Nov 3, 2026
3201c35
Merge branch 'forest-enrich-archive' of github.com:ChainSafe/forest i…
EclesioMeloJunior Nov 3, 2026
ad737e6
backfill cover only the number of state roots advertised at snapshot …
EclesioMeloJunior Nov 3, 2026
dc08f70
chore: validate before writing
EclesioMeloJunior Nov 3, 2026
f5acd03
Merge branch 'main' of github.com:ChainSafe/forest into forest-enrich…
EclesioMeloJunior Nov 3, 2026
440506f
place a max cap on backfill & resolve fail if no extended snapshots a…
EclesioMeloJunior Nov 3, 2026
1aca6c2
chore: use `BlockstoreWithWriteBuffer`
EclesioMeloJunior Nov 3, 2026
6f776ec
chore: include tests
EclesioMeloJunior Nov 4, 2026
34f77da
Merge branch 'main' of github.com:ChainSafe/forest into forest-enrich…
EclesioMeloJunior Nov 4, 2026
4665573
chore: fix lint
EclesioMeloJunior Nov 4, 2026
d513bf8
Merge branch 'main' of github.com:ChainSafe/forest into forest-enrich…
EclesioMeloJunior Nov 5, 2026
2975247
chore: addressing most of the comments
EclesioMeloJunior Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@

### Added

- [#6802](https://github.com/ChainSafe/forest/issues/6802): When importing a snapshot from a URL, Forest now checks the snapshot's `.metadata.json` and, if extended snapshots are advertised, downloads and imports the receipts, events, and the tipset lookup alongside it

- [#7802](https://github.com/ChainSafe/forest/pull/7802): Added `--target <EPOCH>` to `forest-cli sync wait`. The command exits once the node head reaches the given epoch.

- [#7781](https://github.com/ChainSafe/forest/pull/7781): Actor bundles are now verified against their CIDs, and missing or tampered blocks are replaced.
Expand Down
17 changes: 14 additions & 3 deletions docs/docs/users/knowledge_base/enriched_snapshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ forest_snapshot_mainnet_2026-08-14_height_6279840_tipset_lookup.forest.car.zst
```

:::info
Enriched snapshots are a recent addition and are not yet listed by the snapshot
service. Discovery is still to be defined; for now the companion files follow the
suffix convention above next to their base snapshot.
The companion files are listed, with their SHA-256, under `Snapshot.Extended` in
the base snapshot's `.metadata.json`, published next to it as
`<snapshot-url>.metadata.json`.
:::

## Generating
Expand Down Expand Up @@ -61,6 +61,17 @@ at least one must be given.

## Importing into Forest

### Automatically on startup

When Forest imports a snapshot from a URL on startup, with `--auto-download-snapshot` or
`--import-snapshot <url>`, it imports the enriched snapshot too, then indexes the
imported receipts and events (block `logsBloom` and Ethereum mappings), so
`FOREST_ETH_RPC_COMPUTE_BLOOM_ON_MISS=1` is only needed for the manual recipe below.
This step is best-effort: if a companion file can't be downloaded, verified, or
imported, Forest logs a warning and starts without it.

### Manually

`--import-snapshot` accepts a single file and the companion CARs have no chain
spine of their own, so only the base goes through it. The companions are loaded
with `forest-tool db`, which writes directly into the node's key-value store and
Expand Down
11 changes: 4 additions & 7 deletions scripts/tests/external-rpc-checks/init.sh
Comment thread
EclesioMeloJunior marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
#!/usr/bin/env bash
# Runs in the `init` service: imports the snapshot ./resolve.rb picked and
# back-fills the chain index, all before the daemon starts.
# Runs in the `init` service: imports the snapshot ./resolve.rb picked, along
# with its extended snapshots (receipts and events, tipset lookup), before the
# daemon starts.

set -euo pipefail

url=$(< /data/snapshot-url)
epoch=$(< /data/snapshot-epoch)
read -r chain start _ < /data/check-target
read -r chain _ < /data/check-target
Comment thread
EclesioMeloJunior marked this conversation as resolved.
Resolved

forest --chain="${chain}" --encrypt-keystore=false --import-snapshot="${url}" --halt-after-import

# inclusive on both ends
forest-tool index backfill --chain="${chain}" --from="${epoch}" --to="${start}"
9 changes: 6 additions & 3 deletions scripts/tests/external-rpc-checks/resolve.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# frozen_string_literal: true

# Picks the newest ${FOREST_CHAIN} snapshot whose head lies within the first 2000 epochs of
# the UTC day: it holds the state the backfill needs to reach the day's start,
# and the node syncs the rest. Writes its URL, head epoch and the day's epoch range under /data.
# the UTC day: importing it indexes its receipts and events back to the day's start,
# and the node syncs the rest. Writes its URL and the day's epoch range under /data, and fails
# if the snapshot advertises no receipts and events snapshot.

require 'date'
require 'json'
Expand Down Expand Up @@ -47,7 +48,9 @@ def fetch(uri, attempts: 3)
exit 2
end

metadata = JSON.parse(fetch(URI("#{url}.metadata.json")))
abort "#{url} advertises no receipts and events snapshot" unless metadata.dig('Snapshot', 'Extended', 'Augmented')

File.write('/data/snapshot-url', "#{url}\n")
File.write('/data/snapshot-epoch', "#{epoch}\n")
File.write('/data/check-target', "#{chain} #{start} #{finish}\n")
puts "#{chain} #{day.iso8601} is epochs #{start}..#{finish}; snapshot at #{epoch}: #{url}"
36 changes: 20 additions & 16 deletions scripts/tests/external-rpc-checks/verify.rb
Original file line number Diff line number Diff line change
@@ -1,31 +1,35 @@
# frozen_string_literal: true

# Confirms the node indexed the epochs the checks query.
# Confirms the node indexed the epochs the checks query: the tipset is served and the receipts
# of its messages are in the store, read back through the standard chain API.

require 'json'
require 'net/http'

url = URI("http://#{ENV.fetch('FOREST_RPC_URL')}")

ARGV.each do |argument|
epoch = Integer(argument)
def rpc(url, method, params)
request = Net::HTTP::Post.new(url, 'Content-Type' => 'application/json')
request.body = {
jsonrpc: '2.0',
id: 1,
method: 'eth_getBlockByNumber',
params: ["0x#{epoch.to_s(16)}", false]
}.to_json
request.body = { jsonrpc: '2.0', id: 1, method: method, params: params }.to_json
JSON.parse(Net::HTTP.start(url.hostname, url.port) { |http| http.request(request) }.body)
end

response = Net::HTTP.start(url.hostname, url.port) { |http| http.request(request) }
body = JSON.parse(response.body)
ARGV.each do |argument|
epoch = Integer(argument)
block = rpc(url, 'eth_getBlockByNumber', ["0x#{epoch.to_s(16)}", false])
# Error code 12 denotes a null round across implementations.
# https://github.com/ChainSafe/forest/blob/8c980e679b6606c534c6eb5bd8aa03ff8cc6f5c0/src/rpc/methods/eth/errors.rs#L22
null_round = body.dig('error', 'code') == 12

unless body.dig('result', 'number') || null_round
abort "epoch #{epoch} is not indexed: #{response.code} #{response.body}"
if block.dig('error', 'code') == 12
puts "epoch #{epoch} was a null round"
next
end
abort "epoch #{epoch} is not indexed: #{block}" unless block.dig('result', 'number')

puts "epoch #{epoch} #{null_round ? 'was a null round' : 'is indexed'}"
# The receipts of an epoch's messages are stored under its child tipset, the first non-null
# tipset above it. Reading them back is the same check any client would make.
child = rpc(url, 'Filecoin.ChainGetTipSetAfterHeight', [epoch + 1, []])
abort "epoch #{epoch} has no tipset above it: #{child['error']}" if child['error']
receipts = rpc(url, 'Filecoin.ChainGetParentReceipts', [child.dig('result', 'Cids', 0)])
abort "epoch #{epoch} has no receipts: #{receipts['error']}" if receipts['error']
puts "epoch #{epoch} is indexed"
end
62 changes: 61 additions & 1 deletion src/chain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ pub use self::{snapshot_format::*, store::*, weight::*};
use crate::blocks::{Tipset, TipsetKey};
use crate::chain::index::{ChainIndex, ResolveNullTipset};
use crate::cid_collections::{CidHashSet, CidHashSetLike, FileBackedCidHashSet};
use crate::db::IndexMapBlockstore;
use crate::db::car::AnyCar;
use crate::db::car::forest::{self, ForestCarFrame, finalize_frame};
use crate::db::{EthMappingsStore, IndexMapBlockstore};
use crate::ipld::{IpldStream, should_save_block_to_snapshot, stream_chain};
use crate::prelude::*;
use crate::shim::executor::Receipt;
Expand Down Expand Up @@ -481,3 +482,62 @@ pub async fn export_receipts_events_to_forest_car(

Ok(())
}

/// Check that the entry is at a checkpoint epoch and that the stored tipset is at that epoch.
pub fn validate_tipset_lookup_entry<DB>(
db: &DB,
epoch: ChainEpoch,
tsk: &TipsetKey,
) -> anyhow::Result<Tipset>
where
DB: Blockstore,
{
anyhow::ensure!(
ChainIndex::is_tipset_lookup_checkpoint(epoch),
"epoch {epoch} is not a tipset lookup checkpoint"
);
let ts: Tipset = Tipset::load_required(db, tsk)?;
anyhow::ensure!(
ts.epoch() == epoch,
"epochs do not match, {epoch} in hamt, {} in database",
ts.epoch()
);
Ok(ts)
}

/// Imports a tipset lookup snapshot into `db`, calling `on_imported` after each entry is written.
/// Every entry is validated against the stored chain before the first write.
pub fn import_tipset_lookup_snapshot<DB>(
db: &DB,
path: &Path,
mut on_imported: impl FnMut(),
) -> anyhow::Result<()>
where
DB: Blockstore + EthMappingsStore,
{
let started = Instant::now();
let hamt_car = AnyCar::try_from(path)?;
anyhow::ensure!(
hamt_car.header_v1().roots.len() == 1,
"hamt car should have a single root"
);
let hamt_root = *hamt_car.header_v1().roots.first();
let hamt: Hamt<_, TipsetKey, ChainEpoch> =
Hamt::load_with_bit_width(&hamt_root, hamt_car, TIPSET_LOOKUP_HAMT_BIT_WIDTH)
.context("failed to load tipset lookup HAMT")?;
tracing::info!("Validating tipset lookup entries against the chain, this can take a while");
hamt.for_each_cacheless(|&epoch, tsk| {
validate_tipset_lookup_entry(db, epoch, tsk)?;
Ok(())
})?;
hamt.for_each_cacheless(|&epoch, tsk| {
db.set_tipset_key_at_epoch_raw(epoch, tsk)?;
on_imported();
Ok(())
})?;
tracing::info!(
"Imported tipset lookup entries in {:.1}s",
started.elapsed().as_secs_f64()
);
Ok(())
}
70 changes: 70 additions & 0 deletions src/chain/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -604,3 +604,73 @@ async fn ensure_recent_roots_present_checks_oldest_walked_tipset(
expected
);
}

// Imports a lookup table with the given `hamt_epoch -> tipset at tipset_epoch` entries into a
// chain of tipsets at epochs 20, 21 and 40; `None` points at a tipset missing from the chain.
// On an error nothing may have been written, so one invalid entry rejects the valid ones too.
#[rstest]
#[case::checkpoint(&[(40, Some(40))], None)]
#[case::non_checkpoint_epoch(
&[(20, Some(20)), (21, Some(21)), (40, Some(40))],
Some("is not a tipset lookup checkpoint")
)]
#[case::epoch_mismatch(&[(40, Some(20))], Some("epochs do not match"))]
#[case::unknown_tipset(&[(40, None)], Some("Required tipset missing"))]
#[tokio::test]
async fn import_tipset_lookup(
#[case] entries: &[(ChainEpoch, Option<ChainEpoch>)],
#[case] expected_err: Option<&str>,
) {
use crate::chain::index::tests::{genesis_tipset, persist_tipset, tipset_child};

let db = MemoryDB::default();
let mut parent = genesis_tipset();
persist_tipset(&parent, &db);
let mut chain = vec![];
for epoch in [20, 21, 40] {
let ts = tipset_child(&parent, epoch);
persist_tipset(&ts, &db);
chain.push(ts.shallow_clone());
parent = ts;
}

let hamt_store = MemoryDB::default();
let mut hamt: Hamt<_, TipsetKey, ChainEpoch> =
Hamt::new_with_bit_width(&hamt_store, TIPSET_LOOKUP_HAMT_BIT_WIDTH);
let mut expected = vec![];
for &(hamt_epoch, tipset_epoch) in entries {
let tsk = match tipset_epoch {
Some(epoch) => chain
.iter()
.find(|ts| ts.epoch() == epoch)
.unwrap()
.key()
.clone(),
None => tipset_child(&parent, 40).key().clone(),
};
hamt.set(hamt_epoch, tsk.clone()).unwrap();
expected.push((hamt_epoch, tsk));
}
let root = hamt.flush().unwrap();
let mut car = vec![];
hamt_store
.export_forest_car_with_roots(nunny::vec![root], &mut car)
.await
.unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let car_path = temp_dir.path().join("tipset_lookup.forest.car.zst");
std::fs::write(&car_path, car).unwrap();

let result = import_tipset_lookup_snapshot(&db, &car_path, || {});
match expected_err {
None => result.unwrap(),
Some(expected) => {
let err = format!("{:#}", result.unwrap_err());
assert!(err.contains(expected), "{err}");
}
}
for (hamt_epoch, tsk) in expected {
let written = expected_err.is_none().then_some(tsk);
assert_eq!(db.tipset_key_by_epoch(hamt_epoch).unwrap(), written);
}
}
54 changes: 53 additions & 1 deletion src/cli_shared/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use std::{
fmt::Display,
path::{Path, PathBuf},
str::FromStr,
time::Duration,
};

use crate::prelude::*;
Expand Down Expand Up @@ -112,6 +113,24 @@ pub async fn peek(
))
}

/// Returns the final URL after redirection(s).
/// if server rejects `HEAD` requests returns the `url` itself
pub async fn peek_url(url: Url) -> Url {
let response = crate::utils::net::global_http_client()
.head(url.clone())
.timeout(Duration::from_secs(30))
.send()
.await
.and_then(|response| response.error_for_status());
match response {
Ok(response) => response.url().clone(),
Err(e) => {
tracing::debug!("Couldn't follow the redirects of {url}: {e:#}");
url
}
}
}

// Extract file paths from content-disposition values:
// "attachment; filename=\"911520_2023_09_14T06_13_00Z.car.zst\""
// => "911520_2023_09_14T06_13_00Z.car.zst"
Expand Down Expand Up @@ -434,8 +453,41 @@ mod parse {

#[cfg(test)]
mod tests {
use super::parse_content_disposition;
use super::{parse_content_disposition, peek_url};
use reqwest::header::HeaderValue;
use url::Url;

#[rstest::rstest]
#[case::follows_redirect("/latest", "/snapshot.forest.car.zst")]
#[case::head_rejected("/post-only", "/post-only")]
#[case::not_found("/missing", "/missing")]
#[case::redirect_to_rejected("/stale", "/stale")]
#[tokio::test]
async fn peek_url_falls_back_to_given_url(#[case] path: &str, #[case] expected: &str) {
use axum::{
Router,
response::Redirect,
routing::{get, post},
};

let app = Router::new()
.route(
"/latest",
get(|| async { Redirect::temporary("/snapshot.forest.car.zst") }),
)
.route("/snapshot.forest.car.zst", get(|| async { "snapshot" }))
.route("/post-only", post(|| async { "post" }))
.route(
"/stale",
get(|| async { Redirect::temporary("/post-only") }),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await });

let url = Url::parse(&format!("http://{addr}{path}")).unwrap();
assert_eq!(peek_url(url).await.path(), expected);
}

#[test]
fn content_disposition_forest() {
Expand Down
Loading
Loading