read this skill for a token-efficient summary of the invariant subsystem
The invariant subsystem provides a runtime correctness-checking framework for stellar-core. It defines a registry of invariant checks that are executed at key lifecycle events (operation apply, ledger commit, bucket apply, assume-state, and periodic background snapshots). When an invariant is violated, it either throws InvariantDoesNotHold (for strict invariants) or logs an error (for non-strict ones). Invariants are registered at application startup and enabled via configuration patterns (regex matching on invariant names).
Invariant with virtual checkOn* hooks.InvariantManager; owns invariant registration, enablement, dispatch loops, failure handling, and background snapshot scheduling.numSubEntries on accounts matches actual sub-entry counts.LedgerEntry types.numSponsoring/numSponsored counters on accounts match sponsorship extensions.reserveA * reserveB) never decreases.Invariant (abstract base class)The base class for all invariant implementations. Each subclass overrides one or more checkOn* virtual methods and returns an empty string on success or an error description string on failure.
Key members:
mStrict (bool, const) — If true, failure throws InvariantDoesNotHold (fatal). If false, failure is logged as an error but execution continues.getName() — Pure virtual; returns the invariant's unique name string.checkOnOperationApply(operation, result, ltxDelta, events, app) — Called after each operation is applied within a transaction. Receives the operation, its result, the LedgerTxnDelta (all entry changes plus header changes), contract events, and an AppConnector.checkOnBucketApply(bucket, oldestLedger, newestLedger, shadowedKeys) — Called during catchup when a bucket is applied to the database.checkAfterAssumeState(newestLedger) — Called after the BucketList state has been assumed (end of catchup).checkOnLedgerCommit(lclLiveState, lclHotArchiveState, persistentEvicted, tempAndTTLEvicted, restoredFromArchive, restoredFromLiveState) — Called at ledger commit time with eviction/restore vectors.checkSnapshot(liveSnapshot, hotArchiveSnapshot, inMemorySnapshot, isStopping) — Called periodically on a background thread for expensive full-state scans.snapshotForFuzzer() / resetForFuzzer() — (BUILD_TESTS only) Snapshot/restore internal state for fuzzing rollback.Helper function shouldAbortInvariantScan(errorMsg, isStopping) returns true if an error has been found or the node is shutting down, used to short-circuit long-running BucketList scans.
InvariantManager (abstract interface)Provides the public API for registering, enabling, and dispatching invariants.
Key methods:
create(Application&) — Factory; returns InvariantManagerImpl.registerInvariant(shared_ptr<Invariant>) — Adds an invariant to the registry by name.registerInvariant<T>(args...) — Templated convenience for constructing + registering.enableInvariant(name) — Enables invariant(s) matching a regex pattern.checkOnOperationApply(...) / checkOnBucketApply(...) / checkAfterAssumeState(...) / checkOnLedgerCommit(...) — Dispatch calls to all enabled invariants.runStateSnapshotInvariant(...) — Runs checkSnapshot on all enabled invariants in a background thread.shouldRunInvariantSnapshot() / markStartOfInvariantSnapshot() — Coordinate snapshot timing with LedgerManager.start(LedgerManager&) — Initializes the snapshot timer if INVARIANT_EXTRA_CHECKS is enabled.getJsonInfo() — Returns JSON with failure history for the /info endpoint.isBucketApplyInvariantEnabled() — Checks if BucketListIsConsistentWithDatabase is enabled.InvariantManagerImplKey data members:
mConfig — Reference to application config.mInvariants — map<string, shared_ptr<Invariant>>: registry of all invariants by name.mEnabled — vector<shared_ptr<Invariant>>: subset that is currently enabled.mInvariantFailureCount — Medida counter for total failures.mStateSnapshotInvariantSkipped — Medida counter for skipped snapshot runs.mStateSnapshotInvariantRunning — atomic<bool>: true while a background snapshot scan is in progress.mShouldRunStateSnapshotInvariant — atomic<bool>: flag set by the timer, read by LedgerManager.mStateSnapshotTimer — VirtualTimer scheduling periodic snapshot checks.mFailureInformation — map<string, InvariantFailureInformation> guarded by mFailureInformationMutex; tracks last failure ledger and message per invariant.Dispatch logic:
Each checkOn* method iterates over mEnabled, calls the corresponding virtual method on each invariant, and if a non-empty error string is returned, calls onInvariantFailure(). The onInvariantFailure method increments the failure counter, records failure info, and calls handleInvariantFailure() which either throws InvariantDoesNotHold (strict) or logs an error (non-strict). In fuzzing builds, failures always abort().
Protocol version gating:
checkOnOperationApply skips all invariants except EventsAreConsistentWithEntryDiffs for ledgers before protocol version 8.
Snapshot scheduling:
When INVARIANT_EXTRA_CHECKS is enabled, start() calls scheduleSnapshotTimer(). The timer fires snapshotTimerFired(), which sets mShouldRunStateSnapshotInvariant = true if no prior scan is running. LedgerManager reads shouldRunInvariantSnapshot() and, when true, snapshots the state and dispatches runStateSnapshotInvariant() on a background thread. The background thread iterates all enabled invariants calling checkSnapshot(). If the previous scan is still running when the timer fires, the run is skipped and a metric is incremented.
InvariantDoesNotHoldA std::runtime_error subclass thrown when a strict invariant fails. Caught upstream (e.g., in LedgerManager) to trigger node shutdown.
ConservationOfLumens (non-strict)Purpose: Ensures the total supply of lumens is conserved. During normal operations, totalCoins and feePool in the LedgerHeader must not change (except during inflation). The full BucketList snapshot mode sums all native balances across accounts, trustlines, claimable balances, liquidity pools, and Stellar Asset Contract balance entries (both live and hot-archived) and compares to header.totalCoins.
Hooks used: checkOnOperationApply, checkSnapshot.
Key logic:
calculateDeltaBalance() computes the change in native asset balance for each entry delta, using getAssetBalance() which understands SAC contract data entries.deltaTotalCoins == inflationPayouts + deltaFeePool and deltaBalances == inflationPayouts. For non-inflation, all deltas must be zero.countedKeys sets, sums live + hot-archive balances + feePool, and compares to totalCoins. Only runs from protocol V24+.Constructor takes: AssetContractInfo for the lumen SAC contract (contract ID, balance key symbol, amount symbol).
AccountSubEntriesCountIsValid (non-strict)Purpose: Validates that the numSubEntries field on each account matches the actual count of sub-entries (trustlines, offers, data entries, signers). Pool-share trustlines count as 2 sub-entries.
Hook used: checkOnOperationApply.
Key logic: Builds a UnorderedMap<AccountID, SubEntriesChange> tracking deltas in numSubEntries (from the account entry) vs. calculatedSubEntries (from counting sub-entry creates/deletes). Also checks that deleted accounts have no remaining sub-entries other than signers.
BucketListIsConsistentWithDatabase (strict)Purpose: Cross-checks entries in BucketList buckets against the SQL database during catchup/bucket-apply. Only checks entry types not supported by BucketListDB (currently only OFFERs).
Hooks used: checkOnBucketApply, checkAfterAssumeState.
Key logic:
checkOnBucketApply: Iterates a single bucket, verifies ordering, checks lastModifiedLedgerSeq bounds, and compares each LIVE/INIT entry against the database and each DEAD entry is absent from the database. Validates total offer count matches.checkAfterAssumeState: Iterates the entire BucketList, checking all unshadowed offer entries against the database.checkEntireBucketlist(): Offline self-check entry point that loads the complete BucketList and compares against the database.Holds a reference to Application for database and BucketManager access.
LedgerEntryIsValid (non-strict)Purpose: Validates structural correctness, field bounds, and immutability constraints for all ledger entry types after each operation.
Hook used: checkOnOperationApply.
Key logic: Dispatches to type-specific checkIsValid() overloads:
sha256(code) == hash, hash/code immutable after creation.lastModifiedLedgerSeq == current ledgerSeq.LiabilitiesMatchOffers (non-strict)Purpose: Ensures buying/selling liabilities on accounts and trustlines stay in sync with the aggregated liabilities implied by their offers, and that balances respect liability + reserve constraints.
Hook used: checkOnOperationApply.
Key logic (V10+ only for liabilities):
LiabilitiesMap (per-account, per-asset liabilities delta) by adding current entry liabilities and subtracting previous entry liabilities for accounts, trustlines, and offers.exchangeV10WithoutPriceErrorThresholds(...).numWheatReceived; buying liabilities = numSheepSend.checkAuthorized() validates authorization state transitions on trustlines.SponsorshipCountIsValid (non-strict)Purpose: Validates per-account numSponsoring and numSponsored counters match actual sponsorship extensions on entries. Only active from protocol V14+.
Hook used: checkOnOperationApply.
Key logic:
updateCounters() walks entry extensions: if sponsoringID is set, increments numSponsoring for the sponsor and numSponsored for the owning account (or claimableBalanceReserve for claimable balances). Multiplier depends on entry type (accounts = 2, pool-share trustlines = 2, claimable balances = number of claimants, others = 1). Also counts signer-level sponsorships from v2 account extensions.numSponsoring/numSponsored maps) against the actual delta in account entries. Checks that no unmatched changes remain.ConstantProductInvariant (strict)Purpose: Ensures the AMM constant product reserveA * reserveB never decreases for liquidity pool entries (except during withdrawals, SetTrustLineFlags, and AllowTrust operations which are excluded).
Hook used: checkOnOperationApply.
Key logic: For each modified liquidity pool entry, validates currentReserveA * currentReserveB >= previousReserveA * previousReserveB using 128-bit arithmetic (uint128_t).
OrderBookIsNotCrossed (strict, BUILD_TESTS only)Purpose: Maintains an in-memory order book and checks that buy/sell prices never cross (lowest ask ≤ highest bid only allowed if all offers at that price are passive).
Hook used: checkOnOperationApply.
Not registered via normal config. Only registered and enabled explicitly via registerAndEnableInvariant() from fuzzer code or dedicated tests, because it maintains state across calls and cannot handle rollbacks without the snapshotForFuzzer/resetForFuzzer mechanism.
Key data structures:
OrderBook = unordered_map<AssetPair, set<OfferEntry, OfferEntryCmp>> — sorted by price, then passive-flag, then offerID.mOrderBookSnapshot — saved state for fuzzer rollback.Key logic: updateOrderBook() processes LedgerTxnDelta to add/remove offers. check() iterates affected asset pairs and calls checkCrossed() which compares the lowest ask price to the inverse of the lowest bid price. Equal prices are allowed only if at least one side is entirely passive offers.
BucketListStateConsistency (strict)Purpose: Background snapshot invariant that validates consistency between the BucketList, InMemorySorobanState cache, and HotArchive for Soroban entries. Only runs from SOROBAN_PROTOCOL_VERSION+.
Hook used: checkSnapshot.
Properties checked:
InMemorySorobanState with matching value.Implementation: Scans CONTRACT_DATA, CONTRACT_CODE, and TTL entries sequentially via scanForEntriesOfType(), tracking seen keys to handle shadowing. Uses shouldAbortInvariantScan() between scans for early termination.
ArchivedStateConsistency (non-strict)Purpose: Validates that eviction and restoration of Soroban entries are consistent with the live and hot-archive BucketList state. Only runs from the first protocol supporting persistent eviction.
Hook used: checkOnLedgerCommit.
Key logic:
EventsAreConsistentWithEntryDiffs (strict)Purpose: Validates that Stellar Asset Contract (SAC) events (transfer, mint, burn, clawback, set_authorized) are consistent with the actual ledger entry balance changes for each operation.
Hook used: checkOnOperationApply.
Key data structures:
AggregatedEvents — accumulates net balance changes per (SCAddress, Asset) from events, and tracks set_authorized state changes.stellarAssetContractIDs — maps contract hashes to Asset for SAC identification.Key logic:
aggregateEventDiffs() processes all contract events: transfer subtracts from source and adds to destination; mint adds; burn/clawback subtracts. Uses 128-bit arithmetic via Rust bridge (rust_bridge::i128_add/sub). Returns nullopt on malformed events.calculateDeltaBalance() computes the actual balance change and consumeAmount() retrieves the corresponding event amount. Checks they match for accounts, trustlines, claimable balances, liquidity pools, and SAC contract data balance entries.getProtocol23CorruptionEventReconciler().checkAuthorization() validates that trustline authorization changes match set_authorized events.All checkOnOperationApply, checkOnBucketApply, checkAfterAssumeState, and checkOnLedgerCommit calls happen synchronously on the main thread as part of transaction/ledger processing. They iterate the mEnabled vector and short-circuit on the first failure, calling onInvariantFailure().
Expensive invariants (checkSnapshot) run on a background thread managed by LedgerManager:
InvariantManagerImpl::start() schedules mStateSnapshotTimer (period = STATE_SNAPSHOT_INVARIANT_LEDGER_FREQUENCY seconds).snapshotTimerFired() sets mShouldRunStateSnapshotInvariant = true (atomic).LedgerManager checks shouldRunInvariantSnapshot(), snapshots state, calls markStartOfInvariantSnapshot() (sets running flag, clears should-run flag), and dispatches runStateSnapshotInvariant() on a background thread.checkSnapshot(). On completion, clears mStateSnapshotInvariantRunning via gsl::finally.printErrorAndAbort is called to match strict invariant failure behavior.mFailureInformation is protected by mFailureInformationMutex (accessed from both main thread and background snapshot thread via onInvariantFailure()).mStateSnapshotInvariantRunning and mShouldRunStateSnapshotInvariant are atomic<bool> for lock-free coordination between the timer callback, LedgerManager, and background thread.Application owns the InvariantManager (via unique_ptr).InvariantManagerImpl owns all registered invariants (shared_ptr<Invariant> in mInvariants map and duplicated in mEnabled vector).LedgerTxnDelta or snapshots passed in). Exceptions:
BucketListIsConsistentWithDatabase holds an Application& reference for DB/BucketManager access.OrderBookIsNotCrossed maintains a mOrderBook across calls.ConservationOfLumens and LedgerEntryIsValid store AssetContractInfo (computed at registration time from network ID).EventsAreConsistentWithEntryDiffs stores a Hash const& to the network ID.Each invariant provides a static registerInvariant(Application&) method that constructs the invariant with any needed dependencies and calls app.getInvariantManager().registerInvariant<T>(args...). Registration happens at application startup. Enablement happens separately via config patterns (INVARIANT_CHECKS config entries) that are regex-matched against registered invariant names.
LedgerManager / BucketManager
│
├── checkOnOperationApply(op, result, LedgerTxnDelta, events, app)
│ │
│ └── for each enabled Invariant → checkOnOperationApply()
│ └── returns "" (ok) or error string → onInvariantFailure()
│
├── checkOnBucketApply(bucket, ledger, level, isCurr, shadowedKeys)
│ └── for each enabled Invariant → checkOnBucketApply()
│
├── checkOnLedgerCommit(liveState, archiveState, evicted, restored)
│ └── for each enabled Invariant → checkOnLedgerCommit()
│
└── runStateSnapshotInvariant(liveSnap, archiveSnap, inMemSnap, isStopping)
└── [background thread] for each enabled Invariant → checkSnapshot()