diff --git a/.github/scripts/doc-agent/package-lock.json b/.github/scripts/doc-agent/package-lock.json index 87abebe727..29854a6c54 100644 --- a/.github/scripts/doc-agent/package-lock.json +++ b/.github/scripts/doc-agent/package-lock.json @@ -20,7 +20,7 @@ "typescript": "^5.7.0" }, "engines": { - "node": ">=20" + "node": ">=20.12" } }, "node_modules/@anthropic-ai/claude-agent-sdk": { diff --git a/.github/scripts/doc-agent/src/regen-skills.ts b/.github/scripts/doc-agent/src/regen-skills.ts index 7ccc5e1580..30e5493098 100644 --- a/.github/scripts/doc-agent/src/regen-skills.ts +++ b/.github/scripts/doc-agent/src/regen-skills.ts @@ -2,12 +2,16 @@ * Regen-skills mode: rebuild a module's skill file from ai.md inputs. * * For a given module (e.g. `protocol`, `ledger`, `consensus`), collect all - * `.ai.md` files under the matching source paths and ask the agent to - * produce an updated `docs/skills/.md`. + * `.ai.md` files under the matching source paths and ask the Agent SDK to + * write an updated `docs/skills/.md`. + * + * The agent writes the file via the `Write` tool rather than returning the + * skill content as text. This avoids hitting the per-turn output token + * limit on large modules (which previously truncated several skill files). */ import { existsSync, readdirSync, statSync } from 'node:fs'; -import { readFile, writeFile } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import { join, relative, resolve } from 'node:path'; import { query } from '@anthropic-ai/claude-agent-sdk'; import { MODEL, MODULE_SKILL_MAP, PROMPTS_DIR, SKILLS_DIR, XRPLD_ROOT } from './config.js'; @@ -60,16 +64,17 @@ async function loadAiFiles(absPaths: readonly string[]): Promise { * Regenerate the skill file for a given module name. * * @param moduleName - The skill file name without extension (e.g. "protocol", - * "ledger"). Must match a key in the MODULE_SKILL_MAP value set. + * "ledger"). Must match a value in MODULE_SKILL_MAP. */ export async function regenSkills(moduleName: string): Promise { const skillFile = `${moduleName}.md`; const prefixes = prefixesForSkill(skillFile); if (prefixes.length === 0) { - throw new Error( - `Unknown module: ${moduleName}. Valid modules: ${Array.from(new Set(Object.values(MODULE_SKILL_MAP).filter((v): v is string => v !== null))).join(', ')}`, + const known = Array.from( + new Set(Object.values(MODULE_SKILL_MAP).filter((v): v is string => v !== null)), ); + throw new Error(`Unknown module: ${moduleName}. Valid modules: ${known.join(', ')}`); } console.log(`Regenerating skill: ${skillFile}`); @@ -84,6 +89,7 @@ export async function regenSkills(moduleName: string): Promise { const aiFiles = await loadAiFiles(aiPaths); const skillPath = resolve(SKILLS_DIR, skillFile); + const skillRelPath = relative(XRPLD_ROOT, skillPath); const existingSkill = existsSync(skillPath) ? await readFile(skillPath, 'utf8') : '(no existing skill file — create a new one)'; @@ -94,7 +100,11 @@ export async function regenSkills(moduleName: string): Promise { .map((f) => `\n### \`${f.sourcePath}\`\n\n${f.content}`) .join('\n\n---\n'); - const userPrompt = `Regenerate the skill file: \`docs/skills/${skillFile}\` + const userPrompt = `Regenerate the skill file at: \`${skillRelPath}\` + +Use the **Write** tool to write the new content to that path. Do NOT return +the skill content in your message — write it directly to the file. This +avoids hitting per-turn output token limits. ## Existing skill content @@ -104,27 +114,31 @@ ${existingSkill} ${aiBlocks} -Produce the new complete skill file content as your final message.`; +When you have written the file, respond with a brief one-line confirmation.`; - let response = ''; const result = query({ prompt: userPrompt, options: { model: MODEL, systemPrompt, cwd: XRPLD_ROOT, - allowedTools: ['Read', 'Glob', 'Grep'], + allowedTools: ['Write', 'Read', 'Glob', 'Grep'], permissionMode: 'acceptEdits', }, }); + let wroteFile = false; for await (const message of result) { if (message.type === 'assistant') { const content = message.message?.content; if (Array.isArray(content)) { for (const block of content) { - if (block.type === 'text') { - response += block.text; + if (block.type === 'tool_use' && block.name === 'Write') { + const input = block.input as { file_path?: string } | undefined; + if (input?.file_path !== undefined) { + wroteFile = true; + console.log(` Agent wrote: ${input.file_path}`); + } } } } @@ -135,12 +149,10 @@ Produce the new complete skill file content as your final message.`; } } - const trimmed = response.trim(); - if (trimmed.length === 0) { - console.error(' Agent returned empty response — skill file not updated.'); + if (!wroteFile) { + console.error(' Agent did not call Write — skill file not updated.'); return; } - await writeFile(skillPath, `${trimmed}\n`); - console.log(` Wrote: ${relative(XRPLD_ROOT, skillPath)}`); + console.log(` Wrote: ${skillRelPath}`); } diff --git a/docs/skills/consensus.md b/docs/skills/consensus.md index 54e426cf49..29b3c8864c 100644 --- a/docs/skills/consensus.md +++ b/docs/skills/consensus.md @@ -1,57 +1,135 @@ # Consensus -Template-based state machine in `Consensus.h` parameterized by an Adaptor (`RCLConsensus`). Three phases: open -> establish -> accepted. Modes: proposing, observing, wrongLedger, switchedLedger. +Template-based state machine in `Consensus.h` parameterized by an `Adaptor` (production: `RCLConsensus`). Three phases: `open -> establish -> accepted`. Four modes: `proposing`, `observing`, `wrongLedger`, `switchedLedger`. Header-only because of templating; policy decisions (`shouldCloseLedger`, `checkConsensus`) live as free functions in `Consensus.cpp` for independent testability. + +## Architecture + +The consensus engine is fully decoupled from XRPL types via the `Adaptor` template parameter. `Adaptor` provides four type aliases (`Ledger_t`, `TxSet_t`, `NodeID_t`, `PeerPosition_t`) plus callbacks (`onClose`, `onAccept`, `onForceAccept`, `onModeChange`) and queries (`proposersValidated`, `proposersFinished`, `getPrevLedger`). Networking is hooked via `propose()` and three `share()` overloads (position, tx set, individual tx). + +The engine itself has no thread or timer — it is driven externally by `timerEntry()` calls. Thread safety is the caller's responsibility. ## Key Invariants - A ledger cannot close until the previous ledger reaches consensus AND (has transactions OR close time reached) - Proposals must have strictly increasing sequence numbers per peer; stale proposals are silently dropped -- The Avalanche state machine progressively lowers consensus thresholds over time (init -> mid -> late -> stuck) to prevent livelock -- `minCONSENSUS_PCT = 80` is the baseline; timing params: `ledgerMIN_CONSENSUS = 1950ms`, `ledgerMAX_CONSENSUS = 15s` +- `ConsensusResult` constructor asserts `txns.id() == position.position()` — a node's declared position is always a commitment to a specific tx set +- The Avalanche state machine progressively raises consensus thresholds over time (`init -> mid -> late -> stuck`) to force convergence +- `minCONSENSUS_PCT = 80` is the baseline for `checkConsensus`; timing: `ledgerMIN_CONSENSUS = 1950ms`, `ledgerMAX_CONSENSUS = 15s`, `ledgerABANDON_CONSENSUS = 120s` +- `ledgerMAX_CONSENSUS` must stay below `validationFRESHNESS` so waiting validators aren't mistaken for offline - Dead nodes (`deadNodes_`) are permanently excluded for the round once they bow out +- LedgerTrie compression invariant: non-root nodes with zero `tipSupport` must have ≥2 children + +## Phases and Modes + +### Phase transitions (`ConsensusPhase` in `ConsensusTypes.h`) +``` + "close" "accept" + open --------> establish ---------> accepted + ^ | | + |---------------| | + | "startRound" | + |------------------------------------| +``` +Mid-`establish` re-entry to `open` happens inside `handleWrongLedger()` — it preserves surrounding state rather than aborting. `timerEntry`, `gotTxSet`, and `peerProposal` all short-circuit when phase is `accepted`. + +### Mode transitions (`ConsensusMode`) +``` +proposing observing + \ / + \---> wrongLedger <---/ + ^ + v + switchedLedger +``` +`switchedLedger` is a distinct mode (not just `observing`) because close-time logic checks the mode label when deciding whether the previous ledger's close time is authoritative. `MonitoredMode` inner class wraps the enum to make silent mode changes structurally impossible — every `set()` calls `adaptor_.onModeChange(before, after)`. + +## Phase Logic + +### Open phase +`shouldCloseLedger()` is called per timer tick. Priority order (`Consensus.cpp`): +1. Sanity bounds — close immediately if `prevRoundTime` or `timeSincePrevClose` outside `[-1s, 10min]` +2. Majority closed — close if `proposersClosed + proposersValidated > prevProposers / 2` +3. Idle case — only close on `timeSincePrevClose >= ledgerIDLE_INTERVAL` (15s) when no transactions +4. Minimum open time — never close before `ledgerMIN_CLOSE` (2s) +5. Rate limit — block close if `openTime < prevRoundTime / 2` (prevents fast node from outrunning slower validators) + +Close-time reference: if mode is `wrongLedger` or close-time wasn't agreed, use internal `prevCloseTime_` rather than the ledger's recorded close time. + +### Establish phase +Per tick: `updateOurPositions()` → `shouldPause()` → `haveConsensus()`. `ledgerMIN_CONSENSUS` is enforced before any position updates. `updateOurPositions()`: +- Prunes stale peer proposals (older than `proposeFRESHNESS` = 20s) +- Calls `dispute.updateVote(convergePercent_, ...)` on each `DisputedTx` +- Rebuilds the `MutableTxSet` if any vote flipped, re-shares + re-proposes + +`shouldPause()` uses a 5-phase cycle (0–4) keyed off `(ahead - 1) % 5`. Each phase requires progressively more validators current; phase 4 requires all. This cycles to avoid any single threshold being universally right. + +### checkConsensus outcomes (`ConsensusState`) +- `No` — insufficient agreement +- `Yes` — local + network agree on tx set (80% with self counted) +- `MovedOn` — 80% of peers finished without us (self not counted); we lost the race +- `Expired` — abandoned after `prevAgreeTime * ledgerABANDON_CONSENSUS_FACTOR` (factor=10), clamped to `[ledgerMAX_CONSENSUS, ledgerABANDON_CONSENSUS]` + +The zero-peer case in `checkConsensusReached` deliberately refuses consensus until `reachedMax` — prevents premature self-close on a network slow to deliver proposals. The `stalled` case bypasses the percentage check entirely. + +## Avalanche Voting + +Four states defined in `ConsensusParms.h`: + +| State | Time threshold (% of prior round) | Required yes-vote | Next | +|---------|-----------------------------------|-------------------|--------| +| `init` | 0% | 50% | `mid` | +| `mid` | 50% | 65% | `late` | +| `late` | 85% | 70% | `stuck`| +| `stuck` | 200% | 95% | `stuck`| + +Encoded as `std::map` (data-driven, not switch) so theoretical loops are expressible. `getNeededWeight()` returns `(consensusPct, optional)`. Caller does the actual state update. `avMIN_ROUNDS` prevents premature escalation through states on clock jitter. + +`DisputedTx::updateVote()` behaves asymmetrically: +- Proposing: `weight = (yays_*100 + (ourVote_?100:0)) / (nays_+yays_+1)`; `newPosition = weight > requiredPct` +- Not proposing: `newPosition = yays_ > nays_`, `weight = -1`. Observer never distorts proposers' weighted vote. + +Stall detection (`DisputedTx::stalled`) — all must hold: +1. `nextCutoff.consensusTime <= currentCutoff.consensusTime` (terminal state) +2. ≥ `avMIN_ROUNDS` rounds in state +3. `peersUnchanged >= avSTALLED_ROUNDS` OR `currentVoteCounter_ >= avSTALLED_ROUNDS` (OR not AND — defends against a peer flip-flopping to reset the counter) +4. Vote split exceeds `minCONSENSUS_PCT` (80%) in either direction + +`peerUnchangedCounter_` resets to 0 on any peer vote change in `updateDisputes()`. Close-time consensus uses a separate threshold `avCT_CONSENSUS_PCT` (75%) — close-time agreement is a simple majority, not a multi-round ratchet. + +## Proposals (`ConsensusProposal.h`) + +Five fields hashed for signing: `HashPrefix::proposal`, `proposeSeq_`, `closeTime_`, `prevLedgerID_`, `position_`. Hash is `mutable std::optional`, lazily computed; `changePosition()` and `bowOut()` must call `signingHash_.reset()` before mutating. + +Sequence sentinels: +- `seqJoin = 0` — initial proposal (`isInitial()`); `ConsensusCloseTimes` collects these for clock-drift measurement +- `seqLeave = 0xffffffff` — bow-out; `changePosition()` refuses to increment past this + +`seenTime()` is local wall-clock time when last updated, NOT `closeTime_` (the proposer's estimate of when the ledger should close in `NetClock`). Don't conflate them. `isStale(cutoff)` uses `seenTime()`. `operator==` includes `seenTime()`, so logically-identical proposals seen at different times don't compare equal. + +## Wrong-Ledger Recovery + +At every `timerEntry()`, `checkLedger()` calls `adaptor_.getPrevLedger()`. If diverged, `handleWrongLedger()`: +1. Calls `leaveConsensus()` — broadcasts bow-out, drops to `observing` +2. Clears peer state +3. Calls `playbackProposals()` — replays proposals from `recentPeerPositions_` (capped at 10/peer, stored regardless of ledger ID) +4. If correct ledger acquired: `startRoundInternal()` in `switchedLedger` mode; else: stays in `wrongLedger` + +The bounded `recentPeerPositions_` buffer is a deliberate trade-off: small bounded buffer beats dropping proposals during switches. ## Common Bug Patterns - Proposals referencing a stale `prevLedgerID_` after a ledger switch cause split-brain; always check `newPeerProp.prevLedger() != prevLedgerID_` before processing - Resetting the consensus timer during `establish` phase causes re-convergence and potential split; timer must only reset on phase transitions - `DisputedTx::updateVote` changes local vote based on peer pressure; bugs here cause determinism failures across nodes -- `createDisputes()` deduplicates via `compares` set; missing this check creates duplicate disputes that skew vote counts +- `createDisputes()` deduplicates via `result_->compares` set; missing this check creates duplicate disputes that skew vote counts - The `peerUnchangedCounter_` is reset to 0 when any vote changes; bugs in this counter cause premature consensus declaration +- Forgetting `signingHash_.reset()` before mutating a `ConsensusProposal` returns stale hashes +- Comparing wall-clock `seenTime()` against `NetClock` `closeTime_` is a type-shaped bug waiting to happen -## Amendments +## Key Code Patterns -- 80% validator support for 2 weeks to enable; tracked via `AmendmentTable` with `amendmentMap_` -- New amendments: add to `features.macro` with `XRPL_FEATURE`/`XRPL_FIX`, increment `numFeatures` in `Feature.h` -- Unsupported enabled amendment blocks the server (`setAmendmentBlocked`); no mechanism to disable/revoke -- Voting happens each consensus round in `doVoting`; votes are persisted in `FeatureVotes` SQLite table -- `fixAmendmentMajorityCalc` changed the threshold calculation; check which calculation applies - -## UNL and Negative UNL - -- Negative UNL temporarily disables unreliable validators (max 25% of UNL: `negativeUNLMaxListed = 0.25`) -- Scoring uses `buildScoreTable` over recent ledger history; low watermark (50%) = disable candidate, high watermark (80%) = re-enable candidate -- Candidate selection is deterministic via previous ledger hash as randomizing pad -- `newValidatorDisableSkip = FLAG_LEDGER_INTERVAL * 2` prevents disabling newly joined validators prematurely - -## Validations - -- `ValidationParms` defines freshness windows: CURRENT_WALL=5min, CURRENT_LOCAL=3min, SET_EXPIRES=10min, FRESHNESS=20s -- `SeqEnforcer` rejects validations with regressed or duplicate sequence numbers (`ValStatus::badSeq`) -- Conflicting validations (same seq, different hash) are logged as byzantine behavior -- `handleNewValidation` is the entry point: checks trust, adds to `Validations` set, triggers `checkAccept` if current+trusted - -## Transaction Ordering - -- `CanonicalTXSet` orders by: salted account key (XOR with random salt) -> sequence proxy -> transaction ID -- Salt prevents manipulation of ordering by account selection -- `TxQ` uses `OrderCandidates`: higher fee level first, then `txID XOR parentHash` as tiebreaker -- Per-account limit: `maximumTxnPerAccount`; blocked transactions held until blocker resolves - -## Key Patterns - -### Proposal Validation (prevents split-brain) +### Proposal Validation ```cpp -// REQUIRED: reject proposals referencing stale previous ledger if (newPeerProp.prevLedger() != prevLedgerID_) { JLOG(j_.debug()) << "Got proposal for " << newPeerProp.prevLedger() @@ -62,7 +140,6 @@ if (newPeerProp.prevLedger() != prevLedgerID_) ### Complete Bow-Out Handling ```cpp -// REQUIRED: all three steps — unvote, erase position, mark dead if (newPeerProp.isBowOut()) { if (result_) @@ -74,13 +151,92 @@ if (newPeerProp.isBowOut()) } ``` +### CLOG diagnostic pattern +Most methods take `std::unique_ptr const& clog = {}`. `CLOG(clog)` macro appends only when non-null — full round trace available without paying formatting cost on the hot path. + +## Validations (`Validations.h`) + +`Validations` is templated; production uses `RCLValidationsAdaptor`. Five coordinated structures under one `mutex_`: +- `current_`: most recent per node, fast-path for quorum +- `byLedger_`: aged unordered map keyed by ledger ID +- `bySequence_`: aged unordered map for Byzantine detection +- `trie_`: `LedgerTrie` for preferred-ledger calc +- `acquiring_`: validations waiting on locally-unavailable ledgers + +`ValidationParms` windows: `validationCURRENT_WALL=5min`, `validationCURRENT_LOCAL=3min`, `validationCURRENT_EARLY=3min`, `validationSET_EXPIRES=10min`, `validationFRESHNESS=20s` (used only for laggard detection, not staleness). Fields are mutable instance members, not `constexpr` — simulations inject alternate values. + +`isCurrent()` checks two clocks independently: signer's wall time and our local steady-clock first-observation time. Arithmetic promotes to signed 64-bit to avoid underflow on untrusted `signTime`. + +`SeqEnforcer` rejects regressed/duplicate sequences but resets its high-water mark after `validationSET_EXPIRES` with no new validation — long-offline validators can rejoin. + +`add()` classification (in order): +- Same seq, different ledger/sign time → `ValStatus::conflicting` (possible Byzantine) +- Same seq + ledger, different cookie → `ValStatus::multiple` (misconfig/duplicate) +- Otherwise → `ValStatus::badSeq` + +All trie queries go through `withTrie()`, which first `current()`-flushes stale entries then `checkAcquired()`-promotes newly available ledgers. `lastLedger_` tracks each node's trie contribution so `removeTrie()` can atomically undo before re-inserting. + +`getPreferred(curr)` fallback: trie → `acquiring_` (max waiters) → `nullopt`. Conservative switch rule: if preferred is an immediate child of current working ledger, stay put. + +`trustChanged()` iterates `current_` and full `byLedger_` to propagate UNL changes — trie reflects only currently trusted validators. + +`setSeqToKeep([low, high))` pins a range against eviction by "touching" entries near expiry. Throttled to once per `(validationSET_EXPIRES - validationFRESHNESS)` window. + +## LedgerTrie (`LedgerTrie.h`) + +Compressed prefix trie over ledger ancestry — ledger history is treated as a string over the alphabet of ledger IDs. Each `Node` carries a `Span` (half-open `[start_, end_)`), two counters, raw parent pointer, owned children. + +- `tipSupport`: validations exactly matching this node's tip +- `branchSupport`: `tipSupport` + sum of descendants' `branchSupport` + +Counters propagate up the parent chain on every `insert`/`remove`. Non-root nodes with zero tip and ≤1 child violate the compression invariant and are merged. + +`insert()` may do up to two structural ops: +1. Split — extract suffix into new child inheriting children + counts, truncate found node +2. Branch — append new leaf + +`remove()` uses `findByLedgerID()` (O(n) exact match), not the prefix-based `find()`. + +`getPreferred(largestIssued)` — the algorithmic heart. Walks from root using "preferred by branch": validators with last validation below the current frontier are *uncommitted* (could swing any branch). A branch advances only when `branchSupport` exceeds *uncommitted*, and a child wins only when its `branchSupport` lead over the runner-up exceeds *uncommitted* (with `startID()` tie-break). The strictly-greater-than margin prevents thrashing when validators lag. + +`seqSupport: std::map` (ordered for in-sequence walk) drives the uncommitted accounting. + +`checkInvariants()` does full DFS — used heavily in tests; verifies compression rule, counter consistency, parent links, and `seqSupport` sums. + +`Ledger` template contract: cheap copy, `seq()`, `operator[](Seq)` returning `ID{0}` for unknowns, `MakeGenesis{}` tag, free `mismatch(Ledger,Ledger)`. Unique history invariant: agreement on any ancestor ID implies agreement on all earlier ancestors. + +## Amendments + +- 80% validator support for 2 weeks to enable; tracked via `AmendmentTable` with `amendmentMap_` +- New amendments: add to `features.macro` with `XRPL_FEATURE`/`XRPL_FIX`, increment `numFeatures` in `Feature.h` +- Unsupported enabled amendment blocks the server (`setAmendmentBlocked`); no mechanism to disable/revoke +- Voting happens each consensus round in `doVoting`; votes persisted in `FeatureVotes` SQLite table +- `fixAmendmentMajorityCalc` changed the threshold calculation; check which applies + +## UNL and Negative UNL + +- N-UNL temporarily disables unreliable validators (max 25% of UNL: `negativeUNLMaxListed = 0.25`) +- Scoring via `buildScoreTable` over recent ledger history; low watermark 50% = disable candidate, high 80% = re-enable +- Candidate selection deterministic via previous ledger hash as randomizing pad +- `newValidatorDisableSkip = FLAG_LEDGER_INTERVAL * 2` prevents disabling newly joined validators + +## Transaction Ordering + +- `CanonicalTXSet`: salted account key (XOR random salt) → seq proxy → tx ID. Salt prevents ordering manipulation +- `TxQ` uses `OrderCandidates`: higher fee level first, then `txID XOR parentHash` tiebreaker +- Per-account limit `maximumTxnPerAccount`; blocked transactions held until blocker resolves + ## Key Files -- `src/xrpld/consensus/Consensus.h` - state machine -- `src/xrpld/consensus/ConsensusParms.h` - timing/threshold params -- `src/xrpld/app/consensus/RCLConsensus.cpp` - XRPL adaptor -- `src/xrpld/consensus/DisputedTx.h` - dispute tracking -- `src/xrpld/app/misc/detail/AmendmentTable.cpp` - amendment logic -- `src/xrpld/app/misc/NegativeUNLVote.cpp` - N-UNL voting -- `src/xrpld/consensus/Validations.h` - validation tracking -- `src/xrpld/app/misc/CanonicalTXSet.h` - TX ordering +- `src/xrpld/consensus/Consensus.h` — state machine (header-only template) +- `src/xrpld/consensus/Consensus.cpp` — free policy functions (`shouldCloseLedger`, `checkConsensus`, `checkConsensusReached`) +- `src/xrpld/consensus/ConsensusParms.h` — all numeric thresholds; dual-clock (NetClock seconds vs steady ms) +- `src/xrpld/consensus/ConsensusTypes.h` — `ConsensusMode`, `ConsensusPhase`, `ConsensusState`, `ConsensusTimer`, `ConsensusCloseTimes`, `ConsensusResult` +- `src/xrpld/consensus/ConsensusProposal.h` — proposal record with sequence protocol and lazy signing hash +- `src/xrpld/consensus/DisputedTx.h` — per-tx avalanche voting and stall detection +- `src/xrpld/consensus/Validations.h` — validation tracking, indexing, trie integration +- `src/xrpld/consensus/LedgerTrie.h` — compressed ancestry trie for preferred-ledger calc +- `src/xrpld/app/consensus/RCLConsensus.cpp` — XRPL `Adaptor` implementation +- `src/xrpld/app/misc/detail/AmendmentTable.cpp` — amendment voting logic +- `src/xrpld/app/misc/NegativeUNLVote.cpp` — N-UNL voting +- `src/xrpld/app/misc/CanonicalTXSet.h` — tx ordering diff --git a/docs/skills/cryptography.md b/docs/skills/cryptography.md index effda4a1b9..f0e2881ac2 100644 --- a/docs/skills/cryptography.md +++ b/docs/skills/cryptography.md @@ -1,14 +1,17 @@ # Cryptography -XRPL supports secp256k1 (ECDSA) and ed25519 key types. All crypto uses OpenSSL + dedicated libs (libsecp256k1, ed25519-donna). +XRPL supports secp256k1 (ECDSA) and ed25519 key types. All crypto uses OpenSSL + dedicated libs (libsecp256k1, ed25519-donna). The `xrpl::crypto` layer provides three foundational utilities — a CSPRNG, secure memory erasure, and RFC 1751 mnemonic encoding — that underpin all key/seed handling. ## Key Invariants -- `SecretKey` destructor calls `secure_erase` on internal buffer; any code handling secret keys must follow this pattern +- `SecretKey` and `Seed` destructors call `secure_erase` on their internal buffer; any code handling secret keys/seeds must follow this pattern - ed25519 public keys are prefixed with `0xED` (33 bytes total); secp256k1 keys are 33-byte compressed - `sha512Half` (first 32 bytes of SHA-512) is the standard hash used throughout XRPL for node hashing, signing, etc. - `RIPEMD-160(SHA-256(x))` is used for account ID derivation (`ripesha_hasher`) - Base58 encoding includes a type byte prefix and 4-byte checksum (double SHA-256) +- All randomness for cryptographic material flows through `crypto_prng()`; never call OpenSSL's `RAND_bytes` directly and never use `std::rand`/`rand()` +- `csprng_engine` is non-copyable and non-movable by deleted ops; the singleton must be accessed by reference via `crypto_prng()` +- RFC 1751 dictionary has exactly 2^11 = 2048 entries; entries 0–570 are 1–3 char words, 571–2047 are exactly 4 chars (used to split binary search range in `wsrch`) ## Common Bug Patterns @@ -16,12 +19,19 @@ XRPL supports secp256k1 (ECDSA) and ed25519 key types. All crypto uses OpenSSL + - `signDigest` only works with secp256k1; calling it with ed25519 throws a logic error - Signature canonicality: ed25519 `verify` checks signature canonicality before calling `ed25519_sign_open`; non-canonical signatures are rejected - Overlay handshake uses `signDigest` to sign the session fingerprint (`sharedValue`); the signature binds the TLS session to the node identity +- Relying on a naive `memset` to wipe key material — optimizer will eliminate it as a dead store. Must use `secure_erase` +- Forgetting to wipe *intermediate* derivation buffers (SHA-512 halves, scratch arrays) after the final `SecretKey` has taken its copy +- Constructing a second `csprng_engine` instance: forbidden by deleted ctors; sharing one OpenSSL pool through the singleton is required +- Passing `mix_entropy` a buffer and assuming OpenSSL credits it as entropy — the entropy estimate is always 0 (deliberately conservative) +- RFC 1751 decode: distinguish `0` (unknown word), `-1` (malformed input), `-2` (parity failure) — don't collapse all failures into a single error ## Review Checklist -- New crypto code must use `crypto_prng()` singleton for randomness, never raw `rand()` -- Secret key buffers must be `secure_erase`d after use +- New crypto code must use `crypto_prng()` singleton for randomness, never raw `rand()` or direct OpenSSL `RAND_*` +- Secret key buffers must be `secure_erase`d after use (destructors *and* intermediate scratch buffers) - Verify that key type dispatch handles both secp256k1 and ed25519 (or explicitly rejects one with a clear error) +- Any new sensitive type should follow the `SecretKey`/`Seed` pattern: destructor calls `secure_erase` as its first/only action +- New OpenSSL touchpoints should respect the `OPENSSL_VERSION_NUMBER < 0x10100000L` thread-safety guard pattern used in `csprng.cpp` ## Key Patterns @@ -39,6 +49,23 @@ SecretKey sk(Slice{buf, sizeof(buf)}); secure_erase(buf, sizeof(buf)); // MUST erase raw buffer ``` +`secure_erase` delegates to `OPENSSL_cleanse`, which uses volatile writes / opaque function-pointer calls to defeat dead-store elimination. Lives in a separate TU (`secure_erase.cpp`) so the call site cannot inline it away. It does **not** clear CPU registers or caches — it is best-effort for heap/stack only (see Percival 2014). + +### CSPRNG Usage +```cpp +// Singleton access; never copy/store by value +auto& rng = crypto_prng(); + +// Bulk fill — preferred for key material +std::uint8_t buf[32]; +rng(buf, sizeof(buf)); // operator()(void*, size_t) + +// Or via beast adapter satisfying UniformRandomNumberEngine +beast::rngfill(buf, sizeof(buf), crypto_prng()); +``` + +`csprng_engine` satisfies the C++ *UniformRandomNumberEngine* named requirement, so it plugs directly into `std::uniform_int_distribution` and similar. Failure (insufficient entropy) throws `std::runtime_error` via `Throw<>`; callers generally do not catch — propagation halts the operation, which is correct. + ### Key Type Dispatch ```cpp // REQUIRED: handle both key types or explicitly reject @@ -50,10 +77,61 @@ else LogicError("unknown key type"); // MUST NOT fall through silently ``` +### RFC 1751 Mnemonic Encoding +```cpp +// 16-byte (128-bit) seed <-> 12-word mnemonic +std::string words; +RFC1751::getEnglishFromKey(words, std::string{seedBytes, 16}); + +std::string roundTrip; +int rc = RFC1751::getKeyFromEnglish(roundTrip, words); +// rc == 1 success; 0 unknown word; -1 malformed; -2 parity mismatch +``` + +`Seed.cpp` reverses the 16 bytes before/after RFC 1751 encoding to match the RFC's big-endian convention. `standard()` normalizes input by uppercasing and applying visual substitutions `1→L`, `0→O`, `5→S` for handwritten/OCR tolerance. The 2-bit parity per 8-byte half is a transcription check, **not** a cryptographic integrity check. + +`getWordFromBlob` is a separate utility: Jenkins one-at-a-time hash → `% 2048` → one dictionary word. Explicitly **not** cryptographically secure; used in `NetworkOPs.cpp` for `shroudedHostId` (privacy-preserving node label in logs/RPC). + +## Module Layout + +The `xrpl::crypto` foundation has three small, focused TUs: + +| File | Purpose | +|------|---------| +| `csprng.cpp/.h` | `csprng_engine` + `crypto_prng()` singleton; wraps OpenSSL `RAND_bytes`/`RAND_add`/`RAND_poll` | +| `secure_erase.cpp/.h` | One-line delegation to `OPENSSL_cleanse`; the canonical wipe primitive | +| `RFC1751.cpp/.h` | Static class; 2048-word mnemonic codec + `getWordFromBlob` utility | + +These three are used together by the protocol-level key/seed types (`SecretKey`, `PublicKey`, `Seed`) which live in `src/libxrpl/protocol/`. + +### CSPRNG Internals Worth Knowing + +- Constructor calls `RAND_poll()` eagerly to surface OS entropy failures at startup, not at first key gen +- Destructor calls `RAND_cleanup()` only for OpenSSL `< 1.1.0` (modern versions clean up via `atexit`) +- Thread-safety mutex is compile-time gated: `#if (OPENSSL_VERSION_NUMBER < 0x10100000L) || !defined(OPENSSL_THREADS)` — modern builds elide the lock on the hot path because `RAND_bytes` is internally thread-safe +- `mix_entropy` always holds the mutex around `RAND_add`; reads from `std::random_device` happen before locking (independently thread-safe) +- `mix_entropy` passes entropy estimate `0` to `RAND_add` — never claim entropy for `std::random_device` or caller-supplied buffers (they may be weak on some platforms) +- Called on a timer from `Application.cpp` to stir fresh OS entropy during the node's lifetime +- Singleton is a function-local `static` (Meyers singleton); C++11 guarantees thread-safe one-time init + +### RFC 1751 Internals Worth Knowing + +- `extract(s, start, length)` / `insert(s, x, start, length)`: read/write `length ≤ 11` bits at arbitrary offset across a 9-byte buffer; guarded by `XRPL_ASSERT` (stripped in release) +- `insert` uses bitwise OR (not assignment), so the output buffer must start zero-initialized; partial writes accumulate safely +- `btoe` adds a 9th byte for the 2-bit parity computed by summing all 32 pairs of bits across the 64-bit payload; parity occupies bit positions 64–65 +- `etob` validates: exactly 6 words, each 1–4 chars, all in dictionary, parity matches — distinct error codes per failure mode +- `getKeyFromEnglish` uses `boost::algorithm::split` with `token_compress_on` for whitespace tolerance + ## Key Files -- `include/xrpl/protocol/SecretKey.h` / `PublicKey.h` - key types -- `src/libxrpl/protocol/SecretKey.cpp` - signing, key generation -- `src/libxrpl/protocol/PublicKey.cpp` - verification -- `include/xrpl/protocol/digest.h` - hash functions -- `src/xrpld/overlay/detail/Handshake.cpp` - overlay handshake crypto +- `include/xrpl/protocol/SecretKey.h` / `PublicKey.h` — key types +- `src/libxrpl/protocol/SecretKey.cpp` — signing, key generation; canonical example of CSPRNG + `secure_erase` discipline +- `src/libxrpl/protocol/PublicKey.cpp` — verification +- `src/libxrpl/protocol/Seed.cpp` — 128-bit seed; uses RFC 1751 for mnemonic encoding +- `include/xrpl/protocol/digest.h` — hash functions (`sha512Half`, `ripesha_hasher`, etc.) +- `include/xrpl/crypto/csprng.h` + `src/libxrpl/crypto/csprng.cpp` — CSPRNG engine and singleton +- `include/xrpl/crypto/secure_erase.h` + `src/libxrpl/crypto/secure_erase.cpp` — memory wipe primitive +- `include/xrpl/crypto/RFC1751.h` + `src/libxrpl/crypto/RFC1751.cpp` — mnemonic codec +- `src/xrpld/overlay/detail/Handshake.cpp` — overlay handshake crypto +- `src/xrpld/app/main/Application.cpp` — periodic `mix_entropy` calls +- `src/xrpld/app/misc/NetworkOPs.cpp` — uses `getWordFromBlob` for `shroudedHostId` diff --git a/docs/skills/ledger.md b/docs/skills/ledger.md index ce561c57cd..4ec4d7552d 100644 --- a/docs/skills/ledger.md +++ b/docs/skills/ledger.md @@ -1,14 +1,17 @@ # Ledger -Each ledger is an immutable snapshot: header (seq, hashes, close time) + state SHAMap + transaction SHAMap. `LedgerMaster` is the central coordinator. +Each ledger is an immutable snapshot: header (seq, hashes, close time) + state SHAMap + transaction SHAMap. `LedgerMaster` is the central coordinator. The module spans `Ledger` itself, the view hierarchy (`ReadView` → `ApplyView` → `OpenView`/`Sandbox`/`PaymentSandbox`), directory primitives, and a large family of per-object-type helper free functions. ## Key Invariants -- Once `setImmutable()` is called, the ledger and its SHAMaps cannot change; only immutable ledgers can be set in `LedgerHolder` +- Once `setImmutable()` is called, the ledger and its SHAMaps cannot change; only immutable ledgers can be set in `LedgerHolder`. Mutable ledgers must not be shared; immutable ones can be shared lock-free. - Every server always has an open ledger; the open ledger cannot close until previous consensus completes - Ledger header hashes to the ledger's identity hash; includes state root, tx root, parent hash, total coins, close time - `LedgerMaster` tracks: `mPubLedger` (last published), `mValidLedger` (last validated), `mLedgerHistory` (cache) - Validation requires minimum trusted validations (`minVal`); filtered by Negative UNL +- Open ledgers store transactions without metadata; closed ledgers store `addVL(tx)||addVL(meta)` and produce `TxMeta` on apply +- Trust-line `sfBalance` is always stored "low account's perspective"; helpers negate when querying from high side +- Directory invariant: page keys are chosen so the low 96 bits of every token in a page are strictly less than the page key's low 96 bits (NFT pages); for owner/order-book directories, page 0 is the anchor and `sfIndexPrevious` on root points to the tail ## Common Bug Patterns @@ -16,6 +19,13 @@ Each ledger is an immutable snapshot: header (seq, hashes, close time) + state S - Gap detection: if ledgers 603 and 600 exist but 601-602 are missing, `LedgerMaster` requests 602 first, then backfills 601 - `InboundLedger::gotData()` queues data for processing; calling `done()` before all data arrives creates incomplete ledgers - `checkAccept` won't accept a ledger that isn't ahead of the last validated ledger; stale validations are silently ignored +- Calling `ApplyViewImpl::apply()` twice or using the view after apply: the only valid operation post-apply is destruction +- Passing an SLE from `peek()` on view A to `erase()`/`update()` on view B: `ApplyStateTable` enforces pointer identity and `LogicError`s +- Forgetting that `read()` is change-aware but `slesBegin/End` iterates only the base — pending inserts won't appear in SLE iteration on `ApplyViewBase` +- Comparing iterators across different `ReadView` instances: `XRPL_ASSERT` fires in debug; UB in release +- Stale `OpenView::txCount` ordinal in nested/batch views — must use `batch_view_t` constructor to capture `baseTxCount_` +- Calling `removeExpired`/`deleteSLE` in preclaim — preclaim is `ReadView`-only; expiry-driven deletion only happens in doApply +- Forgetting that `directSendNoFee` is not `[[nodiscard]]` (for `DirectStep.cpp` compatibility) — its return must still be inspected ## Ledger Entry Types @@ -23,12 +33,109 @@ Each ledger is an immutable snapshot: header (seq, hashes, close time) + state S - Each entry has an `SOTemplate` defining required/optional fields - Key computation: `Indexes.cpp` computes unique keys (keylets) for each ledger object type - `STLedgerEntry` wraps the serialized data with type-safe field access +- Pseudo-account types (AMM, Vault, LoanBroker) are discovered by scanning `ltACCOUNT_ROOT` SOTemplate for `SField::sMD_PseudoAccount`-flagged fields; no manual registration + +## View Hierarchy + +``` +ReadView (abstract, read-only) + └── DigestAwareReadView (adds per-entry digest for CachedView) + └── Ledger (final; owns stateMap_ + txMap_) + └── OpenView (mutable; ReadView + TxsRawView; delta over base) + └── detail::ApplyViewBase (ApplyView + RawView; buffered via ApplyStateTable) + ├── ApplyViewImpl (commit path; produces TxMeta; carries deliver_) + ├── Sandbox (discardable; flush via apply(RawView&)) + └── PaymentSandbox (overrides credit/balance hooks; DeferredCredits) +``` + +- `ApplyStateTable` (per-tx buffer): actions `cache`/`insert`/`modify`/`erase`; generates `TxMeta` with `sfPreviousFields`/`sfFinalFields`/`sfNewFields` driven by `SField::sMD_*` flags; threads `sfPreviousTxnID`/`sfPreviousTxnLgrSeq` on affected account roots and trust-line endpoints +- `RawStateTable` (used by `OpenView` and `RawStateTable::apply` flush): three actions only; state-machine collapse (insert+erase → removed; insert+replace → insert with new SLE; erase+insert → replace) +- Both tables use `boost::container::pmr::monotonic_buffer_resource` with a 256 KB initial arena; `unique_ptr` for stable address so map allocators work after move +- `CachedView` (`CachedLedger = CachedView`): two-level cache — per-view `map_` plus process-wide `CachedSLEs` (`TaggedCache`) keyed by digest. Hit/hitExpired/miss counters distinguish full hit, digest-known-but-SLE-evicted, and cold miss +- Hooks pattern: `balanceHookIOU/MPT`, `ownerCountHook` (read side, on `ReadView`) and `creditHookIOU/MPT`, `adjustOwnerCountHook`, `issuerSelfDebitHookMPT` (write side, on `ApplyView`) are no-ops by default; `PaymentSandbox` overrides them to prevent within-payment double-spend + +## Directory Structures + +Three distinct paged-list flavors, all `ltDIR_NODE`-based: + +- **Owner / book directories** (`ApplyView::dirInsert`/`dirAppend`/`dirRemove`/`dirDelete`): root at page 0; `sfIndexNext`/`sfIndexPrevious` linked; root's `sfIndexPrevious` points to tail for O(1) append. `dirAppend` preserves insertion order (offers only, asserted); `dirInsert` keeps sorted order within each page. Page overflow detected via deliberate `uint64_t` wraparound (compile-time `static_assert`ed). +- **NFToken pages** (`NFTokenHelpers`): tokens packed into `STArray`-bearing pages, sorted by `compareTokens()` (low 96 bits, then full ID). Last page anchored at `keylet::nftpage_max(owner)`. Split algorithm respects equivalence groups; merge across adjacent pages on remove. `fixNFTokenPageLinks` amendment changes empty-last-page handling. +- **Quality-keyed order books** (`BookDirs`): two-level — `succ()` finds next quality directory in `[root_, getQualityNext(root_))`, then `cdirFirst`/`cdirNext` walks pages within that quality. `BookDirs` iterator transparently crosses quality boundaries. + +The `Dir` class is a simple range adaptor (NFTokenOffer directories + unit tests). `next_page()` is exposed publicly to allow page-skipping traversal (used by `notTooManyOffers`). + +## Helper Module (`include/xrpl/ledger/helpers/`) + +Free functions per ledger-object type. The asset-agnostic dispatcher is `TokenHelpers.h`, which routes `Asset` (`std::variant`) via `std::visit` to `RippleStateHelpers` (IOU) or `MPTokenHelpers` (MPT). + +Conventional split: +- Stateless / preflight-safe checks: take `ReadView const&` +- State-mutating: take `ApplyView&` +- Two-phase pattern: read-only preclaim function (`credentials::valid`/`validDomain`) paired with mutating doApply counterpart (`verifyDepositPreauth`/`verifyValidDomain`) that prunes expired entries + +Key files: `AMMHelpers`, `AccountRootHelpers`, `CredentialHelpers`, `DelegateHelpers`, `DirectoryHelpers`, `EscrowHelpers`, `MPTokenHelpers`, `NFTokenHelpers`, `OfferHelpers`, `PaymentChannelHelpers`, `PermissionedDEXHelpers`, `RippleStateHelpers`, `TokenHelpers`, `VaultHelpers`. + +Policy enums (used to avoid bare bools): `FreezeHandling`, `AuthHandling`, `SpendableHandling`, `WaiveTransferFee`, `AllowMPTOverflow`, `AuthType` (`StrongAuth`/`WeakAuth`/`Legacy` — Legacy maps to StrongAuth for MPT, WeakAuth for IOU). + +## AMM Rounding Contract + +The pool invariant `sqrt(asset1 × asset2) >= LPTokenBalance` is non-negotiable, so every formula has explicit directional rounding: + +- Swap-in: output rounds **down** (trader gets less, pool retains). +- Swap-out: input rounds **up** (trader pays more). +- LP token deposit: tokens **down**, assets **up**. +- LP token withdrawal: tokens **up**, assets **down**. + +`fixAMMv1_1` introduced per-step rounding (vs end-only); `fixAMMv1_3` extended this discipline to LP/deposit/withdraw paths via `multiply(balance, frac, mode)` and the `getRoundedAsset`/`getRoundedLPTokens` wrappers (two overloads each — direct and lambda-deferred). Pre-amendment paths must be preserved for historic replay. + +`adjustLPTokens`: avoids precision loss when adding small token amounts to large `LPTokensBalance` by computing `(balance + tokens) - balance` rather than `tokens`. Becomes a no-op under `fixAMMv1_3`. + +`changeSpotPriceQuality`: aligns AMM synthetic offer to CLOB best quality. Solves a quadratic (or linear, for the alternate constraint) and takes the smaller binding result. `fixAMMv1_1` switched the starting side to always-XRP-first to avoid XRP-drop discretization undershoot. `detail::reduceOffer` applies 0.01% rescue multiplier when quality still falls below target. + +## MPT Specifics + +- `OutstandingAmount` can transiently exceed `MaximumAmount` during payment-engine routing — `AllowMPTOverflow::Yes` raises the ceiling to `UINT64_MAX` for that case; direct sends use `No` and strict cap. The `fixSecurity3_1_3` amendment makes `accountSendMulti` accumulate in exact `uint64_t` (not `STAmount`/`Number`) to avoid 19-digit precision loss in aggregate overflow checks. +- `selfDebit` field on `IssuerValueMPT` in `PaymentSandbox::DeferredCredits` tracks issuer-as-seller offers because the payment engine credits first; `balanceHookSelfIssueMPT` caps available issuance at `origBalance - selfDebit`. +- `enforceMPTokenAuthorization` (doApply) handles the case where a domain-authorized holder lacks an `MPToken` SLE in preclaim — it lazily allocates the SLE on the fly and consumes the `priorBalance` reserve. +- `lockEscrowMPT` does NOT change `OutstandingAmount` (tokens are still in circulation while escrowed); `unlockEscrowMPT` decreases `OutstandingAmount` only by the fee delta (gross - net) under `fixTokenEscrowV1`. + +## Pseudo-Accounts + +Synthetic `AccountRoot` SLEs owned by protocol objects (AMM, Vault, LoanBroker). Address derived from `sha512Half(attempt, parentHash, ownerKey)` → RIPESHA in a loop up to `maxAccountAttempts = 256` (consensus-critical constant). Flags `lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth`; `sfSequence = 0` under `featureSingleAssetVault`/`featureLendingProtocol`. `isPseudoAccount(sle, filter?)` checks for any field tagged `SField::sMD_PseudoAccount` (currently `sfAMMID`, `sfVaultID`, `sfLoanBrokerID`). Pseudo-accounts bypass reserve requirements in `xrpLiquid`. + +## Ledger Timing (`LedgerTiming.h`) + +Adaptive close-time binning prevents clock-skew disagreements. Resolutions: `{10, 20, 30, 60, 90, 120}` seconds; default 30, genesis 10. Adjustment is asymmetric: +- On disagreement, coarsen every ledger (`decreaseLedgerTimeResolutionEvery = 1`) +- On agreement, refine only every 8th ledger (`increaseLedgerTimeResolutionEvery = 8`) + +`roundCloseTime` is epoch-anchored (uses `time_since_epoch()`, not local offset) for deterministic agreement. `effCloseTime` enforces strict monotonicity: `max(rounded, priorCloseTime + 1s)`. `time_point{}` is the sentinel for "no agreed close time" and is returned unchanged. + +## Skip List + +Two-tier on-ledger structure for historical hash lookup: +- `keylet::skip()` (the rolling 256-page): hashes of the 256 immediate ancestors; updated every ledger. +- `keylet::skip(seq)`: every 256-aligned ledger stores a permanent record. `getCandidateLedger(seq)` rounds up to the nearest 256-aligned sequence. + +Non-aligned ledgers older than 256 are unreachable — `hashOfSeq` returns `nullopt`. + +## Canonical Transaction Ordering (`CanonicalTXSet`) + +Retry queue between consensus passes. Sort key: `(account ⊕ salt, SeqProxy, txId)`. +- **Salt**: `LedgerHash` XORed into `AccountID`; prevents account-address mining for persistent ordering advantage. Refreshed by `reset()` each round. +- **`SeqProxy`**: sequences sort before tickets unconditionally (so `TicketCreate` always applies before ticket consumers). +- **`popAcctTransaction()`**: returns next eligible same-account tx — either ticket-based, or sequence exactly `+1` from current. + +`Key::operator==` compares only `txId_` (asymmetric with `operator<`). ## Review Checklist -- New ledger entry types: add to `ledger_entries.macro`, implement keylet in `Indexes.cpp` -- Verify `LedgerCleaner` can handle the new entry type for repair -- Check that acquisition code handles the entry in both `InboundLedger` and `LedgerMaster` +- New ledger entry types: add to `ledger_entries.macro`, implement keylet in `Indexes.cpp`, verify acquisition code in `InboundLedger`/`LedgerMaster`, check `LedgerCleaner` handling +- New pseudo-account types: tag the key field with `SField::sMD_PseudoAccount` in `sfields.macro`; `isPseudoAccount` picks it up automatically. Caller, not `createPseudoAccount`, owns the amendment gate. +- New asset operations: extend `Asset` variant + add branches in `TokenHelpers.h` dispatchers. Don't reach into IOU- or MPT-specific helpers directly unless intentionally bypassing the dispatch layer. +- Helper functions touching balances: respect the read/write hook protocol so `PaymentSandbox` correctly defers credits +- AMM math changes: gate behind an amendment; preserve the pre-amendment formula path +- Directory changes: account for both legacy (unsorted) and modern pages in iteration; verify `cdirNext`/`dirNext` cursor semantics if deleting during iteration (see `cleanupOnAccountDelete` workaround) ## Key Patterns @@ -54,10 +161,48 @@ Keylet keylet::myEntry(AccountID const& id) // Also add to ledger_entries.macro and Indexes.cpp ``` +### Peek/Update Contract +```cpp +// peek() returns shared_ptr; you MUST call update() or erase() with +// the SAME pointer on the SAME view. Crossing views is a LogicError. +auto sle = view.peek(keylet::account(id)); +sle->setFieldU32(sfSequence, seq + 1); +view.update(sle); // promotes cache → modify in ApplyStateTable +``` + +### Two-Phase Expiry Cleanup +```cpp +// preclaim (ReadView) — detect but don't mutate +if (credentials::validDomain(view, domainID, account) == tecEXPIRED) + /* allow through; doApply will clean up */; + +// doApply (ApplyView) — mutate +if (auto ter = verifyValidDomain(view, domainID, account, j); ter != tesSUCCESS) + return ter; // expired credentials deleted as side effect +``` + +### Directional Multiply (AMM) +```cpp +// post-fixAMMv1_3: rounding mode at the final multiply, not at toSTAmount +auto const tokens = getRoundedLPTokens( + rules, + lptAMMBalance, + [&] { return /* fractional formula */; }, + IsDeposit::Yes); // → Number::downward +``` + ## Key Files -- `src/xrpld/app/ledger/Ledger.h` - ledger class -- `src/xrpld/app/ledger/detail/LedgerMaster.cpp` - central coordinator -- `src/xrpld/app/ledger/detail/InboundLedger.cpp` - ledger acquisition -- `include/xrpl/protocol/detail/ledger_entries.macro` - entry type definitions -- `src/libxrpl/protocol/Indexes.cpp` - keylet computation +- `src/xrpld/app/ledger/Ledger.h` / `src/libxrpl/ledger/Ledger.cpp` — ledger class, genesis/successor/load constructors, immutable transition, skip list, NegUNL +- `src/xrpld/app/ledger/detail/LedgerMaster.cpp` — central coordinator +- `src/xrpld/app/ledger/detail/InboundLedger.cpp` — ledger acquisition +- `include/xrpl/protocol/detail/ledger_entries.macro` — entry type definitions +- `src/libxrpl/protocol/Indexes.cpp` — keylet computation +- `include/xrpl/ledger/ReadView.h` + `ApplyView.h` + `OpenView.h` — view interface hierarchy +- `include/xrpl/ledger/detail/ApplyStateTable.h` / `RawStateTable.h` — buffered mutation tables + TxMeta generation +- `include/xrpl/ledger/Sandbox.h` / `PaymentSandbox.h` / `ApplyViewImpl.h` — concrete view types +- `include/xrpl/ledger/CachedView.h` / `CachedSLEs.h` — two-level SLE cache +- `include/xrpl/ledger/CanonicalTXSet.h` — retry-pass deterministic ordering +- `include/xrpl/ledger/LedgerTiming.h` — close-time binning +- `include/xrpl/ledger/helpers/*` — per-object-type free functions +- `src/libxrpl/ledger/helpers/*` — implementations (AMM math, NFT page split, credential lifecycle, MPT overflow) diff --git a/docs/skills/nodestore.md b/docs/skills/nodestore.md index 2674719981..548a5235b2 100644 --- a/docs/skills/nodestore.md +++ b/docs/skills/nodestore.md @@ -1,52 +1,181 @@ # NodeStore -Persistent key-value store for `NodeObject`s (ledger entries). All ledger state is stored here between launches. Keys are 256-bit hashes. +Persistent key-value store for `NodeObject`s (ledger entries). Every piece of ledger state — account states, transactions, ledger headers, SHAMap nodes — is serialized as a `NodeObject` keyed by its 256-bit hash and persisted here. Backends are pluggable (NuDB, RocksDB, in-memory, null) and selected via the `[node_db]` config section. The layer above (`Database`) adds an async read thread pool, batching, and statistics; `Backend` is the narrow storage interface. + +## Architecture + +Four layers, each with a narrow contract: + +1. **`NodeObject`** (`include/xrpl/nodestore/NodeObject.h`) — immutable (type, hash, blob). Constructed only via `createObject()` factory; the `PrivateAccess` tag struct makes the public constructor effectively private while remaining compatible with `std::make_shared`. Hash is *not* verified against data — trust the caller. +2. **`Backend`** (`include/xrpl/nodestore/Backend.h`) — pure abstract key/value interface. `fetch`/`store` are concurrent; `storeBatch`/`for_each` are not. Two-phase init: construct, then `open()`. +3. **`Database`** (`include/xrpl/nodestore/Database.h`) — owns the async read pool and stats; defines pure-virtual `fetchNodeObject(hash, seq, FetchReport&, duplicate)`. Public non-virtual `fetchNodeObject` instruments the private virtual one (timing, hit/miss, scheduler callback) — subclasses cannot bypass metrics. +4. **`Manager`** (`include/xrpl/nodestore/Manager.h`) — singleton registry mapping config `type=` strings to `Factory` instances. `make_Backend()` and `make_Database()` are the construction entry points. + +Two concrete `Database` subclasses: `DatabaseNodeImp` (single backend) and `DatabaseRotatingImp` (two backends for online deletion). ## Key Invariants -- `NodeObject` types: `hotLEDGER` (1), `hotACCOUNT_NODE` (3), `hotTRANSACTION_NODE` (4), `hotDUMMY` (512, cache marker for missing entries) -- Preferred backends: NuDB (append-only) and RocksDB; LevelDB/HyperLevelDB are deprecated -- `TaggedCache` evicts by both `cache_size` (max items) and `cache_age` (max minutes) -- `DatabaseRotatingImp` uses two backends (writable + archive) for online deletion; rotation moves writable to archive, creates new writable, deletes old archive -- Corrupt data triggers fatal logging; unknown/backend errors logged with appropriate severity +- `NodeObjectType` values: `hotUNKNOWN=0`, `hotLEDGER=1`, `hotACCOUNT_NODE=3`, `hotTRANSACTION_NODE=4`, `hotDUMMY=512`. Value 2 is a historical gap. `hotDUMMY` is deliberately outside the contiguous range so it cannot collide with valid types — used as a cache sentinel meaning "confirmed missing." +- Preferred backends: NuDB (append-mostly, default) and RocksDB; Memory and Null are for tests / configured ephemerality. +- `Database` instrumentation is structural: the public `fetchNodeObject()` measures elapsed time, increments atomic counters, and calls `scheduler_.onFetch()` around every private virtual call. +- `Backend::fetch` and `Backend::store` are called concurrently from many threads; `storeBatch` and `for_each` are not. Implementations must reflect this. +- `DatabaseRotatingImp::isSameDB()` and `DatabaseNodeImp::isSameDB()` both return `true` unconditionally — the rotating store is one logical namespace despite physical split. +- Batch writes accumulate up to `batchWriteLimitSize = 65536` objects; peak in-flight memory can be ~2× this because a new batch accumulates while the previous one is being flushed (`Types.h`). +- `EncodedBlob` / `DecodedBlob` define the on-disk format: bytes 0–7 zero-padded (legacy ledger-index field), byte 8 = `NodeObjectType`, bytes 9+ = payload. Both must change together. +- `NuDBBackend::fdRequired()` returns 3 (data, key, log files). `RocksDBBackend::fdRequired()` returns `max_open_files + 128`. `DatabaseRotatingImp` sums both backends' values. + +## On-Disk Format + +Defined jointly by `EncodedBlob` (write) and `DecodedBlob` (read) in `include/xrpl/nodestore/detail/`: + +``` +Bytes 0–7 Zero (historically ledger index; ignored on read) +Byte 8 NodeObjectType (one byte) +Bytes 9+ Raw serialized payload +``` + +The 32-byte hash is the storage key, kept separate from the value. + +- `EncodedBlob` embeds a 1033-byte stack buffer (`payload_`) sized for header + 1024-byte payload (most objects). Only blobs exceeding 1024 bytes heap-allocate. `ptr_` is `uint8_t* const` set at construction; destructor frees iff `ptr_ != payload_.data()`. ~94% of real objects fit the stack buffer. +- `DecodedBlob` is a non-owning view into the raw buffer. Validation is by `wasOk()` flag, not exceptions. `createObject()` asserts `m_success` and copies the payload into an owning `Blob` for the returned `NodeObject`. + +NuDB additionally runs the encoded blob through `nodeobject_compress` (see Compression below). + +## Compression (`include/xrpl/nodestore/detail/codec.h`) + +NuDB-only. Four type tags prefix every stored blob (as a varint): + +| Type | Format | +|---|---| +| 0 | Uncompressed (legacy; never written, still read) | +| 1 | LZ4-compressed | +| 2 | Sparse inner-node (bitmask + present hashes) | +| 3 | Full inner-node (all 16 hashes, no bitmask) | + +**Inner-node fast path**: SHAMap inner nodes are exactly 525 bytes with `HashPrefix::innerNode` at offset 9. The compressor recognizes this and either packs only non-zero child hashes with a 16-bit presence bitmask (type 2) or stores all 16 hashes contiguously (type 3). Reconstruction zeros the `index`, `unused`, and `kind` fields — so a round-trip is *lossy* on those fields. `filter_inner()` pre-zeros them on the source side to make import-time `memcmp` verification succeed. + +**Varint** (`varint.h`): base-127 (not base-128) LEB-style encoding; bit 7 is continuation. Used for the type tag and the LZ4 decompressed-size prefix. The base-127 choice means `0x7F` never appears as a payload byte — minor space cost, no practical impact. + +**BufferFactory pattern**: All codec functions take a callable `void*(size_t)` so allocation policy stays with the caller (NuDB passes its scratch buffer). + +## Async Read Pool (`Database`) + +The base class spawns `readThreads` detached threads at construction. Each loops on `readCondVar_`, dequeues from `read_` (a `std::map>>`), and calls the subclass's private `fetchNodeObject`. + +**Hash coalescing**: Multiple concurrent `asyncFetch()` calls for the same hash collapse into a single map entry; one backend read fires all callbacks. For different `seq` values on the same hash, `isSameDB(seq1, seq2)` decides whether to reuse the fetched object or issue a second fetch. + +**Batched dequeue**: Each worker extracts up to `requestBundle_` entries per lock acquisition (default 4, clamped 1–64 via `rq_bundle` config) to amortize mutex cost. + +**Shutdown ordering — critical**: Derived classes *must* call `stop()` in their own destructors. The base destructor calls `stop()` as a safety net, but by then the derived vtable is already gone — a worker thread blocked in `fetchNodeObject` would invoke a destroyed vtable entry. `stop()` sets `readStopping_`, clears `read_`, broadcasts the condvar, and spin-yields until `readThreads_` reaches zero (asserted within 30s). + +## BatchWriter (`include/xrpl/nodestore/detail/BatchWriter.h`) + +Coalesces individual writes into batches for backends that benefit (RocksDB uses it; NuDB does not — NuDB's `do_insert` is synchronous). + +- Privately inherits `Task`; the backend inherits `BatchWriter::Callback` and provides `writeBatch(Batch const&)`. Same object plays both roles, no extra allocation. +- **Double-buffer swap**: `store()` pushes into `mWriteSet`. `writeBatch()` holds the lock only long enough to swap `mWriteSet` with a fresh local vector, then releases before doing I/O. New stores accumulate concurrently with the flush. +- **`std::recursive_mutex` + `std::condition_variable_any`**: A synchronous scheduler (e.g., `DummyScheduler`) calls `performScheduledTask()` inline on the producer thread, which re-enters `writeBatch` on the same thread — plain `std::mutex` would deadlock. +- **Backpressure**: `store()` blocks on `mWriteCondition` when `mWriteSet.size() >= batchWriteLimitSize` (65536). The double-buffer means peak memory ≈ 2× the limit. +- **`mWritePending` flag**: Ensures only one scheduler task outstanding; the flush loop re-checks the buffer before clearing the flag so no items are silently dropped. +- **Destructor** calls `waitForWriting()` — no data is abandoned on backend teardown. + +## Scheduler + +Pure abstract (`include/xrpl/nodestore/Scheduler.h`): `scheduleTask(Task&)`, `onFetch(FetchReport)`, `onBatchWrite(BatchWriteReport)`. Two implementations: + +- **`DummyScheduler`**: `scheduleTask` runs `task.performScheduledTask()` synchronously on the calling thread; `onFetch`/`onBatchWrite` are no-ops. Used by every NodeStore test and by the bulk-import path in `Application.cpp`. +- **`NodeStoreScheduler`** (production, in `xrpld/app`): posts a `jtWRITE` job to the `JobQueue` (synchronous fallback if queue is stopped); routes `FetchReport` to `jtNS_SYNC_READ`/`jtNS_ASYNC_READ` load events. + +`Task` (`Task.h`) is just a virtual `performScheduledTask()` + virtual destructor. `BatchWriter` inherits it privately so external code cannot treat it as a `Task`. + +## Rotating Backend / Online Deletion + +`DatabaseRotatingImp` (`src/libxrpl/nodestore/DatabaseRotatingImp.cpp`) holds two `shared_ptr`s: writable + archive. `SHAMapStoreImp` (in `xrpld/app`) drives the rotation policy; `DatabaseRotatingImp` only handles the atomic swap. + +**`rotate(newBackend, callback)` sequence** (under `mutex_`): +1. `setDeletePath()` on the old archive, move it into a local `shared_ptr oldArchiveBackend`. +2. Promote `writableBackend_` → `archiveBackend_`. +3. Install `newBackend` → `writableBackend_`. + +Then release the lock and invoke `callback(newWritableName, newArchiveName)`. The callback persists names to the SQL state DB. `oldArchiveBackend` falls out of scope *after* the callback returns — so the directory is deleted only after the state DB knows the new layout. Crash between swap and persist → recoverable from state DB on restart. + +**Snapshot-and-release locking**: Every read/write copies the relevant `shared_ptr` under `mutex_`, releases the lock, then does I/O via the local. Locking across disk I/O would serialize all readers. + +**Fetch fallthrough + promotion**: `fetchNodeObject` tries writable, then archive. If found in archive and `duplicate=true`, re-snapshots `writableBackend_` (to handle a rotation racing with the archive read) and writes the object into the *current* writable. Objects not promoted before the next rotation are gone forever — promotion is the migration mechanism. + +## Manager / Factory Registration + +`ManagerImp` is a Meyers singleton (`static ManagerImp _` in `instance()`). Its constructor calls four free functions (`registerNuDBFactory`, `registerRocksDBFactory`, `registerNullFactory`, `registerMemoryFactory`), each of which creates a function-local `static` factory whose constructor calls `Manager::insert(*this)`. + +**Why this pattern**: Factories are never globals. If a global `Factory` destructor called `Manager::instance().erase()` after `ManagerImp` had been destroyed, the result would be UB (no order guarantee across translation units). Function-local statics initialize after `ManagerImp` and destroy before it — safe. + +- `Manager::find()` is case-insensitive (`boost::iequals`) so `"NuDB"`, `"nudb"`, `"NUDB"` all match. +- `make_Backend()` throws `std::runtime_error` with operator-facing message on missing or unknown `type` key. +- `make_Database()` = `make_Backend()` + `backend->open()` + wrap in `DatabaseNodeImp`. The explicit `open()` separation lets I/O errors surface before the full `Database` stack is built. +- Registry mutex protects `list_` (a `vector` of non-owning pointers). + +## Backend-Specific Notes + +**NuDB** (`backend/NuDBFactory.cpp`): Three on-disk files (`nudb.dat`, `nudb.key`, `nudb.log`); `fdRequired()` = 3. `appnum = 1` embedded in the header, sanity-checked on every open. `nudb_block_size` config key must be a power of 2 between 4096 and 32768; defaults to `nudb::block_size()` (filesystem-native). `for_each()` and `verify()` *close and reopen* the database — incompatible with concurrent access. `fetch()` uses a zero-copy callback into NuDB's internal buffer; decompression must happen inside the callback. `db_.insert()` returning `nudb::error::key_exists` is silently ignored (content-addressed: same hash → same data). + +**RocksDB** (`backend/RocksDBFactory.cpp`): `RocksDBEnv` overrides `StartThread` to name threads `"rocksdb #N"` for profiler visibility. `hard_set` config flag: when false (default), small `cache_mb`/`open_files` values are silently escalated to production-appropriate defaults (1024 MB cache, 8000 FDs). Implements both `Backend` and `BatchWriter::Callback`; uses `BatchWriter` for writes. `storeBatch` is atomic (single `WriteBatch` in WAL); `fetchBatch` is a serial loop with no atomicity. `sync()` is empty — WAL provides durability. Key passed to RocksDB via `std::bit_cast` over the `uint256` — no copy. + +**Memory** (`backend/MemoryFactory.cpp`): `MemoryFactory` owns named `MemoryDB` instances in a case-insensitive map; multiple `MemoryBackend`s opened with the same path share the same `MemoryDB`. Survives backend close/reopen within a process. The `db.open` guard in `MemoryFactory::open()` is dead code (`open` is never set to `true`). `for_each` reads without holding the mutex — caller must ensure no concurrent writes. + +**Null** (`backend/NullFactory.cpp`): All operations no-op; `fetch` returns `notFound`. Exists so `type=none` is a valid config value. Doubles as a minimal reference implementation of `Backend`. ## Common Bug Patterns -- `fetchNodeObject` with `duplicate=true` copies from archive to writable backend; forgetting this in rotating mode means objects disappear after rotation -- `hotDUMMY` objects in cache mark missing entries; code that checks cache hits must distinguish real objects from dummies -- Batch write limit is 65536 objects; exceeding this silently truncates or fails depending on backend -- `fdRequired()` must be called during resource planning; running out of file descriptors causes silent backend failures +- **Forgetting `stop()` in derived `Database` destructor**: see Shutdown ordering above. Symptom is a crash during teardown in a worker thread invoking a destroyed vtable. +- **Holding `DatabaseRotatingImp::mutex_` across I/O**: Will serialize all readers. Always snapshot the `shared_ptr` under lock, release, then I/O. +- **Forgetting `duplicate=true` when archive fallback matters**: Objects fetched from archive are not promoted to writable; next rotation discards them silently. +- **Treating `hotDUMMY` as a real object**: Cache lookups must check the type before dereferencing. +- **Exceeding `batchWriteLimitSize`**: `BatchWriter::store()` blocks the caller; not a silent truncation, but unexpected backpressure can cause deadlock if the same thread is needed to drain. +- **Inaccurate `fdRequired()`**: Causes silent backend failures when the process file descriptor limit is exceeded. Aggregated across all backends by the base `Database`. +- **Changing `EncodedBlob` without `DecodedBlob`**: Breaks on-disk read compatibility silently — both classes define the format jointly. +- **Calling `for_each` / `verify` on NuDB concurrently with reads**: Both close and reopen the database; concurrent access is undefined. +- **Lossy inner-node round-trip**: `nodeobject_compress` zeros `index`/`unused`/`kind` on reconstruction. Code that verifies blobs after compress→decompress must call `filter_inner()` first. ## Review Checklist -- Config changes: verify `[node_db]` section has valid `type`, `path`, and `compression` settings -- Online deletion: ensure `SHAMapStoreImp` coordinates rotation with the application lifecycle -- New backend types: implement the full `Backend` interface including `fdRequired()` - -## Key Patterns - -### Cache Lookup — Distinguish Real vs Dummy -```cpp -// REQUIRED: hotDUMMY marks "confirmed missing" — not a real object -auto obj = cache_.fetch(hash); -if (obj && obj->getType() == hotDUMMY) - return nullptr; // not found, just cached as missing -return obj; -``` - -### Backend File Descriptor Reporting -```cpp -// REQUIRED: every backend must accurately report FD needs -int fdRequired() const override -{ - return fdLimit_; // inaccurate values cause silent failures -} -``` +- Config: `[node_db]` has valid `type`, `path`, and (for NuDB) `nudb_block_size` if present. +- Online deletion: `SHAMapStoreImp` coordinates rotation with application lifecycle; rotation callback must persist new names before old archive can be deleted. +- New backend implementations: full `Backend` interface including `fdRequired()`, both `open()` overloads (default-throws is OK for non-NuDB), accurate concurrency guarantees on `fetch`/`store`, no-op `verify()` if not implementing. +- Derived `Database` subclasses: `stop()` in destructor; override `fetchNodeObject(hash, seq, FetchReport&, duplicate)` not the public non-virtual. +- Any change to on-disk format: update both `EncodedBlob` and `DecodedBlob`; consider backward-compat type tag (codec.h type 0 path is the precedent). ## Key Files -- `include/xrpl/nodestore/NodeObject.h` - object types -- `include/xrpl/nodestore/Backend.h` - backend interface -- `include/xrpl/nodestore/detail/DatabaseNodeImp.h` - standard implementation -- `src/libxrpl/nodestore/DatabaseRotatingImp.cpp` - rotating/online deletion -- `src/xrpld/app/misc/SHAMapStoreImp.cpp` - lifecycle management +### Public interfaces +- `include/xrpl/nodestore/NodeObject.h` — immutable object, factory-only construction +- `include/xrpl/nodestore/Backend.h` — pluggable storage interface +- `include/xrpl/nodestore/Database.h` — async read pool, instrumented fetch +- `include/xrpl/nodestore/DatabaseRotating.h` — adds `rotate()` +- `include/xrpl/nodestore/Manager.h` — singleton factory registry +- `include/xrpl/nodestore/Factory.h` — abstract factory +- `include/xrpl/nodestore/Scheduler.h` / `Task.h` — async dispatch + telemetry +- `include/xrpl/nodestore/DummyScheduler.h` — synchronous, for tests + import +- `include/xrpl/nodestore/Types.h` — `Status`, `Batch`, batch size constants + +### Implementations (detail/) +- `include/xrpl/nodestore/detail/DatabaseNodeImp.h` — single-backend +- `include/xrpl/nodestore/detail/DatabaseRotatingImp.h` — rotation + promotion +- `include/xrpl/nodestore/detail/ManagerImp.h` — singleton + registry +- `include/xrpl/nodestore/detail/BatchWriter.h` — write coalescing + backpressure +- `include/xrpl/nodestore/detail/EncodedBlob.h` / `DecodedBlob.h` — on-disk format +- `include/xrpl/nodestore/detail/codec.h` — LZ4 + inner-node compression +- `include/xrpl/nodestore/detail/varint.h` — base-127 varint for codec type tags + +### Source (libxrpl/nodestore/) +- `src/libxrpl/nodestore/Database.cpp` — read pool, hash coalescing, shutdown +- `src/libxrpl/nodestore/DatabaseNodeImp.cpp` — simple backend wrapper +- `src/libxrpl/nodestore/DatabaseRotatingImp.cpp` — rotation, promotion, snapshot locking +- `src/libxrpl/nodestore/BatchWriter.cpp` — double-buffer swap + backpressure +- `src/libxrpl/nodestore/ManagerImp.cpp` — singleton init + factory registration +- `src/libxrpl/nodestore/backend/NuDBFactory.cpp` — production default +- `src/libxrpl/nodestore/backend/RocksDBFactory.cpp` — alternate production backend +- `src/libxrpl/nodestore/backend/MemoryFactory.cpp` — test/ephemeral +- `src/libxrpl/nodestore/backend/NullFactory.cpp` — `type=none` + +### Lifecycle orchestration +- `src/xrpld/app/misc/SHAMapStoreImp.cpp` — drives `rotate()`, manages state DB diff --git a/docs/skills/peering.md b/docs/skills/peering.md index 8f7effafcd..4d2d9c270d 100644 --- a/docs/skills/peering.md +++ b/docs/skills/peering.md @@ -1,35 +1,67 @@ # Overlay Peering -P2P network using persistent TCP/IP connections. Messages serialized via Protocol Buffers. `OverlayImpl` manages connections; `PeerImp` handles per-peer logic. +P2P network using persistent TCP/IP connections. Messages serialized via Protocol Buffers. `OverlayImpl` manages connections; `PeerImp` handles per-peer logic. `PeerFinder` (sub-module under `peerfinder/`) handles peer discovery, slot accounting, and address caches. ## Key Invariants - Connection preference order: Fixed Peers -> Livecache -> Bootcache -- Cluster connections do NOT count toward connection limits (unlimited) +- Cluster connections and reserved (PeerReservationTable) connections do NOT count toward slot limits in `Counts::can_activate` — they bypass `m_in_active`/`m_out_active` caps +- Validators are forced `peerPrivate=true` by `Config::makeConfig` even without explicit `[peer_private]`; this is "soft" privacy (still accepts inbound, but asks peers not to gossip address) - Protobuf message changes MUST maintain wire compatibility or risk network partitioning -- Squelching: after enough peers relay a validator's messages, a subset is "Selected" and the rest are temporarily muted to reduce bandwidth -- Handshake binds TLS session to node identity via `signDigest` of the session fingerprint +- Squelching: after `MAX_SELECTED_PEERS=5` peers each cross `MAX_MESSAGE_THRESHOLD=20` messages, a random 5-peer subset becomes "Selected"; rest are muted via `TMSquelch` for a randomized window in `[MIN_UNSQUELCH_EXPIRE=300s, ...MAX_UNSQUELCH_EXPIRE_PEERS=3600s]` +- Reduce-relay does not activate for `WAIT_ON_BOOTUP=10min` after process start (`Slots::reduceRelayReady`) +- Handshake binds TLS session to node identity via signature of `makeSharedValue` (SHA-512 XOR of TLS finished messages, then `sha512Half`); a zero shared value (degenerate XOR) is rejected +- Wire format: 6-byte header uncompressed, 10-byte compressed; 26-bit payload size field caps messages at `maximumMessageSize = 64 MiB` +- Hop count cap: `Endpoint` constructor clamps `hops` to `maxHops+1=7`; `Logic::preprocess` drops `hops > maxHops=6` and increments surviving hops by 1 before storage +- TX reduce-relay queue is bounded by `MAX_TX_QUEUE_SIZE=10000` hashes per peer; required to stay under the 64 MiB protocol limit at high TPS +- `peersWithMessage_` (in `Slots`) is `inline static` — shared across all instantiations, not per-instance ## Common Bug Patterns -- PeerFinder slot exhaustion: if `maxPeers` is reached, new outbound connections silently fail; check slot availability before connecting -- `HashRouter::shouldRelay` prevents duplicate relay; bypassing it causes message storms -- `ConnectAttempt::processResponse` on HTTP 503 parses "peer-ips" for alternatives; malformed responses here can crash with bad IP parsing +- PeerFinder slot exhaustion: if `inPeers`/`outPeers` is reached, new connections silently fail; check `Counts::can_activate` and `attempts_needed` +- `HashRouter::shouldRelay` prevents duplicate relay; bypassing it causes message storms (`OverlayImpl::relay` enforces this) +- `ConnectAttempt::processResponse` on HTTP 503 parses `peer-ips` JSON array for redirect; malformed entries are validated as endpoints before being passed to `peerFinder().onRedirects` - `PeerImp::close` must run on the strand; calling from wrong thread causes race conditions on socket and timer state - Destructor chain: `~PeerImp` -> `deletePeer` -> `onPeerDeactivate` -> `on_closed` -> `remove`; interrupting this chain leaks slots +- `~ConnectAttempt` releases the PeerFinder slot via `on_closed(slot_)` only if `slot_ != nullptr`; on successful promotion to `PeerImp`, `slot_` is moved out and must be left null +- `tryAsyncShutdown()` must defer SSL shutdown until `!readPending_ && !writePending_`; calling `async_shutdown` while async I/O is in flight is undefined behavior +- `dynamic_pointer_cast` is required wherever `Manager` API takes `shared_ptr` but `Logic` needs `SlotImp` +- A compressed message from a peer that did NOT negotiate compression is a hard `protocol_error` in `invokeProtocolMessage` (prevents CPU forcing attack) +- Self-squelch attempt (peer sends `TMSquelch` for our own validation key) is silently dropped in `PeerImp::onMessage(TMSquelch)` — never trust a peer to silence us +- `Cluster::for_each` callback must NOT call `Cluster::update` — same non-recursive mutex, deadlock ## Connection Lifecycle -1. `OverlayImpl::connect` -> check resource limits -> allocate PeerFinder slot -> create `ConnectAttempt` -2. Async TCP connect -> TLS handshake -> HTTP upgrade with identity headers -3. `processResponse` -> verify handshake -> create `PeerImp` -> `add_active` -> `run()` -4. `doProtocolStart` -> start async message receive loop -> exchange validator lists and manifests +### Outbound (`ConnectAttempt`) + +1. `OverlayImpl::connect` -> resource check -> `peerFinder().new_outbound_slot()` -> create `ConnectAttempt` +2. Five-phase chain: `async_connect` -> TLS `async_handshake` -> HTTP write -> HTTP read -> `processResponse` +3. Dual-timer scheme: global 25s ceiling (`connectTimeout`) + per-step timers (8/8/3/3/2s); both share `onTimer` callback distinguishing by expiry comparison +4. `ioPending_` flag prevents starting SSL shutdown while another async op is pending on the stream +5. On HTTP 101: `verifyHandshake` -> create `PeerImp` -> move `slot_` and `stream_ptr_` into peer -> `overlay_.add_active(peer)` +6. On HTTP 503 with JSON `peer-ips`: forward to `peerFinder().onRedirects` + +### Inbound (`OverlayImpl::onHandoff`) + +1. HTTP server hands off TLS stream + upgrade request +2. Sequential gates: `processRequest` (for `/crawl`, `/health`, `/vl/`) → resource limit → `new_inbound_slot` → `negotiateProtocolVersion` → `makeSharedValue` → `verifyHandshake` +3. Create `PeerImp`, insert into `m_peers` (slot-keyed); `peer->run()` MUST be called while holding `mutex_` (race vs `stop()` draining list) +4. `m_peers` populated here, but `ids_` only after `activate()` post-protocol-handshake + +## Two-Phase Peer Registration + +- `m_peers`: `PeerFinder::Slot → weak_ptr` — populated at handshake start, used for slot management +- `ids_`: `Peer::id_t → weak_ptr` — populated at `activate()` after protocol handshake; used for broadcast and relay +- Outbound peers (via `ConnectAttempt`) populate both maps together in `add_active` ## Review Checklist - Verify resource manager checks on both inbound and outbound connections -- New protocol messages: update protobuf definitions AND verify wire compatibility +- New protocol messages: update protobuf definitions AND verify wire compatibility; add LZ4 eligibility list in `Message::compress()` if bulk - Squelch changes: test with high peer counts; incorrect squelch logic can silence validators +- Header parsing changes (`ProtocolMessage.h`): the high-bit format guard (`*iter & 0x80`) and reserved-bit checks (`*iter & 0x0C == 0`) MUST remain +- Adding a new compression `Algorithm` enum value: must have high bit set, low nibble zero (so it's extractable via `*iter & 0xF0`); update `Compression.h` dispatch switches or the `UNREACHABLE` guard fires +- Strand discipline: any new method touching socket/queue state must guard with `if (!strand_.running_in_this_thread()) return post(strand_, ...)` ## Key Patterns @@ -48,13 +80,189 @@ if (!strand_.running_in_this_thread()) if (!hashRouter_.shouldRelay(hash)) return; // already relayed — suppress duplicate overlay_.relay(message, hash); -// Bypassing this causes message storms across the network ``` +### Shared Lazy Compression +```cpp +// Message::getBuffer(Compressed::On) — compresses once, shared across N peers +std::call_once(once_flag_, &Message::compress, this); +// Eligible types only (mtTRANSACTION, mtLEDGER_DATA, mtVALIDATOR_LIST, ...); +// Latency-sensitive types (mtPING, mtVALIDATION, mtPROPOSE_LEDGER) excluded. +// Falls back to uncompressed if savings < 4 bytes (compressed header overhead). +``` + +### Resource Charging Batches +```cpp +// PeerImp::onMessageBegin resets fee_; onMessageEnd applies charge once per +// message via charge(). Handlers escalate via fee_.update() (monotonic). +``` + +### Exception-Based Handshake Failures +```cpp +// verifyHandshake() throws std::runtime_error on any check failure; +// callers (ConnectAttempt, PeerImp::doAccept) wrap in try/catch and tear down. +``` + +## Reduce-Relay (Squelch) Architecture + +Two halves, decoupled: + +- **Upstream (`Slot`/`Slots` in `OverlayImpl`)**: counts inbound validator messages per peer, selects 5 sources, calls `SquelchHandler::squelch()` (implemented by `OverlayImpl`) which sends `TMSquelch` over the wire. Uses `UptimeClock`. +- **Downstream (`Squelch` in `PeerImp`)**: receives `TMSquelch`, stores expiry in `hash_map`. `PeerImp::send()` calls `expireSquelch(validator)` before transmitting any validator-keyed message; `false` return → drop, count under `TrafficCount::squelch_suppressed`. + +All `OverlayImpl::updateSlotAndSquelch` calls are dispatched to `strand_` because `Slots` is not thread-safe. + +Squelch expiry is lazy: no background timer. `expireSquelch` removes stale entries on next send. Out-of-bounds durations in incoming `TMSquelch` trigger `feeInvalidData` and `removeSquelch` (defensive clear). + +## TX Reduce-Relay + +When `txReduceRelayEnabled_` (negotiated via `FEATURE_TXRR`): +- Full transactions go to a quota of peers (computed from `TX_REDUCE_RELAY_MIN_PEERS` and `TX_RELAY_PERCENTAGE`) +- Remaining peers get hash announcements via `addTxQueue` → batched `TMHaveTransactions` flushed by periodic `sendTxQueue` +- Peers without the feature always get full message (back-compat) +- Peer list is shuffled with `default_prng()` to avoid systematic bias +- `MAX_TX_QUEUE_SIZE=10000` cap; `doTransactions` rejects requests exceeding this as malformed + +## Tracking State + +`tracking_` (atomic `Tracking` enum): `unknown`, `converged`, `diverged`. Thresholds from `Tuning.h`: +- `convergedLedgerLimit=24` — within this many ledgers of validated index +- `divergedLedgerLimit=128` — beyond this, mark diverged and start the `MAX_DIVERGED_TIME` countdown + +Hysteresis (24 vs 128) prevents oscillation on slightly-behind peers. + +## Send Queue Backpressure (Tuning.h) + +Three tiers: +- `targetSendQueue=128` — below this, peer is healthy; resets `large_sendq_` counter +- `sendqIntervals=4` — consecutive 1-second ticks at-or-above target before disconnect +- `dropSendQueue=192` — refuse new query responses (don't do expensive lookups for stuck peer) +- `sendQueueLogFreq=64` — log every 64th enqueue when queue is large (throttle log spam) + +Other key tuning constants: +- `softMaxReplyNodes=8192`/`hardMaxReplyNodes=12288` — soft/hard caps for `TMLedgerData` node counts +- `maxQueryDepth=3` — recursion limit for `TMGetLedger`; deeper queries rejected as `badData` +- `checkIdlePeers=4` — modulo for timer-driven idle peer scan +- `readBufferBytes=16384` — `constexpr size_t` for socket read buffer (separate from enum for type reasons) + +## PeerFinder Sub-Module + +Implements peer address discovery, slot accounting, and reachability checks. Owned by `OverlayImpl` via `make_Manager()`. Hidden behind `Manager` abstract interface; concrete `ManagerImp` lives in `detail/PeerfinderManager.cpp`. + +### Components + +- **`Logic`**: central decision engine. Holds `slots_`, `connectedAddresses_` (multiset for IP limit), `keys_` (dedup public keys), `fixed_`, `livecache_`, `bootcache_`. Guarded by `std::recursive_mutex lock_`. +- **`Livecache`**: ~30s TTL gossip cache (`Tuning::liveCacheSecondsToLive`). `beast::aged_map` + `boost::intrusive::list` per hop bucket (size `maxHops+2=8`). MUST `shuffle()` before handout — `push_front` insertion is exploitable otherwise. +- **`Bootcache`**: persistent (SQLite via `StoreSqdb`). Bimap (`unordered_set_of` by endpoint, `multiset_of` by valence) for O(1) update and ranked iteration. Valence is a streak counter (clamped to 0 before crossing sign). `staticValence=32` for `[ips]`/`[ips_fixed]`. Throttled writes: 60s cooldown via `flagForUpdate`/`checkUpdate`; destructor force-flushes. +- **`Checker`**: async TCP probe for verifying peer's advertised listening port. Self-managing `async_op` via `shared_ptr` capture in handler; `~Checker` calls `wait()` (not `stop()` — must call `stop()` first explicitly). +- **`Counts`**: pure bookkeeping, no own mutex (relies on `Logic::lock_`). All updates funnel through private `adjust(slot, ±1)`. Fixed/reserved bypass active caps in `can_activate`. +- **`SlotImp`**: concrete slot state. Two constructors (inbound takes both endpoints; outbound only remote, sets `checked=true,canAccept=true` since TCP connect itself proves reachability). State machine enforced by `XRPL_ASSERT` in `state()` and `activate()`. `m_listening_port` is `std::atomic` with `-1` sentinel. +- **`Fixed`**: per-fixed-peer backoff. Fibonacci sequence in minutes: `{1,1,2,3,5,8,13,21,34,55}`, clamped to last index. `failure()` advances; `success()` resets. +- **`Source`**: abstract; only concrete is `SourceStrings` (config `[ips]`). + +### Autoconnect Tier Order + +`Logic::autoconnect()` strictly returns at first non-empty tier: +1. Fixed peers (via `get_fixed`, respecting `Fixed::when()` backoff) +2. Livecache (shuffled, reverse hop order — far peers first for topological diversity) +3. (Bootcache refill placeholder for DNS) +4. Bootcache fallback + +`m_squelches` aged set (60s TTL, `Tuning::recentAttemptDuration`) suppresses rapid retries to same address across calls. + +### Endpoint Gossip + +- **Receiving (`on_endpoints` + `preprocess`)**: rate-limited via per-slot `whenAcceptEndpoints` (`Tuning::secondsPerMessage=151s`, a prime to desync nodes). Random sample-down if oversized. `hops==0` entry's IP replaced with sender's socket address (peer doesn't know own public IP). All surviving hops incremented by 1 before livecache insert. First-hop entries trigger `Checker::async_connect` for reachability test. +- **Sending (`buildEndpointsForPeers`)**: shuffle slots, use `SlotHandouts` per peer, run `handout()` algorithm. Self-advertisement uses zero-address IPv6 sentinel — receiver substitutes socket's remote address. +- **`Handouts` algorithm**: round-robin across multiple targets to ensure fair distribution. `move_back` after each acceptance rotates endpoints. Per-target dedup via `SlotImp::recent_t` (aged map; `filter()` uses `<=` hop comparison; `try_insert` writes both received and sent into recent — pessimistic update). + +## TLS Channel-Binding (Non-Standard) + +`makeSharedValue` derives a 256-bit value from TLS finished messages: +``` +sha512Half(SHA512(my_finished) XOR SHA512(peer_finished)) +``` +Rejects degenerate zero-XOR case. Non-standard (see OpenSSL #5509, XRPLF/rippled #2413). TLS cert verification is explicitly disabled (`verify_none`) — security comes from binding node-public-key signature to this shared value via `Session-Signature` HTTP header. MITM produces different finished values → signature mismatch → rejection. + +## Handshake HTTP Headers + +Built by `buildHandshake`, verified by `verifyHandshake`. Verify order is layered (cheap → expensive): +1. `Network-ID` mismatch +2. `Network-Time` ±20s tolerance +3. `Public-Key` parse, self-connection check +4. `Session-Signature` cryptographic verify +5. `Local-IP`/`Remote-IP` cross-check (NAT diagnostics) + +Feature negotiation via `X-Protocol-Ctl`: `compr=lz4`, `vprr=1`, `txrr=1`, `ledgerreplay=1`. Responder echoes back only features locally configured AND requested (AND-gate). + +## ZeroCopy I/O Adapters + +`ZeroCopyInputStream` wraps `ConstBufferSequence` for protobuf parsing without intermediate copy. `BackUp`/`Skip` support sub-buffer granularity via tracked `pos_` within current buffer. + +`ZeroCopyOutputStream` uses deferred commit pattern: `commit_` tracks bytes promised but not yet committed. Destructor MUST flush trailing `commit_` — protobuf doesn't guarantee terminal `BackUp` or `Next` call. + +## Traffic Categorization + +`TrafficCount::categorize()` is called once at `Message` construction (outbound) and per inbound message. Two-stage: static `unordered_map` for simple types, then `dynamic_cast` for protobuf inspection of `TMLedgerData`/`TMGetLedger` (`requestcookie` distinguishes forwarded vs originated) and `TMGetObjectByHash` (`query()` flag determines get/share). `unknown` is NOT rolled into `total`. + +## Compression Eligibility (Message::compress) + +Skip if ≤70 bytes. Whitelist of eligible types: `mtMANIFESTS`, `mtENDPOINTS`, `mtTRANSACTION`, `mtGET_LEDGER`, `mtLEDGER_DATA`, `mtGET_OBJECTS`, `mtVALIDATOR_LIST`, `mtVALIDATOR_LIST_COLLECTION`, `mtREPLAY_DELTA_RESPONSE`, `mtTRANSACTIONS`. Excludes high-frequency control messages (`mtPING`, `mtVALIDATION`, `mtPROPOSE_LEDGER`, `mtSTATUS_CHANGE`). If compressed size doesn't beat uncompressed minus 4-byte header overhead, fall back to uncompressed. + +## HTTP Endpoints (served by `OverlayImpl::processRequest`) + +- `/crawl` — JSON topology, gated by bitmask config +- `/health` — three-tier status (200/503/500) — HTTP status encodes result so LBs need no JSON parsing +- `/vl/` or `/vl//` — signed validator list + +## Concurrency Notes + +- `OverlayImpl::mutex_` is `std::recursive_mutex` (acknowledged tech debt: `// VFALCO use std::mutex`). Recursion stems from `run()` triggering callbacks back into overlay. +- `cond_` is `condition_variable_any` (needs Lockable, not BasicLockable) for shutdown drain +- `work_` (`executor_work_guard` as `std::optional`) keeps `io_context` alive; `reset()` during `stop()` lets queue drain +- Strand vs mutex: peer registry mutations use `mutex_`; timer/squelch/tx-metrics work uses strand +- `OverlayImpl::Child` registration: destructor auto-removes from `list_`; `stopChildren()` copies pointers before iterating to avoid invalidation +- `PeerImp` field locks: `recentLock_` (ledger state, latency), `nameMutex_` (`shared_mutex` for `name_`); strand-confined fields need no lock + ## Key Files -- `src/xrpld/overlay/detail/OverlayImpl.cpp` - main overlay manager -- `src/xrpld/overlay/detail/PeerImp.cpp` - per-peer logic -- `src/xrpld/overlay/detail/ConnectAttempt.cpp` - outbound connection -- `src/xrpld/overlay/Slot.h` - squelch state machine -- `src/xrpld/overlay/detail/Handshake.cpp` - handshake crypto +### Overlay core +- `src/xrpld/overlay/Overlay.h` / `detail/OverlayImpl.{h,cpp}` — main manager +- `src/xrpld/overlay/Peer.h` / `detail/PeerImp.{h,cpp}` — per-peer logic +- `src/xrpld/overlay/Message.h` / `detail/Message.cpp` — wire envelope, lazy compression +- `src/xrpld/overlay/detail/ConnectAttempt.{h,cpp}` — outbound connection state machine +- `src/xrpld/overlay/detail/Handshake.{h,cpp}` — handshake crypto, feature negotiation +- `src/xrpld/overlay/detail/ProtocolMessage.h` — wire framing, dispatch +- `src/xrpld/overlay/detail/ProtocolVersion.{h,cpp}` — `XRPL/x.y` negotiation +- `src/xrpld/overlay/detail/ZeroCopyStream.h` — protobuf/Asio buffer adapters +- `src/xrpld/overlay/detail/Tuning.h` — all overlay magic numbers + +### Reduce-relay +- `src/xrpld/overlay/Slot.h` — per-validator state machine + selection algorithm +- `src/xrpld/overlay/Squelch.h` — per-peer suppression enforcement +- `src/xrpld/overlay/ReduceRelayCommon.h` — all reduce-relay constants + +### Telemetry +- `src/xrpld/overlay/detail/TrafficCount.{h,cpp}` — per-category byte/message counters +- `src/xrpld/overlay/detail/TxMetrics.{h,cpp}` — rolling averages for tx reduce-relay + +### Cluster +- `src/xrpld/overlay/Cluster.h` / `ClusterNode.h` / `detail/Cluster.cpp` — trusted-node registry with heterogeneous lookup + +### PeerSet (data acquisition) +- `src/xrpld/overlay/PeerSet.h` / `detail/PeerSet.cpp` — scored peer selection for InboundLedger etc. + +### Reservations +- `src/xrpld/overlay/detail/PeerReservationTable.cpp` — persistent allowlist via SQLite + +### PeerFinder +- `src/xrpld/peerfinder/PeerfinderManager.h` / `Slot.h` / `make_Manager.h` — public interface +- `src/xrpld/peerfinder/detail/Logic.h` — central decision engine +- `src/xrpld/peerfinder/detail/Livecache.h` / `Bootcache.{h,cpp}` — address caches +- `src/xrpld/peerfinder/detail/Checker.h` — async reachability prober +- `src/xrpld/peerfinder/detail/SlotImp.{h,cpp}` — slot state machine +- `src/xrpld/peerfinder/detail/Counts.h` — slot bookkeeping +- `src/xrpld/peerfinder/detail/Fixed.h` — Fibonacci backoff +- `src/xrpld/peerfinder/detail/Handouts.h` — fair distribution algorithm +- `src/xrpld/peerfinder/detail/StoreSqdb.h` — SQLite persistence +- `src/xrpld/peerfinder/detail/Tuning.h` — peerfinder magic numbers diff --git a/docs/skills/rpc.md b/docs/skills/rpc.md index 665863deb5..0fdb782dfd 100644 --- a/docs/skills/rpc.md +++ b/docs/skills/rpc.md @@ -1,61 +1,190 @@ # RPC -JSON-RPC over HTTP/WebSocket and gRPC. Central handler table dispatches by method name + API version. Roles: ADMIN, USER, IDENTIFIED, PROXY, FORBID. +JSON-RPC over HTTP/WebSocket and gRPC. Central handler table dispatches by method name + API version. Roles: ADMIN, USER, IDENTIFIED, PROXY, FORBID, GUEST. ## Key Invariants -- Handler table in `Handler.cpp`: each entry = `{name, function, role, condition, minApiVer, maxApiVer}` -- `conditionMet` checks server state (e.g., `NEEDS_CURRENT_LEDGER`) before invoking handler -- API v2.0+ errors: structured objects with `status`, `code`, `message`; earlier: flat fields in response -- Sensitive fields (`passphrase`, `secret`, `seed`, `seed_hex`) are masked in error responses -- Batch requests: `"method": "batch"` with `"params"` array; each sub-request processed independently +- Handler table in `Handler.cpp`: each entry = `{name, function, role, condition, minApiVer, maxApiVer}` as `std::multimap`. Same method name can have multiple entries with **non-overlapping** version ranges; overlap is a fatal `LogicError()` at startup. +- `conditionMet` checks amendment-blocked, UNL expired, operating mode ≥ SYNCING, validated ledger age < `Tuning::maxValidatedLedgerAge` (2 min), and validated/current gap ≤ 10 ledgers. Standalone mode bypasses age checks. +- API v1 vs v2 error code split: v1 emits `rpcNO_NETWORK`/`rpcNO_CURRENT`/`rpcNO_CLOSED`; v2+ collapses to `rpcNOT_SYNCED`. +- Sensitive fields (`passphrase`, `secret`, `seed`, `seed_hex`) are masked as `` in error response echoes. +- Batch requests: top-level `"method": "batch"` with `params` array; each sub-request processed independently and accumulated into JSON array. +- API v2 enforces strict JSON typing on previously-permissive boolean fields (e.g., `signer_lists`, `binary`, `forward`, `transactions`). ## Common Bug Patterns -- New handler without entry in `Handler.cpp` static array = handler silently unreachable -- Wrong `role_` on handler: USER-level handler with admin-only data leaks; ADMIN handler accessible to users = security hole -- `conditionMet` returning false causes a generic error; ensure new conditions are documented -- Resource charging: each request gets a fee via `Resource::Consumer`; missing charge allows DoS -- `maxRequestSize` (RPC::Tuning) rejection happens before JSON parsing; oversized requests get no error detail +- New handler without entry in `Handler.cpp` static array = handler silently unreachable. +- Wrong `role_` on handler: USER-level with admin data leaks; ADMIN handler accessible to users = security hole. +- `conditionMet` returning false: ensure new conditions are documented and version-coded errors are paired. +- Resource charging: each request gets a fee via `Resource::Consumer`; missing charge allows DoS. +- `maxRequestSize` (1 MB) rejected before JSON parsing; oversized requests get no error detail. +- Marker pagination: callers can forge markers pointing into other accounts' directories — always call `RPC::isRelatedToAccount` before resuming. +- `parse()` returning `std::nullopt` is a programming-error sentinel for type system; user-facing errors go through `required` / `Expected`. +- `loadType` must be set early in handler — escalates to `feeExceptionRPC` automatically on exception if still `feeReferenceRPC`. ## Adding New RPC Handler 1. Declare in `Handlers.h`: `Json::Value doMyCommand(RPC::JsonContext&);` -2. Implement in new file under `src/xrpld/rpc/handlers/` -3. Register in `Handler.cpp` static array with role, condition, version range -4. For gRPC: define in `xrp_ledger.proto`, add `CallData` in `GRPCServerImpl::setupListeners()` +2. Implement in new file under `src/xrpld/rpc/handlers//`. +3. Register in `Handler.cpp` `handlerArray` with role, condition, version range. +4. For class-based new-style handler (rare; only `LedgerHandler`, `VersionHandler`): expose static `name`, `role`, `condition`, `minApiVer`, `maxApiVer`; implement `check()` / `writeResult()`; register via `addHandler()`. +5. For gRPC: define in `xrp_ledger.proto`, add `CallData` in `GRPCServerImpl::setupListeners()`, write `doXxxGrpc(RPC::GRPCContext&)` returning `std::pair`. -## Subscriptions +## Handler Patterns -- WebSocket clients can subscribe to: `server`, `ledger`, `book_changes`, `transactions`, `validations`, `manifests`, `peer_status` (admin), `consensus` -- `WSInfoSub` delivers events via weak pointer to `WSSession`; dead sessions are automatically cleaned up -- `RPCSub` delivers to remote URL endpoints with auth and SSL support - -## Key Patterns - -### Handler Table Registration +### Old-style registration (typical) ```cpp // In Handler.cpp handlerArray[] — REQUIRED for every new handler: {"my_command", byRef(&doMyCommand), Role::USER, NO_CONDITION}, -// role MUST match security requirements: -// Role::ADMIN for internal-only, Role::USER for public API -// condition: NEEDS_CURRENT_LEDGER, NEEDS_NETWORK_CONNECTION, or NO_CONDITION +// role: ADMIN for internal/sensitive, USER for public +// condition: NO_CONDITION, NEEDS_NETWORK_CONNECTION, NEEDS_CURRENT_LEDGER, NEEDS_CLOSED_LEDGER +// version range defaults to [apiMinimumSupportedVersion, apiMaximumValidVersion] +// To version-bound: `{"ledger_header", byRef(&doLedgerHeader), Role::USER, NO_CONDITION, 1, 1}` ``` -### Version-Ranged Handler +### Version-Ranged Class Handler ```cpp -// New-style handler with API version range -template <> Handler handlerFrom() -{ return {MyCommandHandler::name, &handle, - MyCommandHandler::role, MyCommandHandler::condition, - MyCommandHandler::minApiVer, MyCommandHandler::maxApiVer}; +// Class with static metadata; registered in HandlerTable ctor via addHandler() +template <> Handler handlerFrom() { + return {MyCommandHandler::name, &handle, + MyCommandHandler::role, MyCommandHandler::condition, + MyCommandHandler::minApiVer, MyCommandHandler::maxApiVer}; } ``` +### Ledger Resolution +- `RPC::lookupLedger(ledger, context)` for JSON path — handles `ledger_hash`/`ledger_index`/legacy `ledger`/shortcut strings. +- `RPC::ledgerFromRequest(ledger, context)` for gRPC. +- `RPC::getOrAcquireLedger(context)` returns `Expected, Json::Value>` and triggers `InboundLedgers::acquire()` for missing ledgers (used only by `ledger_request` admin command). + +### Pagination Idiom +- Marker format: `","` for owner-directory handlers; raw hex for NFT page chains. +- Request `limit + 1` from `forEachItemAfter`; if `count == limit + 1`, emit marker from limit-th item. +- Always validate marker SLE belongs to requesting account before resuming. +- Limits from `RPC::Tuning::` clamped via `readLimitField()`; admin/unlimited roles bypass clamp. + ## Key Files -- `src/xrpld/rpc/handlers/Handlers.h` - authoritative handler list -- `src/xrpld/rpc/detail/Handler.cpp` - handler table and dispatch -- `src/xrpld/rpc/detail/RPCHandler.cpp` - request processing pipeline -- `src/xrpld/rpc/detail/ServerHandler.cpp` - HTTP/WS entry points -- `include/xrpl/protocol/ErrorCodes.h` - error code definitions +### Top-level +- `src/xrpld/rpc/handlers/Handlers.h` — authoritative declarations of all old-style handler functions (~67 entries). +- `src/xrpld/rpc/detail/Handler.cpp` — handler table, `getHandler()`, `HandlerTable` singleton, version overlap enforcement. +- `src/xrpld/rpc/detail/Handler.h` — `Handler` struct, `Condition` enum, `conditionMet()` template. +- `src/xrpld/rpc/detail/RPCHandler.cpp` — `doCommand()` pipeline: load-shed, role check, condition check, perf-log instrumented dispatch. +- `src/xrpld/rpc/detail/ServerHandler.cpp` — HTTP/WS server entry; auth, batch handling, version-aware error formatting, secret masking, HTTP status from RPC error codes (ripplerpc ≥ 3.0). +- `src/xrpld/rpc/RPCHandler.h` — `doCommand`, `roleRequired` declarations. +- `src/xrpld/rpc/Context.h` — `Context`, `JsonContext`, `GRPCContext` aggregate dispatch envelopes. +- `src/xrpld/rpc/Request.h` — simpler `Request` envelope (less used; lives alongside `Context`). +- `src/xrpld/rpc/Status.h` — unified error type bridging `TER`, `error_code_i`, and bare int with `inject()` to JSON. +- `src/xrpld/rpc/Role.h` — `Role` enum, `isUnlimited`, `requestRole`, `ipAllowed`, `forwardedFor`. +- `src/xrpld/rpc/detail/Role.cpp` — IP subnet matching, secure_gateway resolution, RFC 7239 / `X-Forwarded-For` parsing. +- `src/xrpld/rpc/detail/RPCHelpers.cpp` / `.h` — pagination, seed parsing, keypair derivation, ledger-entry type selection, MPT/IOU asset parsing. +- `src/xrpld/rpc/detail/RPCLedgerHelpers.cpp` / `.h` — `lookupLedger`, `getLedger`, `getOrAcquireLedger`; staleness checks; gRPC `ledgerFromSpecifier`. +- `src/xrpld/rpc/detail/Tuning.h` — all numeric tunables (limits, ranges, throttles). +- `include/xrpl/protocol/ErrorCodes.h` — `error_code_i`, `inject_error`, `ErrorInfo` table, HTTP status mapping. + +### Subscriptions +- `src/xrpld/rpc/detail/WSInfoSub.h` — WebSocket `InfoSub` subclass; `Json::stream`-based zero-intermediate serialization into `multi_buffer`. +- `src/xrpld/rpc/RPCSub.h` / `detail/RPCSub.cpp` — outbound HTTP/HTTPS push subscription ("webhook"); producer/consumer with `jtCLIENT_SUBSCRIBE` jobs; carries `VFALCO TODO` markers (legacy). +- Streams: `server`, `ledger`, `book_changes`, `transactions`, `transactions_proposed` (`rt_transactions` deprecated alias), `validations`, `manifests`, `peer_status` (admin), `consensus`. +- `account_history_tx_stream` is experimental; gated on `useTxTables()`; supports `stop_history_tx_only` in unsubscribe. + +### Pathfinding +- `src/xrpld/rpc/detail/Pathfinder.cpp` / `.h` — three-phase engine: `findPaths()` (template expansion via static `mPathTable`), `computePathRanks()` (RippleCalc simulation), `getBestPaths()` (selection with covering-path). +- `src/xrpld/rpc/detail/PathRequest.cpp` / `.h` — per-request state machine; two constructors for `path_find` (subscription) vs `ripple_path_find` (legacy callback); adaptive `iLevel`. +- `src/xrpld/rpc/detail/PathRequestManager.cpp` / `.h` — collection of `wptr`; re-entrant `updateAll()` loop; shared `AssetCache` via `weak_ptr` (intentional, see `getAssetCache`). +- `src/xrpld/rpc/detail/AssetCache.cpp` / `.h` — per-ledger thread-safe trust line + MPT cache; direction-superset optimization for trust lines; `shared_ptr>` null sentinels for empty accounts. +- `src/xrpld/rpc/detail/AccountAssets.cpp` / `.h` — `accountSourceAssets` / `accountDestAssets` for path source/dest currency enumeration. +- `src/xrpld/rpc/detail/TrustLine.cpp` / `.h` — `TrustLineBase` + `PathFindTrustLine` (memory-minimal) + `RPCTrustLine` (adds quality rates). +- `src/xrpld/rpc/detail/MPT.h` — `PathFindMPT` (MPTID + zeroBalance + maxedOut). +- `src/xrpld/rpc/detail/PathfinderUtils.h` — `largestAmount`, `convertAmount`, `convertAllCheck` for "convert all" sentinel handling. +- `src/xrpld/rpc/detail/LegacyPathFind.cpp` / `.h` — RAII concurrency guard for synchronous `ripple_path_find`; lock-free CAS on `inProgress` counter; admin bypass. +- `src/xrpld/rpc/detail/RippleLineCache.cpp` / `.h` — **empty stubs**; functionality replaced by `AssetCache`. Still `#include`d in two files for inert compatibility. + +### Transaction Signing / Submission +- `src/xrpld/rpc/detail/TransactionSign.cpp` / `.h` — `transactionSign`, `transactionSubmit`, `transactionSignFor`, `transactionSubmitMultiSigned`; `SigningForParams` mode discriminator; round-trip "sterilization" via `transactionConstructImpl`. +- Fee pipeline: `checkFee` → `getCurrentNetworkFee` (max of load-scaled base fee and TxQ-escalated open ledger fee, capped by `fee_mult_max`/`fee_div_max`). +- `ProcessTransactionFn` dependency injection via `getProcessTxnFn(NetworkOPs&)` for testability. +- `acctMatchesPubKey` handles three account states: unactivated (master-only), master+regular both valid, master disabled (regular only). + +### Utility / Enrichment +- `src/xrpld/rpc/BookChanges.h` — header-only template `computeBookChanges(ledger)`; produces OHLCV per pair; reused by RPC handler and `book_changes` subscription stream. +- `src/xrpld/rpc/CTID.h` — Concise Transaction ID (XLS-15d): 16-hex `C` + 28-bit ledgerSeq + 16-bit txnIdx + 16-bit netID. Boost regex; `boost::regex` not `std::regex`. +- `src/xrpld/rpc/DeliveredAmount.h` / `detail/DeliveredAmount.cpp` — `insertDeliveredAmount`; three-tier resolution; threshold = ledger 4594095 (Jan 2014) or close time 446000000s; `"unavailable"` string sentinel for pre-threshold ledgers; lazy lambdas avoid `LedgerMaster` calls when meta has `sfDeliveredAmount`. +- `src/xrpld/rpc/MPTokenIssuanceID.h` / `detail/MPTokenIssuanceID.cpp` — `insertMPTokenIssuanceID`; mirrors `DeliveredAmount` three-function pattern (eligibility / extraction / injection). +- `src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp` — built once at startup via Meyers singleton; SHA-512-half hash for client cache invalidation; X-macro–driven from protocol headers. +- `src/xrpld/rpc/GRPCHandlers.h` — declarations for 4 gRPC handlers (`doLedgerGrpc`, `doLedgerEntryGrpc`, `doLedgerDataGrpc`, `doLedgerDiffGrpc`). Contract: non-OK `grpc::Status` discards the response object. +- `src/xrpld/rpc/Output.h` — `boost::utility/string_ref`-based output sink. Vestigial; not used by current codebase (canonical sink is `Json::Output`). +- `src/xrpld/rpc/json_body.h` — Boost.Beast `Body` type for JSON HTTP responses; both `reader` and `writer` implement BodyReader concept (eager, one-shot). + +### Client-side +- `src/xrpld/rpc/RPCCall.h` / `detail/RPCCall.cpp` — `xrpld` CLI dispatch; `RPCParser` with static `Command[]` table mapping method → parse function; "trusted interface" — minimal validation by design. + +## Resource Cost Tiers + +Set `context.loadType` early. Tiers (from `Fees.h`): +- `feeReferenceRPC` — default; auto-escalates to `feeExceptionRPC` on uncaught exception. +- `feeMediumBurdenRPC` — directory walks, account_lines, account_offers, simulate, history paging, tx_reduce_relay-class ops. +- `feeHeavyBurdenRPC` — pathfinding, signing, gateway_balances, ledger_request, submit_multisigned. + +## Tuning Constants (in `Tuning.h`) + +- `maxRequestSize = 1_000_000` — rejected pre-parse in `ServerHandler`. +- `maxJobQueueClients = 500` — `RPCHandler::fillHandler` returns `rpcTOO_BUSY`; admin/unlimited bypass. +- `maxValidatedLedgerAge = 2 min`. +- `maxPathfindsInProgress = 2`, `maxPathfindJobCount = 50`, `max_src_cur = 18`, `max_auto_src_cur = 88`. +- `binaryPageLength = 2048`, `jsonPageLength = 256` — selected via `pageLength(isBinary)` in `ledger_data`. +- Per-command `LimitRange`: most account queries `{10, 200, 400}`; `bookOffers {0, 60, 100}`; `nftOffers {50, 250, 500}`; `noRippleCheck {10, 300, 400}`; `accountNFTokens {20, 100, 400}`. +- `defaultAutoFillFeeMultiplier = 10`, `defaultAutoFillFeeDivisor = 1`. + +## Two-Tier Signing Access Gate + +Sign-related handlers (`sign`, `sign_for`, `submit` with `tx_json`, `channel_authorize`) enforce: +```cpp +if (context.role != Role::ADMIN && !context.app.config().canSign()) + return rpcNOT_SUPPORTED; +``` +`canSign()` reflects `[signing_support]` config; defaults false. Public nodes refuse to sign by default. All `sign`/`sign_for` responses include a `deprecated` warning steering clients to local/offline signing. + +## API Version Behavioral Differences + +- `apiCommandLineVersion` is used by the CLI; defaults differ from inbound. +- v2 promotes fields from inside transaction objects to top-level: `hash`, `ledger_index`, `ledger_hash`, `close_time_iso`. +- v2 renames metadata keys: `tx` → `tx_json`, `meta` → `meta_blob` for binary. +- v2 renames Payment `Amount` → `DeliverMax` (via `RPC::insertDeliverMax`). +- v2 strict boolean typing; v1 silently coerces. +- v2 rejects mixing `ledger_index_min`/`max` with `ledger_index`/`ledger_hash` in `account_tx`; v1 tolerates. +- v2 enforces precise marker objects in `account_tx` (`{ledger, seq}` integers). +- v3 (beta) adds human-readable singleton aliases in `ledger_entry` index lookup. + +## Handler Subdirectory Map + +- `handlers/account/` — `AccountInfo`, `AccountLines`, `AccountChannels`, `AccountCurrencies`, `AccountNFTs`, `AccountObjects`, `AccountOffers`, `AccountTx`, `GatewayBalances`, `NoRippleCheck`, `OwnerInfo` (legacy). +- `handlers/admin/` — `BlackList`, `UnlList`, plus subdirectories for `data/` (CanDelete, LedgerCleaner, LedgerRequest), `keygen/` (WalletPropose, ValidationCreate), `log/`, `peer/`, `server_control/` (Stop, LedgerAccept — standalone-only), `signing/` (ChannelAuthorize, Sign, SignFor), `status/` (ConsensusInfo, FetchInfo, GetCounts, Print, ValidatorInfo, Validators, ValidatorListSites). +- `handlers/ledger/` — `Ledger` (class-based), `LedgerClosed`, `LedgerCurrent`, `LedgerData`, `LedgerDiff` (gRPC-only), `LedgerEntry` (parser table from `ledger_entries.macro`), `LedgerHeader`. +- `handlers/orderbook/` — `AMMInfo`, `BookChanges`, `BookOffers`, `DepositAuthorized`, `GetAggregatePrice`, `NFTBuyOffers` / `NFTSellOffers` / `NFTOffersHelpers.h`, `PathFind` (subscription), `RipplePathFind` (one-shot). +- `handlers/server_info/` — `Fee`, `Feature`, `Manifest`, `ServerDefinitions`, `ServerInfo`, `ServerState`, `Version.h` (class-based). +- `handlers/subscribe/` — `Subscribe`, `Unsubscribe`. +- `handlers/transaction/` — `Simulate` (dry-run via `tapDRY_RUN`), `Submit`, `SubmitMultiSigned`, `Tx`, `TransactionEntry` (ledger-pinned), `TxHistory` (paginated, `useTxTables()`-gated, deep-page cap 10000 for non-admin), `TxReduceRelay`. +- `handlers/utility/` — `Ping` (role-conditional response), `Random`. +- Top-level `handlers/`: `ChannelVerify` (no admin restriction, stateless), `VaultInfo` (XLS-66, vault + MPT issuance lookup). + +## gRPC Specifics + +- Four handlers: `Ledger`, `LedgerEntry`, `LedgerData` (binary-only, fixed page=2048, supports `marker`+`end_marker` for range parallelism), `LedgerDiff` (SHAMap delta; downcast `ReadView`→`Ledger` is the validation gate). +- `doLedgerGrpc` adds `get_objects` (state diff via `SHAMap::compare`) and `get_object_neighbors` (DEX best-offer tracking via `keylet::quality`). +- Handlers return `std::pair`. Non-OK status discards response. +- Error mapping: `rpcINVALID_PARAMS` → `INVALID_ARGUMENT`; ledger missing → `NOT_FOUND`; diff overflow → `RESOURCE_EXHAUSTED`. + +## Key Gotchas + +- `noEvents` (`rpcNO_EVENTS`) is returned by `path_find` and `subscribe`/`unsubscribe` for non-WebSocket transports — HTTP has no push channel. +- `LegacyPathFind` admit-failure means destructor must not decrement; uses `m_isOk` flag. +- `getMasterKey` returns the input key unchanged when no manifest exists — used in `doManifest`/`doValidatorInfo` to distinguish "is master key" from "ephemeral with no manifest". +- `ledger_accept` only works in standalone mode; takes master mutex; drives `Consensus::simulate`. +- `ChannelAuthorize` / `ChannelVerify` use `HashPrefix::paymentChannelClaim` ('CLM') as domain separator; canonical message = prefix + 32-byte channelID + 8-byte drops. +- `deposit_authorized` with credentials: must sort `(issuer, type)` pairs canonically via `credentials::makeSorted` before computing keylet; `lifeExtender` vector keeps SLEs alive so `Slice` views into `sfCredentialType` remain valid. +- `LedgerEntry` uses `Expected` parser return type rather than exceptions; v1 still re-throws `Json::error` for compatibility. +- `nft_buy_offers` / `nft_sell_offers` differ only by `keylet::nft_buys` vs `keylet::nft_sells` — both delegate to `enumerateNFTOffers` in `NFTOffersHelpers.h`. +- `getCountsJson` (in `GetCounts.h`) is callable from non-RPC contexts (e.g., `OverlayImpl::getCountsJson`). +- `wallet_propose` entropy warning: <80 bits → strong warning; passphrase that already encodes the seed (1751/Base58/hex) suppresses warning. +- Account marker security: always verify `RPC::isRelatedToAccount(*ledger, sle, accountID)` on resumed pagination — prevents cross-account directory traversal. diff --git a/docs/skills/shamap.md b/docs/skills/shamap.md index e8baa8060b..e74aff2d8d 100644 --- a/docs/skills/shamap.md +++ b/docs/skills/shamap.md @@ -1,61 +1,299 @@ # SHAMap -Merkle radix trie (radix 16) enabling O(1) subtree comparison via hash. Used for both state tree and transaction tree. Root is always a `SHAMapInnerNode`. +Authenticated 16-way radix Merkle trie. Every ledger has two: a TRANSACTION tree (txid → tx, with or without metadata) and a STATE tree (object key → serialized object). Root hash is what validators sign — two nodes agree on ledger state iff their root hashes match. Tree depth is fixed at 64 (256-bit keys consumed 4 bits per level). -## Key Invariants +Root is always a `SHAMapInnerNode`. Leaves only appear at depth 64. -- Mutable SHAMaps have non-zero `cowid`; immutable have `cowid=0`. Once immutable, nodes persist for the map's lifetime with NO mechanism to remove them -- Copy-on-write: `unshareNode` must be called before mutating any node in a mutable SHAMap; failing this corrupts shared snapshots -- Inner nodes have up to 16 children; hash is computed from children's hashes. Leaf hash is computed from data + type-specific prefix -- `canonicalize` ensures only one instance per hash in the cache; prevents races between threads -- `SHAMapInnerNode` uses atomic operations + locking (`std::atomic lock_`) for concurrent child access +## Core Invariants -## Common Bug Patterns +- **Tree shape**: `branchFactor = 16`, `leafDepth = 64`, max 65 levels (root at depth 0). One nibble per level. +- **CoW ownership**: each node has a `cowid_`. Non-zero → exclusively owned by that map and mutable. Zero → shared/canonicalized, must not be mutated. `setItem()`, `setChild()`, `dirtyUp()` all assert `cowid_ != 0`. +- **`unshareNode` before any mutation** of a shared node — #1 bug class. Skipping it corrupts every snapshot sharing the node. +- **`canonicalize`** ensures one in-memory instance per hash via `Family::getTreeNodeCache()`. Asserts `cowid == 0` on entry. +- **`SHAMapNodeID` masking**: `id_ == (id_ & depthMask(depth_))` is enforced by constructor. Two nodes at the same depth on the same path always have identical `SHAMapNodeID`. +- **Inner node concurrency**: per-child bit spinlock in `lock_` (`std::atomic`, one bit per branch). Allows concurrent reads of different children. `setChild`/`shareChild` skip locking because they require CoW ownership. +- **Leaf size floor**: `SHAMapItem::size() >= 12` asserted at leaf construction. -- Modifying a node without calling `unshareNode` first corrupts the snapshot that shares it; this is the #1 SHAMap bug class -- `getMissingNodes` uses deferred async reads; processing completions out of order causes incorrect "full below" marking -- Inner node serialization has two formats (compressed vs full) chosen by branch count; mismatched deserializer causes corruption -- `addKnownNode` traverses toward target; if branch is empty or hash mismatches, returns "invalid" -- callers must handle this gracefully -- Proof path verification walks leaf-to-root; incorrect key at any level causes false negative +## State Machine -## Serialization Formats - -- **Compressed**: only non-empty branches serialized (saves space for sparse nodes) -- **Full**: all 16 branches including empty ones (used for dense nodes) -- Choice is automatic in `serializeForWire` based on branch count - -## Leaf Node Types - -- `SHAMapAccountStateLeafNode` - account state entries -- `SHAMapTxLeafNode` - transactions -- `SHAMapTxPlusMetaLeafNode` - transactions with metadata -- Each uses a different hash prefix for domain separation - -## Key Patterns - -### State Machine ```cpp enum class SHAMapState { - Modifying = 0, // can add/remove objects - Immutable = 1, // FROZEN — no changes allowed - Synching = 2, // hash fixed, missing nodes can be added - Invalid = 3, // corrupt — do not use + Modifying = 0, // open ledger — can add/remove/update + Immutable = 1, // frozen — no writes; asserts guard + Synching = 2, // root hash known; missing nodes can be added + Invalid = 3, // corrupt; consensus must discard }; -// VERIFY: no peek()/insert()/erase() calls on Immutable maps ``` -### COW Discipline (#1 Bug Class) +`setImmutable()` asserts state ≠ `Invalid`. Hash-mismatch or position-mismatch during `addKnownNode` transitions to `Invalid` (not a crash). + +## Copy-on-Write Discipline (#1 Bug Class) + ```cpp // REQUIRED before mutating any shared node: -auto node = unshareNode(branch, key); // copies if shared -node->setChild(index, child); // now safe to modify -// BUG: skipping unshareNode corrupts snapshots sharing the node +auto node = unshareNode(branch, key); // clones if shared +node->setChild(index, child); // safe to modify ``` +`snapShot(isMutable)` does NOT copy nodes. It bumps the original's `cowid_` and shares the `root_` pointer. Subsequent writes call `unshareNode()` which clones on first touch. Immutable snapshots of immutable maps share everything with zero clone cost. + +The copy constructor's `unshare()` call breaks sharing eagerly when either side is mutable, preventing later mutations from racing. + +`getHash()` does `const_cast(*this).unshare()` when the root hash is zero — acknowledged design compromise (logical read, physical mutate). + ## Key Files -- `include/xrpl/shamap/SHAMap.h` - main class -- `include/xrpl/shamap/SHAMapInnerNode.h` - inner node (COW, threading) -- `include/xrpl/shamap/SHAMapLeafNode.h` - leaf node base -- `src/libxrpl/shamap/SHAMapSync.cpp` - sync, missing nodes, proofs -- `src/libxrpl/shamap/SHAMapDelta.cpp` - walkMap, parallel traversal +- `include/xrpl/shamap/SHAMap.h` — main class, state machine, MissingNodes struct +- `include/xrpl/shamap/SHAMapTreeNode.h` — base; `cowid_`, `hash_`, `IntrusiveRefCounts`, wire-type constants +- `include/xrpl/shamap/SHAMapInnerNode.h` — 16-way branch, sparse `TaggedPointer`, per-bit spinlocks, `fullBelowGen_` +- `include/xrpl/shamap/SHAMapLeafNode.h` — abstract leaf base, `item_` slot, `setItem` returns hash-changed bool +- `include/xrpl/shamap/SHAMapTxLeafNode.h`, `SHAMapTxPlusMetaLeafNode.h`, `SHAMapAccountStateLeafNode.h` — three leaf types +- `include/xrpl/shamap/SHAMapItem.h` — slab-allocated, struct-hack payload, intrusive refcount +- `include/xrpl/shamap/SHAMapNodeID.h` — (depth, masked-prefix) tree address +- `include/xrpl/shamap/SHAMapMissingNode.h` — exception + `SHAMapType` enum (TX/STATE/FREE) +- `include/xrpl/shamap/SHAMapAddNode.h` — useful/duplicate/invalid accumulator for sync results +- `include/xrpl/shamap/SHAMapSyncFilter.h` — pull/notify interface for fetch packs and ephemeral caches +- `include/xrpl/shamap/Family.h` — bundles NodeStore, two caches, missing-node recovery +- `include/xrpl/shamap/FullBelowCache.h` — "subtree complete locally" memo with generation counter +- `include/xrpl/shamap/TreeNodeCache.h` — `TaggedCache` with intrusive ptrs +- `include/xrpl/shamap/detail/TaggedPointer.h` / `.ipp` — sparse 16-child storage with 2-bit tag +- `src/libxrpl/shamap/SHAMap.cpp` — mutation (add/del/update), traversal, fetch, flush +- `src/libxrpl/shamap/SHAMapSync.cpp` — getMissingNodes, getNodeFat, addRootNode/addKnownNode, proofs +- `src/libxrpl/shamap/SHAMapDelta.cpp` — compare, walkMap, walkMapParallel +- `src/libxrpl/shamap/SHAMapInnerNode.cpp` — sparse storage mechanics, hash, serialization +- `src/libxrpl/shamap/SHAMapTreeNode.cpp` — deserialization factories +- `src/libxrpl/shamap/SHAMapNodeID.cpp` — depth mask table, branch nav, wire format + +## Three Concrete Leaf Types + +All inherit `SHAMapLeafNode` (which inherits `SHAMapTreeNode`). All `final`. Differ only in hash prefix, wire-type byte, and whether the key is fed into the hash. + +| Class | Hash prefix | Key in hash? | Key in wire? | Wire-type byte | +|---|---|---|---|---| +| `SHAMapTxLeafNode` | `transactionID` (`TXN`) | no | no | `wireTypeTransaction = 0` | +| `SHAMapTxPlusMetaLeafNode` | `txNode` (`SND`) | yes | yes | `wireTypeTransactionWithMeta = 4` | +| `SHAMapAccountStateLeafNode` | `leafNode` (`MLN`) | yes | yes | `wireTypeAccountState = 1` | + +**Why the asymmetry**: a transaction's ID *is* `sha512Half(prefix, blob)`, so the key is already implied by the data. Account state keys (account address, offer index, etc.) do NOT appear in the blob and must be hashed in or two distinct objects with identical payloads would collide. + +Open ledgers hold transactions as `tnTRANSACTION_NM`; closed ledgers rebuild the tx tree as `tnTRANSACTION_MD` after metadata is attached. Hash prefix difference makes the two roots structurally incompatible — by design. + +Each concrete leaf has two constructors: hash-recompute (used by initial `make_*`) and hash-supplied (used by `clone()` and deserialization with `hashValid = true`). + +## SHAMapItem + +Single allocation: struct fields + payload bytes via struct hack (placement-new'd after `sizeof(*this)`). Constructor is `private`, all copy/move deleted; only path is `make_shamapitem()`. + +Backed by `detail::slabber` — a `SlabAllocatorSet` with seven tiers (128/192/272/384/564/772/1052 extra bytes, 40–60 MiB pools each, 2 MiB block alignment for THP). Falls back to `new uint8_t[]` for oversize. Max payload 16 MiB (asserted). + +`refcount_` is `mutable std::atomic`. `intrusive_ptr_add_ref` calls `LogicError` if count was already zero (resurrection guard). `intrusive_ptr_release` runs `std::destroy_at` then returns memory to `slabber.deallocate()`, falling through to `delete[]` when the pointer didn't come from a slab. + +`SHAMapLeafNode::item_` is `boost::intrusive_ptr` — items are immutable; mutation produces a new item. + +## Inner Node: Sparse Storage via TaggedPointer + +`SHAMapInnerNode::hashesAndChildren_` is a single `TaggedPointer` holding two co-located arrays (`SHAMapHash[N]` followed by `SharedPtr[N]`). N comes from `boundaries = {2, 4, 6, 16}` indexed by the 2-bit tag stored in the pointer's low bits. + +- `SHAMapHash` is `static_assert`'d to have `alignof >= 4`, freeing the low 2 bits. +- Tag 3 (N=16) is the **dense** case; tags 0–2 are sparse, with non-empty children packed in branch-number order. +- `isBranch_` (`uint16_t`) is the authoritative occupancy bitset. Translation: `getChildIndex(i) = popcnt16(isBranch_ & ((1<, 4>` indexed by tag — O(1), no virtual calls. + +This typically cuts inner-node memory to ~25% of a dense layout. + +**Resize**: `resizeChildArrays()` uses `TaggedPointer`'s move-restructuring constructors. The 4-arg form (`srcBranches`, `dstBranches`, `toAllocate`) handles simultaneous reshape — in-place if size class is unchanged (shifts within the existing allocation), otherwise allocates new and placement-copies. + +**`RawAllocateTag` constructor**: allocates without running element constructors. Used internally only, always paired with explicit placement-new loops; destructor unconditionally runs explicit destructors. + +`iterChildren(F)` exposes all 16 logical branches (zero-hash for empties — needed for `updateHash`). `iterNonEmptyChildIndexes(F)` gives `(branchNum, arrayIdx)` — used for mutation and serialization. + +## SHAMapNodeID + +Two fields: `uint256 id_` (path prefix, masked to depth) and `unsigned depth_` (0–64). The static `depthMask()` table has 65 entries: nibble `d/2` gets `0xF0` at odd depths, `0xFF` at even, accumulating top-down. + +- Constructor **rejects** unmasked input (asserts `id == id & depthMask`). +- `createID(depth, key)` is the factory that **applies** the mask for you. Asymmetric API by design. +- `getChildNodeID(m)` throws (not just asserts) at `leafDepth` — corrupted data may trigger this in release builds. +- `selectBranch(id, hash)` reads the nibble at `id.depth` from `hash`. The traversal primitive used everywhere. +- Wire format: 33 bytes (32 id + 1 depth). `deserializeSHAMapNodeID` returns `std::optional` and validates size, depth ≤ 64, and the mask invariant. + +## Hash Computation + +Inner: `sha512Half(HashPrefix::innerNode, hashes[0..15])` — always feed all 16, zeros for empties. Hash is identical regardless of dense/sparse storage. + +`updateHash()` (read from `hashes` array directly) vs `updateHashDeep()` (pull from child pointers first, then compute) — the latter is used after batch mutations where in-memory child hashes were updated but the local hashes array wasn't synced. + +## Wire Serialization Formats + +**Inner nodes** — two formats chosen by occupancy in `serializeForWire`: +- **Compressed** (< 12 children): per non-empty branch, 32-byte hash + 1-byte branch index, total 33·n bytes. +- **Full** (≥ 12 children): all 16 hashes in order, 512 bytes. + +Type byte appended at the end. `makeFullInner()` / `makeCompressedInner()` are the matching factories, each validating exact size. + +`serializeWithPrefix` always emits the full 16-hash form prefixed with `HashPrefix::innerNode` (used for hashing). + +**Leaves** — wire format trails with the single-byte wire-type tag at the END (not start). `makeFromWire` reads `rawNode[size-1]` to dispatch. + +`makeFromPrefix(slice, hash)` uses the leading 4-byte `HashPrefix` and is the trusted (hash-known) path — propagates `hashValid = true` to skip recompute in leaf constructors. + +## Mutation Mechanics + +All three mutations follow: walk-with-stack → local change → `dirtyUp()`. + +- **`addGiveItem`**: empty branch → create leaf there. Collision with existing leaf → loop creating inner nodes deeper until keys diverge (respects merge property — inner nodes only where ≥2 items coexist below). +- **`delItem`**: drop the leaf, walk up reducing child counts. Zero children → null out. One child + `onlyBelow()` confirms a single leaf below → collapse inner, hoist leaf upward via `makeTypedLeaf` with the original leaf type. +- **`updateGiveItem`**: locate, unshare, swap payload; call `dirtyUp` only if `setItem()` returns `true` (hash actually changed). Avoids spurious rehashing. +- **`dirtyUp`**: consume stack bottom-up, `unshareNode` each, `setChild` to link in the updated subtree. Produces a CoW-owned chain from mutation point to root. + +## Node Fetching (Backed vs Unbacked) + +`backed_ = true` integrates with `Family::db()`; `backed_ = false` (set via `setUnbacked()`) is in-memory only (e.g., transient tx-processing trees). + +`fetchNodeNT` tiered lookup: +1. `cacheLookup()` → `Family::getTreeNodeCache()` +2. `fetchNodeFromDB()` → `Family::db().fetchNodeObject()` +3. `checkFilter()` → `SHAMapSyncFilter::getNode()`, then notifies via `gotNode(true, ...)` + +Misses return `nullptr` (`fetchNodeNT`) or throw `SHAMapMissingNode` (`fetchNode`, `descendThrow`). `finishFetch` catches `std::runtime_error` from deserialization, logs, and suppresses (doesn't crash). + +On miss, `full_` is cleared and `Family::missingNodeAcquireBySeq(seq, hash)` is called — links to inbound-ledger acquisition pipeline. + +`descendAsync` is the non-blocking variant: posts `Family::db().asyncFetch()`, sets `pending = true`, invokes user callback on completion. + +## Missing-Node Discovery (`getMissingNodes`) + +`MissingNodes` inner struct holds traversal state. Several details matter: + +- **`stack_` is `std::stack>`** — NOT vector. Raw `SHAMapInnerNode*` pointers held in entries must remain valid across pushes; vector reallocation would invalidate them. +- **Random start nibble**: `firstChild = rand_int(255)` per stack entry. Concurrent callers on the same map produce different request sets — maximizes coverage when many peers ask in parallel. +- **`maxDefer_ = 512`** in-flight async reads. When reached, `gmn_ProcessDeferredReads` blocks on a CV draining the batch. Completed nodes are canonicalized and the parent is revisited via `resumes_`. +- **FullBelow short-circuit**: before descending, `touch_if_exists(hash)` on the cache; on success, skip the subtree. After confirming complete, `insert(hash)` and `setFullBelowGen(generation)` on the in-memory node. +- Two helpers: `gmn_ProcessNodes` (per-node descent + bookkeeping), `gmn_ProcessDeferredReads` (I/O completion handler). + +**Gotcha**: processing async completions out of order would mark "full below" incorrectly. The `resumes_` map + deferred-batch barrier prevent this. + +## Serving Nodes to Peers: `getNodeFat` + +Bundles a target node plus a bounded-depth subtree in one response to amortize sync latency. Depth budget only decrements when an inner node has > 1 child — single-child chains (compressed radix paths) traverse for free. `fatLeaves=true` includes adjacent leaves; otherwise inner-only. + +## Ingesting Nodes: `addRootNode` / `addKnownNode` + +Returns `SHAMapAddNode` (tri-state: useful / duplicate / invalid). Aggregated via `+=` across batches in `InboundLedger`. + +`addKnownNode` performs **two integrity checks**: +1. Deserialized node's hash matches the parent-branch hash. +2. For leaves at `leafDepth`: reconstructed `SHAMapNodeID` from the leaf's actual key matches the claimed `SHAMapNodeID`. This closes a theoretical hash-collision-at-wrong-position attack. + +Mismatch transitions the map to `Invalid` — graceful, not a crash. Skips descent into FullBelow subtrees. + +**Gotcha**: callers must handle `invalid` gracefully — empty branch or hash mismatch on traversal is legitimate when peer data is stale. + +`isGood()` returns `(good + duplicate) > bad` — duplicates count positively (benign), only `bad` is evidence of misbehavior. `isUseful()` is stricter: did we actually make progress? + +## Merkle Proofs + +`getProofPath(key)` collects nodes from leaf to root (via `walkTowardsKey` with stack), serialized leaf-first. + +`verifyProofPath(rootHash, key, path)` is **static** — no live tree needed. Walks root-to-leaf, verifying each hash and using `selectBranch` to pick the next expected hash. Length bounded at 65. Deserialization wrapped in try/catch (network input). + +**Gotcha**: wrong key at any level causes false negative — verifier walks down using the *supplied* key's nibbles, not anything inside the path data. + +## Comparison / Delta (`SHAMapDelta.cpp`) + +`compare` short-circuits at root: `if (getHash() == other.getHash()) return true`. This is THE point of a Merkle tree — matching subtrees never visited. + +Returns `Delta = std::map` where `DeltaItem = pair`. Null first → added; null second → deleted; both → modified. + +Four dispatch cases at each pair (leaf/leaf, inner/leaf, leaf/inner, inner/inner). Asymmetric cases delegate to `walkBranch`, which uses an `isFirstMap` bool to preserve (first-map version, second-map version) ordering in the pair regardless of which side is the subtree. + +**`maxCount` defense**: passed by reference into `walkBranch`, decremented per insertion. Returns false on truncation. The ledger-diff RPC passes `INT_MAX` (unlimited); the sync RPC passes 256 (bounded exposure to malicious or fabricated diffs). + +## walkMap / walkMapParallel + +Completeness check: traverses, recording any node hash referenced but absent (via `descendNoStore`, which returns null instead of throwing) into a `vector`. + +**`walkMapParallel`** partitions at depth 1 — one `std::thread` per non-empty, non-leaf top-level child (up to 16-way). Each thread has its own `nodeStack`; `missingNodes` and an `exceptions` vector are mutex-shared. + +**Critical**: an uncaught exception in a `std::thread` calls `std::terminate`. Worker lambda catches `SHAMapMissingNode` and records to `exceptions` instead — must inspect on return. Return value `true` ⇔ no thrown exceptions, NOT ⇔ no missing nodes (those are always in the vector). + +## Family Interface + +`Family` is the abstract collaborator bundle: `db()`, `getFullBelowCache()`, `getTreeNodeCache()`, `missingNodeAcquireBy{Seq,Hash}()`, `journal()`, `sweep()`, `reset()`. + +Non-copyable, non-movable (SHAMap stores `Family&`; moving would dangle references). + +Production impl: `NodeFamily` (in `src/xrpld/shamap/`). Tests use lighter-weight in-memory versions in `src/test/shamap/common.h`. + +`NodeFamily::missingNodeAcquireBySeq` maintains a `maxSeq_` high-water under `maxSeqMutex_` to avoid redundant acquisition requests when many SHAMaps simultaneously hit missing nodes. + +## FullBelowCache + +`KeyCache` — stores only hashes (no values), thread-safe, time-expiring (default 2 minutes). Wrapped in `BasicFullBelowCache` to add a generation counter. + +**Two-layer invalidation**: +- Per-node `fullBelowGen_` on `SHAMapInnerNode` (compared against current cache generation in `isFullBelow(gen)`). +- The cache itself (queried via `touch_if_exists`). + +`clear()` purges entries AND increments `m_gen` — this is a zero-cost global invalidation of every in-memory marker (mismatched generation → `isFullBelow` returns false). `reset()` purges and sets `m_gen = 1` (used at startup / hard reset). + +Only `backed_ = true` maps participate. + +## TreeNodeCache + +```cpp +using TreeNodeCache = TaggedCache< + uint256, SHAMapTreeNode, false, + intr_ptr::SharedWeakUnionPtr, + intr_ptr::SharedPtr>; +``` + +Two reasons for intrusive (not `std::shared_ptr`): +1. **Earlier memory reclamation**: `SHAMapInnerNode::partialDestructor()` releases the 16-way child array as soon as the strong count hits zero, even while weak references in the cache are still live. `std::make_shared` co-allocates control block + object, blocking reclamation until all weaks expire. +2. **Single-word strong/weak**: `SharedWeakUnion` uses one pointer-sized word with a low-bit tag (alignment guarantees the bit is free). Demoting hot → cold flips one bit in place instead of swapping `shared_ptr` ↔ `weak_ptr`. + +## Canonicalization + +`canonicalize(hash, nodePtr)` deduplicates: if the cache already holds this hash, replace the local pointer with the cached one; otherwise insert. Asserts `cowid == 0` (only shareable nodes can be canonical). `cacheLookup()` asserts the same on returned nodes. + +`canonicalizeChild()` on inner nodes: when two threads concurrently fetch the same child from disk, the per-child spinlock serializes; first writer wins, late writer's freshly-deserialized node is discarded. The incumbent hash is verified to match before installation. + +## SyncFilter + +Two-method interface bridging SHAMap to peer fetch packs and consensus tx caches. + +- `getNode(hash) -> optional`: filter's chance to supply a node from a transient source (fetch pack, consensus cache). +- `gotNode(fromFilter, hash, ledgerSeq, Blob&&, type)`: notification of node receipt. **`Blob&&` may be moved/destroyed — do not reuse**. `fromFilter=true` means data came from this filter's own `getNode` (no need to re-store); `false` means it came from the network and should be persisted. + +Implementations: +- `AccountStateSF`, `TransactionStateSF` — write to NodeStore + fetch pack. Only used on add paths. +- `ConsensusTransSetSF` — backed by `TaggedCache`. Used on both add AND check (since the backing store is purely transient). + +## Flushing + +`walkSubTree(doWrite, type)` is post-order DFS with **explicit stack** (tree may be 64 deep — recursion risks stack overflow). Per node: `preFlushNode()` clones if needed (protects other maps sharing it), recompute hash, `unshare()` (set `cowid = 0`), and if `doWrite`, serialize and persist via `Family::db()`. + +- `flushDirty()` → `walkSubTree(backed_, type)`. +- `unshare()` → `walkSubTree(false, ...)`. Used to make everything shareable without writing. + +## Common Bug Patterns + +- Modifying a node without `unshareNode` first → corrupts every snapshot sharing it. +- Using `std::vector` (instead of `std::deque`) as backing for the `MissingNodes` stack → raw inner-node pointers invalidated on push. +- Processing async fetch completions out of order in `getMissingNodes` → incorrect `setFullBelowGen`, subsequent walks skip incomplete subtrees. +- Inner serialization format mismatch (compressed/full) → silent corruption. The branch-count cutoff is 12. +- `addKnownNode` returning `invalid` is normal (empty branch, hash mismatch) — callers must handle, not assume valid. +- Proof verification with wrong key at any level → false negative; the verifier uses the supplied key's nibbles to pick branches. +- Failing to inspect the `exceptions` vector after `walkMapParallel` — workers swallow `SHAMapMissingNode` to avoid `std::terminate`; missing nodes go into the result vector but the return value reflects exceptions. +- Mutating a node returned from the `TreeNodeCache` — they have `cowid == 0` by invariant. +- Using a non-leaf-aligned inner node count (`< 12`) but emitting full-inner format, or vice versa — deserializer enforces exact sizes and throws. + +## SHAMapMissingNode Catch Policy + +The exception flows up out of `descendThrow` and friends. Catch handlers split into: + +- **Recovery** (`LedgerCleaner`, `LedgerMaster`): catch, log at warn, schedule `getInboundLedgers().acquire()`. +- **Fatal/abort** (`RCLConsensus` consensus timer): catch, log at error, `Rethrow()` — crashes the consensus round. +- **Silent failure** (`Ledger.cpp`): catch, return failure — incomplete state tree is treated as invalid ledger. + +`SHAMapType` enum values (`TRANSACTION = 1`, `STATE = 2`, `FREE = 3`) are part of the wire protocol — do NOT change. diff --git a/docs/skills/sql.md b/docs/skills/sql.md index 30bd2d713e..2d23983b37 100644 --- a/docs/skills/sql.md +++ b/docs/skills/sql.md @@ -1,14 +1,15 @@ # SQL Database -SQLite via SOCI for ledger/transaction history. Only SQLite is supported; Postgres has no implementation despite interface comments. +SQLite via SOCI for ledger/transaction history. Only SQLite is supported; the backend name is validated and any non-`sqlite` value throws at config parse time. ## Key Invariants - Two main databases: `lgrdb_` (ledger) and `txdb_` (transactions, optional via `useTxTables` config) - Transaction tables are optional; disabling them means no transaction history or account_tx queries -- WAL checkpointing triggers when WAL file grows beyond threshold; scheduled via job queue +- WAL checkpointing offloads to `JobQueue` (jtWAL); at most one checkpoint job in flight per `DatabaseCon` (guarded by `running_` mutex) - Database init failure is fatal (throws exception, prevents construction) - Free disk space < 512MB triggers fatal error on write operations +- File extension inconsistency: `validators` and `peerfinder` use `.sqlite`; all other DBs use `.db`. This is historical and enforced in `detail::getSociInit` ## Schema @@ -20,20 +21,65 @@ SQLite via SOCI for ledger/transaction history. Only SQLite is supported; Postgr ## Common Bug Patterns - No schema migration system; `CREATE TABLE IF NOT EXISTS` means old schemas silently persist with missing columns -- PeerFinder DB is the exception -- it has schema versioning via `SchemaVersion` table +- PeerFinder DB is the exception — it has schema versioning via `SchemaVersion` table - `safety_level` config affects journal_mode and synchronous; "low" can lose data on crash - `page_size` must be power of 2 between 512-65536; invalid values cause init failure - Online deletion coordinates between NodeStore rotation and SQL table pruning; race conditions here lose history +- Empty database name passed to `detail::getSociSqliteInit` throws — silent fallback paths are not provided +- A `WALCheckpointer` registered with `sqlite3_wal_hook` outlives its `DatabaseCon` if a checkpoint job is in flight; teardown must wait for the job to drain (see Lifecycle below) ## Configuration | Option | Section | Values | Default | |--------|---------|--------|---------| -| `backend` | `[relational_db]` | `sqlite` only | sqlite | +| `backend` | `[sqdb]` / `[relational_db]` | `sqlite` only | sqlite | | `page_size` | `[sqlite]` | 512-65536, power of 2 | 4096 | | `safety_level` | `[sqlite]` | high, medium, low | high | | `journal_size_limit` | `[sqlite]` | integer >= 0 | 1582080 | +## WAL Checkpointer Lifecycle + +The checkpointer subsystem is the trickiest part of this module. SQLite's WAL hook is a C callback registered on the native `sqlite3*` connection, but the work runs on a `JobQueue` thread that may still be executing when the owning `DatabaseCon` is destroyed. + +### ID-based hook indirection +- `WALCheckpointer` (in `SociDB.cpp`) is registered with `sqlite3_wal_hook` using a `std::uintptr_t id_` cast to `void*`, **not** a raw `this` pointer. +- The C hook calls `checkpointerFromId()` which looks up the ID in a process-wide `CheckpointersCollection` (in `DatabaseCon.cpp`). If the lookup returns null, the hook deregisters itself via `sqlite3_wal_hook(conn, nullptr, nullptr)`. +- This protects against the hook firing on a writer thread between the `DatabaseCon` being torn down and the hook being unwired. + +### Session ownership split +- `DatabaseCon` holds `std::shared_ptr`. +- `WALCheckpointer` holds only `std::weak_ptr`. Intentional: if the checkpointer held a `shared_ptr`, an in-flight job would keep the WAL lock alive and a freshly-opened replacement `DatabaseCon` would fail to acquire it. +- `WALCheckpointer::checkpoint()` calls `session_.lock()` and bails silently if expired. + +### Destructor wait +`DatabaseCon::~DatabaseCon` sequence (order matters): +1. `checkpointers.erase(checkpointer_->id())` — future hook invocations now no-op. +2. Take a `weak_ptr` to the checkpointer, then `checkpointer_.reset()`. +3. Busy-poll `wk.use_count() != 0` with 100 ms sleeps until all in-flight job lambdas release their `shared_ptr`. + +The 100 ms poll is deliberate (rare event, simpler than a condvar). Without this wait, reopening the same SQLite file immediately after destruction can fail because the old checkpoint job still holds the WAL lock. + +### Checkpoint job behavior +- Triggered by `sqlite3_wal_hook` after every WAL write; module-level `checkpointPageCount = 1000` mirrors SQLite's auto-checkpoint threshold. +- `schedule()` uses a `running_` bool under a mutex to ensure single in-flight job; if `JobQueue` rejects the job, `running_` is reset. +- The enqueued lambda captures `std::weak_ptr` so a destroyed `DatabaseCon` causes the job to exit without touching the session. +- `checkpoint()` calls `sqlite3_wal_checkpoint_v2` with `SQLITE_CHECKPOINT_PASSIVE`. `SQLITE_LOCKED` is logged at trace (expected under reader contention); other errors are warnings. +- Net effect: routes checkpoint work off the writer thread onto `jtWAL`. SQLite would otherwise do this synchronously on whichever thread crossed the page threshold. + +### setupCheckpointing +- Separated from `DatabaseCon` constructors so checkpointing is opt-in. +- Constructors taking a `CheckpointerSetup` open the DB first, then call `setupCheckpointing(JobQueue*, ServiceRegistry&)`. +- Null `JobQueue*` throws `std::logic_error` (programming error, not runtime). +- The checkpointer must be inserted into `CheckpointersCollection` **before** returning from setup, because the WAL hook is armed inside the `WALCheckpointer` constructor and writes can fire it immediately. + +## SOCI Adapter Notes + +- `getConnection(session&)` (`SociDB.cpp`) recovers the raw `sqlite3*` via `dynamic_cast`. This is the only intentional break in the SOCI abstraction; needed for WAL hooks and `sqlite3_db_status`. +- `getKBUsedAll()` → `sqlite3_memory_used()` (process-global). `getKBUsedDB()` → `SQLITE_DBSTATUS_CACHE_USED` (per-connection). +- Four `convert()` overloads bridge `soci::blob` and `std::vector` / `std::string`. Empty blobs require `blob.trim(0)` rather than `blob.write(nullptr, 0)`. +- `SociDB.cpp` opens with `#pragma clang diagnostic ignored "-Wdeprecated"` because SOCI headers use deprecated constructs; scoped to this TU only. +- `DBConfig` is two-phase: parse params, open later. `detail::getSociInit` and `detail::getSociSqliteInit` resolve backend + path; the `.sqlite` vs `.db` extension fork lives in `getSociInit`. + ## Key Patterns ### Schema Evolution Caveat @@ -51,10 +97,20 @@ if (freeDiskSpace < minDiskFree) Throw("Not enough disk space for database write"); ``` +### WAL Hook Cookie +```cpp +// Always pass an integer ID, never `this`. The DatabaseCon may be +// destroyed while a hook invocation is mid-flight on a writer thread. +sqlite3_wal_hook(conn, &walHookCallback, + reinterpret_cast(checkpointer->id())); +``` + ## Key Files -- `src/xrpld/app/rdb/backend/detail/SQLiteDatabase.cpp` - main implementation -- `src/xrpld/app/main/DBInit.h` - schema definitions -- `src/xrpld/core/detail/DatabaseCon.cpp` - connection setup and pragmas -- `src/xrpld/app/rdb/backend/detail/Node.cpp` - ledger/tx operations -- `src/xrpld/app/rdb/detail/State.cpp` - deletion state tracking +- `src/xrpld/app/rdb/backend/detail/SQLiteDatabase.cpp` — main implementation +- `src/xrpld/app/main/DBInit.h` — schema definitions +- `src/xrpld/core/detail/DatabaseCon.cpp` — kept for historical reference; lifecycle now in `libxrpl` +- `src/libxrpl/rdb/DatabaseCon.cpp` — connection lifecycle, `CheckpointersCollection`, destructor drain +- `src/libxrpl/rdb/SociDB.cpp` — SOCI/SQLite adapter, `WALCheckpointer`, blob conversion, memory stats +- `src/xrpld/app/rdb/backend/detail/Node.cpp` — ledger/tx operations +- `src/xrpld/app/rdb/detail/State.cpp` — deletion state tracking diff --git a/docs/skills/transactors.md b/docs/skills/transactors.md index b0be63aa32..9ed34024b4 100644 --- a/docs/skills/transactors.md +++ b/docs/skills/transactors.md @@ -2,25 +2,104 @@ Transaction processing pipeline: preflight (static validation) -> preclaim (ledger state checks) -> doApply (state mutation). Base class `Transactor` in `src/libxrpl/tx/`. +## Pipeline Architecture + +### Three Phases + +1. **`preflight`** — stateless, no ledger access. Validates fields, flags, signatures (cached via HashRouter). Cheap, parallelizable. Returns `NotTEC`. Caller-cacheable. +2. **`preclaim`** — read-only `ReadView` access. Checks account exists, fee sufficient, sequence valid. Returns `TER`. Sets `likelyToClaimFee` for relay decisions. +3. **`doApply`** — mutable `ApplyView` access. Only runs if `preclaim` returned `tesSUCCESS` and `likelyToClaimFee` is true. + +`apply()` in `apply.cpp` composes all three; templated on a preflight callable so the same `preclaim`+`doApply` machinery serves normal and batch-inner transactions. `applyTransaction()` adds `tapRETRY` semantics and dispatches to `applyBatchTransactions()` after a successful `ttBATCH`. + +### Compile-time Polymorphism (Name Hiding, Not Virtual) + +`Transactor::invokePreflight` calls `T::checkExtraFeatures`, `preflight1`, `T::getFlagsMask`, `T::preflight`, `preflight2`, `T::preflightSigValidated` by name. The static methods on derived classes participate via name hiding — derived classes MUST NOT define `invokePreflight` themselves, nor call `preflight1`/`preflight2` directly. Only `doApply()` is virtual. + +`with_txn_type()` in `applySteps.cpp` uses an X-macro over `transactions.macro` to convert runtime `TxType` to a compile-time template parameter via a switch dispatch — no virtual dispatch, no included transactor headers (forbidden in `applySteps.cpp`). + +### `ConsequencesFactoryType` + +Each transactor declares `static constexpr ConsequencesFactoryType ConsequencesFactory{...}`: +- **`Normal`** — standard fee/sequence consequences. Most transactors. +- **`Blocker`** — queues block later transactions from same account. Examples: `SetRegularKey`, `AccountDelete`, `SignerListSet`, `XChainAddClaimAttestation`, `XChainClaim` (any attestation may trigger immediate fund movement). +- **`Custom`** — derived class implements `makeTxConsequences(PreflightContext const&)`. Examples: `Payment` (XRP spend), `OfferCreate` (XRP TakerGets), `XChainCommit`, `TicketCreate` (multi-sequence), `AccountSet` (conditional blocker on auth/master flags), `LoanSet` (counterparty signers). + +The `consequences_helper` dispatch in `applySteps.cpp` uses C++20 `requires` clauses to pick the right factory at compile time. + ## Key Invariants - Pipeline is strict: preflight runs WITHOUT ledger state, preclaim runs WITH read-only view, doApply runs with mutable view - `preflight` validates all fields exist and are well-formed; this is the ONLY place to reject malformed transactions cheaply -- Fee is always deducted even if the transaction fails (`tecCLAIM` pattern); `payFee` runs before `doApply` +- Fee is always deducted on `tecCLAIM`; `payFee` runs before `doApply` - Sequence/ticket consumption happens in `consumeSeqProxy`; must succeed before any state changes - Invariant checkers run after `doApply`; they can veto the transaction post-execution +- Amendment gating belongs in `checkExtraFeatures`, NOT in `preflight`. The framework guards on the central permission registry first. +- `tem*`/`tef*`/`tel*` results: fee NOT charged, transaction not included. `tec*` results: fee charged, transaction included. + +## State Commitment & tec* Rollback (CRITICAL for review) + +**`doApply` mutations are NOT committed until `ctx_.apply()` is called at the end of `operator()`.** All peek/insert/update/erase during `doApply` go into an `ApplyContext` view (`view_`) layered on top of `base_`. Whether that view gets flushed to `base_` depends entirely on the TER that `doApply` returns. + +`ApplyContext::discard()` ([src/libxrpl/tx/ApplyContext.cpp](src/libxrpl/tx/ApplyContext.cpp)) replaces `view_` with a fresh view on `base_` — **every doApply mutation is thrown away**: +```cpp +void ApplyContext::discard() { view_.emplace(&base_, flags_); } +``` + +### Return-code decision table (in `Transactor::operator()`) + +| doApply returns | What commits to the ledger | +|---|---| +| `tesSUCCESS` | All doApply mutations + fee + seq (via `ctx_.apply`) | +| `tec*` (normal, `!tapRETRY`) | `reset(fee)` calls `discard()`, then re-applies fee + seq only. **All doApply mutations reverted.** | +| `tec*` with `tapFAIL_HARD` | `discard()` called directly, nothing committed (not even fee) | +| `tec*` with `tapRETRY` | `applied=false`, `ctx_.apply` never called, tx re-queued | +| `tef*` / `tem*` / `ter*` | `applied=false`, `ctx_.apply` never called | +| `tecINVARIANT_FAILED` after invariants | reset again, commit fee only | + +`isTecClaimHardFail(ter, flags) = isTecClaim(ter) && !(flags & tapRETRY)` — drives the reset path. + +### What this means + +- **A `tec*` return from doApply acts as a full-transaction rollback.** You do NOT need to order mutations defensively. If a helper called late in doApply returns `tec*`, everything mutated earlier in the same doApply is discarded via `discard()`. +- **Orphan-state bugs of the form "we mutated X then returned tec* so X is now in an inconsistent state" are not possible at the transactor boundary.** +- **The real failure mode is within `doApply` itself**: stale SLE pointers, missing `view().update(sle)` after mutation, mutating values read by value instead of peek. These are in-memory bugs, not state-commit bugs. +- **Sandboxes inside `doApply` add nesting, not safety.** `PaymentSandbox` / nested `ApplyView` are useful when you need to conditionally commit a subset of changes *within* a single doApply (e.g., apply offers but revert if the net outcome fails). They are not needed to protect against doApply's own `tec*` return. +- **Only `ctx_.apply(result)` publishes to `base_`**; a doApply that returns early, throws, or crashes never reaches that call. + +### Verifying a suspected orphan-state bug + +Before claiming "directory removed but SLE not erased because tec\*": +1. Read the caller of `doApply` — confirm the TER path (`operator()` in Transactor.cpp). +2. Check whether `discard()` is reached via `reset()` or the `tapFAIL_HARD` branch. +3. If both paths call `discard()`, the mutations cannot persist on tec\*. +4. Look instead for: missing `view().update(sle)` after mutation, stale SLE pointers, or genuine non-atomic side effects (e.g., hash router flags, which are NOT in the ApplyContext view). + +## Apply Loop Details (Transactor::operator()()) + +1. RAII guards: `NumberSO`, `CurrentTransactionRulesGuard` (for `fixUniversalNumber`, `featureSingleAssetVault`, `featureLendingProtocol`) +2. Debug builds: serialize/re-parse round-trip catches serdes mismatches +3. `apply()` runs `preCompute()` → captures `preFeeBalance_` → `consumeSeqProxy()` → `payFee()` → updates `sfAccountTxnID` → `doApply()` +4. Enforces `tecOVERSIZE` if metadata exceeds `oversizeMetaDataCap` +5. Special `tec` codes (`tecOVERSIZE`, `tecKILLED`, `tecINCOMPLETE`, `tecEXPIRED`) trigger context-diff visitation then targeted cleanup: `removeUnfundedOffers`, `removeExpiredNFTokenOffers`, `removeDeletedTrustLines`, `removeDeletedMPTs`, `removeExpiredCredentials` +6. `ctx_.checkInvariants()` runs all 25+ invariants; failure causes second reset + re-check; second failure escalates to `tefINVARIANT_FAILED` (not included in ledger) +7. `tapDRY_RUN` forces `applied=false` unconditionally ## Common Bug Patterns - New transaction type missing preflight validation for new fields = malformed transactions reach doApply and corrupt state - Forgetting to handle `tecCLAIM` in doApply: fee is deducted but no other state changes should occur -- Batch transactions (`Batch` type) have their own signing path (`checkBatchSign`); changes to signing must cover both paths +- Batch transactions have their own signing path (`checkBatchSign`); changes to signing must cover both paths - `calculateBaseFee` override without updating `minimumFee` causes fee calculation divergence between nodes - Missing invariant checker update for new ledger entry types = silent constraint violations +- Forgetting amendment gating: place feature checks in `checkExtraFeatures`, NOT `preflight` +- Using `view().update()` on a stale SLE pointer after another mutation +- Computing reserve against `view().peek(account)->getFieldAmount(sfBalance)` AFTER fee deduction instead of `preFeeBalance_` +- Missing `associateAsset(*sle, asset)` call at end of `doApply` for SLEs with `STNumber` or `STTakesAsset` fields (lending/vault transactors) ## Transactor Template -### Header (`include/xrpl/tx/transactors/MyTx.h`) +### Header (`include/xrpl/tx/transactors/.../MyTx.h`) ```cpp #pragma once #include @@ -31,7 +110,7 @@ public: static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; explicit MyTransaction(ApplyContext& ctx) : Transactor(ctx) {} - static bool checkExtraFeatures(PreflightContext const& ctx); + static bool checkExtraFeatures(PreflightContext const& ctx); // amendment gating static std::uint32_t getFlagsMask(PreflightContext const& ctx); static NotTEC preflight(PreflightContext const& ctx); // NO ledger static TER preclaim(PreclaimContext const& ctx); // read-only @@ -40,10 +119,10 @@ public: } ``` -### Implementation (`src/libxrpl/tx/transactors/MyFeature/MyTx.cpp`) +### Implementation ```cpp bool MyTransaction::checkExtraFeatures(PreflightContext const& ctx) -{ // REQUIRED: gate on amendment +{ // PREFERRED location for amendment checks return ctx.rules.enabled(featureMyFeature); } @@ -73,69 +152,324 @@ TER MyTransaction::doApply() ### Registration Checklist ```cpp // ALL of these are REQUIRED for a new transaction type: -// 1. transactions.macro: TRANSACTION(ttMY_TYPE, N, MyTx, delegation, fields) -// 2. applySteps.cpp: case ttMY_TYPE: return invoke(...); +// 1. transactions.macro: TRANSACTION(ttMY_TYPE, N, MyTx, delegation, fields, privileges) +// 2. applySteps.cpp: case ttMY_TYPE: dispatched via X-macro automatically // 3. features.macro: XRPL_FEATURE(MyFeature, Supported::yes, DefaultNo) // 4. Feature.h: increment numFeatures -// 5. InvariantCheck.cpp: update if new ledger objects created +// 5. InvariantCheck.cpp: update privilege mask + checkers if new ledger objects // 6. Batch.cpp: add to disabledTxTypes if not batch-compatible +// 7. Permission table: add granular permissions if delegable ``` -## Transaction Lifecycle +### Common Field Constraints (constants in `Protocol.h`) +- `maxCredentialURILength` = 256, `maxCredentialTypeLength` = 64 +- `maxTokenURILength` = 256 (NFT URI), `dirMaxTokensPerPage` = 32 +- `maxMultiSigners` = 32, `MaxPathSize` = 6, `MaxPathLength` = 8 +- `maxBatchTxCount` = 8, `maxOracleDataSeries` = 10 +- `maxPermissionedDomainCredentialsArraySize` = 10 +- `maxDeletableTokenOfferEntries` = 500, `maxDeletableDirEntries` = 1000 +- `maxDeletableAMMTrustLines` = 512, `maxMPTokenAmount` = 0x7FFF_FFFF_FFFF_FFFF +- `maxDataPayloadLength`, `maxMPTokenMetadataLength` = 1024 -1. `preflight` (static checks, no ledger) -> `PreflightResult` -2. `preclaim` (ledger state, read-only) -> TER -3. `operator()` orchestrates: `checkSeqProxy` -> `checkPriorTxAndLastLedger` -> `checkFee` -> `checkSign` -> `apply` -4. `Transactor::apply()` runs `consumeSeqProxy` -> `payFee` -> `doApply` and returns a TER -5. `operator()` inspects the TER, decides whether to commit (`ctx_.apply`) or discard (`ctx_.discard`/`reset`) +## The Big Patterns -## State Commitment & tec* Rollback (CRITICAL for review) +### Sandbox Pattern (Atomic Sub-operation) -**`doApply` mutations are NOT committed until `ctx_.apply()` is called at the end of `operator()`.** All peek/insert/update/erase during `doApply` go into an `ApplyContext` view (`view_`) layered on top of `base_`. Whether that view gets flushed to `base_` depends entirely on the TER that `doApply` returns. +Used when multiple mutations must all succeed or all be discarded *within* a single `doApply`: -`ApplyContext::discard()` ([src/libxrpl/tx/ApplyContext.cpp](src/libxrpl/tx/ApplyContext.cpp)) replaces `view_` with a fresh view on `base_` — **every doApply mutation is thrown away**: ```cpp -void ApplyContext::discard() { view_.emplace(&base_, flags_); } +TER doApply() override { + Sandbox sb(&view()); + auto const result = applyGuts(sb, ...); + if (isTesSuccess(result)) + sb.apply(ctx_.rawView()); + return result; +} ``` -### Return-code decision table (in `Transactor::operator()`, [src/libxrpl/tx/Transactor.cpp](src/libxrpl/tx/Transactor.cpp)) +Variants: +- `PaymentSandbox` — for `flow()` calls (used by `Payment`, `CheckCash`, `OfferCreate` crossing). Required because `flow()` uses deferred-credit accounting. +- Dual sandbox in `OfferCreate`: `sb` (main) + `sbCancel` (offer cleanup); commit one or the other based on `tfFillOrKill` outcome. +- Nested sandboxes: `applyBatchTransactions` uses `wholeBatchView` (over outer view) + `perTxBatchView` (per inner tx). -| doApply returns | What commits to the ledger | +### Reserve Check Convention + +ALWAYS check against `preFeeBalance_` (snapshot before fee deduction), not the current post-fee balance. This deliberately allows accounts to dip into reserve to pay the fee while still requiring full reserve coverage for new owned objects. + +```cpp +auto const reserve = view().fees().accountReserve(ownerCount + 1); +if (preFeeBalance_ < reserve) + return tecINSUFFICIENT_RESERVE; +``` + +### Owner Directory + Owner Count Pattern + +Creating an owned object: +1. `view().dirInsert(keylet::ownerDir(owner), key, ...)` → returns page index +2. Store page index in SLE's `sfOwnerNode` (and `sfDestinationNode`, `sfIssuerNode`, etc., for multi-party objects) +3. `adjustOwnerCount(view, sleOwner, +N, j)` where N is the reserve cost +4. `view().insert(sle)` + +Deleting an owned object: +1. Read `sfOwnerNode` (etc.) from SLE +2. `view().dirRemove(keylet::ownerDir(owner), pageIndex, key, false)` — O(1) using cached page +3. `adjustOwnerCount(view, sleOwner, -N, j)` +4. `view().erase(sle)` + +Reserve cost is usually 1 unit per object, but: +- `AccountDelete`, `LedgerStateFix`, `AMMCreate` charge a full reserve via `calculateOwnerReserveFee` instead of base fee +- Two-object structures (`Vault`, `LoanBroker`) charge +2 for object + pseudo-account +- `SignerListSet` post-amendment uses `lsfOneOwnerCount` flag (1 unit regardless of N signers); pre-amendment charges 2+N +- `OracleSet` uses tiered count: 1 unit for ≤5 price pairs, 2 units for more + +### Pseudo-Account Pattern + +Synthetic `AccountRoot` SLEs with disabled master key, used to hold protocol-managed assets on behalf of users. Created via `createPseudoAccount(view, ownerKey, sfDiscriminator)`. Examples and their discriminator fields: + +| Construct | Discriminator | Owns | +|---|---|---| +| AMM | `sfAMMID` | LP token issuance, both pool asset trustlines/MPTokens | +| Vault | `sfVaultID` | Vault asset holding, share MPTokenIssuance | +| LoanBroker | `sfLoanBrokerID` | Cover capital holding | + +Pseudo-account guard rules: +- `ValidPseudoAccounts` invariant: exactly one discriminator field, sequence never changes, required flags (`lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth`), no `sfRegularKey` +- Many transactors explicitly reject pseudo-account destinations (`tecPSEUDO_ACCOUNT`): `Payment` direct, `CheckCreate`, `PaymentChannelCreate`, `VaultCreate` (asset issuer), `Clawback` (holder) +- `MPTokenAuthorize` issuer-path skips pseudo-account holders (they are implicitly always authorized) +- Pseudo-accounts cannot sign — when `featureLendingProtocol` active, `checkSign` rejects with `tefBAD_AUTH` + +### `associateAsset` Convention + +After mutating any SLE that contains `STNumber` or `STTakesAsset`-derived fields (loan, broker, vault objects), call `associateAsset(*sle, asset)` at the end of `doApply`. This re-rounds stored numeric values to the asset's precision. Per `STTakesAsset.h` contract, this must be the last operation after all writes are complete. Failing to call it produces silent precision corruption. + +## Permission & Delegation System + +### `checkPermission` (called from preclaim) + +Validates the optional `sfDelegate` field. If absent, normal account signing applies. If present: +1. Read `DelegateObject` at `keylet::delegate(account, delegate)`; missing → `terNO_DELEGATE_PERMISSION` +2. Try full transaction-type permission via `checkTxPermission()` (uses `TxType + 1` encoding) +3. Fall back to granular permission via `loadGranularPermission()` + per-transactor logic + +### Granular Permissions + +Permission values store both forms in single `uint32_t`: +- Transaction types: `TxType + 1` (always ≤ `UINT16_MAX`, since `+1` shift avoids ambiguous zero) +- Granular permissions: values `> UINT16_MAX`, enumerated in `permissions.macro` + +`DelegateUtils.cpp` provides: +- `checkTxPermission()` — linear scan for `TxType + 1` match +- `loadGranularPermission()` — populates per-tx-type granular set via `Permission::getInstance().getGranularTxType()` reverse-map + +Examples of granular permissions: +- `Payment` direct only: `PaymentMint` (issuer source), `PaymentBurn` (issuer destination) — blocked if `sfPaths` or asset conversion +- `AccountSet`: field-level grants per metadata field (`AccountDomainSet`, `AccountTransferRateSet`, etc.); flag changes blocked entirely +- `TrustSet`: `TrustlineAuthorize`, `TrustlineFreeze`, `TrustlineUnfreeze`; cannot create new lines, cannot change limit +- `MPTokenIssuanceSet`: `MPTokenIssuanceLock`, `MPTokenIssuanceUnlock` + +## Permission Model & Cross-Transactor Static Interfaces + +Several transactors expose `static deleteSLE`/`removeFromLedger`/equivalent methods on `ApplyView` so other transactors (especially `AccountDelete`) can clean up owned objects without constructing a fake transaction: + +- `DepositPreauth::removeFromLedger(ApplyView&, uint256, Journal)` +- `DIDDelete::deleteSLE(ApplyView&, SLE, AccountID, Journal)` +- `OracleDelete::deleteOracle(ApplyView&, SLE, AccountID, Journal)` +- `DelegateSet::deleteDelegate(ApplyView&, SLE, AccountID, Journal)` +- `SignerListSet::removeFromLedger(ApplyView&, ServiceRegistry&, AccountID, Journal)` +- `MPTokenIssuanceCreate::create(ApplyView&, Journal, MPTCreateArgs)` — used by `VaultCreate` to mint share token +- `AMMWithdraw::withdraw`/`equalWithdrawTokens` — used by `AMMClawback`, `AMMDelete` +- `LoanManage::unimpairLoan/impairLoan/defaultLoan` — used by `LoanPay` + +`AccountDelete` uses a `nonObligationDeleter()` switch over `LedgerEntryType` returning a `DeleterFuncPtr`. `nullptr` means "obligation, cannot delete". Recognized as non-obligations: offers, signer lists, tickets, deposit preauth, NFT offers, DIDs, oracles, credentials, delegates. + +## Signature Verification + +`checkSign()` (in preclaim) dispatches: +1. **Batch inner** (`tfInnerBatchTxn`): asserts no key/sig/signers; outer batch authorized them +2. **Dry-run** (`tapDRY_RUN`): skipped if no key/signers +3. **Multi-sign** (`sfSigners` present): delegates to `checkMultiSign()` +4. **Single sig**: derives signer from public key, calls `checkSingleSign()` + +`checkSingleSign()` precedence: regular key → enabled master key → `tefMASTER_DISABLED`. + +`checkMultiSign()` performs O(n) linear merge of sorted `sfSigners` against the sorted `SignerEntry` list from the account's signer list SLE. Terminates with `tefBAD_QUORUM` if accumulated weight < `sfSignerQuorum`. + +`checkBatchSign()` validates the outer batch transaction's `sfBatchSigners` array. Outer account is excluded from `sfBatchSigners`; unsigned-account inner transactions (e.g., funding an account creation) are permitted if signed by their master key. + +`LoanSet::checkSign()` overrides to verify both the primary signer AND the `sfCounterpartySignature` sub-object (which may itself be single or multisig). `calculateBaseFee` adds one `baseFee` per counterparty signer. + +## Validation Helpers (in `Transactor`) + +- `validNumericRange(opt, min, max)` — absent optional is valid +- `validNumericMinimum(opt, min)` — absent optional is valid +- Overloads for `unit::ValueUnit` for type-safe units + +These follow the convention that an absent optional field is valid; only present values are range-checked. + +## Invariant Checker Framework + +After every successful or fee-claiming transaction, every checker in the `InvariantChecks` tuple runs. Two-phase: `visitEntry(isDelete, before, after)` per modified SLE, then `finalize(tx, result, fee, view, journal)` once. + +### Dispatch (in `ApplyContext::checkInvariantsHelper`) + +Uses `std::index_sequence` + fold expression for variadic visit. Critically, `finalize()` results are collected into a `std::array` then checked with `std::all_of` — NOT short-circuited with `&&` — so every failing invariant logs its own diagnostic. + +### `failInvariantCheck` Escalation + +- First failure → `tecINVARIANT_FAILED` (committed to ledger, fee charged) +- Repeated failure during retry → `tefINVARIANT_FAILED` (not included in ledger) + +### The `enforce` Pattern (Soft Rollout) + +```cpp +bool const enforce = view.rules().enabled(featureX); +if (violation) { + JLOG(j.fatal()) << "..."; + XRPL_ASSERT(enforce, "..."); // fires in debug builds regardless + return !enforce; // returns true (passes) if amendment off +} +``` + +This lets invariants ship before activation: violations log unconditionally (visible to operators), assertion fires in debug/test builds (catches dev mistakes), but only become consensus-breaking when the gating amendment activates. + +### Privilege System (`InvariantCheckPrivilege.h`) + +`Privilege` bitmask enum + `hasPrivilege(STTx, Privilege)` (implemented via `transactions.macro` X-macro). Used by checkers to know what each transaction type may legitimately do: + +| Privilege | Granted to (examples) | |---|---| -| `tesSUCCESS` | All doApply mutations + fee + seq (via `ctx_.apply`) | -| `tec*` (normal, `!tapRETRY`) | `reset(fee)` calls `discard()`, then re-applies fee + seq only. **All doApply mutations reverted.** | -| `tec*` with `tapFAIL_HARD` | `discard()` called directly, nothing committed (not even fee) | -| `tec*` with `tapRETRY` | `applied=false`, `ctx_.apply` never called, tx re-queued | -| `tef*` / `tem*` / `ter*` | `applied=false`, `ctx_.apply` never called | -| `tecINVARIANT_FAILED` after invariants | reset again, commit fee only | +| `createAcct` | `Payment` (XRP funding) | +| `createPseudoAcct` | `AMMCreate`, `VaultCreate`, `LoanBrokerSet` | +| `mustDeleteAcct` | `AccountDelete`, `AMMDelete` | +| `mayDeleteAcct` | `AMMWithdraw`, `AMMClawback` | +| `overrideFreeze` | `AMMClawback` (only against AMM trust lines, not global freeze) | +| `changeNFTCounts` | `NFTokenMint`, `NFTokenBurn` | +| `createMPTIssuance` / `destroyMPTIssuance` | `MPTokenIssuanceCreate`/`Destroy`, also `VaultCreate`/`Delete` | +| `mustAuthorizeMPT` / `mayAuthorizeMPT` | `MPTokenAuthorize`, AMM withdraw/clawback | +| `mayCreateMPT` / `mayDeleteMPT` | `Payment`, `CheckCash`, `AMMCreate`, `AMMDelete` | +| `mustModifyVault` / `mayModifyVault` | Vault transactors, loan transactors | -`isTecClaimHardFail(ter, flags) = isTecClaim(ter) && !(flags & tapRETRY)` ([include/xrpl/tx/applySteps.h](include/xrpl/tx/applySteps.h)) — this predicate is what drives the reset path for normal consensus application. +### The 25+ Registered Invariants -### What this means for transactor authors and reviewers +| Checker | What it enforces | +|---|---| +| `TransactionFeeCheck` | Fee non-negative, < INITIAL_XRP, ≤ sfFee | +| `XRPNotCreated` | Net XRP delta across accounts/paychans/escrows = -fee | +| `XRPBalanceChecks` | Every account balance is native XRP in [0, INITIAL_XRP] | +| `NoBadOffers` | No negative-amount, no XRP-for-XRP offers | +| `NoZeroEscrow` | Escrow/MPT amounts within bounds; MPT locked ≤ outstanding | +| `AccountRootsNotDeleted` | Account deletion cardinality matches `must`/`may` privilege | +| `AccountRootsDeletedClean` | Deleted account had zero balance + zero owner count + no orphaned objects (trust lines, escrows, offers, NFT pages, paychans, pseudo-account linked objects) | +| `ValidNewAccountRoot` | New accounts only from `createAcct`/`createPseudoAcct`; correct initial seq + flags | +| `ValidPseudoAccounts` | Exactly one discriminator, sequence unchanged, required flags, no regular key | +| `ValidClawback` | At most one trust line/MPT modified, holder balance non-negative | +| `NoModifiedUnmodifiableFields` | `sfLedgerEntryType`/`sfLedgerIndex` immutable; loan/broker origination fields immutable | +| `LedgerEntryTypesMatch` | Modified entries don't change type; new entries are recognized types | +| `NoXRPTrustLines` | No trust line uses XRP as currency | +| `NoDeepFreezeTrustLinesWithoutFreeze` | DeepFreeze flag requires regular Freeze flag | +| `TransfersNotFrozen` | Trust line transfers respect global/per-line/deep freeze (gated `featureDeepFreeze`) | +| `ValidNFTokenPage` | Page links coherent, size 1-32 tokens, sorted, valid URIs | +| `NFTokenCountTracking` | `sfMintedNFTokens`/`sfBurnedNFTokens` only change with `changeNFTCounts` privilege; strict monotonic increase on success | +| `ValidMPTIssuance` | MPT issuance/holder counts match transaction privileges | +| `ValidMPTPayment` | OutstandingAmount = sum(holder MPTAmount + LockedAmount); overflow detection | +| `ValidAMM` | Per-tx-type rules: create exact `sqrt(A*B)`, deposit/withdraw constant-product invariant `sqrt(x*y) ≥ LPSupply`, vote/bid leave pool unchanged | +| `ValidPermissionedDomain` | AcceptedCredentials non-empty, ≤ max size, unique, sorted | +| `ValidPermissionedDEX` | Domain-scoped tx only touches offers/dirs with matching domain; hybrid offers structurally valid | +| `ValidVault` | Per-tx-type rules: deposit/withdraw asset/share conservation, immutable fields unchanged, loss only via loan ops | +| `ValidLoan` | Payment completion bidirectional (paymentRemaining=0 ↔ all outstanding=0), `lsfLoanOverpayment` immutable, non-negative fees, positive `sfPeriodicPayment` | +| `ValidLoanBroker` | Sequence monotonic, non-negative cover/debt, vault exists, cover ≤ pseudo-account balance (== under `fixSecurity3_1_3` except at delete) | -- **A `tec*` return from doApply acts as a full-transaction rollback.** You do NOT need to order mutations defensively so that all checks come before any state changes. If a helper called late in doApply returns `tec*`, everything mutated earlier in the same doApply is discarded via `discard()`. -- **Orphan-state bugs of the form "we mutated X then returned tec* so X is now in an inconsistent state" are not possible at the transactor boundary.** The ApplyContext isolates the whole doApply as an atomic unit. -- **The real failure mode is within `doApply` itself**: if you call `view().update(sle)` on a stale SLE pointer, or mutate a variable you read by value instead of peek, those are real bugs — but they are in-memory bugs, not state-commit bugs. -- **Sandboxes inside `doApply` add nesting, not safety.** `PaymentSandbox` / nested `ApplyView` are useful when you need to conditionally commit a subset of changes *within* a single doApply (e.g., apply offers but revert if the net outcome fails). They are not needed to protect against doApply's own `tec*` return — that rollback is automatic. -- **Only `ctx_.apply(result)` publishes to `base_`**; a doApply that `return`s early, throws, or crashes never reaches that call, so base_ stays clean. +## doApply Order Convention (Cleanup) -### Verifying a suspected orphan-state bug +When erasing an SLE that participates in directories, the order is **always**: +1. Remove from owner directory (and destination/issuer directory if applicable) via `dirRemove` with stored `sfOwnerNode`/etc. +2. `adjustOwnerCount(view, sleOwner, -N, j)` +3. `view().erase(sle)` -Before claiming "directory removed but SLE not erased because tec\*": -1. Read the caller of `doApply` — confirm the TER path (`operator()` in Transactor.cpp). -2. Check whether `discard()` is reached via `reset()` or the `tapFAIL_HARD` branch. -3. If both paths call `discard()`, the mutations cannot persist on tec\*. -4. Look instead for: missing `view().update(sle)` after mutation, stale SLE pointers, or genuine non-atomic side effects (e.g., hash router flags, which are NOT in the ApplyContext view). +Erasing first would lose the page index needed for `dirRemove`. Many transactors guard `dirRemove` failure with `tefBAD_LEDGER` and `LCOV_EXCL` markers — these branches represent ledger corruption rather than user error. -## Permission System +## Failure Modes Worth Special-Casing -- `checkSign` dispatches to `checkSingleSign`, `checkMultiSign`, or `checkBatchSign` -- `checkPermission` validates delegated authority for delegatable transaction types -- Multi-sign requires M-of-N signers matching the signer list; weight threshold must be met +- `tecOVERSIZE`: metadata too large. `operator()` re-runs `doApply` after `reset()` to collect cleanup targets only +- `tecINCOMPLETE`: progress was made but more work remains. `AMMDelete` and `VaultDelete` commit partial work on this code — caller resubmits +- `tecPATH_DRY`: payment path exhausted. `Payment` converts retry codes from `RippleCalc` to this (forces fee deduction, prevents path-spam) +- `tecKILLED`: order/loan time-window expired or sequence overflow (`LoanSet` arithmetic overflow check) +- `tecEXPIRED`: legitimately expired object; some transactors (e.g., `NFTokenAcceptOffer` under `fixExpiredNFTokenOfferRemoval`) clean up before returning this +- `tecINSUFFICIENT_RESERVE`: reserve check failed against `preFeeBalance_` +- `tecINTERNAL` / `tefBAD_LEDGER`: ledger corruption sentinels. Often marked `LCOV_EXCL` because preclaim should have prevented them +- `terNO_AMM`, `terNO_DELEGATE_PERMISSION`, `terNO_ACCOUNT`, `terNO_LINE`: retryable failures + +## Hash Router Caching + +Some expensive operations cache results in the `HashRouter` using private flag bits to avoid recomputation across multiple validation passes: + +- **Signature verification** (`apply.cpp` `checkValidity`): `SF_SIGBAD`, `SF_SIGGOOD`, `SF_LOCALBAD`, `SF_LOCALGOOD` (PRIVATE1–PRIVATE4) +- **Crypto-condition validation** (`EscrowFinish::preflightSigValidated`): `SF_CF_VALID`, `SF_CF_INVALID` (PRIVATE5–PRIVATE6) + +The `forceValidity()` API can promote cached state but cannot downgrade (never sets `SF_SIGBAD`) — used to mark locally-submitted transactions as pre-verified. + +## Batch Transactions + +`Batch` (in `system/Batch.cpp`) bundles 2-8 inner transactions with one of four execution policies (mutually exclusive, enforced via `std::popcount`): +- `tfAllOrNothing`: any failure aborts, full rollback +- `tfUntilFailure`: stop at first failure, keep prior successes +- `tfOnlyOne`: stop at first success +- `tfIndependent`: run all, commit successes + +**Critical for new transactors:** Update `disabledTxTypes` in `Batch.cpp` if your type cannot run inside a batch. Currently disabled: all `ttVAULT_*` and `ttLOAN_*` types (multi-step state machines whose invariants are difficult to reason about under batch atomicity). + +Inner transaction rules (enforced in `Batch::preflight`): +- `tfInnerBatchTxn` flag must be set +- Empty `sfSigningPubKey`, no `sfTxnSignature`, no `sfSigners` +- Fee = 0 XRP +- Exactly one of `sfSequence` (nonzero) or `sfTicketSequence` +- For `tfAllOrNothing`/`tfUntilFailure`: no duplicate sequence/ticket values across same-account inner txs + +`Batch::preflightSigValidated` reconciles `sfBatchSigners` against the set of inner-tx accounts that differ from outer account (plus any `sfCounterparty` accounts). The outer account is explicitly excluded from `sfBatchSigners`. + +`Batch::doApply()` returns `tesSUCCESS` and does nothing — `applyBatchTransactions()` in `apply.cpp` is called separately by `applyTransaction()` after the outer apply succeeds, executing inner txs in a nested `wholeBatchView`/`perTxBatchView` sandbox structure. + +`Batch::calculateBaseFee` = `baseFee + Σ(inner tx fees) + numSigners × baseFee`. Overflow guards everywhere (marked `LCOV_EXCL`). ## Key Files -- `src/xrpld/app/tx/detail/Transactor.cpp` - base class and pipeline -- `include/xrpl/protocol/detail/transactions.macro` - type definitions -- `src/xrpld/app/tx/detail/` - per-type implementations (Payment.cpp, OfferCreate.cpp, etc.) -- `src/xrpld/app/tx/detail/InvariantCheck.cpp` - post-execution invariant checks +- `src/libxrpl/tx/Transactor.cpp` - base class, three-phase pipeline, fee calculation, signature dispatch +- `src/libxrpl/tx/ApplyContext.cpp` - sandboxed view management, `discard()`, invariant orchestration +- `src/libxrpl/tx/apply.cpp` - top-level `apply()`, `checkValidity()` caching, `applyBatchTransactions()` +- `src/libxrpl/tx/applySteps.cpp` - X-macro dispatch via `with_txn_type`, `TxConsequences` factories +- `src/libxrpl/tx/SignerEntries.cpp` - multi-sig signer list deserialization (`SignerEntries::deserialize`) +- `include/xrpl/protocol/detail/transactions.macro` - canonical type definitions, privileges, features +- `src/libxrpl/tx/transactors/.../` - one subdirectory per feature family (account, dex, escrow, lending, vault, etc.) +- `src/libxrpl/tx/invariants/` - 25+ invariant checkers; add new ones to `InvariantChecks` tuple in `InvariantCheck.h` +- `src/libxrpl/tx/paths/` - payment flow engine (`Flow.cpp`, `StrandFlow.h`, `BookStep.cpp`, `RippleCalc.cpp`) used by `Payment`, `CheckCash`, `OfferCreate` crossing + +## Payment Path Engine Notes + +`Payment`, `OfferCreate` (crossing), and `CheckCash` (IOU/MPT) all route through `flow()` in `Flow.cpp` → `StrandFlow.h`. Key concepts: + +- A **strand** is a `std::vector>`; each `Step` is one hop (`DirectStepI`, `BookStepXX`, `XRPEndpointStep`, `MPTEndpointStep`) +- Two-pass execution: reverse pass (compute required input for desired output) then forward pass (compute output for actual input) +- Limiting step detection: if reverse pass cannot satisfy desired output, that step is identified as the bottleneck and used as the anchor for forward pass +- Multi-strand flow uses `ActiveStrands` priority queue sorted by `qualityUpperBound`; one strand consumed per outer iteration (probe-and-push) +- Safety limits: `MaxOffersToConsume` = 1000 per book step, `maxTries` = 1000 outer iterations, `maxOffersToConsider` = 1500 cumulative, `AMMContext::MaxIterations` = 30 +- `PaymentSandbox` (not regular `Sandbox`) is required because `flow()` uses deferred-credit accounting +- AMM offers are synthesized by `AMMLiquidity` to look like CLOB offers to `BookStep`; single-path uses `changeSpotPriceQuality`, multi-path uses Fibonacci-scaled offer sizes + +## Asset Type Dispatch Pattern + +Modern transactors that support both IOU (`Issue`) and MPT (`MPTIssue`) assets use template specialization + `std::visit` rather than runtime branching. The pattern: + +```cpp +TER MyTx::preclaim(PreclaimContext const& ctx) { + return std::visit( + [&](T const&) { return preclaimHelper(ctx); }, + ctx.tx[sfAmount].asset().value()); +} + +template +static TER preclaimHelper(PreclaimContext const& ctx); +template <> TER preclaimHelper(...); +template <> TER preclaimHelper(...); +``` + +Used by `Clawback`, `Escrow*`, `Vault*`, `AMM*Withdraw/Deposit`, `LoanBrokerCoverClawback`. Each specialization handles asset-type-specific permission flags (`lsfAllowTrustLineClawback`/`lsfNoFreeze` vs `lsfMPTCanClawback`), authorization (`StrongAuth` vs `WeakAuth`), and freeze checks (`tecFROZEN` vs `tecLOCKED`).