From ae304e4c3c63f17b1d1278b19294821cdcccaa71 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 3 Nov 2026 20:49:08 +0700 Subject: [PATCH 1/2] docs: describe how drive-abci reads Dash Core and records Core fork activations The Component Pipeline chapter gains a "Syncing with Dash Core" section that expands steps 4 and 5 of run_block_proposal: - how the chain-locked core height moves, and how init chain starts it; - chain lock verification and submitting the lock to Core, including the almost-synced wait and both refusals; - why every node's Core answers about the same block, and the gap left by Core accepting a chain lock before it has the block; - the Core reads bound to that height, and what each step does on a Core error; - the startup wait, and the rules for adding a Core read. A new Architecture page, Core Fork Activations, describes: - the Misc record of Core forks Platform has seen active; - when update_core_info v1 asks Core, and why every node records the same height; - the window after the protocol upgrade, and the rule for consumers; - V24's coinbase signal, and the code that reads the record; - how to add a fork, and the preconditions for a fork without a block-enforced signal; - a possible table-driven evolution, marked as not implemented. Coding Conventions links both from a short rule next to "Consensus code reads the version from state". Every path and symbol was checked against the code. Claims about Tenderdash and Dash Core were checked against their sources. An accuracy review and a clarity review are folded in. Documentation only, so there is no test. Co-Authored-By: Claude Opus 5.5 --- book/src/SUMMARY.md | 1 + book/src/architecture/component-pipeline.md | 213 +++++++++++++- .../src/architecture/core-fork-activations.md | 266 ++++++++++++++++++ book/src/contributing/coding-conventions.md | 11 + 4 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 book/src/architecture/core-fork-activations.md diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index d4c1a6f368a..acb9fb32d32 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -11,6 +11,7 @@ - [Monorepo Overview](architecture/overview.md) - [Component Pipeline](architecture/component-pipeline.md) +- [Core Fork Activations](architecture/core-fork-activations.md) # Contributing diff --git a/book/src/architecture/component-pipeline.md b/book/src/architecture/component-pipeline.md index e7cc066216a..8d4666f2516 100644 --- a/book/src/architecture/component-pipeline.md +++ b/book/src/architecture/component-pipeline.md @@ -254,7 +254,7 @@ happens within a single block. Here is the sequence, in order: 2. Validate block follows previous block (height, core height) 3. Clear drive block cache 4. Verify chain lock (if core chain lock update present) -5. Update core info (masternode list, quorums) +5. Update core info (Core fork activations, masternode list, quorums) 6. Update validator proposed app version 7. Rebroadcast expired withdrawals 8. Update broadcasted withdrawal statuses @@ -274,6 +274,217 @@ Steps 1-10 are "block-level housekeeping." Step 11 is where individual state transitions are decoded, validated, transformed into actions, and applied to GroveDB. Steps 12-18 finalize the block's effects. +## Syncing with Dash Core + +Steps 4 and 5 are where a block first meets Dash Core, and several later steps +read Core too. Every Platform validator is an evonode: a Dash Core masternode +that also runs Tenderdash and drive-abci. drive-abci asks that node's Core, over +RPC, about masternodes, quorums, withdrawals and asset locks. Each validator +asks its own Core, at its own moment, and those Cores are never exactly in step, +yet every validator must get the same answer. The rule that makes this work: +consensus code reads Core at the block's chain-locked core height, never at +Core's tip. + +Two Core concepts carry the argument. A **quorum** is a group of masternodes +that Core selects deterministically to produce threshold signatures together. A +**chain lock** is a quorum's signature over the Core block at one height. A +quorum signs only one block per height, so the network has a single +chain-locked block at each height, and Core treats it as final: it never +reorganizes a chain-locked block away. Core produces a block about every 2.5 +minutes, far less often than Platform. + +### The chain-locked core height + +Every block carries a chain-locked core height, written `c` below +(`core_chain_locked_height` in the proposal, `block_info.core_height` while the +block runs). It is part of the block, so every validator executes the block at +the same `c`, wherever its own Core's tip happens to be. + +`c` moves only in a block that carries a chain lock update. Tenderdash enforces +this: a block with an update sets `c` to the update's height, which must be +above the previous block's `c`, and a block without one keeps the previous +block's `c`. drive-abci also checks, on its own, that `c` never goes down +(`next_block_to` in `packages/rs-drive-abci/src/execution/types/block_state_info/v0/mod.rs`). +Core blocks are rare next to Platform blocks, so most blocks carry no update. + +The proposer takes the update from its own Core. `prepare_proposal` +(`packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs`) asks Core for +its best chain lock and proposes it when its height is above the previous +block's `c` (in the chain's first block, whatever its height). When Core has +nothing newer, or cannot be reached, the block keeps the previous block's `c`. +Choosing the update this way is not a consensus read: the chain lock goes into +the block, and every other validator verifies it instead of asking its own Core +for one. + +Init chain has no chain lock update. `init_chain` +(`packages/rs-drive-abci/src/execution/engine/initialization/init_chain/v0/mod.rs`) +uses the genesis core height: the one in the genesis file or, by default, the +height at which Core activated `mn_rr`, the Core fork Platform requires. It +waits until that height is at or below Core's best chain lock +(`initial_core_height_and_time`), then runs `update_core_info` there. Block 1 +treats the genesis core height as the previous block's `c`. + +### Verifying the chain lock and syncing Core to it + +A validator that did not propose the block checks the update before anything +reads Core. This is step 4, `verify_chain_lock` in +`packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/`: + +1. It checks the signature against the chain lock quorums in Platform's state. + When those cannot settle it, it leaves the check to its Core. +2. It submits the chain lock to its Core (`make_sure_core_is_synced_to_chain_lock`, + Core's `submitchainlock`). Core answers with the height of the best chain + lock it now holds. +3. If that height is at or above `c`, Core is synced. If it is one or two blocks + short (`recent_block_count_amount`), drive-abci asks for Core's best chain + lock up to five more times, 200 ms apart. If Core is further behind, or + still short after that, the proposal is refused with + `ChainLockedBlockNotKnownByCore`. + +A chain lock that fails the check is refused with `InvalidChainLock`, and so is +one the validator cannot check or submit because a Core call fails. A Core too +far behind to know the quorum that signed the chain lock lands here too: +`submitchainlock` then answers with an error. Both refusals are rejections: +`process_proposal` answers `Reject`, the validator does not vote for the +proposal, and consensus moves on to another round. The proposer skips these +checks, because the chain lock came from its own Core. + +### Why every node's Core answers about the same block + +Once a Core holds a chain lock and has the block it names, it puts that block +on its active chain and never reorganizes it away, and the lock covers every +block below it too. So once a validator's Core holds a chain lock at or above +`c` and has the block it names, the block at `c` on its active chain is the +network's single chain-locked block at `c`. `getblockhash(c)`, and every read +keyed by `c` or a lower height, answers about that block on every validator. A +read at Core's tip would instead answer about whichever block each Core had +reached. + +One gap remains: Core accepts a chain lock before it has the block, and counts +it in the height `submitchainlock` reports. Such a Core usually has no block at +`c` yet, so a read at `c` fails and the block fails on that node (see +[When Core fails](#when-core-fails)). If its active chain holds a competing +block at `c` instead, the read answers about that block, and that node's results +for the block can differ from everyone else's. A difference in what it writes to +GroveDB shows at once, because the app hash is GroveDB's root hash; a difference +kept only in Platform state, which the app hash does not cover, can stay hidden +longer. Checking that Core's block at `c` is the one the chain lock names, +before any read at `c`, would close the gap; drive-abci does not check it. + +### What Platform reads from Core + +Two places keep what Platform learns from Core. GroveDB holds the state the app +hash commits to. Platform state (`PlatformState`) is drive-abci's view of the +chain around it: the masternode list, the quorums and validator sets, the +protocol versions and the last committed block. It is saved after every block in +GroveDB's auxiliary storage, which the app hash does not cover. + +Several reads use the coinbase payload: the first transaction of every Core +block, the coinbase, carries a versioned payload with commitments such as the +credit pool balance. + +`update_core_info` (step 5, +`packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_core_info/`) +runs at init chain and in every block that moves `c`. In a block that keeps +`c` it returns before asking Core anything. It reads, in this order (paths +relative to `core_based_updates/`): + +| Read | Asked of Core | Code | Kept in | +|---|---|---|---| +| Core fork activations, from protocol version 15 | The coinbase payload version of the block at `c`, until the fork is recorded | `update_core_info/v1/` | GroveDB `Misc`; see [Core Fork Activations](core-fork-activations.md) | +| Masternode list | `protx listdiff` from the previous block's `c` to `c`; also the coinbase payload version at `c` when a reported operator key may only be re-encoded | `update_masternode_list/` | Platform state, and masternode identities in GroveDB | +| Quorums | `quorum listextended` at `c`, `quorum info` for each new quorum | `update_quorum_info/` | Platform state: the validator sets and the chain lock and instant lock quorums | + +Other steps read Core at `c` or below too. The paths in the first two rows are +under `packages/rs-drive-abci/src/execution/platform_events/withdrawals/`: + +| Step | Read | Asked of Core | Code | Kept in | +|---|---|---|---|---| +| 14 | Core's credit pool balance (the Dash locked on Core that backs Platform credits), from protocol version 14 | The coinbase of each Core block up to `c` not yet recorded, back to the start of the withdrawal limit's window, at most 32 per block | `scan_core_blocks_for_withdrawals/`; `calculate_core_anchored_withdrawal_limit/` asks Core itself only for a height not yet recorded | GroveDB, `WithdrawalTransactions (80) / [6]`, one balance per core height | +| 8 | The statuses of the asset unlocks Platform broadcast (the Core transactions that pay out withdrawals) | `getassetunlockstatuses` at `c`, in blocks that move `c` | `update_broadcasted_withdrawal_statuses/` | The withdrawal documents | +| 11 | Chain asset lock proofs (Dash locked on Core to fund an identity, proven by the chain-locked height that includes it) | The asset lock transaction, which must be mined at or below the proof's core height, itself no higher than Platform's last committed core height | `packages/rs-drive-abci/src/execution/validation/state_transition/common/asset_lock/` | What the state transition writes | + +### When Core fails + +Core RPC calls retry for about 11 seconds on connection errors and on the +errors Core returns while it is still starting, and give up on any other error +at once (`retry!` in `packages/rs-drive-abci/src/rpc/core.rs`). Once the +retries are spent, no step reads the error as a default such as "not active", +"no change" or a zero balance. What happens next depends on the step: + +- In step 4, the proposal is refused with `InvalidChainLock`, as above. +- For a chain asset lock proof, the state transition fails with an internal + error: the proposer leaves it out of its block, and a validator rejects a + proposal that contains it. +- Everywhere else, including `update_core_info`, the credit pool reads and the + asset unlock statuses, the step returns the error and the node cannot execute + the block. drive-abci hands the error to Tenderdash as an exception, which + Tenderdash treats as fatal: it stops on that node, and after a restart the + node fails the same block again until its Core answers. If that holds for + more than a third of the validators, the chain stops until their Cores + recover. + +A rejection is the node's verdict on the proposal, and consensus moves on to +another round. An exception means the node could not judge the block at all, so +it does not vote on it either way. + +In a block that keeps `c`, `update_core_info` asks nothing, the credit pool scan +asks nothing once it has caught up, and only a state transition with a chain +asset lock proof still reads Core. So a proposer whose Core is unreachable still +proposes, at the previous block's `c`, and the chain goes on through short Core +outages. + +### Waiting for Core at startup + +Before drive-abci opens its ABCI port, `wait_for_core_to_sync` +(`packages/rs-drive-abci/src/core/wait_for_core_to_sync/`) polls Core every 5 +seconds until Core has a chain lock and reports its blockchain and masternode +sync complete. It then makes one test read, the credit pool balance at Core's +best chain lock, so that a Core whose RPC whitelist does not allow +`getspecialtxes` stops the node at startup rather than at the first block that +needs it. Tenderdash does not start before the ABCI port is open. The wait is +not part of consensus: it only keeps a node from joining before its Core can +answer. + +### Adding a Core read + +A new read of Core in block execution follows the same rules: + +- **Go through `CoreRPCLike`** (`packages/rs-drive-abci/src/rpc/core.rs`), so + tests can answer the call with `MockCoreRPCLike`. +- **Read at `c`.** Ask about `block_info.core_height`, or a lower height, + inside the block. Never ask about Core's tip, its mempool, or anything else + whose answer depends on when it is asked. `get_fork_info` reads Core's + deployments at its tip; it is fit for choosing the genesis core height and + for nothing inside a block. Proposing a chain lock reads Core's best chain + lock, but that answer goes into the block, where every other validator + verifies it. +- **Keep nothing between blocks except in state.** No field on `Platform` that + holds an answer, or something derived from one. A failed round throws its + transaction away, but such a field would keep what that round read, and a + restarted node would start without it. A cache of Core's raw answers keyed by + a chain-locked height, like the prefetcher in + `packages/rs-drive-abci/src/rpc/prefetch.rs`, is fine: it returns what Core + would return anyway. +- **A Core error fails the block.** Return it; never turn it into a default. An + answer that cannot be read is an error too (`DashCoreBadResponseError`). +- **Store what later blocks depend on.** When a later block's outcome depends + on the answer, keep it in GroveDB, where the app hash covers it, as the credit + pool scan and the fork record do, instead of asking Core again later. + Platform state also survives restarts, but the app hash does not cover it, so + a wrong answer kept there can go unnoticed. +- **Prefer blocks that move `c`.** A read in a block that keeps `c` makes every + block depend on Core, and a proposer whose Core is unreachable can then no + longer propose. +- **Allow the RPC for drive-abci.** dashmate gives drive-abci a Core RPC user, + `drive_consensus`, with a method whitelist + (`packages/dashmate/configs/defaults/getBaseConfigFactory.js`). A new method + goes in it, with a config migration for existing nodes; otherwise every node + fails the first block that makes the call. +- **It is consensus behaviour.** A new read, or a change in what an answer + decides, is a new generation selected by the unreleased protocol version, like + any other behaviour change. + ## State Transition Processing State transition processing (step 11 above) follows its own pipeline within diff --git a/book/src/architecture/core-fork-activations.md b/book/src/architecture/core-fork-activations.md new file mode 100644 index 00000000000..a1e42bcd9c9 --- /dev/null +++ b/book/src/architecture/core-fork-activations.md @@ -0,0 +1,266 @@ +# Core Fork Activations + +Some Platform behaviour depends on whether a Dash Core fork is active, for +example a transaction format that Core accepts only once the fork is active. +Core calls its forks deployments. Asking Core every time the answer is needed +would add a Core call, and a way for the block to fail, wherever the answer is +used. Instead Drive keeps a record of the Core forks Platform has seen active. +`update_core_info` fills it in at the block's chain-locked core height `c` (see +[Syncing with Dash Core](component-pipeline.md#syncing-with-dash-core)), and +code that needs to know reads the record. Available from protocol version 15. + +## The record + +Each tracked fork has at most one item in the `Misc` tree (see +[The GroveDB Structure](../drive/grovedb-structure.md)): + +```text +Misc (104) / [b'F', ] -> chain-locked core height, u32 big endian +``` + +| Core fork | `CoreFork` | Key | Constant | +|---|---|---|---| +| Dash Core's `DEPLOYMENT_V24` | `CoreFork::V24` | `[b'F', 0]` | `CORE_FORK_V24_ACTIVATION_KEY` | + +- The value, `h` below, is the `c` of the first Platform block, or of init chain, + that checked the fork and found it active. It is not Core's activation height: + it is at or above it, and above it when `c` jumped past it or when the + protocol version that tracks the fork came later. Because `h` is the `c` of a + Platform block, it is the same on every node. +- The item is written once and never changed or deleted. Writing a fork that is + already recorded is a `CorruptedCodeExecution` error. +- It lives in GroveDB, so the app hash covers it, and clients can prove it with + `getPathElements`. +- No migration is needed: `Misc` exists on every chain, and a fork never + recorded reads as absent. + +`CoreFork` (`packages/rs-drive/src/drive/system/core_fork_activations/mod.rs`) is +`#[repr(u8)]`. A variant's discriminant is its id in stored keys, so it must +never change; the enum is marked `// @append_only`, which CI enforces (see +[Append-only structures are enforced by CI](../contributing/coding-conventions.md#append-only-structures-are-enforced-by-ci)). +`CoreFork::activation_key` maps each variant to its key. + +### Reading it + +```rust +// packages/rs-drive/src/drive/system/core_fork_activations/fetch_core_fork_activation_height/mod.rs +pub fn fetch_core_fork_activation_height( + &self, + fork: CoreFork, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> Result, Error> +``` + +Read in a block after `update_core_info`, `Some(h)` means the fork is active at +the block's `c`: `c` never decreases, so `h <= c`, and Core forks never switch +off. `None` means Platform has not seen the fork active. It does not mean the +fork is inactive (see +[The window after a protocol upgrade](#the-window-after-a-protocol-upgrade)). +To ask about another height `x`, compare it with `h`: the fork is known active +at `x` when `x >= h`, and the record says nothing about heights below `h`. + +Always pass the block's transaction. A record written in the current block +exists only in that transaction until the block commits. + +Nothing caches the record outside GroveDB. A node-local copy would survive a +round that failed and was thrown away, and the next round could then read a +write that never happened. GroveDB is the cache: once a fork is recorded, Core +is never asked about it again. + +The read and its writer, `store_core_fork_activation_height`, are +`OptionalFeatureVersion` slots in `drive.methods.platform_system`. Both are +`None` before protocol version 15, where calling them returns +`VersionNotActive`. + +## When Platform asks Core + +Each tracked fork has a signal: something Core reports that tells whether the +fork is active at a given block (see [V24's signal](#v24s-signal)). +`update_core_info` v1, selected from protocol version 15, starts with +`record_core_fork_activations_v1`, then updates the masternode list and the +quorums as v0 does. For each fork the generation tracks (V24 in v1) that is not +yet recorded, it asks Core the fork's signal at `c`: + +```mermaid +flowchart TD + Start["update_core_info v1"] --> Moved{"init chain, or
c moved?"} + Moved -->|"No"| Done["return: Core is not asked"] + Moved -->|"Yes"| Recorded{"each tracked fork:
already recorded?"} + Recorded -->|"Yes"| Rest["masternode list, quorums"] + Recorded -->|"No"| Ask["ask Core the signal at c"] + Ask -->|"active"| Write["write h = c"] --> Rest + Ask -->|"not active"| Rest + Ask -->|"error or unreadable answer"| Fail["block fails, nothing written"] +``` + +So Core is asked about a fork: + +- at init chain, at the genesis core height, for a chain born at protocol + version 15 or later (testnet and mainnet were born at protocol version 1); +- in every block at protocol version 15 or later that moves `c`, until the fork + is recorded; +- never in a block that keeps `c`, and never again once the fork is recorded. + +Each check is one `getblockhash` and one `getspecialtxes`, in a block that is +already calling Core. + +### Why every node records the same height + +The record after block N depends only on: + +- the record after block N - 1; +- block N's protocol version; +- the `c` of blocks N - 1 and N; +- the fork's signal at block N's `c`. + +The first three are part of the chain. The signal is read at a chain-locked +height, so every node's Core reports it for the same Core block (see +[Why every node's Core answers about the same block](component-pipeline.md#why-every-nodes-core-answers-about-the-same-block)). +Init chain writes its record before block 1 begins. A round that fails, or that +the network replaces, discards its transaction and the write with it. Nothing +depends on when a node started, whether it restarted, or how fast its Core is. + +A node whose Core answers wrongly records a different `h` from everyone else, +and its app hash differs in the very block that records it. + +The unit tests at the bottom of +`packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_core_info/v1/mod.rs` +pin this, including that the record agrees with Core after every block that +moves `c`. The strategy tests in +`packages/rs-drive-abci/tests/strategy_tests/test_cases/core_fork_activation_tests.rs` +cover restarts, the upgrade from protocol version 14 and failed rounds. + +## The window after a protocol upgrade + +The first block of the protocol version that tracks a fork checks the fork only +if it moves `c`. If it keeps `c`, the fork stays unrecorded until the first +block under that version that does move it. That is usually within one Core +block, about 2.5 minutes, and longer while chain locks stall. During that window +Core may already have the fork active while the record says nothing. + +Outside that window the record never lags: every block that moves `c` checks, +and a block that keeps `c` has the same `c`, and so the same answer, as the last +block that checked. + +The rule for every consumer of the record: it must be safe while a fork is +active but not yet recorded, or it must run after `update_core_info` in a block +that moves `c`. Code earlier in the block sees the previous block's record. In +particular the protocol upgrade hooks, `upgrade_protocol_version_on_epoch_change` +and `perform_events_on_first_block_of_protocol_change`, run before +`update_core_info`, so they cannot rely on the record for a fork that the same +upgrade starts tracking. + +Closing the window would mean asking Core in the first block of the new protocol +version whatever its `c`. That would put Core on the upgrade block's critical +path: a node whose Core failed could not execute the upgrade block, and the +upgrade would wait for every Core that was down. + +## V24's signal + +Every Core block's coinbase carries a versioned payload. Dash Core requires +coinbase payload version 4 (`MERKLE_ROOT_ASSETUNLOCKS`) in exactly the blocks +where V24 is active, and refuses it in every other block (`CheckCbTx` in Core's +`src/evo/cbtx.cpp`). Core applies this rule only once V20 is active, as it is on +every chain Platform runs on. So the coinbase of block `c` has payload version 4 +or higher if and only if V24 is active at `c`. Core enforces this inside the +block, so every Core that holds block `c` agrees, whatever its own deployment +parameters. + +drive-abci reads the signal with `get_coinbase_payload_version(c)` +(`packages/rs-drive-abci/src/rpc/core.rs`, compared with +`V24_COINBASE_PAYLOAD_VERSION`): `getblockhash(c)`, then `getspecialtxes` for +the coinbase alone. A coinbase without a payload is a +`DashCoreBadResponseError`, never "not active". A Core older than v24, whose +coinbase versions stop at 3, treats version 4 as invalid: it stops before the +first V24 block, has no block at `c`, and the read fails rather than answering +"not active". + +Core refuses every coinbase version above 4. Reading "4 or higher" as active +assumes that a later Core version which allows a higher one keeps V24's rule. + +## Consumers + +Code that depends on a Core fork reads the record through +`fetch_core_fork_activation_height`, passing the block's transaction, and +follows the rule in +[The window after a protocol upgrade](#the-window-after-a-protocol-upgrade). + +One piece of code still asks Core about V24 directly: the masternode list +update's `drop_unchanged_operator_keys`, which keeps a masternode's operator key +when Core re-encodes it after V24. It runs under every protocol version, and +from protocol version 15 it runs after the record is updated, in blocks that +move `c`, so it gets the same answer as the record. + +## Adding a fork + +A fork is added in three places: + +1. A `CoreFork` variant with the next id, its key constant (`[b'F', id]`), its + `activation_key` arm, and a `StructureNode` for the key in + `packages/rs-drive/src/drive/system/structure.rs`, with `since` set to the + protocol version. Regenerate and commit the exported structure file, as + "Adding GroveDB structure" in the + [Coding Conventions](../contributing/coding-conventions.md#checklists) + describes. +2. A new `update_core_info` generation whose recording helper includes the fork + and its signal, selected by the protocol version that first consumes the + fork. The list of tracked forks belongs to the generation because each fork + brings its own signal code. Earlier generations keep their list, so earlier + protocol versions never ask about the new fork. +3. The consumers, which read `fetch_core_fork_activation_height` and follow the + rule in + [The window after a protocol upgrade](#the-window-after-a-protocol-upgrade). + Code that depends on a fork reads the record; it does not ask Core. + +Prefer a signal that Core enforces inside the block, as V24's coinbase version +is. + +### Forks without a block-enforced signal + +No tracked fork uses such a signal, and drive-abci has no code for one. Some +forks leave no trace in the block: `evo_shares`, for example, is an EHF +deployment (one that masternodes activate through a quorum-signed signal) with +no coinbase signal. The only way to ask Core about such a deployment `D` at `c` +is `getdeploymentinfo` at the block before it: +`getdeploymentinfo(getblockhash(c - 1))` reports `D`'s state for the block after +`c - 1`, which is `c`. `CoreRPCLike` has no call for it: `get_fork_info` reads +deployments at Core's tip, which only init chain may do. + +This answer comes from each Core binary's own deployment parameters rather than +from the block, so two Cores running different releases can answer differently +about the same block. Core also leaves out every deployment whose start time is +`NEVER_ACTIVE`. Tracking `D` in a protocol version therefore requires all of +these: + +- the minimum Core version dashmate requires must have `D`'s final parameters + for every network; +- `D` must be scheduled, not `NEVER_ACTIVE`, on every network that runs that + protocol version. Otherwise every Core leaves `D` out, the missing answer is + an error, and every block that moves `c` fails; +- drive-abci must check at startup that its Core reports `D`, before the node + signals support for that protocol version. + +## Possible evolution + +> **Not implemented.** This describes a possible future shape, not current +> code. + +Today each fork's signal is code inside an `update_core_info` generation. Once +a second fork needs tracking, the signals could become data: + +```rust +// A sketch, not in the codebase +enum CoreForkSignal { + /// The coinbase of block `c` has at least this payload version + CoinbasePayloadVersion(u16), + /// `getdeploymentinfo` at `getblockhash(c - 1)` reports the deployment active + Deployment(&'static str), +} +``` + +The list of tracked forks, each with its signal, would then move into the +version tables, beside `update_core_info`. Tracking a fork whose signal is of a +known kind would become a table entry in the protocol version that first +consumes it, instead of a new generation. With a single fork it is not worth +the indirection. diff --git a/book/src/contributing/coding-conventions.md b/book/src/contributing/coding-conventions.md index e734f4845b3..2bd4174ea78 100644 --- a/book/src/contributing/coding-conventions.md +++ b/book/src/contributing/coding-conventions.md @@ -193,6 +193,17 @@ set through `PlatformVersionCurrentVersion::set_current`. Tests that build platforms at different protocol versions race on it. Prefer the explicit `to_value(platform_version)` style API wherever a version is in hand. +### Consensus code reads Core at the chain-locked height + +Every validator asks its own Dash Core, so a read is deterministic only when it +asks about the block's chain-locked core height (`block_info.core_height`), +never Core's tip, and a Core error is never read as a default such as "not +active". [Syncing with Dash +Core](../architecture/component-pipeline.md#syncing-with-dash-core) explains why +and lists the rules for a new read, and [Core Fork +Activations](../architecture/core-fork-activations.md) describes the record that +code depending on a Core fork reads. + ### Dispatcher shape The [Versioned Dispatch](../versioning/versioned-dispatch.md) chapter shows the From 16f8f4e6b262250b4123e8851e6cf118c8b5384a Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 4 Nov 2026 12:05:16 +0700 Subject: [PATCH 2/2] docs: name the verifychainlock check that precedes chain lock submission Step 4 of the Syncing with Dash Core section now says that, from protocol version 15, a chain lock Platform cannot verify against its own quorums goes through Core's verifychainlock before it is submitted. Protocol versions up to 14 rely on submitchainlock alone. It also says that submitchainlock returns Core's best height without checking the signature when Core already holds a chain lock at or above that height. Documentation only, so there is no test. Co-Authored-By: Claude Opus 5.5 --- book/src/architecture/component-pipeline.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/book/src/architecture/component-pipeline.md b/book/src/architecture/component-pipeline.md index 8d4666f2516..eb236b7c3e5 100644 --- a/book/src/architecture/component-pipeline.md +++ b/book/src/architecture/component-pipeline.md @@ -331,10 +331,15 @@ reads Core. This is step 4, `verify_chain_lock` in `packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/`: 1. It checks the signature against the chain lock quorums in Platform's state. - When those cannot settle it, it leaves the check to its Core. + When those cannot settle it, it asks Core (`verify_chain_lock_through_core`): + from protocol version 15 it calls Core's `verifychainlock` first and refuses + the update if that check fails. Protocol versions up to 14 skip that call + and rely on the submission in step 2. 2. It submits the chain lock to its Core (`make_sure_core_is_synced_to_chain_lock`, Core's `submitchainlock`). Core answers with the height of the best chain - lock it now holds. + lock it now holds. Submitting does not by itself authenticate a chain lock: + when Core already holds one at or above its height, `submitchainlock` + returns that height without checking the signature. 3. If that height is at or above `c`, Core is synced. If it is one or two blocks short (`recent_block_count_amount`), drive-abci asks for Core's best chain lock up to five more times, 200 ms apart. If Core is further behind, or