Skip to content

node: start from a pre-built store checked by its hot digest (--assumehot) - #1198

Open
rorp wants to merge 1 commit into
reardencode:masterfrom
rorp:node/assumehot
Open

rorp wants to merge 1 commit into
reardencode:masterfrom
rorp:node/assumehot

Conversation

@rorp

@rorp rorp commented Oct 24, 2026

Copy link
Copy Markdown

Fixes #590

Start a node from another node's store instead of running IBD.

Usage

# on the source, after a clean stop (SIGTERM)
rbitcoin-node --datadir SRC --network NET --hot-digest
# NET HEIGHT TIPHASH DIGEST

# on the new node, first start only
rbitcoin-node --datadir NEW --network NET --assumehot-from SRC --assumehot DIGEST

What it does

  • The digest covers the confirmed chain and its spend state, by height. It does not depend on store.secret, orphans or reorg history.
  • The import runs in run_node, before the store opens, while holding the source's datadir lock.
  • It refuses:
    • a non-empty or --datadir-cold destination;
    • a source that is split with --datadir-cold, on an old schema, not cleanly stopped, or pruned.
  • It reads only blocks from the source. Every header and body is checked. Spends, fees and indexes are recomputed. Scripts are not run.
  • It writes store.import/ and renames it to store/ only if the digest matches.
  • Both flags are CLI only.

Trust

The digest names the chain, and you trust whoever gave it to you. There is no background validation, unlike Core's assumeutxo.

Tests

  • rbitcoin-query hot_digest suite: the digest, the import, each refusal, and tampered blocks and headers.
  • node_cli_and_surface_smoke: CLI exit codes, wrong hash, locked source, import.
  • New journey end_of_ibd::assumehot_import_then_follow: node B imports node A's store with no peer, serves Electrum history, then syncs and follows over P2P.
  • Linux: all CI gates pass, 3102 tests, coverage 91.32%, CRAP 0 offenders.

Not covered

  • A production-scale (non-tiny) store layout.
  • An import with --sh-index off.
  • Supporting a split (cold) source.

@reardencode

Copy link
Copy Markdown
Owner

Interesting approach - doesn't this require the destination host to have twice the storage of the final datadir though?

I think to actually land this feature, need a way for the receiving node to import the store without needing significantly more storage than the final store size.

Maybe even a v2 protocol extension that lets an rbitcoin node serve structured hot data to another rbitcoin node for quick sync or something.

Open to other ideas too, or to you contradicting me.

@reardencode

Copy link
Copy Markdown
Owner

(rebase to get green coverage)

@rorp

rorp commented Oct 24, 2026

Copy link
Copy Markdown
Author

There are trade-offs with any approach. This approach provides atomic changes, needs ~2x disk space. The in-place approach would require decode all things that encrypted with the original store.secret and re-encode with a new secret, which is not that atomic, but obviously does not need that much disk space.

I can can do one or another, or both :)

@reardencode

Copy link
Copy Markdown
Owner

Will continue thinking about what I think the most rbitcoiny thing here is. Thank you!

Won't merge this feature until after 0.9 sometime in the next week.

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES. 2 bugs, 2 nits.

  • Connect-time consensus is not re-run, so a store's own --hot-digest still imports an invalid chain. See import_hot / hot_import_check_block.
  • Opening the source can rewrite that datadir. See import_hot_store.

Nits: a later start that still passes --assumehot* exits because store/ is not empty (the operator note already says to drop the flags). assumehot_import_then_follow extends with an empty block, so it never spends a coin that exists only in the imported store.

The extra disk copy is already the open question on the thread.

Comment thread crates/rbitcoin-query/src/hot_digest.rs Outdated
hash: *hash,
});
}
self.confirm_blocks_run(&prepared)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import_hot stops at confirm_blocks_run plus annotate_spends_for_confirmed. Connect's structural phase never runs, so a second spend of one output is stored as a multi-spender, and an immature coinbase, a BIP30 overwrite, a BIP68 failure, or a coinbase above subsidy plus fees is kept. hot_import_check_block calls ValidationContext::archive_structure, so BIP34 and the pre-segwit witness rule are skipped too. --hot-digest hashes the store as it sits, so that store's own digest matches. The new node then serves the tip with no background validation and no RPC warning. Run those structural checks during the import (scripts can stay off) and cover a bad store whose expected digest is its own --hot-digest.

Comment thread crates/rbitcoin-query/src/hot_digest.rs Outdated
seal.tip_height
)));
}
let src = Query::open_or_create_layout(src)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Query::open_or_create_layout revalidates the tip window and can shrink the source and rewrite tip_seal before the match check on the next lines. HeaderHead::open also rewrites header.head when the file has fewer slots than the open target. assumehot_import builds that target from the destination layout (..dst.clone()), so the destination --head-scale and RBITCOIN_HEAD_SLOTS_HEADER apply to the source. A refused import, or a source whose header head is smaller than that target, changes the datadir being copied. Open the source without repair and without a head rewrite.

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

COMMENT. Both bugs from a8b21fb are fixed. Rebase onto current master: the coverage job failed on this head, and a rebase is what cleared it last time.

Fixed: import_hot connects through confirm_wire_run (genesis through accept_and_connect_block) under a height-only milestone, so a bad store's own digest is refused for an immature coinbase, a double spend, a coinbase above subsidy, and a missing BIP34 push. The source opens through open_import_source / HeaderHead::open_source; a tiny header.head stays at its own slots, and a tip past tip_seal is refused without being shrunk.

Nits still open: a later start that still passes --assumehot* exits because store/ is not empty. assumehot_import_then_follow still extends with an empty block, so it never spends an imported coin.

if dst.dir.exists() {
let mut entries = std::fs::read_dir(&dst.dir).map_err(|e| StoreError::io(&dst.dir, e))?;
if entries.next().is_some() {
return Err(HotImportError::NotEmpty);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: a second start that still passes --assumehot* hits this and exits. The imported store is already the one just written. Ignore the flags when store/ is non-empty and its digest matches, and fail only on a mismatch.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b559984, but not by re-checking the digest. Once the node follows a block, the store's digest changes, because it covers spenders as of the tip. Recomputing it is also a full walk on every restart.

Instead, the import writes store/assumehot (height, tip hash, digest) into store.import/ before the rename, so it lands with the store. On start, kept_import reads it before the source lock is taken:

  • same digest: log "already imported; flags ignored" and open the store, also past the imported tip and with the source deleted;
  • another digest: refused, naming both digests;
  • no record but store/ is not empty: refused as before.

Covered by assumehot_restart_finds_its_own_import, and by the journey restarting with the flags after A's datadir is deleted.

assert!(!b.join("store.import").exists());

let last = blocks.last().unwrap();
let missed = mine_regtest_block(last.block_hash(), last.header.time + 1, height + 1, vec![]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this block has no inputs, and extend_one does not spend an imported output either. The follow only shows that an empty block connects. Spend a coin that exists only because the import recomputed it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b559984. The block B missed now spends script B's coin (the fixture's 0x52 output, which B has only from the import) to 0x53. B fetches it from the miner and connects it, and the test checks that the 0x53 history holds the spend. extend_one then follows one more block as before.

@rorp

rorp commented Oct 26, 2026

Copy link
Copy Markdown
Author

Both nits are addressed in b559984, which is rebased onto 4fe38d4.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consider accepting a pre-built datadir with hash (and signature?) to skip IBD (--assumehot)

2 participants