Version: 27 (stellar-core v27.0.0 / Protocol 27) Status: Informational Date: 2026-06-21
- Introduction
- Architecture
- Data Types
- History Archive Structure
- Checkpoint Publishing Pipeline
- Catchup Configuration and Range
- Ledger Apply Manager
- Catchup Pipeline
- Ledger Chain Verification
- Bucket Application
- Transaction Replay
- Transaction Results Verification
- Buffered Ledger Application
- Error Handling and Crash Recovery
- Invariants and Safety Properties
- Constants
- References
- Appendix A: History Archive Layout Example
- Appendix B: Catchup Phase Sequence
- Appendix C: Knit-to-LCL Decision Matrix
This specification defines the observable protocol behavior of the Stellar catchup, replay, and history publishing subsystem. It covers:
- The structure of history archives published by validators.
- The pipeline by which a node incrementally builds, finalizes, and uploads checkpoints to history archives.
- The catchup algorithm by which a desynchronized node reconstitutes ledger state from history archives and re-enters consensus.
- The verification rules a conforming implementation MUST apply to history archive content before accepting it as authoritative.
This specification is implementation agnostic. It is derived exclusively from the vetted stellar-core C++ implementation (v27.0.0). Any conforming implementation that produces identical history archive contents and identical post-catchup ledger state for all valid inputs is considered correct.
Out of scope:
- File transfer mechanics (HTTP, S3, GCS, command templates).
- Compression formats (gzip) and on-disk file naming conventions used internally by the implementation.
- Work-graph scheduling, threading models, retry counts, parallelism strategies.
- Database schemas, in-memory caching, metrics, logging.
- The internal structure of buckets (see BUCKETLISTDB_SPEC) and the internal structure of ledger close (see LEDGER_SPEC).
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL in this document are to be interpreted as described in RFC 2119.
| Term | Definition |
|---|---|
| Checkpoint | A unit of historical content covering 64 consecutive ledgers, anchored at the last ledger of the range (a value of the form K*64 - 1). |
| Checkpoint Ledger | The last ledger of a checkpoint; equivalently checkpointContainingLedger(L) for any L in the checkpoint. |
| Checkpoint Frequency | The constant CHECKPOINT_FREQUENCY = 64 ledgers. |
| HAS | History Archive State — a JSON document describing the BucketList snapshot at a given ledger. |
| LCL | Last Closed Ledger — the most recently applied and committed ledger on the local node. |
| LCL+1 | The ledger immediately following the LCL; the next ledger expected for application. |
| Genesis | The fictional ledger 0; ledger 1 is the first real ledger. |
| Buffered Ledger | A LedgerCloseData value received from consensus but not yet applied, held in memory pending application. |
| Trusted Hash | A (ledgerSeq, hash) pair whose authenticity is established by a source outside the history archive (typically SCP nomination or operator-supplied input). |
| Trust Anchor | Either the LCL (rooted in the local node's prior committed state) or a trusted hash supplied at catchup initiation. |
| ONLINE Mode | Catchup mode in which the node is connected to the network and buffers new ledgers from consensus during catchup. |
| OFFLINE Mode | Catchup mode in which the node is not connected to the network; only archive content drives state. |
| Specification | Relationship |
|---|---|
| BUCKETLISTDB_SPEC | Defines bucket structure, BucketList hash, and BucketApplicator semantics referenced by §10. |
| LEDGER_SPEC | Defines applyLedger and the ledger close pipeline invoked by §11 and §13. |
| HERDER_SPEC | Defines the SCP-tracked state set by §8 and the source of buffered ledgers consumed by §13. |
| TX_SPEC | Defines transaction sets and transaction results whose hashes are verified in §11–§12. |
Algorithms use camelCase pseudocode. Protocol-version conditional behavior is annotated @version(≥N) or @version(<N). XDR enums and error codes are rendered SCREAMING_SNAKE_CASE. Cross-spec references use plain text (e.g., BUCKETLISTDB_SPEC §5).
The catchup, replay, and history publishing subsystem encompasses two complementary, concurrently-running workflows:
- Publishing: A synchronized validator that has consensus participation enabled produces, every 64 ledgers, a complete checkpoint of recent history and uploads it to one or more history archives.
- Catchup: A desynchronized or newly-joining node downloads history archive content, verifies it against a trust anchor, reconstitutes BucketList state, and replays per-checkpoint transactions to converge with the network.
graph TD
subgraph Local["Local Node"]
LM["LedgerManager<br/>(close pipeline)"]
CB["CheckpointBuilder<br/>(streaming append)"]
PQ[("publish queue<br/>(durable HAS files)")]
BL[("BucketList<br/>+ Hot Archive")]
LAM["LedgerApplyManager<br/>(syncing-ledger buffer)"]
CW["CatchupWork<br/>(state machine)"]
end
subgraph Archive["History Archive (remote)"]
WK[".well-known/<br/>stellar-history.json"]
HIST["history/<NN>/.../history-<LSEQ>.json"]
LED["ledger/<NN>/.../ledger-<LSEQ>.xdr.gz"]
TX["transaction/<NN>/.../transaction-<LSEQ>.xdr.gz"]
RES["results/<NN>/.../results-<LSEQ>.xdr.gz"]
BUCK["bucket/<hh>/.../bucket-<HASH>.xdr.gz"]
end
SCP["SCP / Herder<br/>(externalized values)"]
LM -->|"append per-ledger"| CB
LM -->|"every 64 ledgers"| PQ
CB -->|"finalize on commit"| PQ
PQ -->|"PublishWork"| LED
PQ -->|"PublishWork"| TX
PQ -->|"PublishWork"| RES
PQ -->|"PublishWork"| HIST
PQ -->|"PublishWork"| WK
BL -->|"differing buckets"| BUCK
SCP -->|"LedgerCloseData"| LAM
LAM -->|"sequential apply"| LM
LAM -->|"trigger catchup"| CW
CW -->|"GET HAS"| WK
CW -->|"GET HAS"| HIST
CW -->|"GET headers"| LED
CW -->|"GET txs"| TX
CW -->|"GET buckets"| BUCK
CW -->|"verify + apply"| LM
LM --> BL
A validator MAY operate as a pure observer (catchup only), as a pure publisher (publish only), or as both (the typical full validator). Publishing requires both a configured get and put command for at least one archive; nodes that lack writable archives produce no checkpoints but still consume them.
A HAS is a JSON document describing the BucketList state at a single ledger boundary. Two on-the-wire versions exist:
| Field | Type | Description |
|---|---|---|
version |
unsigned | 1 (pre Hot Archive) or 2 (with Hot Archive). |
server |
string | Implementation version string of the producer; informational. |
networkPassphrase |
string | Network identifier (e.g., "Public Global Stellar Network ; September 2015"). REQUIRED when version >= 2. |
currentLedger |
uint32 | Ledger sequence number this HAS describes. MUST be the last ledger of a checkpoint. |
currentBuckets |
array | The live BucketList. MUST have exactly LIVE_BUCKETLIST_LEVELS (= 11) entries. |
hotArchiveBuckets |
array | The Hot Archive BucketList. Present iff version >= 2. MUST have exactly HOT_ARCHIVE_BUCKETLIST_LEVELS entries when present. |
Each bucket-level entry has the form:
| Field | Type | Description |
|---|---|---|
curr |
hex-string (Hash) | Hash of the curr bucket at this level. |
snap |
hex-string (Hash) | Hash of the snap bucket at this level. |
next |
object | Future-bucket state: either clear, output-hash, or live (with input hashes). |
The next field's representation is documented in BUCKETLISTDB_SPEC §6.
A LedgerRange is a half-open (mFirst, mCount) pair denoting the range [mFirst, mFirst + mCount). A CheckpointRange is a LedgerRange whose mFirst is firstLedgerInCheckpointContaining(L) for some L and whose length is a non-negative multiple of 64 (with the special-case exception that the first checkpoint contains 63 real ledgers).
| Field | Type | Description |
|---|---|---|
toLedger |
uint32 | Destination ledger; the special value 0 (= CURRENT) resolves to "latest known checkpoint" from the archive. |
hash |
optional Hash | When supplied, fixes a trusted hash for toLedger. |
count |
uint32 | Number of past ledgers to replay before toLedger. 0 = minimal (buckets only when possible); UINT32_MAX = complete (full history replay). |
mode |
enum | One of OFFLINE_BASIC, OFFLINE_COMPLETE, ONLINE. |
A CatchupRange is computed from a (lcl, CatchupConfiguration, HistoryManager) triple and consists of:
| Field | Type | Description |
|---|---|---|
applyBuckets |
bool | Whether to apply a bucket snapshot. |
applyBucketsAtLedger |
uint32 | The checkpoint ledger at which buckets are applied; 0 iff !applyBuckets. |
replayRange |
LedgerRange | Half-open range of ledgers to replay after any bucket application. |
A LedgerCloseData value bundles the information required to apply a single ledger. Defined in HERDER_SPEC; the catchup subsystem consumes it as an opaque ledger-close request whose contents include ledgerSeq, transaction set, StellarValue, and (optionally) an expected hash for the resulting ledger header.
A pair of (LedgerHeader, Hash) written into ledger files. The Hash MUST be SHA256(xdr_to_opaque(header)). Defined in LEDGER_SPEC; this subsystem treats it as the smallest unit of header-stream data.
The two entry types written into transaction and results files, respectively. Each entry is keyed by ledgerSeq and contains either a TxSetXDRFrame (legacy v0 or generalized v1) or a TransactionResultSet.
A history archive is a content-addressable tree served over an arbitrary public-readable medium (HTTP, S3, etc.). Five file types exist:
| Type | Path Template | Encoding |
|---|---|---|
| Well-known HAS | .well-known/stellar-history.json |
JSON (latest checkpoint) |
| HAS | history/<NN>/<NN>/<NN>/history-<LSEQ>.json |
JSON |
| Ledger headers | ledger/<NN>/<NN>/<NN>/ledger-<LSEQ>.xdr.gz |
gzipped XDR stream |
| Transactions | transaction/<NN>/<NN>/<NN>/transaction-<LSEQ>.xdr.gz |
gzipped XDR stream |
| Results | results/<NN>/<NN>/<NN>/results-<LSEQ>.xdr.gz |
gzipped XDR stream |
| SCP messages | scp/<NN>/<NN>/<NN>/scp-<LSEQ>.xdr.gz |
gzipped XDR stream (optional) |
| Bucket | bucket/<NN>/<NN>/<NN>/bucket-<HASH>.xdr.gz |
gzipped XDR stream |
LSEQ denotes the 8-digit lowercase-hex representation of the checkpoint ledger number; <NN> denotes one of three two-character hex prefixes of that ledger number (or of the bucket hash, for buckets). For example, checkpoint 0x0000003f (= 63) is stored under history/00/00/00/history-0000003f.json.
Given a checkpoint ledger L and a type T:
hex = lowercaseHex8(L)prefix0 = hex[0..2],prefix1 = hex[2..4],prefix2 = hex[4..6]remoteDir = typeString(T) + "/" + prefix0 + "/" + prefix1 + "/" + prefix2remoteName = remoteDir + "/" + typeString(T) + "-" + hex + ".xdr.gz"
For buckets, the same prefix-tree scheme MUST be applied to the bucket's content hash (hex-encoded) rather than to a ledger sequence.
The constant CHECKPOINT_FREQUENCY SHALL be 64 ledgers in production deployments. Checkpoint K covers the inclusive ledger range [K*64, ((K+1)*64) - 1]. Each range contains exactly 64 ledgers, except for the first range [0, 63], which contains only the 63 real ledgers [1, 63] (there is no ledger 0).
A ledger L is the last ledger of a checkpoint iff (L + 1) mod 64 == 0. Implementations MUST compute the containing checkpoint of an arbitrary ledger L as:
checkpointContainingLedger(L) = ((L / 64) + 1) * 64 - 1
A ledger L is the first ledger of a checkpoint iff:
firstLedgerInCheckpointContaining(L) =
checkpointContainingLedger(L) - (sizeOfCheckpointContaining(L) - 1)
where sizeOfCheckpointContaining(L) = 63 if L < 64, else 64.
A "ledger zero" pseudo-checkpoint is also written to a fresh archive at history/00/00/00/history-00000000.json, with all-zero bucket hashes. It signals only that the archive has been initialized and carries no transaction or ledger-header content.
When a conforming implementation receives a HAS, it MUST validate the following before treating its contents as authoritative:
versionis1or2. Other values MUST be rejected.- If
version >= 2,networkPassphraseMUST equal the local node's configured network passphrase; otherwise the HAS MUST be rejected. currentBucketshas exactlyLIVE_BUCKETLIST_LEVELSentries.- If
version >= 2,hotArchiveBucketshas exactlyHOT_ARCHIVE_BUCKETLIST_LEVELSentries. - Iterating each BucketList from the highest level downward, both
snapandcurrat each level are valid buckets whosebucketVersions form a non-decreasing sequence as level decreases (newer levels have versions greater-or-equal to older levels). Thesnapat a given level is processed before itscurrbecausesnapis older thancurr. Any version inversion MUST cause rejection. - Level 0's
nextfield MUST be clear (no future merge is tracked at the youngest level). - For levels
i >= 1, given the previous level'ssnap(i.e.,buckets[i-1].snap) with versionprevSnapVersion:@version(≥12)(FIRST_PROTOCOL_SHADOWS_REMOVED, i.e.,prevSnapVersion >= 12):nextat leveliMUST be clear. Future merges no longer track shadows.@version(<12):nextat leveliMUST have a resolved output hash (hasOutputHash() == true); a live, unresolved future MUST cause rejection.
- If a downloaded bucket file exceeds
MAX_HISTORY_ARCHIVE_BUCKET_SIZE = 100 GB, the bucket MUST be rejected as invalid.
The BucketList hash carried in a ledger header MUST be computed as follows (and MUST match what the HAS implies, on pain of catchup failure):
function bucketListHash(has):
liveHash = sha256()
for each level in has.currentBuckets:
levelHash = sha256()
levelHash.add(level.curr)
levelHash.add(level.snap)
liveHash.add(levelHash.finish())
if has.version >= 2:
hotHash = sha256()
for each level in has.hotArchiveBuckets:
levelHash = sha256()
levelHash.add(level.curr)
levelHash.add(level.snap)
hotHash.add(levelHash.finish())
combined = sha256()
combined.add(liveHash.finish())
combined.add(hotHash.finish())
return combined.finish()
else:
return liveHash.finish()
A node with at least one writable archive MUST publish a fresh checkpoint every 64 ledgers. Publication is structured as four logical phases, executed across a streaming append phase that overlaps consensus and an asynchronous upload phase that follows ledger commit.
On every closeLedger(L), the publishing node:
- Opens (if not already open) three streaming XDR output files in append mode, one each for ledger headers, transactions, and transaction results. Each is created with a
.dirtysuffix and isfsync-on-write. - Appends a
LedgerHeaderHistoryEntry(header, sha256(xdr_to_opaque(header)))to the ledger-header file. - If the closed ledger contained a non-empty transaction set: appends a
TransactionHistoryEntry(with thetxSetor thegeneralizedTxSet, depending on whether the set is a v0 wire form or a v1 generalized form) to the transactions file and aTransactionHistoryResultEntrywithtxResultSetto the results file. Empty ledgers SHALL NOT append transaction or results entries (gaps are permitted in these two streams, but not in the headers stream).
The append-phase invariant is: dirty publish files always end at a ledger sequence >= LCL in the database. Conversely, after checkpointComplete (§5.2), finalized files always end at a ledger sequence <= LCL.
When closeLedger(L) runs with L equal to a checkpoint ledger (isLastLedgerInCheckpoint(L)):
- After the ledger transaction commits, the publishing node closes the three dirty streams.
- For each of the three file types, the dirty file is renamed via a durable rename (rename followed by
fsyncof the containing directory) to its canonical (non-dirty) name. This renaming step is atomic and survives crashes: a finalized file always reflects committed content. - If a finalized file already exists at the target name (from a prior aborted attempt), the rename is skipped.
After finalization, the three checkpoint files are immutable until publication completes (or until a subsequent deletePublishedFiles removes them).
In parallel with file finalization, the closing node produces a HAS describing the BucketList state at the checkpoint ledger:
- Construct a HAS object:
@version(≥23)(FIRST_PROTOCOL_SUPPORTING_PERSISTENT_EVICTION, equivalent to ledger protocol 23):version = 2; populate bothcurrentBucketsandhotArchiveBuckets.@version(<23):version = 1; populate onlycurrentBuckets.
- Set
networkPassphraseto the local node's configured network passphrase. - Serialize the HAS to a dirty file in the publish queue directory; durably rename it to a temporary "queued" file
<seq>.checkpoint.dirtypending ledger commit. - After ledger commit, durably rename the temporary file to
<seq>.checkpoint(the finalized queued state).
The set of .checkpoint files in the publish queue directory constitutes the HAS publish queue. Each file represents one pending publish operation.
For each .checkpoint file in the queue (oldest first):
- ResolveSnapshotWork: any unresolved
FutureBuckets referenced by the HAS are resolved (their merges driven to completion). - WriteSnapshotWork: an SCP-history file (containing the SCP messages used to externalize the ledgers in the checkpoint) is emitted to a temporary directory. If no SCP messages exist for the range, no SCP file is uploaded.
- PutSnapshotFilesWork: for each writable archive,
a. The archive's current well-known HAS is fetched.
b. The set of differing buckets (those present in the local HAS but not in the remote, computed by walking both BucketLists from highest level to lowest) is computed.
c. Each differing file (ledger, transactions, results, optional SCP, and each differing bucket) is gzipped.
d. Files are uploaded to their respective remote paths (§4.1).
e. The HAS itself is uploaded twice: once to its permanent path
history/<NN>/<NN>/<NN>/history-<LSEQ>.jsonand once (overwriting) to.well-known/stellar-history.json. - On successful upload to all configured writable archives:
- The HAS file is dequeued.
- Local
ledger-,transaction-,results-, and.checkpointfiles for the just-published checkpoint MAY be deleted (thedeletePublishedFilesoperation walks backwards through up toMAX_PUBLISH_DELETE_CHECKPOINTS = 100prior checkpoints to clean up any straggler files).
- On any failure: the HAS remains queued and the operation is retried on a future scheduling.
A node MAY add a configurable delay before initiating upload (PUBLISH_TO_ARCHIVE_DELAY) so that local merge work has time to complete before futures are forced.
The set of bucket hashes to upload from a local HAS H relative to a remote archive's HAS H' is computed as follows:
- Initialize
inhibit = { all-zero-hash }. - For each level of
H', insert that level'scurr,snap, and (resolved)nextoutput hash intoinhibit. - Walk the levels of
Hfrom highest to lowest. For each level, for each ofsnap,nextoutput hash (if any),curr(in that order), append the hash to the output list iff it is not already ininhibit, and add it toinhibit.
This produces a list ordered from largest/oldest bucket (high level snap) to smallest/newest (level 0 curr).
When catchup is replaying transactions in OFFLINE mode while the local publisher is also queueing checkpoints, the catchup pipeline applies backpressure:
- While
publishQueueLength <= PUBLISH_QUEUE_UNBLOCK_APPLICATION(= 8), replay continues unimpeded. - When
publishQueueLength > PUBLISH_QUEUE_MAX_SIZE(= 16), replay pauses (each per-checkpoint apply step waits) until the queue drains to or belowPUBLISH_QUEUE_UNBLOCK_APPLICATION.
This prevents catchup-driven publishing from running away from upload capacity. Backpressure is hysteretic: the pause-threshold and resume-threshold differ, preventing oscillation. ONLINE catchup does not apply this backpressure — it is intended to converge quickly with the network.
On startup, the node's restoreCheckpoint(lcl) procedure restores a consistent publishing state from the LCL recorded in the database:
- Checkpoint files:
cleanup(lcl)is called.- The three potentially-dirty files for
checkpointContainingLedger(lcl)are scanned. - Each dirty file is read sequentially; entries with
ledgerSeq <= lclare written to a tmp file, and the file is truncated at the first entry whoseledgerSeq > lclor whose XDR is malformed. - The truncated tmp file is durably renamed back over the dirty file.
- For ledger headers, an additional check enforces that the recovered file ends at exactly
lcl. Transactions and results files are permitted to end earlier (empty ledgers leave no entries). - If none of the three dirty files exist (publishing was disabled before the crash),
skipFirstCheckpointSinceItIsIncomplete = true. Subsequent appends will skip ledgers until the next checkpoint-alignedfirstLedgerInCheckpoint. - If some dirty files exist but not all three, the recovery MUST fail (the partial state is unrecoverable).
- The three potentially-dirty files for
- Publish queue tmp files: any
<seq>.checkpoint.dirtyfile withseq > lclis removed. - Checkpoint finalization: if
isLastLedgerInCheckpoint(lcl)and the canonical files have not yet been finalized,maybeCheckpointComplete(lcl)is invoked, performing the rename step described in §5.2.
After restoreCheckpoint completes, the publishing pipeline is in a state from which forward operation is well-defined.
The three catchup modes derive from CatchupConfiguration.mode:
| Mode | Semantics |
|---|---|
OFFLINE_BASIC |
Node is not connected. Files of unused types MAY be skipped during validation; only files needed for the chosen catchup path are downloaded and verified. |
OFFLINE_COMPLETE |
Node is not connected. All file types in the catchup range are validated regardless of whether they are applied. Notably, transaction results files are downloaded and verified per §12. |
ONLINE |
Node is connected; SCP-buffered ledgers are drained after catchup completes (§13). |
The three traditional strategies map to count values:
| Strategy | Count | Description |
|---|---|---|
| Minimal | 0 |
Apply buckets only; no transaction replay. Permitted only when toLedger lies exactly on a checkpoint boundary and LCL is genesis. |
| Recent | configured value (typical: a few thousand) | Apply buckets at a checkpoint just before toLedger - count + 1, then replay forward. |
| Complete | UINT32_MAX |
Replay every ledger from genesis to toLedger; no bucket application. |
A configured value of "max" MUST parse to UINT32_MAX. A configured value of "current" for the destination ledger MUST parse to CatchupConfiguration::CURRENT (= 0), which is resolved later against the archive's well-known HAS.
Given lcl and a resolved CatchupConfiguration (with toLedger != CURRENT), the CatchupRange is computed as follows. Preconditions: lcl >= GENESIS_LEDGER_SEQ, toLedger > GENESIS_LEDGER_SEQ, toLedger > lcl.
function calculateCatchupRange(lcl, cfg, hm):
init = GENESIS_LEDGER_SEQ # = 1
fullReplayCount = cfg.toLedger - lcl
# Case 1: LCL is not genesis. Replay forward from LCL+1.
if lcl > init:
return CatchupRange(replayRange = (lcl + 1, fullReplayCount))
# All remaining cases: lcl == genesis.
fullReplay = (init + 1, fullReplayCount)
# Case 2: count >= entire required replay range.
if cfg.count >= fullReplayCount:
return CatchupRange(replayRange = fullReplay)
# Case 3: bucket-only catchup. Count is zero AND target is exactly on
# a checkpoint boundary.
if cfg.count == 0 and isLastLedgerInCheckpoint(cfg.toLedger):
return CatchupRange(applyBucketsAtLedger = cfg.toLedger)
targetStart = cfg.toLedger - cfg.count + 1
firstInCheckpoint = firstLedgerInCheckpointContaining(targetStart)
# Case 4: target lies entirely within the first checkpoint.
if firstInCheckpoint == init:
return CatchupRange(replayRange = fullReplay)
# Case 5: apply buckets at the prior checkpoint, then replay.
applyBucketsAt = lastLedgerBeforeCheckpointContaining(targetStart)
replay = (firstInCheckpoint, cfg.toLedger - applyBucketsAt)
return CatchupRange(applyBucketsAt, replay)
The five cases are mutually exclusive and exhaustive. The CatchupRange invariants are checked after construction:
- At least one of
applyBucketsorreplayLedgersis true. - If only replay (cases 1, 2, 4):
replayRange.mFirst != 0. - If buckets+replay (case 5):
applyBucketsAtLedger + 1 == replayRange.mFirst. - If buckets only (case 3):
replayRange.mFirst == 0.
The Ledger Apply Manager mediates between consensus (which produces a stream of LedgerCloseData values) and the ledger close pipeline (which applies them one at a time). It is also responsible for triggering catchup when the node falls behind.
The Apply Manager maintains an in-memory ordered map syncingLedgers : ledgerSeq -> LedgerCloseData. The following invariants MUST hold at every quiescent point:
- (a)
syncingLedgersis empty, OR - (b)
syncingLedgers.firstKey() == lastQueuedToApply + 1(the buffer is contiguous with the apply stream), OR - (c)
syncingLedgerscontains at mostCHECKPOINT_FREQUENCY + 1(= 65) entries, all of which lie in{L, L+1, ..., L+63, L+64}for someL = firstLedgerInCheckpointContaining(L). (The buffer holds at most one full checkpoint of ledgers plus the first ledger of the following checkpoint.)
A separate counter largestLedgerSeqHeard records the highest ledger sequence ever observed by the manager (whether buffered, applied, or skipped).
When the manager receives a new LedgerCloseData (with sequence S) it performs the following decision tree (in order):
- Cleanup: if
CatchupWorkhas completed (success or failure), it is reset. If it failed fatally (§14.3), setcatchupFatalFailure = true. - Refresh
lastQueuedToApplyfromLedgerManager.getLastClosedLedgerNum(). - Skip-stale: if
S <= lastQueuedToApply, returnPROCESSED_ALL_LEDGERS_SEQUENTIALLY. The ledger is silently dropped. - Buffer: insert
(S, LedgerCloseData)intosyncingLedgers. UpdatelargestLedgerSeqHeard = max(largestLedgerSeqHeard, S). - Try sequential apply: if no
CatchupWorkis currently running andS == lastQueuedToApply + 1, calltryApplySyncingLedgers()(§7.3) and returnPROCESSED_ALL_LEDGERS_SEQUENTIALLY. - In-flight catchup: if
CatchupWorkis running:- If
S <= catchupConfig.toLedger, the ledger is permitted in the buffer (it will be drained in §13). - Otherwise, call
trimSyncingLedgers()(§7.4) and returnWAIT_TO_APPLY_BUFFERED_OR_CATCHUP.
- If
- Buffer maintenance: call
trimSyncingLedgers(). - Trigger catchup: if the first buffered ledger is the first ledger of a checkpoint, the buffer contains more than one ledger, the LedgerManager is not currently applying, and
catchupFatalFailureis false, invokestartOnlineCatchup(). Otherwise emit a status message describing why catchup has not started (e.g., "Waiting for trigger ledger") and returnWAIT_TO_APPLY_BUFFERED_OR_CATCHUP.
tryApplySyncingLedgers iterates over the buffer from its smallest entry forward. For each entry whose sequence equals lastQueuedToApply + 1:
- If
lastQueuedToApply - lcl >= MAX_EXTERNALIZE_LEDGER_APPLY_DRIFT(= 12), apply scheduling stops; the node will gracefully transition to catchup mode on the next received ledger. - Otherwise,
applyLedger(lcd, externalize=true)is invoked (synchronously in single-threaded mode, or posted to the ledger-close thread in parallel mode). lastQueuedToApplyis advanced to the just-applied ledger.- The applied entry is removed from the buffer.
Iteration stops on the first non-contiguous gap. The remaining entries stay buffered for a future call.
trimSyncingLedgers discards ledgers that cannot contribute to a future catchup operation:
- Remove all entries with
ledgerSeq < lastQueuedToApply + 1(stale ledgers). - Let
lastBuffered = largest key. IfisFirstLedgerInCheckpoint(lastBuffered):- The checkpoint containing
lastBufferedhas not yet been published. Retain onlylastBufferedplus the entire prior checkpoint.
- The checkpoint containing
- Otherwise:
- The checkpoint containing
lastBufferedhas begun publishing. Retain only entries with sequence>= firstLedgerInCheckpointContaining(lastBuffered).
- The checkpoint containing
startOnlineCatchup constructs a CatchupConfiguration:
toLedger = firstBufferedLedgerSeq - 1(catchup ends one before the earliest buffered ledger so that the first buffered ledger'spreviousLedgerHashcan be cross-checked against the apex of the verified chain).hash = firstBufferedLedger.txSet.previousLedgerHash(the trust anchor at the apex).count = config.CATCHUP_COMPLETE ? UINT32_MAX : config.CATCHUP_RECENT.mode = ONLINE.
It is a precondition error to invoke startOnlineCatchup with fewer than 2 buffered ledgers. The check for "first buffered ledger is the first ledger of a checkpoint" (§7.2 step 8) ensures this precondition.
A single CatchupWork instance drives one end-to-end catchup pass. Its high-level state sequence is:
flowchart TD
A[Start: CatchupWork constructed] --> B[Fetch top-of-archive HAS]
B --> C{Validate HAS}
C -->|invalid| FAIL[Fail]
C -->|valid| D[Resolve CatchupConfiguration]
D --> E[Compute CatchupRange]
E --> F{applyBuckets?}
F -->|yes| G[Fetch bucket-checkpoint HAS]
F -->|no| H[Download + verify ledger chain]
G --> H
H --> I{Verification result}
I -->|fail| J{Fatal?}
J -->|yes| FAIL
J -->|no| K[Retry from earlier checkpoint or different archive]
I -->|ok| L[Save mVerifiedLedgerRangeStart]
L --> M[Restore Herder tracking from archive]
M --> N{OFFLINE_COMPLETE?}
N -->|yes| O[Download + verify tx results]
N -->|no| P{applyBuckets?}
O --> P
P -->|yes| Q[Download + apply buckets]
P -->|no| R{replayLedgers?}
Q --> R
R -->|yes| S[Per-checkpoint replay]
R -->|no| T[Apply buffered ledgers]
S --> T
T --> U[Success]
The catchup pipeline begins by fetching a HAS from the chosen archive:
- If
cfg.toLedger == CURRENT, fetch.well-known/stellar-history.json. Otherwise, fetch the HAS forcheckpointContainingLedger(cfg.toLedger). - Load and parse the HAS.
- Validate per §4.4.
- If
mHAS.currentLedger <= lcl, fail with "Nothing to catch up to" (the network has not progressed beyond local state). - Resolve
CatchupConfiguration.toLedgertomHAS.currentLedgerif it wasCURRENT. - Compute the
CatchupRange(§6.3). - If
applyBuckets, fetch a second HAS for the bucket-apply ledger (unless the bucket-apply ledger coincides with the already-fetched HAS, in which case the existing HAS is reused).
For the full range getFullRangeIncludingBucketApply(), the pipeline issues a batch download of all checkpoint ledger files. After the batch download completes, VerifyLedgerChainWork verifies the chain (§9).
After verification, the pipeline assembles a WorkSequence of dependent steps:
- Herder-state consistency: scan the archive's ledger file for
catchupRange.last()and set the Herder's tracked SCP value to that ledger'sscpValue. This bootstraps Herder so it can rejoin consensus after catchup. - (If
OFFLINE_COMPLETE) Verify tx results: per §12, ensure all transaction-result files in the replay range hash correctly. - (If
applyBuckets) Download and apply buckets: per §10. - (If
replayLedgers) Download and apply transactions: per §11. - Apply buffered ledgers: per §13.
After the last checkpoint of the replay range has been applied, the pipeline checks whether any SCP-buffered ledgers remain (these accumulated in the LedgerApplyManager's syncingLedgers while catchup was running). If so, ApplyBufferedLedgersWork is invoked (§13). It applies as many as possible in order from the buffer; any gap stops draining. After this step succeeds with no further buffered ledgers, catchup is complete.
After bucket application completes, the pipeline asserts (§10.6):
bucketHAS.currentLedger == verifiedLedgerRangeStart.header.ledgerSeqbucketHAS.getBucketListHash() == verifiedLedgerRangeStart.header.bucketListHashverifiedLedgerRangeStart.header.ledgerSeq >= lcl.header.ledgerSeq(catchup MUST NOT apply buckets at a ledger older than LCL)
After transaction replay completes, the pipeline asserts:
mLastApplied.hash == LedgerManager.getLastClosedLedgerHeader().hashmLastApplied.header == LedgerManager.getLastClosedLedgerHeader().header
Any mismatch aborts catchup.
The ledger chain is verified in reverse order, walking from the highest-numbered checkpoint in the catchup range down to the lowest. This reverse walk is what allows a single trusted hash at the apex to authenticate every ledger below it via the chain of previousLedgerHash links.
VerifyLedgerChainWork is parameterized with two trust anchors:
lastClosed: the local node's LCL,(seq, hash). The pairhashis required to be present (the local node always has a hash for LCL).trustedMaxLedger: a future-typed(seq, optional<hash>)for the highest ledger of the verification range. If the hash isNone, no upper-end trusted hash exists and the chain is verified internally only.
For ONLINE catchup the upper trust anchor's hash is the buffered ledger's previousLedgerHash (§7.5). For OFFLINE catchup with an operator-supplied hash, that hash is used directly. For OFFLINE catchup with no hash, the upper anchor's hash is None and the implementation MUST log that verification is "skipping" the trusted-hash check.
Each invocation of verifyHistoryOfSingleCheckpoint verifies a single checkpoint file. Variables incoming (the link from the next-higher checkpoint, written by the previous iteration into verifiedAhead) and verifiedAhead (the link to pass down to the next-lower checkpoint) chain the iterations together.
For each ledger header curr in the checkpoint file (read in order):
- Catastrophic version mismatch: if
curr.header.ledgerVersion > config.LEDGER_PROTOCOL_VERSION, setchainDisagreesWithLocalState = VERIFY_STATUS_ERR_BAD_LEDGER_VERSION. Do not return yet — continue verifying so that the propagation chain remains consistent. - LCL-overlap check: if
curr.header.ledgerSeq == lcl.seq, verifysha256(xdr_to_opaque(curr.header)) == lcl.hash. On mismatch, setchainDisagreesWithLocalState = VERIFY_STATUS_ERR_BAD_HASH. - LCL-link check: if
curr.header.ledgerSeq == lcl.seq + 1, verify the linkcurr.header.previousLedgerHash == lcl.hash. On mismatch, setchainDisagreesWithLocalState = result. - Resume-trust check: if a
maxPrevVerifiedhash anchor was supplied andcurr.header.ledgerSeq == maxPrevVerified.seq, verifycurr.hash == maxPrevVerified.hash. Mismatch is fatal (VERIFY_STATUS_ERR_BAD_HASH). - Checkpoint-start check (first ledger of file): verify
isFirstLedgerInCheckpoint(curr.header.ledgerSeq); on failure returnVERIFY_STATUS_ERR_MISSING_ENTRIES. Also verify the standalone hashverifyLedgerHistoryEntry(curr)(just thesha256of the header equalscurr.hash). - Sequential link check (subsequent ledgers): verify
curr.header.ledgerSeq == prev.header.ledgerSeq + 1. Lower →VERIFY_STATUS_ERR_UNDERSHOT; higher →VERIFY_STATUS_ERR_OVERSHOT. Then verifycurr.header.previousLedgerHash == prev.hashandverifyLedgerHistoryEntry(curr). - Stop at range end: if
curr.header.ledgerSeq == rangeLast, stop reading this file early (the file may extend beyond the requested range).
After the loop, verify that the last-read curr is either the checkpoint ledger or rangeLast. Any other value indicates a corrupt file → VERIFY_STATUS_ERR_MISSING_ENTRIES.
Then:
- Save
verifiedAhead = (first.header.ledgerSeq - 1, first.header.previousLedgerHash)for the next-lower checkpoint. - If this iteration is the first (i.e., the apex checkpoint), use
trustedMaxLedger.get()instead ofincomingfor the apex check. - Apex check:
verifyLastLedgerInCheckpoint(curr, incoming)— ifincoming.hashis present, verifycurr.hash == incoming.hash; mismatch →VERIFY_STATUS_ERR_BAD_HASH.
If this iteration is the lowest checkpoint:
- Write
(first.header.ledgerSeq - 1, first.header.previousLedgerHash)to the work'sverifiedMinLedgerPrevoutput (the downstream catchup steps read this asmVerifiedLedgerRangeStart). - Record
mMaxVerifiedLedgerOfMinCheckpoint = curr; this is the apex of the lowest verified checkpoint and is used as the bucket-apply target.
A failure to read an XDR-format header is mapped:
xdr_bad_message_size→VERIFY_STATUS_ERR_BAD_LEDGER_VERSION. The implementation interprets this as "the archive's ledger headers are encoded against a future protocol version this binary cannot deserialize" and signals upgrade-required.- Any other deserialization error →
VERIFY_STATUS_ERR_CORRUPT_HEADER.
After verification completes, VerifyLedgerChainWork.onSuccess resolves a fatalFailurePromise to (chainDisagreesWithLocalState != None) && hasTrustedHash. The interpretation is:
- If the chain verified internally AND was authenticated by a trusted hash at the apex, AND it disagreed with local LCL or local version, then the failure is fatal: the local node's state is incompatible with the network and no amount of retry will recover.
LedgerApplyManagerMUST setcatchupFatalFailure = trueand refuse to retry catchup. - If no trusted hash was supplied, a local disagreement MAY indicate a corrupt archive; the catchup MAY be retried against a different archive.
The mapping of LedgerVerificationStatus to corrective action:
| Status | Action |
|---|---|
VERIFY_STATUS_OK |
Continue to next-lower checkpoint, or finish if at lowest. |
VERIFY_STATUS_ERR_BAD_LEDGER_VERSION |
Fatal. Local node version is incompatible with the network. |
VERIFY_STATUS_ERR_BAD_HASH |
Retry from a different archive (or fatal if a trusted hash authenticated the bad chain). |
VERIFY_STATUS_ERR_OVERSHOT |
Archive corruption; retry. |
VERIFY_STATUS_ERR_UNDERSHOT |
Archive corruption; retry. |
VERIFY_STATUS_ERR_MISSING_ENTRIES |
Archive corruption; retry. |
VERIFY_STATUS_ERR_CORRUPT_HEADER |
Archive or local-FS corruption; retry. |
When the CatchupRange calls for applyBuckets, the catchup pipeline reconstitutes ledger state directly from the BucketList described in the bucket-apply HAS.
The set of differing buckets (relative to the local BucketManager's already-known buckets) is computed using the §5.5 algorithm against the local HAS. Each differing bucket is downloaded, gunzipped, and verified.
Per-bucket verification (VerifyBucketWork):
- The downloaded file's size MUST NOT exceed
MAX_HISTORY_ARCHIVE_BUCKET_SIZE = 100 GB; otherwise verification fails. - The file is read end-to-end and a SHA-256 hash is computed.
- The computed hash MUST equal the hash declared in the HAS; otherwise verification fails.
After all buckets are downloaded but before any application, the HAS is re-validated using containsValidBuckets (§4.4). Failure aborts catchup with no state mutations. Before bucket application begins, the LedgerManager's apply-state phase is reset to SETTING_UP_STATE, and a persistent-state flag REBUILD_FOR_OFFER_TABLE is set so that an interrupted bucket apply is recoverable.
For each level i from 0 to LIVE_BUCKETLIST_LEVELS - 1, in order, and for each bucket within that level (curr then snap):
- Track the minimum-seen
bucketVersionacross all buckets applied so far (mMinProtocolVersionSeen). - For each entry in the bucket, in stored order:
- If the entry's
LedgerKeyhas already been seen (mSeenKeys), skip it. The first occurrence of any key in this traversal order is necessarily the newest version, because the BucketList is sorted with newer levels first. - Otherwise, apply the entry to the database and insert the key into
mSeenKeys.
- If the entry's
- After all entries in the bucket are applied, run bucket-apply invariants and advance to the next bucket.
The protocol-version semantics used during apply are the maximum of LEDGER_PROTOCOL_VERSION and mVerifiedLedgerRangeStart.header.ledgerVersion. The implementation temporarily writes this version into the loaded ledger header for the duration of bucket application and restores it afterward.
The Hot Archive BucketList is not applied to live SQL state; only the live BucketList contributes to entry application. Hot Archive buckets are still indexed (§10.4) so they can be queried for retroactive evictions per BUCKETLISTDB_SPEC §10.
After application, all applied buckets are indexed (in-memory plus on-disk index per BUCKETLISTDB_SPEC §7). Hot Archive buckets are indexed at this stage as well, even though they are not applied to the live SQL state.
After indexing completes, AssumeStateWork:
- Verifies that every bucket referenced by the HAS (curr, snap, and any resolved next output hash, on both live and hot archive lists) is present in the
BucketManager. Any missing bucket is an unrecoverable error. - Calls
BucketManager.assumeState(has, maxProtocolVersion, restartMerges=true)which restores the in-memory BucketList from the HAS and resumes any in-progress merges. (See BUCKETLISTDB_SPEC §6 for the merge state machine.) - Runs
InvariantManager.checkAfterAssumeState.
Once all buckets are applied and state is assumed:
- The consistency asserts of §8.5 are checked.
LedgerManager.setLastClosedLedger(verifiedLedgerRangeStart, rebuildInMemoryState)updates LCL. TherebuildInMemoryStateflag istruein ONLINE mode or in OFFLINE mode that has a replay phase, andfalsefor offline buckets-only catchup.- The
REBUILD_FOR_OFFER_TABLEpersistent-state flag is cleared. If the node crashes aftersetLastClosedLedgerbut before this flag is cleared, the new HAS is already durable; the rebuild on restart is harmless re-application of buckets to a consistent state.
The replay phase is checkpoint-driven: for each checkpoint in the replay range, the per-checkpoint transaction file is downloaded, the ledger headers file is opened, and each ledger in the checkpoint is reconstructed and applied.
For checkpoint K (starting from the lowest checkpoint covering replayRange.mFirst and ascending to the highest covering replayRange.last()):
- Download
transaction-<K>.xdr.gz; gunzip to local path. - Open
ledger-<K>.xdr(already downloaded in §8.2) andtransaction-<K>.xdr. - Determine the inclusive range to apply for this checkpoint:
low = firstLedgerInCheckpointContaining(K)(the first real ledger of the checkpoint).high = min(K, replayRange.last()).
- Sequentially read ledger headers from the ledger file. For each header with
header.ledgerSeqin[low, high], perform the knit-to-LCL analysis (§11.2). If the header survives the analysis, construct aLedgerCloseDataand invokeapplyLedger(lcd, externalize=false). - After each apply, verify
LedgerManager.getLastClosedLedgerHeader().hash == header.hash. Mismatch is a hard failure.
Conditional per-ledger application requires that any in-flight BucketList merges that block apply (specifically, merges at levels not greater than getMaxMergeLevel(LCL + 1)) have completed. The pipeline polls every 500 ms until this predicate is satisfied.
The local transaction file is deleted after the checkpoint's apply completes (low water mark; deletion is non-fatal).
The construction of ApplyCheckpointWork enforces two structural preconditions: the ledger range's mFirst MUST equal firstLedgerInCheckpointContaining(K), and the range's last() MUST NOT exceed K. A LedgerRange that spans more than one checkpoint MUST NOT be passed to a single ApplyCheckpointWork.
Reading the next header from the file (call it H, with sequence S), the implementation MUST handle five cases:
| Case | Condition | Action |
|---|---|---|
| 1 | S + 1 < LCL.seq |
Skip. Header is older than the start of the replay range. |
| 2 | S + 1 == LCL.seq |
Verify H.hash == LCL.previousLedgerHash. Mismatch is fatal. Skip (do not apply); H is the predecessor of LCL. |
| 3 | S == LCL.seq |
Verify H.hash == LCL.hash. Mismatch is fatal. Skip. |
| 4 | S == LCL.seq + 1 |
This is the next ledger to apply. Verify H.previousLedgerHash == LCL.hash; mismatch is fatal. Build a LedgerCloseData and apply. |
| 5 | S > LCL.seq + 1 |
Replay has overshot the current ledger. This is fatal — it means the local apply path stalled or LCL is in an unexpected state. |
When constructing the LedgerCloseData (case 4):
- Look up the next
TransactionHistoryEntryfor the sameledgerSeqfrom the transaction file. If absent (an empty ledger), construct an emptyTxSetXDRFrameusing LCL's header as basis. - If the
TransactionHistoryEntry.ext.v() == 0, decodetxSetas the legacy v0 wire form; otherwise decodeext.generalizedTxSet()as the generalized v1 form. - Verify
header.scpValue.txSetHash == txSet.contentsHash. Mismatch is fatal (the archive's transaction set does not match the verified header). - Construct
LedgerCloseData(ledgerSeq, txSet, header.scpValue, expectedHash=header.hash).
The expected hash is propagated through applyLedger and verified against the apply outcome (§11.1 step 5).
When the catchup mode is offline() (either OFFLINE_BASIC or OFFLINE_COMPLETE), each per-checkpoint apply step is wrapped in a conditional work item that pauses until the publish queue (§5.6) has drained. ONLINE catchup does not apply this backpressure — it is intended to converge quickly with the network, and the publish queue is expected to drain naturally as the node re-syncs.
Both transaction and results files MAY have gaps (empty ledgers produce no entry). The ledger header file MUST NOT have gaps within a checkpoint. When a transaction entry for the current ledger is absent, the implementation constructs an empty TxSetXDRFrame from the current LCL header.
In OFFLINE_COMPLETE mode, the catchup pipeline downloads and verifies every per-checkpoint results file in the replay range, even though the results are not directly applied (transaction outcomes are produced fresh by the local apply).
For each checkpoint K in the replay range:
- Download
results-<K>.xdr.gzand gunzip. - Open the local
ledger-<K>.xdrand the freshly-downloadedresults-<K>.xdr. - For each
LedgerHeaderHistoryEntrycurrin the ledger file: a. Read the matchingTransactionHistoryResultEntryforcurr.header.ledgerSeqfrom the results file. The reader enforces that result ledger sequences are strictly increasing within the file, and never exceedKor fall belowfirstLedgerInCheckpointContaining(K). b. ComputeresultSetHash = sha256(xdr_to_opaque(resultEntry.txResultSet)). c. VerifyresultSetHash == curr.header.txSetResultHash. Mismatch is fatal for the catchup. d. The genesis ledger is exempt: ifcurr.header.ledgerSeq == GENESIS_LEDGER_SEQandresultEntry.txResultSet.resultsis empty, the hash check is skipped.
OFFLINE_BASIC mode SHALL NOT perform this verification. ONLINE mode SHALL NOT perform this verification (the live tx-result hash is verified by SCP nomination).
When catchup completes successfully (or is started in ONLINE mode), the LedgerApplyManager's syncingLedgers buffer MAY contain ledgers that arrived from consensus during the catchup. ApplyBufferedLedgersWork drains these:
- Call
maybeGetNextBufferedLedgerToApply(). This trims stale entries, then returns the smallest buffered ledger iff its sequence is exactlylastQueuedToApply + 1. - If
Noneis returned: no more contiguous buffered ledgers; finish. - Construct an
ApplyLedgerWorkfor the returned ledger, wrap it in a conditional work item that waits for any blocking BucketList merges to complete, and apply. - After application, repeat from step 1.
The drain operation MAY terminate without emptying the buffer if there is a gap (a ledger is missing from the buffer that would otherwise lie between two buffered ledgers). The remaining buffered ledgers MUST be retained: a subsequent ledger arrival could fill the gap, or further catchup MAY be triggered.
After ApplyBufferedLedgersWork succeeds and no further buffered ledger is contiguous, catchup MUST report success only if maybeGetNextBufferedLedgerToApply() returns None.
Within a single CatchupWork:
- File download steps MAY retry against the current archive a small number of times, then rotate to a different archive from the configured pool.
- Top-level catchup work does not retry once verification or apply fails: the caller is expected to schedule a new
CatchupWorkafter addressing the cause.
When a batch download exhausts its retries on a given archive, it rotates to a different readable archive from the pool selected at random by HistoryArchiveManager.selectRandomReadableHistoryArchive(). Rotation is an internal recovery mechanism: a successful catchup MAY combine files from multiple archives provided each individual file passes verification.
A catchup failure is fatal (i.e., MUST NOT be retried automatically) iff the chain verified against a trusted hash anchor AND was found internally consistent AND disagreed with the local node's LCL or local protocol version. The LedgerApplyManager MUST honor catchupFatalFailure = true and refuse to schedule further catchup attempts until the operator intervenes.
All other failure classes — archive corruption, missing files, hash mismatches without a trust anchor, transient network errors — MAY be retried.
Publishing-side crash recovery is described in §5.7. Summary:
- Dirty checkpoint files are truncated to LCL on restart.
- The publish queue's tmp files are purged for any seq > LCL.
- If LCL is on a checkpoint boundary, finalization is re-attempted (idempotent).
If a crash occurs during bucket application, the REBUILD_FOR_OFFER_TABLE persistent-state flag survives the crash. On restart, the node will detect this flag and re-trigger catchup (the database is in an inconsistent state and SHALL NOT be trusted as LCL). The setLastClosedLedger + clearRebuildForOfferTable pair is the commit boundary for a successful bucket apply.
If a crash occurs during transaction replay, no special recovery action is required: the partial replay's results were either committed (and reflected in LCL) or rolled back (and not). On restart, normal ledger close pipeline operation resumes from the current LCL and the apply manager will trigger a fresh catchup if still desynchronized.
| ID | Statement |
|---|---|
| INV-C1 | Chain monotonic: for every pair of consecutive LedgerHeaderHistoryEntry entries (prev, curr) in a ledger-history file, curr.header.ledgerSeq == prev.header.ledgerSeq + 1 AND curr.header.previousLedgerHash == prev.hash AND sha256(xdr_to_opaque(curr.header)) == curr.hash. |
| INV-C2 | Checkpoint alignment: a checkpoint file MUST begin with the first ledger of the checkpoint (isFirstLedgerInCheckpoint(L)) and end with the checkpoint ledger (isLastLedgerInCheckpoint(L)), except that the first checkpoint omits ledger 0. |
| INV-C3 | HAS integrity: a valid HAS contains exactly LIVE_BUCKETLIST_LEVELS entries in currentBuckets; if version >= 2, exactly HOT_ARCHIVE_BUCKETLIST_LEVELS entries in hotArchiveBuckets; bucket versions are non-decreasing from older (high level) to newer (low level); level 0 next is clear; level-i next is clear iff prior level's snap is @version(≥12), else has resolved output hash. |
| INV-C4 | BucketList hash agreement: for any HAS H and corresponding ledger header L.header, bucketListHash(H) == L.header.bucketListHash. |
| INV-C5 | Trust anchor authentication: a catchup that completes successfully MUST have verified the chain against either the local LCL or an externally-supplied trusted hash; chains verified only internally (no anchor on either end) MUST NOT be promoted to LCL. |
| INV-C6 | Tx result hash check (OFFLINE_COMPLETE): for every non-genesis ledger L in the replay range, sha256(xdr_to_opaque(L.txResultSet)) == L.header.txSetResultHash. |
| INV-C7 | Knit-to-LCL exclusivity: each replayed ledger header from a checkpoint file MUST fall into exactly one of the five cases in §11.2; case 5 (overshoot) MUST cause catchup failure. |
| INV-C8 | Buffered drain ordering: ApplyBufferedLedgersWork SHALL apply buffered ledgers strictly in ascending sequence order. It MUST NOT skip a gap; the first gap terminates the drain. |
| INV-C9 | Bucket-apply newest wins: during bucket application, for any LedgerKey K, the entry applied is the entry from the first bucket (in the level-0-curr, level-0-snap, level-1-curr, ... traversal order) in which K appears. |
| INV-C10 | Publish file finalization boundary: a finalized (canonical-named) checkpoint file MUST contain only ledger sequences <= LCL at the time of finalization. A dirty file MAY contain sequences > LCL only between append and commit, never observable after a crash recovery completes. |
| INV-C11 | Publish queue durability: a HAS in <seq>.checkpoint (finalized) MUST correspond to a committed ledger; a HAS in <seq>.checkpoint.dirty (tmp) corresponds to a ledger that has been appended but not yet committed. On restart, all .dirty files with seq > LCL MUST be removed. |
| INV-C12 | No retry on fatal: once catchupFatalFailure = true, the apply manager MUST NOT schedule a new CatchupWork until the flag is cleared by operator intervention. |
| INV-C13 | Range exclusivity: a CatchupRange falls into exactly one of the five cases of §6.3; in particular applyBuckets && replayLedgers implies applyBucketsAtLedger + 1 == replayRange.mFirst. |
| INV-C14 | Replay determinism: applying the same ordered sequence of LedgerCloseData values to the same starting LCL MUST produce the same LCL hash. This is the core determinism property that catchup relies on; any divergence indicates a bug or version incompatibility. |
| INV-C15 | Catchup never applies older state: post-bucket-apply, verifiedLedgerRangeStart.header.ledgerSeq >= lcl.header.ledgerSeq. A catchup that would apply state earlier than the local LCL MUST abort before mutating state. |
| Constant | Value | Description | Section |
|---|---|---|---|
CHECKPOINT_FREQUENCY |
64 | Number of ledgers per checkpoint. | 4.3 |
MAX_HISTORY_ARCHIVE_BUCKET_SIZE |
100 GB | Maximum permitted size of a single downloaded bucket. | 4.4, 10.1 |
HISTORY_ARCHIVE_STATE_VERSION_BEFORE_HOT_ARCHIVE |
1 | HAS version prior to Hot Archive support. | 3.1 |
HISTORY_ARCHIVE_STATE_VERSION_WITH_HOT_ARCHIVE |
2 | HAS version including hotArchiveBuckets. Used @version(≥23). |
3.1, 5.3 |
LIVE_BUCKETLIST_LEVELS |
11 | Number of levels in the live BucketList (see BUCKETLISTDB_SPEC). | 4.4 |
HOT_ARCHIVE_BUCKETLIST_LEVELS |
(see BUCKETLISTDB_SPEC) | Number of levels in the Hot Archive BucketList. | 4.4 |
GENESIS_LEDGER_SEQ |
1 | The first real ledger sequence number. | 6.3 |
FIRST_PROTOCOL_SHADOWS_REMOVED |
12 | Protocol version threshold above which BucketList merges no longer track shadows; next is always clear. |
4.4 |
FIRST_PROTOCOL_SUPPORTING_PERSISTENT_EVICTION |
23 | Protocol version threshold above which HAS version == 2 is emitted with hotArchiveBuckets. |
5.3 |
MAX_EXTERNALIZE_LEDGER_APPLY_DRIFT |
12 | Maximum gap between LCL and lastQueuedToApply before sequential application stops scheduling and the node will fall into catchup. |
7.3 |
PUBLISH_QUEUE_UNBLOCK_APPLICATION |
8 | Publish queue depth below which offline catchup replay resumes after a backpressure pause. | 5.6, 11.4 |
PUBLISH_QUEUE_MAX_SIZE |
16 | Publish queue depth above which offline catchup replay pauses. | 5.6, 11.4 |
MAX_PUBLISH_DELETE_CHECKPOINTS |
100 | Maximum number of prior checkpoints scanned by deletePublishedFiles. |
5.4 |
CONDITIONAL_APPLY_POLL_INTERVAL |
500 ms | Polling interval for per-ledger conditional apply (waiting on blocking merges). | 11.1 |
The CHECKPOINT_FREQUENCY value of 64 is consensus-fixed (every conforming archive uses the same frequency); the others are tunable parameters that conforming implementations MAY adjust, except where noted.
| Reference | Description |
|---|---|
| stellar-core-v27.0.0 | The reference implementation, stellar-core/ submodule at v27.0.0. |
| RFC-2119 | Key words for use in RFCs to Indicate Requirement Levels. |
| RFC-5785 | Defining Well-Known Uniform Resource Identifiers. The .well-known/stellar-history.json convention follows this RFC. |
| BUCKETLISTDB_SPEC | Stellar Bucket List Database Specification. Defines BucketList structure, bucket merge semantics, and bucket-apply algorithms referenced by §10. |
| LEDGER_SPEC | Stellar Ledger Close Pipeline Specification. Defines applyLedger invoked by §11 and §13. |
| HERDER_SPEC | Stellar Herder Specification. Defines the LedgerCloseData type and the SCP-tracked state restored in §8.3. |
| TX_SPEC | Stellar Transaction Specification. Defines transaction sets and the txSetResultHash checked in §12. |
The following is a snapshot of a small history archive after three checkpoints have been published. Checkpoints are numbered 0x3f (= 63), 0x7f (= 127), and 0xbf (= 191).
archive-root/
├── .well-known/
│ └── stellar-history.json # HAS for checkpoint 0xbf
├── history/
│ └── 00/00/00/
│ ├── history-00000000.json # ledger-zero pseudo-checkpoint
│ ├── history-0000003f.json # HAS for checkpoint 0x3f
│ ├── history-0000007f.json # HAS for checkpoint 0x7f
│ └── history-000000bf.json # HAS for checkpoint 0xbf
├── ledger/
│ └── 00/00/00/
│ ├── ledger-0000003f.xdr.gz # ledgers 1..63 (63 headers)
│ ├── ledger-0000007f.xdr.gz # ledgers 64..127 (64 headers)
│ └── ledger-000000bf.xdr.gz # ledgers 128..191
├── transaction/
│ └── 00/00/00/
│ ├── transaction-0000003f.xdr.gz
│ ├── transaction-0000007f.xdr.gz
│ └── transaction-000000bf.xdr.gz
├── results/
│ └── 00/00/00/
│ ├── results-0000003f.xdr.gz
│ ├── results-0000007f.xdr.gz
│ └── results-000000bf.xdr.gz
└── bucket/
├── 03/8a/2f/
│ └── bucket-038a2f...xdr.gz # one of many buckets
└── ...
A version == 2 HAS at checkpoint 0xbf might serialize as:
{
"version": 2,
"server": "v27.0.0",
"networkPassphrase": "Public Global Stellar Network ; September 2015",
"currentLedger": 191,
"currentBuckets": [
{ "curr": "0000...", "next": { "state": 0 }, "snap": "038a..." },
{ "curr": "5712...", "next": { "state": 0 }, "snap": "0000..." }
],
"hotArchiveBuckets": [ "..." ]
}(Truncated for readability; actual HAS has 11 currentBuckets entries plus 11 hotArchiveBuckets entries.)
The following sequence diagram traces a typical ONLINE catchup triggered by a node that fell out of sync and started buffering ledgers around sequence 1088. Checkpoint frequency is 64, so 1088 is the first ledger of a checkpoint.
sequenceDiagram
participant SCP
participant LAM as LedgerApplyManager
participant CW as CatchupWork
participant ARCH as Archive
participant BM as BucketManager
participant LM as LedgerManager
SCP->>LAM: ledger 1090, 1091, 1092 (LCL=999)
LAM->>LAM: buffer, trim
SCP->>LAM: ledger 1089 (out of order)
LAM->>LAM: buffer (now contains 1088..1092 after trim)
Note over LAM: 1088 is first-of-checkpoint,<br/>trigger online catchup
LAM->>CW: startCatchup(toLedger=1087,<br/>trustedHash=prevHash(1088), count=RECENT)
CW->>ARCH: GET .well-known/stellar-history.json
ARCH-->>CW: HAS at latest checkpoint
CW->>CW: resolve(toLedger), CatchupRange computed
CW->>ARCH: GET HAS for bucket-apply ledger
ARCH-->>CW: HAS at apply-buckets checkpoint
CW->>ARCH: BatchDownload ledger-*.xdr.gz for verify range
ARCH-->>CW: ledger files
CW->>CW: VerifyLedgerChainWork<br/>(reverse from apex to base)
Note over CW: chain verifies against<br/>trustedHash at 1087
CW->>ARCH: GET differing buckets
ARCH-->>CW: bucket files
CW->>CW: VerifyBucketWork (per bucket)
CW->>BM: ApplyBucketsWork (state at bucket-apply ledger)
BM-->>CW: state applied, AssumeState complete
CW->>LM: setLastClosedLedger(bucket-apply ledger)
CW->>ARCH: GET transaction-*.xdr.gz
ARCH-->>CW: tx files
CW->>LM: applyLedger per ledger up to 1087
CW->>LAM: maybeGetNextBufferedLedgerToApply
LAM-->>CW: ledger 1088
CW->>LM: applyLedger(1088)..applyLedger(1092)
CW-->>LAM: SUCCESS
LAM->>LAM: clear mCatchupWork, node is back in sync
This matrix summarizes the five cases of §11.2 by header sequence S relative to LCL.seq. H denotes the just-read LedgerHeaderHistoryEntry.
| S vs LCL.seq | Case | Hash Check | Apply? | Failure Mode |
|---|---|---|---|---|
S + 1 < LCL.seq |
1 | none | no | none — silently skipped |
S + 1 == LCL.seq |
2 | H.hash == LCL.previousLedgerHash |
no | fatal if mismatch |
S == LCL.seq |
3 | H.hash == LCL.hash |
no | fatal if mismatch |
S == LCL.seq + 1 |
4 | H.previousLedgerHash == LCL.hash AND post-apply LCL.hash == H.hash |
yes | fatal if either check fails or txSet hash diverges |
S > LCL.seq + 1 |
5 | none (pre-apply) | no (rejected) | always fatal — replay overshot the apply target |
A replay run that processes a checkpoint file MUST exercise only cases 1–4 in well-formed inputs; case 5 is a structural error.