Skip to content

Checkpoint warp sync: mining-ready node in minutes (--sync warp) - #660

Open
n13 wants to merge 2 commits into
mainfrom
checkpoint-warp-sync
Open

Checkpoint warp sync: mining-ready node in minutes (--sync warp)#660
n13 wants to merge 2 commits into
mainfrom
checkpoint-warp-sync

Conversation

@n13

@n13 n13 commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Adds checkpoint-based warp sync: a new node reaches the network tip and can start mining in minutes — bounded by state-download bandwidth, not chain age — instead of re-executing the whole chain (currently 1.5–2 h and growing).

How it works

  1. At startup (--sync warp) the node resolves a checkpoint — a single trusted header, normally the network's current finalized head (best − max_reorg_depth).
  2. The existing WarpSyncConfig::WithTarget machinery (the parachain path — no GRANDPA needed) downloads the checkpoint block, then state-syncs the full state at that block, Merkle-proof-verified against the header's state_root.
  3. From the checkpoint to the tip the node syncs with normal full verification (execution + runtime seal checks). Mining gates open exactly here (is_major_syncing already covers warp/state phases).

History below the checkpoint is not downloaded; the node serves chain data from the checkpoint forward (see "Backfill" below).

Checkpoint resolution (documented in docs/FAST_SYNC.md)

First match wins:

  1. --checkpoint-header 0x… — operator-pinned SCALE header.
  2. Fetched from checkpoint endpoints (--checkpoint-url …, or the chain spec checkpointUrls property, now set for Heisenberg and Planck). Each endpoint's returned header must hash (Poseidon) to its reported finalized head; the candidate is the lowest finalized height among responders; every other responder must confirm that hash via chain_getBlockHash. Any disagreement aborts.
  3. The release anchor — a checkpointHeader chain spec property refreshed by CI at release cut (CI wiring is a follow-up; stale anchors still sync, catch-up just re-executes blocks mined since the release).

A fetched target may never be older than the anchor, and at the anchor's height it must equal the anchor. Independently, PowBlockImport rejects any imported block that contradicts the anchor at its height.

Consensus changes (client/consensus/qpow)

Imports are classified by where their state can come from:

  • StateImport (StateAction::ApplyChanges(StorageChanges::Import)) — the proof-verified state-sync target. No parent state exists by construction, so inherent/seal runtime checks are skipped; cumulative work is seeded (like genesis — post-target fork choice only compares its descendants); the block is finalized on import; create_gap = false (see below).
  • HistoricalSkip (StateAction::Skip at or below the finalized head) — defense-in-depth for skip-execution imports of already-finalized heights: no work accounting, never best.
  • FullVerify — everything else keeps today's exact behavior. Skip above the finalized head does not grant a verification skip, so unsolicited near-tip blocks still get their seals runtime-verified.

finalize_canonical_at_depth gained an early return when the target is at or below the current finalized head (post-warp, best − 100 points below the finalized target for the first 100 blocks).

Why backfill is disabled (create_gap = false)

Upstream gap backfill fills ascending from genesis and relies on the verifier to validate headers statelessly — possible for BABE (epoch data chains forward from genesis), impossible for QPoW (target difficulty lives in state, and retarget rules changed across runtime upgrades). Ascending fill would let a malicious peer write fabricated, unverified history into the canonical number→hash index. Until backfill verifies linkage (descending fill, or a pre-verified header chain walked down from the checkpoint), warp-synced nodes simply don't backfill. Nodes needing full history full-sync as today.

