Version: 27 (stellar-core v27.0.0 / Protocol 27) Status: Informational Date: 2026-06-21
- Introduction
- Architecture
- Data Types
- Ledger Close Pipeline
- Apply State Phase Machine
- Transaction Application
- LedgerTxn Nested Transactional State
- Protocol and Network Upgrades
- Ledger Header Management
- Soroban Network Configuration
- Soroban State Management
- Commit and Persistence
- Ledger Close Meta
- Genesis Ledger
- Invariants and Safety Properties
- Constants
- References
- Appendix A: LedgerTxn Entry Merge Matrix
- Appendix B: Ledger Close Pipeline Flowchart
- Appendix C: Skip-List Construction Example
This specification describes the Stellar ledger close pipeline: the
deterministic sequence by which a node, having received an externalized
consensus value, transforms the last closed ledger (LCL) into a new closed
ledger by applying a transaction set, optional protocol or network-config
upgrades, eviction, and state archival. It defines the apply-state phase
machine, the nested transactional state model (LedgerTxn), the ledger header
update sequence, the production of LedgerCloseMeta, and the persistence of
the resulting state to buckets, the database, and history archives.
This specification is implementation agnostic. It is derived exclusively
from the vetted stellar-core C++ implementation (v27.0.0). Any conforming
implementation that produces an identical sequence of LedgerHeader hashes,
identical bucket-list contents, identical TransactionResultSet contents, and
an identical stream of LedgerCloseMeta for all valid inputs is considered
correct.
Out of scope:
- Consensus (SCP nomination and ballot protocol): see SCP_SPEC.
- Herder mechanics (transaction queue, candidate combination, transaction set construction): see HERDER_SPEC.
- Individual transaction and operation semantics (precondition checking, operation effects, Soroban host-side execution): see TX_SPEC.
- Bucket-list internals (merge algorithm, level sizing, hot archive merge rules): see BUCKETLISTDB_SPEC.
- Catchup, replay, and history archive publishing: see CATCHUP_SPEC.
- Implementation-internal details: SQL schemas, threading models, caching strategies, logging, metrics, file-system layouts.
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 and RFC 8174.
| Term | Definition |
|---|---|
| Ledger | A snapshot of the global state at a given sequence number, identified by its LedgerHeader and the SHA-256 hash thereof. |
| LCL | Last Closed Ledger; the most recent ledger fully committed to the local node. |
| LedgerSeq | A 32-bit ledger sequence number; the genesis ledger has sequence 1. |
| LedgerHeader | The XDR structure that summarizes a ledger by reference to its transaction set, transaction result set, bucket-list root hash, previous header hash, skip list, close time, total coins, fee pool, and protocol version. |
| LedgerCloseData | The unit handed from Herder to the ledger close pipeline: a (ledgerSeq, txSet, StellarValue, expectedHash?) tuple. |
| StellarValue | The XDR value externalized by SCP, containing the txSetHash, closeTime, upgrades, and ext fields. |
| txSet | The set of transactions to apply this ledger, organized into phases and (in protocol 23+) parallel stages. |
| LedgerTxn | A nestable in-memory transactional view of ledger state used during transaction application. |
| LedgerTxnRoot | The terminal parent in a LedgerTxn chain; commits flush to the database and the live bucket list. |
| InternalLedgerEntry | A wrapper around either a LedgerEntry, a sponsorship marker, a sponsorship counter, or a MaxSeqNumToApply marker. |
| Sealing | The act of finalizing a LedgerTxn for read-only inspection (after which mutating operations throw). |
| HAS | History Archive State; the JSON-serializable record of the bucket-list state at a checkpoint, persisted to the database and to history archives. |
| ApplyState | The mutable working state of the apply thread, comprising the in-memory Soroban state, the module cache, and the apply phase. |
| InMemorySorobanState | The in-memory map of CONTRACT_DATA, CONTRACT_CODE, and TTL entries used to serve Soroban reads during apply (protocol 23+). |
| Module Cache | A shared cache of compiled Wasm modules used by the Soroban host (protocol 23+). |
| Hot Archive | A separate bucket list, introduced in protocol 23, that retains evicted persistent Soroban entries until restoration. |
| Restoration | The act of bringing an expired persistent Soroban entry back into the live state by paying rent. |
| Eviction | The act of removing expired Soroban entries from the live bucket list at ledger close; persistent entries are placed in the hot archive, temporary entries are deleted. |
Algorithms are expressed in camelCase pseudocode. XDR enumerators (e.g.,
LEDGER_ENTRY_CREATED, LEDGER_UPGRADE_VERSION) are written in
SCREAMING_SNAKE_CASE. Protocol-version guards are written @version(>=N)
or @version(<N); the canonical thresholds used in this specification are
Protocol 9, 11, 19, 20, 22, 23, 24, 25, and 26. Cross-references to peer
specifications use the plain-text form SPEC_NAME §N.N.
| Specification | Relationship |
|---|---|
| HERDER_SPEC | Produces LedgerCloseData and delivers it to the ledger pipeline via the valueExternalized entry point; receives the lastClosedLedgerIncreased callback after commit. |
| TX_SPEC | Defines the per-transaction fee-processing, validation, application, and post-apply behavior driven by the ledger pipeline in §6. |
| BUCKETLISTDB_SPEC | Defines the live bucket list and hot archive structures; the ledger pipeline produces (initEntries, liveEntries, deadEntries) batches and an EvictedStateVectors payload for the bucket manager. |
| CATCHUP_SPEC | Drives setLastClosedLedger and applyLedger via the LedgerApplyManager; defines the bucket-apply phase that resets the apply state to SETTING_UP_STATE. |
| SCP_SPEC | Provides the externalized StellarValue that the ledger pipeline consumes; opaque to the pipeline beyond (txSetHash, closeTime, upgrades, ext). |
| OVERLAY_SPEC | Independent: the pipeline does not interact with overlay directly. |
The ledger close pipeline is a single-writer, multi-reader subsystem with two logical actors:
- The main thread, which receives consensus values from Herder, manages the last-closed-ledger (LCL) snapshot, publishes history checkpoints, and notifies external subsystems.
- The apply thread, which owns the heavy work of applying transactions, reading and updating in-memory and on-disk state, and producing the new ledger header. A node MAY collapse the two roles onto a single thread; a conforming implementation MUST otherwise produce the same observable outputs.
The apply thread MAY spawn short-lived Soroban worker threads during the parallel Soroban phase (protocol 23+). These threads operate on an immutable snapshot of the apply state and do not commit changes; their results are merged back by the apply thread.
graph TD
HERDER[Herder /<br/>SCP externalize]
LAM[LedgerApplyManager<br/>queues ledgers, decides<br/>apply vs catchup]
LM[LedgerManager<br/>applyLedger / close]
APPLY[Apply pipeline<br/>fee-phase -><br/>tx-apply -><br/>upgrades -> seal]
BM[BucketManager<br/>addLiveBatch /<br/>addHotArchiveBatch]
DB[(Persistent State<br/>ledger header + HAS)]
HM[HistoryManager<br/>checkpoint queue]
LCL[LCL snapshot<br/>+ HAS + Soroban<br/>network config]
META[LedgerCloseMeta<br/>stream]
HERDER --> LAM
LAM --> LM
LM --> APPLY
APPLY --> BM
APPLY --> DB
APPLY --> HM
APPLY --> META
APPLY --> LCL
LCL --> HERDER
The pipeline is driven by valueExternalized(ledgerData, isLatestSlot),
which delegates to the LedgerApplyManager (see CATCHUP_SPEC §6) to either
queue the ledger for the apply thread or trigger catchup. When the
LedgerApplyManager releases a LedgerCloseData to the apply thread, it
invokes applyLedger(ledgerData, calledViaExternalize). Catchup MAY invoke
applyLedger directly with calledViaExternalize = false.
The pipeline MUST preserve the following ordering invariant on the four sequence-number checkpoints maintained by the system:
LCL <= A <= Q <= H
where H is the largest ledger sequence heard from the network, Q is the
largest ledger sequence dequeued and posted to the apply thread, A is the
ledger sequence currently being applied, and LCL is the ledger sequence
reflected in the main thread's last-closed-ledger snapshot. Any conforming
implementation MUST maintain this monotonic ordering.
The LedgerHeader XDR structure is the canonical summary of a closed ledger.
Its fields, in canonical order:
| Field | Type | Description |
|---|---|---|
ledgerVersion |
uint32 |
Protocol version active for this ledger. |
previousLedgerHash |
Hash |
SHA-256 of the previous LedgerHeader. |
scpValue |
StellarValue |
Embeds txSetHash, closeTime, upgrades, ext. |
txSetResultHash |
Hash |
SHA-256 of the TransactionResultSet. |
bucketListHash |
Hash |
Root hash of the live bucket list (protocol 22 and earlier) or `SHA-256(liveBLHash |
ledgerSeq |
uint32 |
This ledger's sequence number. |
totalCoins |
int64 |
Total lumens in existence in this ledger. |
feePool |
int64 |
Accumulated fees not yet distributed. |
inflationSeq |
uint32 |
Number of inflation operations applied. |
idPool |
uint64 |
Monotonic counter for offer/data IDs. |
baseFee |
uint32 |
Per-operation base fee in stroops. |
baseReserve |
uint32 |
Per-entry reserve in stroops. |
maxTxSetSize |
uint32 |
Maximum classic tx set size (ops in protocol < 11, txs in protocol 11+). |
skipList |
Hash[4] |
Four-level skip list of historic bucket-list hashes (see §9.3). |
ext |
union | Reserved for future extension; ext.v(1).flags carries the disable-liquidity-pool-trading flag. |
LedgerKey is the discriminated union (XDR) keying ledger state. Its types
are: ACCOUNT, TRUSTLINE, OFFER, DATA, CLAIMABLE_BALANCE,
LIQUIDITY_POOL, CONTRACT_DATA, CONTRACT_CODE, CONFIG_SETTING, and
TTL. LedgerEntry carries the corresponding entry data plus
lastModifiedLedgerSeq and an ext block (including sponsorship and rent
fields).
A LedgerEntry's lastModifiedLedgerSeq MUST equal the ledgerSeq of the
ledger in which it was most recently created or modified (see §7.7).
An InternalLedgerEntry is a non-XDR wrapper used internally by LedgerTxn,
discriminated by InternalLedgerEntryType:
| Type | Use |
|---|---|
LEDGER_ENTRY |
Wraps a real XDR LedgerEntry. |
SPONSORSHIP |
Tracks per-account sponsorship relationships within a transaction's bounds. |
SPONSORSHIP_COUNTER |
Tracks the number of objects an account sponsors. |
MAX_SEQ_NUM_TO_APPLY |
Records, for protocol 19+ ledgers containing AccountMerge, the maximum sequence number an account may reach within the same ledger (see §6.2). |
Sponsorship and sponsorship-counter entries MUST be empty across LedgerTxn
seal boundaries that surface to the bucket batch — sponsorship state is
reconciled within the per-transaction LedgerTxn (see TX_SPEC §11).
MAX_SEQ_NUM_TO_APPLY entries exist only during the fee-processing phase.
Produced by Herder and consumed by the pipeline. Fields:
| Field | Type | Description |
|---|---|---|
ledgerSeq |
uint32 |
Sequence of the ledger being closed. |
txSet |
TxSetXDRFrame |
The transaction set, hash-referenced from StellarValue. |
value |
StellarValue |
The externalized SCP value. |
expectedHash |
Hash? |
Optional hash to verify against the locally computed header hash; used in catchup. |
expectedResults (test-only) |
TransactionResultSet? |
Replay-mode expected results. |
LedgerCloseMeta is an XDR union, currently with three versions (v0, v1, v2),
emitted to subscribers (e.g., Horizon) for every closed ledger.
| Version | Required protocol | Contents |
|---|---|---|
| v0 | protocol < 20 | ledgerHeader, txSet, txProcessing[] (each with feeProcessing and txApplyProcessing), upgradesProcessing[], optional ext. |
| v1 | protocol 20-22 | v0 + totalByteSizeOfLiveSorobanState, evictedKeys[], ext v1 carrying sorobanFeeWrite1KB. |
| v2 | protocol 23+ | v1 + per-tx postTxApplyFeeProcessing (for Soroban refund accounting). |
A pipeline MUST select the meta version from the protocol version that was
active at the start of the ledger (initialLedgerVers), not the
potentially upgraded version (see §9.2).
Each entry tracked inside a LedgerTxn carries a three-valued state:
| State | Semantics |
|---|---|
INIT |
The entry was first created at this LedgerTxn level (no prior version exists in any parent). |
LIVE |
The entry was modified at this LedgerTxn level (a prior version exists). |
DELETED |
The entry was erased at this LedgerTxn level. |
Used in protocol 23+ to track entries restored from the hot archive and from the live bucket list during a single ledger. Layout:
RestoredEntries:
hotArchive: Map<LedgerKey, LedgerEntry> // hot-archive restorations
liveBucketList: Map<LedgerKey, LedgerEntry> // live-BL TTL-only restorations
A key MUST NOT appear in both maps within the same ledger; the maps are disjoint by construction (see INV-L5).
The pipeline is invoked by applyLedger(ledgerData, calledViaExternalize).
It executes the following numbered steps in order. Each step MUST be
observable-deterministic across conforming implementations.
- If the node is stopping, the pipeline returns without action.
- If a Wasm module compilation was started during the previous ledger
close, it MUST be finished before continuing (
finishPendingCompilation). - The apply state transitions from
READY_TO_APPLYtoAPPLYING(§5). - A root-level LedgerTxn
ltxis opened against the LedgerTxnRoot. - The previous header is loaded; its SHA-256 is computed as
prevHash. header.ledgerSeqis incremented by 1.header.previousLedgerHashis set toprevHash.
- If
header.ledgerVersion > Config::CURRENT_LEDGER_PROTOCOL_VERSION, the pipeline MUST throwcannot apply ledger with not supported version; a ledger MUST NOT be applied beyond the implementation's compiled protocol support. - If
txSet.previousLedgerHash() != prevHash, the pipeline MUST throwtxset mismatch. This guarantees the txset is rooted at the current LCL. - If
txSet.getContentsHash() != ledgerData.value.txSetHash, the pipeline MUST throwcorrupt transaction set. This guarantees the txset matches the consensus value. header.scpValueis assigned toledgerData.value.- The txset is converted to its applicable form via
txSet.prepareForApply(prevHeader); if the result is null, the pipeline MUST throwtransaction set cannot be processed.
- If meta streaming is enabled, a
LedgerCloseMetaFrameis constructed at the meta version corresponding toheader.ledgerVersion(see §13). Tx-processing slots are reserved and the txset is populated into the meta.
- Source-account IDs are prefetched (
prefetchTxSourceIds). processFeesSeqNums(txSet, ltx, meta, ledgerData)is invoked (see §6.2). It produces a vector ofMutableTransactionResult— exactly one per transaction in the txset, ingetPhasesInApplyOrderorder — with fees already charged and (for protocol 19+) per-sourceMAX_SEQ_NUM_TO_APPLYmarkers committed toltx.
applyTransactions(txSet, mutableTxResults, ltx, meta)is invoked (see §6.3). It produces aTransactionResultSetaligned with the apply-order traversal of phases.
- If the node is configured to store historical data
(
MODE_STORES_HISTORY_MISC), the per-checkpoint transaction set and result set are appended via the HistoryManager. header.txSetResultHashis set toxdrSha256(txResultSet).
- The apply state transitions from
APPLYINGtoCOMMITTING(§5).
- For each
upgradeinheader.scpValue.upgrades, in order:- The upgrade is validated via
Upgrades::isValidForApply(see §8). - If
XDR_INVALIDorINVALID, the upgrade is logged and skipped. - If
VALID, a nestedLedgerTxnltxUpgrade(ltx)is opened, the upgrade is applied viaUpgrades::applyTo, itsLedgerEntryChangesare pushed intometa.upgradesProcessing, andltxUpgradeis committed. - If
Upgrades::applyTothrows, the exception is caught and logged; the upgrade is skipped.upgradeAppliedis set to true on success.
- The upgrade is validated via
initialLedgerVerscapturesledgerVersionfrom before any upgrade;maybeNewVersionisledgerVersionafter upgrades.- The current
ledgerSeqis captured. sealLedgerTxnAndStoreInBucketsAndDB(...)(see §12.1) is invoked:- Snapshots of the live and hot-archive bucket lists from the LCL are copied into the call.
finalizeLedgerTxnChangesis invoked: it resolves the background eviction scan, processes hot-archive evictions and restorations (protocol 23+), snapshots the Soroban state size into the network config window (protocol 20+), loads the post-upgrade Soroban network config, seals the LedgerTxn viagetAllEntries, and feeds(initEntries, liveEntries, deadEntries)toaddLiveBatch.- The module cache is updated: evicted entries dropped, new contract code added.
- The in-memory Soroban state is updated.
- The unsealed header is finalized:
snapshotLedgerwrites thebucketListHashand skip list (§9.3), and the header + HAS are persisted viastorePersistentStateAndLedgerHeaderInDB. - A new LCL snapshot is produced.
- If meta is enabled and the protocol started at SOROBAN_PROTOCOL_VERSION
or later,
meta.setNetworkConfiguration(sorobanConfig)is invoked with the post-apply Soroban config and theEMIT_LEDGER_CLOSE_META_EXT_V1flag. - If
ledgerData.expectedHashis set and does not equal the locally computedlastClosedLedgerHeader.hash, the pipeline MUST throwLocal node's ledger corrupted during close. This is the hash-chain check that protects against silent state corruption (see INV-L11). - The completed meta is moved to
mNextMetaToEmit, then emitted viaemitNextMeta.
After §4.9 the pipeline MUST execute the following steps in this exact order:
| # | Step |
|---|---|
| 1 | maybeQueueHistoryCheckpoint(ledgerSeq, maybeNewVersion) — queues the next checkpoint within the current SQL transaction. Uses the post-upgrade ledger version. |
| 2 | ltx.commit() — persists the SQL transaction. |
| 3 | maybeCheckpointComplete(ledgerSeq) — finalizes any newly complete checkpoint files. |
| 4 | If protocol >= 20, start the background eviction scan for the next ledger using the post-commit snapshot. |
| 5 | The apply state transitions from COMMITTING to READY_TO_APPLY (§5). Copy the in-memory Soroban state if the snapshot invariant is enabled for this ledger. |
| 6 | (in advanceLedgerStateAndPublish, on main thread) publishQueuedHistory — kicks off history publishing for queued checkpoints. |
| 7 | (in advanceLedgerStateAndPublish, on main thread) forgetUnreferencedBuckets(HAS) — garbage-collects unreferenced bucket files. |
| 8 | (in advanceLedgerStateAndPublish, on main thread) Update LM state via ledgerCloseComplete — possibly transition to LM_SYNCED_STATE, notify Herder via lastClosedLedgerIncreased, and trigger the snapshot invariant. |
The split between steps 1-5 (apply thread, post-seal) and 6-8 (main thread)
exists because LCL is owned by the main thread; the apply thread MUST post
back the new CompleteConstLedgerState to the main thread for installation
into mLastClosedLedgerState.
The apply state cycles through four phases:
stateDiagram-v2
[*] --> SETTING_UP_STATE
SETTING_UP_STATE --> READY_TO_APPLY: markEndOfSetupPhase
READY_TO_APPLY --> SETTING_UP_STATE: resetToSetupPhase<br/>(e.g. lost sync,<br/>bucket-apply)
READY_TO_APPLY --> APPLYING: markStartOfApplying
APPLYING --> COMMITTING: markStartOfCommitting
COMMITTING --> READY_TO_APPLY: markEndOfCommitting
Phase semantics:
| Phase | Mutability of ApplyState | Soroban worker threads | Typical work |
|---|---|---|---|
SETTING_UP_STATE |
Mutable by primary apply thread. | None. | Startup; post-bucket-apply state setup; populating in-memory Soroban state. |
READY_TO_APPLY |
Immutable. | None. | Idle between ledgers; ApplyState is a fixed snapshot. |
APPLYING |
Immutable for the primary thread except via the aggregating LedgerTxn. | MAY be live, reading immutable state. | Fee phase, sequential phase, parallel Soroban phase. |
COMMITTING |
Mutable by primary apply thread only. | MUST be joined. | Apply upgrades, seal, persist, advance header. |
The pipeline MUST enforce these transitions via runtime assertion. In
particular, while the apply thread is in APPLYING, the primary thread MUST
NOT mutate InMemorySorobanState or the module cache; Soroban worker
threads MAY only call const methods of ApplyState.
A node that has fallen out of sync and is starting catchup MUST reset the
apply state to SETTING_UP_STATE (via markApplyStateReset) before
performing bucket-apply.
Transaction application is two-phased: a fee phase that charges fees and binds sequence numbers, followed by an apply phase that executes each transaction's operations. The apply phase itself is structured by phases of the txset (classic and Soroban), and (in protocol 23+) the Soroban phase MAY be parallel, organized into stages and clusters.
The order of phases used during apply is txSet.getPhasesInApplyOrder() —
this differs from the consensus order. Within a phase, the per-phase apply
order is defined by TxSetFrame::getTxsInApplyOrder (see HERDER_SPEC §6.5):
transactions are sorted such that a given source account's transactions are
strictly sequence-number ordered, while inter-account ordering is
randomized using a seed derived from the txset hash.
For each transaction in apply order:
- A nested
LedgerTxnltxTxis opened over the outer fee LedgerTxn. tx.processFeeSeqNum(ltxTx, baseFee)is invoked (see TX_SPEC §7):- The fee is charged from the fee source account.
- The sequence number of the source account is advanced.
- Sequence-number preconditions are validated.
- The transaction's
MutableTransactionResultis captured intotxResults[i]. - @version(>=19) For each transaction whose source-account sequence
number is being advanced, the maximum sequence number seen this ledger
is tracked per account in
accToMaxSeq. If any transaction in the txset contains anACCOUNT_MERGEoperation, the booleanmergeSeenis set. - If meta is enabled, the fee-processing changes (
ltxTx.getChanges()) are pushed into the meta'sfeeProcessingfor this transaction. ltxTx.commit().
After all transactions have been fee-processed:
- @version(>=19) If
mergeSeenis true, for each(accountID, seqNum)inaccToMaxSeq, anInternalLedgerEntryof typeMAX_SEQ_NUM_TO_APPLYis created. If such an entry already exists in the outer LedgerTxn, the pipeline MUST throwfound unexpected MAX_SEQ_NUM_TO_APPLY. - The outer fee LedgerTxn is committed.
The MAX_SEQ_NUM_TO_APPLY entries are consumed by transaction application
to ensure that a transaction whose source account is later merged in the
same ledger still observes its declared sequence number (see TX_SPEC §5.6).
For each phase in apply order:
- If
phase.isParallel()is true, the phase MUST be applied viaapplyParallelPhase(§6.5). - Otherwise, the phase MUST be applied via
applySequentialPhase(§6.4).
The Soroban network configuration (post-protocol-20) MUST be loaded once
from the LedgerTxn before the loop and reused for the parallel-phase
invocations. The base PRNG seed for Soroban transactions is
sorobanBasePrngSeed = txSet.getContentsHash().
After all phases, processPostTxSetApply is invoked to handle Soroban
post-tx-set processing (refunds, post-tx-apply fee meta) for the parallel
phase (see §6.6).
For each transaction tx in the phase, in apply order:
- A
TransactionMetaBuilderis constructed at the current ledger version. - A
TRANSACTION_EVENT_STAGE_BEFORE_ALL_TXSfee event is emitted into the meta. - A per-tx seed is derived: for Soroban transactions,
subSeed = SHA-256(sorobanBasePrngSeed || index)whereindexis the global transaction index encoded as auint64. For classic transactions, the base seed is used unchanged. tx.apply(ltx, tm, mutableTxResult, sorobanConfig, subSeed)is invoked (see TX_SPEC §6).tx.processPostApply(ltx, tm, mutableTxResult)is invoked (see TX_SPEC §13).- Refundable fee meta is set if present.
processResultAndMeta(meta, index, tm, tx, mutableTxResult, txResultSet)appends the result pair, increments success/failure counters, and stores the per-tx meta.
The parallel phase is structured as an ordered list of stages; within each stage, an ordered list of clusters; within each cluster, an ordered list of transactions. Clusters within a stage are guaranteed footprint-disjoint by the Herder construction (see HERDER_SPEC §6.4) and MAY thus be applied concurrently. Stages MUST be applied serially.
For each stage:
- A
GlobalParallelApplyLedgerStateis constructed wrapping(app, ltx, allStages, inMemorySorobanState, sorobanConfig). - For each cluster
cin the stage, an independentThreadParallelApplyLedgerStateis constructed and a Soroban worker thread is dispatched to runapplyThread(c, ...):- For each
txBundlein the cluster, in cluster-order:flushRoTTLBumpsInTxWriteFootprint(txBundle)is invoked.subSeed = SHA-256(sorobanBasePrngSeed || txNum).txBundle.tx.parallelApply(...)is invoked (see TX_SPEC §11.5).- On success,
commitChangesFromSuccessfulTxaccumulates per-tx changes into the thread state.
- After all bundles,
flushRemainingRoTTLBumps()is invoked.
- For each
- All worker thread results are gathered (
std::future::get); any exception MUST abort with a fatal error. - After the threads join,
checkAllTxBundleInvariantsMUST run per-tx invariant checks against the operation-level delta produced by the thread. globalParState.commitChangesFromThreads(threadStates, stage)merges the stage's accumulated state into the global state.
After all stages:
globalParState.commitChangesToLedgerTxn(ltx)MUST be invoked, which transfers the accumulated parallel-phase changes (including restorations) into the outer LedgerTxnltx.
Cluster and stage counts MAY be exposed as observability metrics but do not affect consensus.
For the parallel phase, after applyTransactions returns:
- For each
txBundlein stage/cluster order:- A nested LedgerTxn
ltxInner(ltx)is opened. tx.processPostTxSetApply(ltxInner, resPayload, txEventManager)is invoked (see TX_SPEC §11.6). This is the Soroban refund pathway.- If meta is enabled,
meta.setPostTxApplyFeeProcessing( ltxInner.getChanges(), txNum)records the post-apply fee diff (v2 meta only). ltxInner.commit().processResultAndMetarecords the (possibly refund-adjusted) result.
- A nested LedgerTxn
The sequential (classic) phase does not currently use post-tx-set apply.
Before the fee phase, prefetchTxSourceIds collects the set of keys
implied by tx.insertKeysForFeeProcessing across all transactions and
issues a bulk prefetch against the LedgerTxnRoot if PREFETCH_BATCH_SIZE > 0
and not all buckets are in memory. Similarly, prefetchTransactionData
issues a bulk prefetch of all keys implied by tx.insertKeysForTxApply
before the apply loop. Prefetching is advisory and does not affect
consensus.
LedgerTxn is the in-memory transactional view of ledger state used
throughout the pipeline. It is the sole mechanism by which operations,
transactions, and the close pipeline observe and mutate LedgerEntrys.
There are three roles:
| Role | Definition |
|---|---|
LedgerTxnRoot |
The terminal parent. Reads cascade down into the LCL bucket-list snapshot (or, for offers, into SQL); commits flush to the bucket list and database. |
LedgerTxn (non-root) |
An in-memory nested transaction; commits flush into its parent's entry map. |
AbstractLedgerTxnParent |
The interface common to both. |
A LedgerTxn is constructed with a reference to its parent
(AbstractLedgerTxnParent) and is then automatically attached as the
parent's mChild. The following invariants MUST hold:
- INV-L1: Single-child. At any given time, a parent MUST have at most one active child LedgerTxn. Construction of a second child MUST throw.
- INV-L2: Same-thread access. A LedgerTxn MUST be accessed only from the thread that opened it, until it is committed or rolled back. Violation MUST abort the program.
- A
LedgerTxnMUST NOT be opened against a sealed parent or against a parent that already has a child.
Each entry stored in a LedgerTxn's mEntry map is associated with one of
three states (see §3.6): INIT, LIVE, DELETED.
create(entry)produces anINITentry. It throws if any newer version of the key (in self or any parent) already exists.load(key)traverses parents, finds the newest version, and inserts aLIVEcopy intomEntry(the entry's state in the parent's map is left unchanged). It throws if the key is already active in this LedgerTxn.erase(key)produces aDELETEDentry. It throws if no version of the key exists in self or any parent. It throws if the key is currently active.loadWithoutRecord(key)is identical toloadexcept no record is written intomEntry; if a record already exists, that record's state is retained.
createWithoutLoading, updateWithoutLoading, eraseWithoutLoading are
bulk-loading shortcuts that bypass the "loading" semantics and are used
only by catchup's bucket-apply phase; they MUST NOT be used during
transaction processing. The eraseWithoutLoading shortcut weakens the
LedgerTxn's consistency to EXTRA_DELETES (see §7.9).
A LedgerTxn maintains an mActive map of currently-handed-out
LedgerTxnEntry handles. The handles are weakly linked to internal
records; opening a child MUST deactivate all parent handles, preventing
two concurrency anomalies:
- Stale reads of parent entries while a child holds modified versions.
- Lost updates when modifying a parent entry that the child has also modified.
A double-indirect handle design ensures that the destructor of a
LedgerTxnEntry always removes its entry from mActive even after
parent-side deactivation. Handles MUST NOT be retained across the lifetime
of a sub-LedgerTxn.
A LedgerTxn is sealed the first time any of getChanges(),
getDelta(), or getAllEntries() is called, or as part of its own
commit(). Sealing has these observable effects:
mIsSealedis set; further mutation throws.lastModifiedLedgerSeqis updated on every non-deleted, non-sponsorship entry to equalmHeader->ledgerSeq(ifmShouldUpdateLastModified).- The multi-order-book is cleared.
- Active handles are cleared.
- The active header handle is reset.
After sealing, only the header MAY be re-unsealed via unsealHeader(f),
which MUST NOT modify entries. This is used to write the bucketListHash
and skip-list values into the header AFTER the entry set has been finalized
(see §12.1).
LedgerTxn::commit() MUST:
- Run
maybeUpdateLastModifiedThenInvokeThenSeal, which seals self and produces an iterator overmEntry. - Invoke
parent.commitChild(iter, mRestoredEntries, mConsistency). - Reset self (the implementation pointer).
LedgerTxn::Impl::commitChild(iter, restoredEntries, consistency) MUST:
- Copy the child header into a unique pointer (for swap-based exception safety).
- If self has any active entries, abort (active parent entries during child commit is a logic error).
- For each
(key, entryPtr)in the iterator, invokeupdateEntry(key, ..., entryPtr, /*effectiveActive=*/false)on self. - Update self's worst-best-offer map via
forAllWorstBestOfferson the child. - Merge
restoredEntriesfrom child into self viaaddRestoresFrom. The per-key uniqueness MUST be preserved across the merge: a key restored in the child MUST NOT already be restored in self. - Update consistency:
mConsistency = max(mConsistency, cons)whereEXTRA_DELETES > EXACT. - Swap in the child header.
- Clear
mChild.
LedgerTxn::rollback() MUST simply notify the parent (which clears
mChild) and reset self. No changes propagate.
When committing a child into a parent, each child entry is merged with the corresponding parent entry (if any) at the same key. The merge rules are summarized in Appendix A; the key non-trivial cases are:
- Parent INIT, Child DELETED: the parent entry is annihilated
(removed from
mEntry). This represents an entry that was created and immediately deleted within the lifetime of the closer transaction and has no observable effect on the database. - Parent DELETED, Child INIT: the merged state is
LIVE. The entry was deleted at the parent level but a child re-creates it; this can occur only because the deleted entry must have existed prior to the delete (otherwise the delete would have thrown). - Parent LIVE, Child INIT: MUST throw
cannot commit a child init entry into a parent live entry. A child cannot validly INIT an entry the parent already considers LIVE. - Parent INIT, Child INIT: not possible by
createsemantics — acreatethrows if any parent has the key. - Parent DELETED, Child LIVE: MUST throw
cannot set deleted entry to live. - Parent DELETED, Child DELETED: MUST throw
cannot delete deleted entry.
See Appendix A for the complete 3x3 matrix.
Inside the seal-and-store helper, for every non-deleted entry of
LEDGER_ENTRY type, lastModifiedLedgerSeq MUST be set to the current
mHeader->ledgerSeq if the LedgerTxn was constructed with
shouldUpdateLastModified = true (the default). This is the unique source
of lastModifiedLedgerSeq for normally-applied transactions.
These three accessors all seal the LedgerTxn:
getAllEntries(initEntries, liveEntries, deadEntries)partitionsLEDGER_ENTRYentries:INIT-> initEntries,LIVE-> liveEntries,DELETED-> deadEntries (as keys). Non-LEDGER_ENTRYentries are skipped. This is the input toBucketManager::addLiveBatch.getChanges()produces an XDRLedgerEntryChangesarray in (CREATED/STATE+UPDATED/STATE+REMOVED) form, used for the meta. This MUST NOT be called on a LedgerTxn withEXTRA_DELETESconsistency.getDelta()produces a structured(current, previous)pair per entry for the Invariants subsystem.
A LedgerTxn's mConsistency is one of:
| Value | Meaning |
|---|---|
EXACT |
The LedgerTxn faithfully reflects the database. Default. |
EXTRA_DELETES |
At least one eraseWithoutLoading call has occurred; the LedgerTxn MAY contain spurious deletes for keys that never existed. getChanges / getDelta / getDeadEntries MUST NOT be invoked on such a LedgerTxn. |
createWithoutLoading does not weaken consistency — INIT-then-DELETE is
stored the same way as just INIT (and is annihilated naturally).
Erasure of a CONFIG_SETTING key MUST throw Configuration settings cannot be erased. Config settings MAY only be created (during the V20
upgrade or subsequent upgrades) and updated (via config upgrades).
StellarValue.upgrades is a list of UpgradeType opaque XDR blobs.
After transaction application, the pipeline applies upgrades sequentially.
LedgerUpgrade is an XDR union over the following types:
| Type | Field | Effect |
|---|---|---|
LEDGER_UPGRADE_VERSION |
newLedgerVersion: uint32 |
Sets header.ledgerVersion. Triggers applyVersionUpgrade, which MAY also create new ledger entries (Soroban config in v20+, etc.). |
LEDGER_UPGRADE_BASE_FEE |
newBaseFee: uint32 |
Sets header.baseFee. |
LEDGER_UPGRADE_MAX_TX_SET_SIZE |
newMaxTxSetSize: uint32 |
Sets header.maxTxSetSize. |
LEDGER_UPGRADE_BASE_RESERVE |
newBaseReserve: uint32 |
Sets header.baseReserve and runs the liability-rescaling upgrade. |
LEDGER_UPGRADE_FLAGS |
newFlags: uint32 |
Sets header.ext.v1().flags. |
LEDGER_UPGRADE_CONFIG |
newConfig: ConfigUpgradeSetKey |
Applies a network-config upgrade encoded as a ConfigUpgradeSetFrame retrieved from a CONTRACT_DATA entry. |
LEDGER_UPGRADE_MAX_SOROBAN_TX_SET_SIZE |
newMaxSorobanTxSetSize: uint32 |
Updates the Soroban max-tx-count config setting. |
For each UpgradeType:
- Deserialize as
LedgerUpgrade. If deserialization fails, returnXDR_INVALID. - Run type-specific validity checks (e.g., the new protocol version is
supported and is monotonically increasing; the new flags are
recognized; the config upgrade key resolves to a valid
ConfigUpgradeSet). On failure, returnINVALID. - Otherwise, return
VALID.
Invalid upgrades MUST be skipped, not aborted. The pipeline logs and continues with the next upgrade.
For each VALID upgrade lupgrade, the pipeline opens a nested LedgerTxn
ltxUpgrade(ltx) and invokes Upgrades::applyTo(lupgrade, app, ltxUpgrade):
LEDGER_UPGRADE_VERSION:applyVersionUpgradesets the newledgerVersionand, if upgrading into a Soroban-enabled protocol version, MAY create the initialCONFIG_SETTINGentries viaSorobanNetworkConfig::createLedgerEntriesForV20,createCostTypesForV21,createCostTypesForV22,createAndUpdateLedgerEntriesForV23,createCostTypesForV25,updateCostTypesForV26,createLedgerEntriesForV26, as applicable to the new version.LEDGER_UPGRADE_BASE_FEE/MAX_TX_SET_SIZE/FLAGS: a simple header field assignment.LEDGER_UPGRADE_BASE_RESERVE: updates the reserve and rescales any pending liabilities/sponsorships affected by the new reserve.LEDGER_UPGRADE_CONFIG: loads theConfigUpgradeSetFramefrom the ledger via the embeddedConfigUpgradeSetKey, re-validates it (isValidForApply()MUST returnVALID), and applies it toltxviaConfigUpgradeSetFrame::applyTo.LEDGER_UPGRADE_MAX_SOROBAN_TX_SET_SIZE: updates the Soroban parallel-execution config setting.
After each upgrade, ltxUpgrade.getChanges() is captured into
meta.upgradesProcessing[i] (as an UpgradeEntryMeta with the
canonicalized lupgrade and its LedgerEntryChanges), then
ltxUpgrade.commit() flushes the upgrade into ltx.
Exceptions thrown by Upgrades::applyTo MUST be caught; the upgrade is
logged and skipped. upgradeApplied is set to true iff at least one
upgrade was applied successfully.
A protocol-version upgrade may have downstream effects observable later in the same close cycle:
- The
initialLedgerVers(captured before upgrades) MUST be used to drive the meta version selection and the "in pre-upgrade protocol" branches infinalizeLedgerTxnChanges. ThemaybeNewVersionMUST be used for history-checkpoint queuing (Step 1 of §4.11) and for evaluating protocol-version branches that depend on the upgraded version. - A version upgrade into Soroban (P20) MAY emit Soroban-meta fields
but only if
initialLedgerVers >= SOROBAN_PROTOCOL_VERSIONalready; otherwise meta is v0 and Soroban fields MUST NOT be set. - A version upgrade from P23 to P24 on a production network MUST invoke
the
p23_hot_archive_bugcorrection path when adding the hot-archive batch. - If a protocol or config upgrade alters the in-memory Soroban state-size
formula,
handleUpgradeAffectingSorobanInMemoryStateSizeMUST be called to recompute and overwrite all stored state-size snapshots before the size-sensitive next step proceeds.
Within applyLedger, the header is mutated in the following order:
ledgerSeq += 1(immediately after opening the root LedgerTxn).previousLedgerHash = SHA-256(prevHeader).scpValue = ledgerData.value(which setscloseTime,txSetHash,upgrades).- Transaction application MAY indirectly mutate header fields via
operations (e.g.,
InflationincreasesinflationSeq; account-merge and offer creation updateidPool; fees are added tofeePool). - After tx-apply,
txSetResultHash = SHA-256(txResultSet). - Upgrades MAY mutate header fields (
ledgerVersion,baseFee,maxTxSetSize,baseReserve,ext.v1().flags). - The LedgerTxn is sealed; the header is then unsealed via
unsealHeader(f)for the final updates:bucketListHashis set byBucketManager::snapshotLedger(header).skipListis updated bycalculateSkipValues(header)(see §9.3).
After unsealHeader completes, the header is finalized and its SHA-256 is
the canonical ledger hash.
A LedgerHeader MUST be considered valid (for storage) iff:
ledgerSeq <= INT32_MAX.scpValue.closeTime <= INT64_MAX.feePool >= 0.idPool <= INT64_MAX.
A pipeline MUST refuse to load or persist a header that fails these checks.
The skipList field is a fixed-size array of 4 hashes. After
bucketListHash is set, the skip list is updated according to the current
ledgerSeq modulo the skip constants:
SKIP_1 = 50
SKIP_2 = 5000
SKIP_3 = 50000
SKIP_4 = 500000
Algorithm (calculateSkipValues):
if (ledgerSeq mod SKIP_1) == 0:
v1 = ledgerSeq - SKIP_1
if v1 > 0 and (v1 mod SKIP_2) == 0:
v2 = ledgerSeq - SKIP_2 - SKIP_1
if v2 > 0 and (v2 mod SKIP_3) == 0:
v3 = ledgerSeq - SKIP_3 - SKIP_2 - SKIP_1
if v3 > 0 and (v3 mod SKIP_4) == 0:
skipList[3] = skipList[2]
skipList[2] = skipList[1]
skipList[1] = skipList[0]
skipList[0] = bucketListHash
Cascading semantics: at every SKIP_1 boundary, skipList[0] is
overwritten with the new bucketListHash. At deeper boundaries the older
slots are shifted up by one before the overwrite. See Appendix C for a
worked example.
The canonical hash of a LedgerHeader is SHA-256(xdr_encode(header)).
All cross-ledger references — previousLedgerHash, the entries in
skipList, the expectedHash field of LedgerCloseData, and references
in archived LedgerHeaderHistoryEntrys — use this canonical hash.
@version(>=23) The bucket-list hash that feeds bucketListHash is
SHA-256(liveBLHash || hotArchiveBLHash); @version(<23) it is the live
bucket list hash directly.
The Soroban network configuration is a set of CONFIG_SETTING ledger
entries written at the Protocol 20 upgrade and updated by subsequent
protocol-version and config upgrades. It governs Soroban resource limits,
cost model parameters, rent fees, eviction settings, and (from Protocol 23)
SCP timing.
| Category | Fields (representative) |
|---|---|
| Contract size | maxContractSizeBytes, maxContractDataKeySizeBytes, maxContractDataEntrySizeBytes. |
| Compute | ledgerMaxInstructions, txMaxInstructions, feeRatePerInstructionsIncrement, txMemoryLimit. |
| Ledger access | ledgerMaxDiskReadEntries, ledgerMaxDiskReadBytes, ledgerMaxWriteLedgerEntries, ledgerMaxWriteBytes, plus per-tx versions; per-entry and per-1KB read/write fees. |
| Historical | feeHistorical1KB. |
| Contract events | txMaxContractEventsSizeBytes, feeContractEventsSize1KB. |
| Bandwidth | ledgerMaxTransactionSizesBytes, txMaxSizeBytes, feeTransactionSize1KB. |
| State archival | maxEntriesToArchive, minPersistentEntryLifetime, minTemporaryEntryLifetime, maxEntryLifetime, eviction iterator, rent-rate denominators, state-size sliding window. |
| Cost model | cpuCostParams, memCostParams arrays of (linearTerm, constantTerm) tuples per host-function cost type. |
| Execution lanes | ledgerMaxTxCount. |
| Parallel execution | ledgerMaxDependentTxClusters (MUST NOT exceed MAX_LEDGER_DEPENDENT_TX_CLUSTERS = 128). |
| Soroban state size | sorobanStateTargetSizeBytes, rentFee1KBSorobanStateSizeLow, rentFee1KBSorobanStateSizeHigh, sorobanStateRentFeeGrowthFactor. |
| SCP timing (P23+) | ledgerTargetCloseTimeMilliseconds, nominationTimeoutInitialMs, nominationTimeoutIncrementMs, ballotTimeoutInitialMs, ballotTimeoutIncrementMs (bounded by Minimum / MaximumSorobanNetworkConfig). |
| Ledger cost extension (P23+) | feeFlatRateWrite1KB, txMaxFootprintEntries. |
The MinimumSorobanNetworkConfig struct defines the lower bounds an
upgrade MUST satisfy (e.g., TX_MAX_READ_LEDGER_ENTRIES >= 3,
TX_MAX_SIZE_BYTES >= 10000, MAXIMUM_ENTRY_LIFETIME <= 1054080, ...).
An upgrade that does not satisfy the minimums MUST be rejected by
isValidConfigSettingEntry.
SorobanNetworkConfig::loadFromLedger(LedgerSnapshot | Snapshot | LedgerTxn)
reads every relevant CONFIG_SETTING entry by ConfigSettingID and
populates an in-memory SorobanNetworkConfig struct. The pipeline MUST
load the config from the current ltx once at the start of the apply
phase (for the parallel-phase invocation) and again at the end of
finalizeLedgerTxnChanges (for the post-upgrade meta).
A sliding window of sorobanStateSize samples is maintained in the
CONFIG_SETTING_STATE_ARCHIVAL entry. At each ledger whose ledgerSeq
is divisible by the window's samplePeriod, the oldest entry is dropped
and a new sample is pushed:
- @version(<23): the sample is
bucketManager.getLiveBucketList().getSize(). - @version(>=23): the sample is the in-memory Soroban state size as of the start of the ledger (snapshotted before the in-memory state is updated with this ledger's new entries — see §11.3).
The window provides smoothed input to rent-fee computations.
InMemorySorobanState is an in-memory map of all Soroban CONTRACT_DATA,
CONTRACT_CODE, and TTL entries. It is co-located with the apply state
and is the authoritative source for Soroban reads during transaction
apply.
Co-location of TTL with its data:
CONTRACT_DATAentries are stored inmContractDataEntries, keyed by the SHA-256 hash of the TTL key (getTTLKey(contractDataKey).keyHash). Each entry carries itsliveUntilLedgerSeqandlastModifiedLedgerSeqinline.CONTRACT_CODEentries are stored inmContractCodeEntries, keyed by the TTL key hash. Each entry carries TTL data plus asizeBytesfield reflecting the in-memory module size (used for the state-size computation).- TTL entries are not stored separately; the TTL is folded into the
data/code entry. Lookup of a
TTLkey MUST reconstruct the TTL entry from the underlying data/code entry's TTL fields.
The state MUST be thread-safe for concurrent reads (during the
APPLYING phase) but is not thread-safe for concurrent writes; the
primary apply thread is the sole writer.
After all transactions are applied and getAllEntries(initEntries, liveEntries, deadEntries) is invoked on the outer LedgerTxn:
- New
CONTRACT_CODEentries are added to the module cache. bucketManager.addLiveBatch(header, initEntries, liveEntries, deadEntries)is invoked.applyState.updateInMemorySorobanState(initEntries, liveEntries, deadEntries, header, sorobanConfig)is invoked.
updateState MUST process the entries by category:
- For TTL entries (data type
TTL), look up the data/code entry by key hash and update its TTL fields. If a TTL arrives before its data entry (only possible during initialization from a snapshot), buffer it inmPendingTTLs. - For
CONTRACT_DATAentries, create or update inmContractDataEntries. - For
CONTRACT_CODEentries, create or update inmContractCodeEntries, recomputingsizeBytesfrom the config and protocol version. - For deleted keys, remove the corresponding entries.
After update, mLastClosedLedgerSeq = ledgerSeq.
The pipeline MUST snapshot the in-memory state size into the sliding
window before the new ledger's entries are flushed into the in-memory
state. As a result, the sample taken at ledger N reflects the state
size at the end of ledger N - 1. This is a deliberate protocol-level
ordering.
The module cache is a Rust-side cache of compiled Wasm modules (one per
protocol version in mModuleCacheProtocols, which spans
REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION through
Config::CURRENT_LEDGER_PROTOCOL_VERSION). It is the sole compiled-form
of contract code available to the Soroban host during apply.
- On startup or after bucket-apply,
compileAllContractsInLedger(snap, ledgerVersion)populates the cache from the LCL snapshot. - On every
addLiveBatch, contract code entries ininitEntriesandliveEntriesare compiled into the cache viaaddAnyContractsToModuleCache. - On eviction or hot-archive transfer,
evictFromModuleCacheremoves the corresponding compiled modules. - After commit,
maybeRebuildModuleCache(snapshot, initialLedgerVers)MAY trigger a background recompile if the cache's memory-budget estimate exceeds twice the last-recompile size times the per-byte worst-case multiplier frommemCostParams[VmInstantiation].
A node MUST finishPendingCompilation before starting the next
applyLedger.
When a transaction's RestoreFootprint operation references a key that
has been evicted to the hot archive:
- The data and TTL entries are read from the hot archive bucket list
(see BUCKETLISTDB_SPEC §10), the TTL is recomputed using the current
network config, and the restored entries are re-created in
ltx. - The keys are recorded into
mRestoredEntries.hotArchive.
When a RestoreFootprint references a key still in the live bucket list
but expired (TTL passed), only the TTL is updated; the entries are
recorded into mRestoredEntries.liveBucketList.
The two maps MUST be disjoint within a single ledger (INV-L5,
restored-entries mutual exclusion).
Under a mutex held against the live bucket list, the pipeline:
- Loads
ledgerHeader = ltx.loadHeader().current(). - Invokes
finalizeLedgerTxnChanges(lclSnapshot, lclHotArchiveSnapshot, ltx, meta, ledgerHeader, initialLedgerVers):- @version(>=20): resolves the background eviction scan against the
LCL snapshot and the modified-key set
(
ltx.getAllKeysWithoutSealing()), producingEvictedStateVectors{deletedKeys, archivedEntries}. - @version(>=23): checks per-ledger invariants
(
checkOnLedgerCommit); if this is the P23 -> P24 upgrade ledger on the production network, thep23_hot_archive_bugfixup pathway is applied; otherwisebucketManager.addHotArchiveBatch(header, archivedEntries, restoredHotArchiveKeys)is invoked. The optionalProtocol23CorruptionDataVerifierMAY validate evicted entries against a pre-loaded corruption dataset. - @version(>=20): populates
meta.evictedKeys(v1/v2 meta). - @version(>=20): updates the module cache (evict + add).
- Snapshots the Soroban state size into the sliding window (§10.4).
- Loads
finalSorobanConfigfrom the post-upgrade ledger. - Calls
ltx.getAllEntries(initEntries, liveEntries, deadEntries)— this seals the LedgerTxn. - Adds any new contract code to the module cache.
- Invokes
bucketManager.addLiveBatch(header, initEntries, liveEntries, deadEntries). - Invokes
applyState.updateInMemorySorobanState(...).
- @version(>=20): resolves the background eviction scan against the
LCL snapshot and the modified-key set
(
- Re-opens the header via
ltx.unsealHeader([&](LedgerHeader& lh){ ... })and:- Calls
bucketManager.snapshotLedger(lh)(setsbucketListHashand skip-list). - Calls
storePersistentStateAndLedgerHeaderInDB(lh, /*appendToCheckpoint=*/true)to persist the HAS, the encoded header, and append the header to the current checkpoint. - Builds the new
CompleteConstLedgerStateviaadvanceBucketListSnapshotAndMakeLedgerState(lh, has)and stores it in the localres.
- Calls
- @version(>=REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION): triggers
maybeRebuildModuleCache(snapshot, initialLedgerVers).
After return, the pipeline returns to applyLedger's subtle 8-step
sequence (§4.11) starting from Step 1.
storePersistentStateAndLedgerHeaderInDB(header, appendToCheckpoint):
- Builds a
HistoryArchiveStatefrom the live bucket list. @version(>=FIRST_PROTOCOL_SUPPORTING_PERSISTENT_EVICTION) the HAS additionally includes the hot-archive bucket list. - Persists
(kHistoryArchiveState, has.toString())and(kLastClosedLedgerHeader, base64(xdr_encode(header)))intoPersistentState(in the LCL table). - If
appendToCheckpoint, appendsheaderto the in-progress checkpoint file viaHistoryManager::appendLedgerHeader.
The HAS is the durable serialization of the bucket-list state and is the
unit of recovery: on restart, the bucket manager rehydrates from the
stored HAS, and the LCL is reconstructed by decoding
kLastClosedLedgerHeader. The two MUST agree on ledgerSeq; a mismatch
MUST be treated as database corruption (see INV-L13).
After persistence, the pipeline constructs a CompleteConstLedgerState
containing:
| Component | Source |
|---|---|
bucketSnapshot |
A new searchable snapshot of the live bucket list. |
hotArchiveSnapshot |
A new searchable snapshot of the hot archive bucket list. |
lastClosedLedgerHeader |
(header, SHA-256(header)). |
historyArchiveState |
The HAS computed above. |
sorobanConfig (optional) |
Loaded from the post-apply ledger (protocol >= 20). |
This state is shared (immutable) and replaces the previous LCL state on
the main thread (mLastClosedLedgerState). It is the snapshot served to
external readers between this close and the next.
LedgerCloseMetaFrame is constructed at the protocol version active at
the start of the ledger (initialLedgerVers). The version selected is:
@version(<20) -> v0
@version(>=20 and <23) -> v1
@version(>=23) -> v2
A LEDGER_UPGRADE_VERSION to a higher meta version within the same
ledger does NOT bump the meta version: the meta MUST remain at the
initial version, because it is structurally shaped at construction time.
| Field | v0 | v1 | v2 |
|---|---|---|---|
ledgerHeader |
yes | yes | yes |
txSet |
yes | yes | yes |
txProcessing[i].feeProcessing |
yes | yes | yes |
txProcessing[i].txApplyProcessing |
yes | yes | yes |
txProcessing[i].result |
yes | yes | yes |
txProcessing[i].postTxApplyFeeProcessing |
no | no | yes |
upgradesProcessing[] |
yes | yes | yes |
evictedKeys[] |
no | yes | yes |
totalByteSizeOfLiveSorobanState |
no | yes | yes |
ext.v1().sorobanFeeWrite1KB |
no | optional | optional |
Eviction-key entries evictedKeys[] MUST contain temporary and TTL keys
that were deleted plus the keys of persistent entries that were
archived (NOT the entries themselves).
The pipeline MUST populate the meta in this order:
populateTxSet(txSet).- Per transaction:
pushTxFeeProcessing(feeChanges)during the fee phase. - Per transaction:
setTxProcessingMetaAndResultPair(tm, result, index)immediately afterprocessResultAndMeta. - (Parallel phase only, v2 meta) Per tx:
setPostTxApplyFeeProcessing( changes, index). - Per applied upgrade: an
UpgradeEntryMetaappended toupgradesProcessing. - @version(>=20):
populateEvictedEntries(evictedState). - @version(>=20):
setNetworkConfiguration(sorobanConfig, emitExtV1). ledgerHeader = appliedLedgerState.lastClosedLedgerHeader.
Meta is emitted exactly once per ledger via emitNextMeta, which writes
the XDR to the configured output stream and flushes. If a crash occurs
between commit and emit on a subsequent close, the previous meta MAY be
re-emitted (duplicates are tolerated by downstream consumers).
A separate debug meta stream MAY be opened on METADATA_DEBUG_LEDGERS
segment boundaries for diagnostics.
The genesis ledger is the starting point of the chain when a node initializes a new database.
| Constant | Value |
|---|---|
GENESIS_LEDGER_SEQ |
1 |
GENESIS_LEDGER_VERSION |
0 |
GENESIS_LEDGER_BASE_FEE |
100 |
GENESIS_LEDGER_BASE_RESERVE |
100000000 |
GENESIS_LEDGER_MAX_TX_SIZE |
100 |
GENESIS_LEDGER_TOTAL_COINS |
1000000000000000000 |
- The apply state MUST be in
SETTING_UP_STATE. - A root LedgerTxn
ltxis opened withshouldUpdateLastModified = false. - The genesis
LedgerHeaderis installed. - A single root
AccountEntryis created with public keySecretKey::fromSeed(networkID).getPublicKey(), threshold[1, 0, 0, 0], and balance equal tototalCoins. sealLedgerTxnAndStoreInBucketsAndDB(...)is invoked withinitialLedgerVers = 0.- The resulting LCL state is installed.
A node MAY override the genesis protocol version, base fee, reserve, and
max-tx-set size via the USE_CONFIG_FOR_GENESIS configuration; in that
case SorobanNetworkConfig::initializeGenesisLedgerForTesting MAY also
populate the Soroban config setting entries at genesis.
After startNewLedger, setLastClosedLedger(lastClosed, /*rebuild=*/...)
is invoked to complete the LCL setup, which optionally rebuilds the
in-memory Soroban state and module cache. The apply state then
transitions from SETTING_UP_STATE to READY_TO_APPLY.
The following invariants are protocol-deterministic and MUST hold across all conforming implementations.
INV-L1: Single-child LedgerTxn. At any instant, an
AbstractLedgerTxnParent SHALL have at most one active child. Attempting
to add a second child MUST throw. Why: prevents stale reads and lost
updates between concurrent overlapping transactions.
INV-L2: Same-thread LedgerTxn access. A LedgerTxn SHALL be accessed
only from the thread that constructed it, until commit or rollback.
Violation MUST abort. Why: LedgerTxn is intentionally not thread-safe.
INV-L3: Monotonic ledger sequence and hash chain. Every applied
ledger MUST have ledgerSeq = prev.ledgerSeq + 1 and
previousLedgerHash = SHA-256(prev.header). The pipeline MUST verify the
txset's declared previousLedgerHash matches the local LCL hash before
applying. Why: the hash chain is the spine of consensus determinism.
INV-L4: Total coins conservation. Total coins (`header.totalCoins + sum_of_all_account_balances + locked_in_offers
- locked_in_pools + locked_in_claimable_balances
) MUST remain invariant across ledger close, modulo deliberate inflationary effects (which adjustheader.totalCoinsthemselves). Why: monetary conservation. (Enforced by theConservationOfLumens` invariant.)
INV-L5: Restored entries mutual exclusion. Within a single ledger,
the same LedgerKey MUST NOT appear in both
mRestoredEntries.hotArchive and mRestoredEntries.liveBucketList.
Why: an entry was either evicted to the hot archive (paying restoration
cost) or still in the live bucket list (only its TTL is updated) —
never both. Asserted at commit time via getEntryOpt.
INV-L6: Sealed-after-commit. Once a LedgerTxn has been sealed
(via commit, getChanges, getDelta, or getAllEntries), all further
mutating operations MUST throw LedgerTxn is sealed. The header MAY be
re-unsealed via unsealHeader(f) for bucket-list and skip-list updates
ONLY.
INV-L7: Fee pool non-negative. header.feePool >= 0 MUST hold at all
times. The pipeline MUST refuse to encode a header with a negative fee
pool.
INV-L8: Phase-state safety. Mutating operations on ApplyState
(updating in-memory Soroban state, module cache, etc.) are permitted
only in SETTING_UP_STATE or COMMITTING. Reads during APPLYING
are permitted from any Soroban worker thread. Why: this enforces the
single-writer property of the apply pipeline.
INV-L9: LedgerHeader validity. Encoded headers MUST satisfy
ledgerSeq <= INT32_MAX, scpValue.closeTime <= INT64_MAX,
feePool >= 0, idPool <= INT64_MAX.
INV-L10: TxSet rooting. The applied txset MUST have
previousLedgerHash == SHA-256(prev_header) AND
getContentsHash() == ledgerData.value.txSetHash. Failure of either
MUST abort apply.
INV-L11: Expected-hash check. If ledgerData.expectedHash is set
(typically by catchup), the locally computed post-apply header hash MUST
equal it; otherwise the pipeline MUST abort with "ledger corrupted during
close".
INV-L12: Single SCP value per LCL. Once an LCL of ledgerSeq = N is
committed, no other distinct LedgerCloseData for ledgerSeq = N MAY be
applied. This is enforced by the LedgerApplyManager's queue ordering
(LCL <= A <= Q <= H).
INV-L13: HAS / LCL agreement. On reload, the persisted HAS and the
persisted LCL header MUST agree on ledgerSeq. Disagreement MUST be
treated as database corruption.
INV-L14: Configuration immutability. CONFIG_SETTING ledger entries
MUST NOT be erased; they MAY only be created (at the V20 upgrade and
subsequent protocol upgrades) or updated (via LEDGER_UPGRADE_CONFIG).
INV-L15: Header re-seal must not modify entries. unsealHeader(f)
gives f mutable access to the header only; f MUST NOT modify the
entry map. This invariant is preserved by exposing only LedgerHeader&
to the callback.
| Constant | Value | Description | Section |
|---|---|---|---|
GENESIS_LEDGER_SEQ |
1 | Sequence of the genesis ledger. | 14.1 |
GENESIS_LEDGER_VERSION |
0 | Protocol version at genesis. | 14.1 |
GENESIS_LEDGER_BASE_FEE |
100 | Base fee at genesis (stroops). | 14.1 |
GENESIS_LEDGER_BASE_RESERVE |
100000000 | Base reserve at genesis (stroops). | 14.1 |
GENESIS_LEDGER_MAX_TX_SIZE |
100 | Max txset size at genesis. | 14.1 |
GENESIS_LEDGER_TOTAL_COINS |
1000000000000000000 | Total coins at genesis. | 14.1 |
SKIP_1 |
50 | First skip-list cadence. | 9.3 |
SKIP_2 |
5000 | Second skip-list cadence. | 9.3 |
SKIP_3 |
50000 | Third skip-list cadence. | 9.3 |
SKIP_4 |
500000 | Fourth skip-list cadence. | 9.3 |
LEDGER_ENTRY_BATCH_COMMIT_SIZE |
4095 (0xfff) | Heuristic bulk-commit batch size (advisory). | 7 |
MAX_LEDGER_DEPENDENT_TX_CLUSTERS |
128 | Upper bound on ledgerMaxDependentTxClusters. |
10.1 |
SOROBAN_PROTOCOL_VERSION |
20 | First Soroban-enabled protocol. | 10 |
REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION |
23 | First protocol with shared module cache. | 11.4 |
PARALLEL_SOROBAN_PHASE_PROTOCOL_VERSION |
23 | First protocol with parallel Soroban phase (and v2 meta). | 13.1 |
FIRST_PROTOCOL_SUPPORTING_PERSISTENT_EVICTION |
23 | First protocol with hot-archive eviction. | 12.2 |
The Soroban network-configuration constants in §10 (minimums, initial
values, P23-upgraded values) are RECOMMENDED defaults defined in
InitialSorobanNetworkConfig, MinimumSorobanNetworkConfig,
MaximumSorobanNetworkConfig, and Protcol23UpgradedConfig of the
reference implementation. Networks MUST satisfy the minimums via
isValidConfigSettingEntry during upgrades.
| Reference | Description |
|---|---|
| [1] | CAP-0046 "Soroban Smart Contracts" |
| [2] | CAP-0046-12 "Soroban Resource Fees" |
| [3] | CAP-0057 "Hot Archive and Restoration" |
| [4] | CAP-0063 "Parallel Soroban Transaction Apply" |
| [5] | stellar-core v27.0.0 source: src/ledger/, src/main/ |
| [6] | XDR: Stellar-ledger.x, Stellar-ledger-entries.x, Stellar-internal.x |
| [7] | HERDER_SPEC §6 — Transaction set construction and apply ordering |
| [8] | TX_SPEC §6, §7, §11 — Transaction lifecycle, fee processing, parallel apply |
| [9] | BUCKETLISTDB_SPEC §6, §10 — Live bucket list and hot archive |
| [10] | CATCHUP_SPEC §6 — LedgerApplyManager and catchup integration |
| [11] | RFC 2119, RFC 8174 — Key words for use in RFCs |
When commitChild merges a child entry at key K into a parent's entry
map, the resulting state is determined by the existing parent state and
the child's state. Below: rows are the parent's current state at K;
columns are the child's incoming state. An empty parent (no entry at K)
results in insertion of the child entry as-is.
| Parent / Child | INIT | LIVE | DELETED |
|---|---|---|---|
| (none) | insert INIT | insert LIVE | insert DELETED |
| INIT | impossible (create would have thrown) |
parent becomes LIVE | parent entry annihilated (erased from map) |
| LIVE | THROW (cannot commit a child init entry into a parent live entry) |
parent becomes LIVE (entry overwritten) | parent becomes DELETED |
| DELETED | parent becomes LIVE (entry restored) | THROW (cannot set deleted entry to live) |
THROW (cannot delete deleted entry) |
Notes:
- The annihilation case (Parent INIT + Child DELETED) is essential to bucket-list correctness: an entry created and immediately destroyed within a single closer transaction MUST leave no trace in the bucket batch.
- The "DELETED + INIT -> LIVE" case occurs when an earlier sibling
transaction deleted an existing entry (so it must have existed prior)
and a later sibling re-creates it via
create. The merged state isLIVEbecause the entry pre-existed. - All "THROW" cases trigger
printErrorAndAbortat the commit site, treating them as fatal logic errors.
flowchart TD
start([valueExternalized]) --> LAM{LAM.processLedger:<br/>contiguous?}
LAM -- yes --> applyLedger[applyLedger called]
LAM -- no --> catchup[Trigger catchup<br/>state := LM_CATCHING_UP_STATE]
applyLedger --> finishComp[Finish pending<br/>module compilation]
finishComp --> startApply[markStartOfApplying]
startApply --> openLtx[Open LedgerTxn ltx]
openLtx --> hdrSetup[Increment ledgerSeq;<br/>set previousLedgerHash;<br/>set scpValue]
hdrSetup --> validate{Validate:<br/>version OK?<br/>txSet rooted?<br/>txSet hash OK?}
validate -- no --> abort[THROW]
validate -- yes --> fees[processFeesSeqNums]
fees --> apply[applyTransactions:<br/>sequential + parallel]
apply --> resHash[txSetResultHash :=<br/>SHA-256 of txResultSet]
resHash --> startCommit[markStartOfCommitting]
startCommit --> upgrades[For each upgrade:<br/>validate, apply,<br/>capture meta]
upgrades --> seal[sealLedgerTxnAndStoreInBucketsAndDB]
seal --> finalize[finalizeLedgerTxnChanges:<br/>eviction, hot archive,<br/>state-size snapshot,<br/>getAllEntries,<br/>addLiveBatch,<br/>updateInMemorySorobanState]
finalize --> unseal[unsealHeader:<br/>snapshotLedger,<br/>store HAS+header]
unseal --> hashCheck{expectedHash<br/>matches?}
hashCheck -- no --> abort
hashCheck -- yes --> emitMeta[Emit LedgerCloseMeta]
emitMeta --> step1[Queue history checkpoint]
step1 --> step2[ltx.commit]
step2 --> step3[maybeCheckpointComplete]
step3 --> step4[Start next eviction scan]
step4 --> step5[markEndOfCommitting;<br/>snapshot invariant state]
step5 --> postMain[advanceLedgerStateAndPublish on main thread]
postMain --> step6[publishQueuedHistory]
step6 --> step7[forgetUnreferencedBuckets]
step7 --> step8[ledgerCloseComplete:<br/>maybe synced,<br/>notify Herder,<br/>invariant snapshot]
step8 --> done([Ready for next ledger])
Suppose bucketListHash is freshly computed for each closing ledger. The
skip-list values immediately after snapshotLedger are:
| ledgerSeq | Trigger | skipList[0] |
skipList[1] |
skipList[2] |
skipList[3] |
|---|---|---|---|---|---|
| 49 | none (49 mod 50 != 0) | (unchanged) | (unchanged) | (unchanged) | (unchanged) |
| 50 | seq mod 50 = 0 | H_50 |
(unchanged) | (unchanged) | (unchanged) |
| 100 | seq mod 50 = 0 | H_100 |
(unchanged) | (unchanged) | (unchanged) |
| 5000 | seq mod 50 = 0; v1 = 4950, 4950 mod 5000 != 0 | H_5000 |
(unchanged) | (unchanged) | (unchanged) |
| 5050 | seq mod 50 = 0; v1 = 5000, 5000 mod 5000 = 0; v2 = 0, halt | H_5050 |
H_5000 (shifted from slot 0) |
(unchanged) | (unchanged) |
| 50050 | seq mod 50 = 0; v1 = 50000, 50000 mod 5000 = 0; v2 = 45000, 45000 mod 50000 != 0 | H_50050 |
shifted | (unchanged) | (unchanged) |
| 55050 | seq mod 50 = 0; v1 = 55000, 55000 mod 5000 = 0; v2 = 50000, 50000 mod 50000 = 0; v3 = 0, halt | H_55050 |
shifted | shifted | (unchanged) |
| 555050 | all three cadence levels divisible; v3 = 500000, 500000 mod 500000 = 0 | H_555050 |
shifted | shifted | shifted |
The exact crossings depend on the precise sequence numbers; the point is
that skipList[k] slot is advanced only when the running difference
remains a positive multiple of SKIP_{k+1}.
Use cases: skip-list slots enable fast historic verification. Slot 0
provides a bucketListHash every 50 ledgers, slot 1 every 5050 ledgers,
slot 2 every 55050 ledgers, slot 3 every 555050 ledgers, allowing
logarithmic skip-back traversal of the historic chain.