--sync fast/fast-unsafe are rejected with a pointer to --sync warp for the same reason (and fast sync's pivot trigger is incompatible with depth-based finality anyway). Warp nodes get a rolling 256-block state window instead of the forced ArchiveCanonical (archive refuses warp by design).

Validated live against Heisenberg

Fresh node, --sync warp, default spec (checkpoint fetched from the a1/a2 endpoints, both agreeing):

13:45:25 🎯 Warp sync target fetched from checkpoint endpoints: #802314 (0x4f9b…f4e0)
13:45:27 Warp sync is complete, continuing with state sync.
13:58:35 🎯 State sync target #802314 (0x4f9b…f4e0) imported and finalized; serving chain from this block forward
13:58:35 State sync is complete, continuing with block sync.
13:58:36 💤 Idle (2 peers), best: #802467 (0x0d6d…e98d), finalized #802367 (0xb886…2de4)
  • Depth finalization runs correctly over the warp base (finalized = best − 100 one second after reaching the tip); catch-up of the 153 blocks mined during state download executed in ~1 s with full seal verification.
  • Zero errors or warnings in the entire node log; no gap/backfill activity.
  • RPC behaves as designed: chain_getBlockHash(1) → null (no pre-checkpoint history), tip queries and system_syncState track the live chain.

Wall clock was 13 min, essentially all state download at the ~150 KiB/s the public endpoints currently serve — on unthrottled state serving this is minutes. Chain-age-independent either way.

Unit tests cover the import-policy classification, checkpoint hex parsing, and anchor validation.

Follow-ups

  • CI: inject a fresh checkpointHeader anchor into the bundled chain specs at release cut.
  • Verified history backfill (descending fill or pre-verified header chain), then re-enable create_gap.
  • Header-chain verification of fetched targets (hash linkage + stateless achieved-work via qpow-math) to remove the residual all-endpoints-collude trust.
  • In-protocol checkpoint fetch via peer quorum, removing URL configuration.

n13 added 2 commits August 11, 2026 13:59
A new node resolves a checkpoint (the network's finalized head, fetched
from agreeing RPC endpoints with a release-anchor fallback), warp-syncs
to it via WarpSyncConfig::WithTarget, downloads the state with Merkle
proofs against the checkpoint's state_root, and fully verifies only
checkpoint -> tip. History below the checkpoint is not downloaded;
verified backfill is a follow-up.

- PowBlockImport classifies imports: the proof-verified state target
  (finalized on import, work seeded, no block gap recorded) and
  skip-execution imports below the finalized head are exempt from
  runtime seal verification, which requires parent state; everything
  else keeps full verification.
- Release anchor (checkpointHeader chain spec property) is enforced at
  import: any block contradicting it at its height is rejected.
- finalize_canonical_at_depth early-returns when the target is at or
  below the finalized head.
- --sync fast/fast-unsafe rejected (unverifiable for QPoW); warp nodes
  use windowed state pruning instead of forced archive.

Validated live against Heisenberg: fresh node to mining-ready tip with
zero log errors; depth finalization correct over the warp base.
Flags the run path used to accept but silently override or ignore now
either work or fail loudly:

- --node-key: honored (was stomped by the file-identity override; sc_cli
  already parses it as a Dilithium secret and applies the authority
  safety check). --node-key-file behavior unchanged.
- --state-pruning: must match what the node enforces. Full-sync nodes
  are fixed to archive-canonical; warp nodes accept a numeric window
  >= 256 (new capability) and reject archive modes.
- --blocks-pruning: fixed to archive-canonical; other values rejected.
- --checkpoint-header/--checkpoint-url: rejected without --sync warp.

Verified fine as-is: --network-backend and --node-key-type are already
single-variant (litep2p / dilithium), --unsafe-force-node-key-generation
runs live in sc_cli config creation, and all subcommands honor their own
flags (pruning flags legitimately apply to import/check DB creation, so
rejection is run-path only).
@n13

n13 commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Added a CLI sweep commit (0ec0b29): audited every run-path flag for silently-ignored or silently-overridden behavior.

Now honored: --node-key — sc_cli already parses it as a Dilithium secret (with the authority safety check), but the run path stomped it with the file-based identity. The override now only applies when no explicit key is given. Verified live: same key ⇒ same peer ID across runs, different keys ⇒ different peer IDs.

Now rejected loudly instead of silently overridden:

  • --state-pruning values that don't match what the node enforces — full-sync nodes are fixed to archive-canonical; warp nodes accept a numeric window ≥ 256 (new capability) and reject archive modes.
  • --blocks-pruning other than archive-canonical.
  • --checkpoint-header / --checkpoint-url without --sync warp.

Audited and fine as-is: --network-backend and --node-key-type (already single-variant: litep2p / dilithium), --unsafe-force-node-key-generation (live via sc_cli config creation, which runs before the override), and all subcommands (build-spec, check-block, export-blocks, export-state, import-blocks, purge-chain, revert, chain-info, key, benchmark) — they honor their own flags, including pruning for DB-creating subcommands, which is why rejection is run-path only.

All five rejection paths smoke-tested against the release binary with clear error messages and exit 1.

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.

1 participant