Skip to content

fix(platform): harden v12 to v13 protocol upgrades#4222

Open
shumkov wants to merge 3 commits into
v4.2-devfrom
protocol-upgrade-v12-v13-review
Open

fix(platform): harden v12 to v13 protocol upgrades#4222
shumkov wants to merge 3 commits into
v4.2-devfrom
protocol-upgrade-v12-v13-review

Conversation

@shumkov

@shumkov shumkov commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Issue being fixed or feature implemented

The v12-to-v13 upgrade review found two concrete release-path defects:

  • speculative v13 loading could make explicit v12 system-contract cache reads return newer in-memory contracts after a rejected candidate block;
  • Dashmate beta.2 could stamp the new config format while leaving official Platform images pinned to stable :4 tags.

The repository also lacked a reusable, release-tag-pinned workflow for reviewing and rehearsing protocol upgrades end to end.

What was done?

  • Preserve exact system-contract materializations by protocol version, including historical reads after speculative loading.
  • Add lifecycle coverage for validator signaling, v12/v13 lock-in, v13 activation, restart recovery, GroveDB persistence, rejected-transition retry, and populated DPNS transfer history.
  • Bound Dashmate migrations to (fromVersion, toVersion] and add a beta.3 repair for exact official stable image pins while preserving custom registries, digests, and tags.
  • Add a shared protocol-upgrade review skill, checklist, comparison helper, and reviewed v12-to-v13 specification. Release certification requires immutable tags; mutable development refs are explicitly pre-release evidence only.

The review also confirmed that PoSe-banned validators may retain an earlier protocol vote. That behavior is intentionally unchanged here: v12 and v13 both dispatch the existing v0 methods, so changing persisted vote writes during this rolling release could make mixed binaries compute different roots. It needs a future protocol-versioned method change.

How Has This Been Tested?

All executed checks passed:

  • cargo test -p drive-abci execution::platform_events::protocol_upgrade --lib -- --nocapture — 43 passed
  • cargo test -p drive cache::system_contracts::tests --lib -- --nocapture — 3 passed
  • cargo test -p drive-abci --test strategy_tests run_chain_v12_to_v13_locks_in_before_activation -- --nocapture — 1 passed
  • cargo test -p drive-abci test_v12_username_survives_retried_v13_transition_and_records_transfer --lib -- --nocapture — 1 passed
  • yarn workspace dashmate exec mocha test/unit/config/configFile/migrateConfigFileFactory.spec.js --reporter spec — 5 passed
  • focused Dashmate ESLint, cargo fmt --all -- --check, and git diff --check
  • skill syntax/validation plus exact-tag review for v4.0.0v4.1.0-beta.2; non-tag targets fail unless explicitly enabled as pre-release review

The live three-validator rehearsal rolled published v4.0.0 Platform images to v4.1.0-beta.2 one validator at a time without stopping Core. It locked at height 82796 (current=12,next=13), activated at height 82912, and reached height 82949 with every node at current=13,next=13, catchingUp=false, and app hash c6845c8ed89d015f954d05ffbe01f9d7529834f4384415bbc81409cafcddb674.

This is not labeled full release certification. Three gates remain explicitly skipped:

  • live two-identity DPNS fixture and post-upgrade transfer;
  • live validator restart during the lock-in epoch;
  • rejected populated activation candidate through an entire public proposal round.

Their deterministic contract, restart, cache, rollback, and retry equivalents passed.

Breaking Changes

None.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

Summary by CodeRabbit

  • New Features

    • Added protocol-upgrade review and rehearsal workflows with structured evidence reporting and controlled execution modes.
    • Added automated checks for protocol version transitions, activation behavior, restarts, retries, and persisted state.
  • Bug Fixes

    • System contracts now remain correctly available for their applicable protocol versions during and after upgrades.
    • Configuration migrations no longer apply changes beyond the requested target version.
    • Customized Docker image settings are preserved during configuration upgrades.
  • Tests

    • Expanded coverage for protocol transitions, contract caching, retries, restarts, and configuration migration scenarios.

shumkov added 3 commits July 24, 2026 16:06
Keep exact protocol materializations in the system contract cache so speculative upgrade loading cannot leak a newer contract into historical reads.

Test would have caught this in CI: ✖ before fix, explicit v12/v8 reads returned the speculative v13/v9 objects; ✔ after fix, protocol-specific cache, rollback/retry, and upgrade lifecycle tests pass.
Bound config migrations to the requested target version and add the beta.3 repair for exact official :4 Platform image pins while preserving custom registries, digests, and tags.

Test would have caught this in CI: ✖ before fix, stable pins remained on :4 and beta.3 migrations ran for beta.2 targets; ✔ after fix, all five focused migration tests pass.
Add the shared review skill, release-tag comparison helper, transition checklist, and reviewed v12-to-v13 design and evidence. Release certification now requires immutable tags; mutable development refs are explicitly limited to pre-release review.
@shumkov
shumkov requested a review from QuantumExplorer as a code owner July 24, 2026 09:09
@github-actions github-actions Bot added this to the v4.1.0 milestone Jul 24, 2026
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a protocol-upgrade review skill and tooling, protocol-versioned system-contract caching, v12-to-v13 transition and restart tests, and Dashmate beta configuration migration safeguards.

Changes

Protocol upgrade validation

Layer / File(s) Summary
Review workflow and evidence contracts
.claude/skills/protocol-upgrade-test/SKILL.md, .claude/skills/protocol-upgrade-test/references/*, .claude/skills/protocol-upgrade-test/agents/openai.yaml, packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/check_for_desired_protocol_upgrade/*
Defines review modes, transition audits, deterministic test requirements, rehearsal procedures, evidence statuses, and updated epoch-threshold comments.
Release transition inspection tooling
.claude/skills/protocol-upgrade-test/scripts/review_transition.sh
Resolves release refs and SHAs, validates package and protocol support, and reports upgrade-sensitive files, symbols, tests, and focused diffs.
Protocol upgrade skill specification
docs/PROTOCOL_UPGRADE_TEST_SKILL_SPEC.md
Documents the v12-to-v13 rehearsal, reviewed behavior, testing layers, authority modes, and completion criteria.
Protocol-versioned system-contract cache
packages/rs-drive/src/cache/system_contracts.rs
Materializes system contracts by protocol version, enforces activation boundaries, reloads versioned entries, and tests version-specific lookup behavior.
Upgrade transition and restart coverage
packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns.rs, packages/rs-drive-abci/tests/strategy_tests/test_cases/upgrade_fork_tests.rs
Covers rejected and retried first-block transitions, DPNS preservation and transfer behavior, epoch activation, restarts, and cache reconstruction.
Dashmate beta configuration migration
packages/dashmate/configs/getConfigFileMigrationsFactory.js, packages/dashmate/src/config/configFile/migrateConfigFileFactory.js, packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js
Bounds migration selection by target version and repairs official beta image pins while preserving customized values.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant UpgradeTest
  participant FullAbciApplication
  participant SystemDataContracts
  participant DPNS
  UpgradeTest->>FullAbciApplication: run rejected v13 transition
  FullAbciApplication->>SystemDataContracts: query protocol-specific contracts
  SystemDataContracts-->>FullAbciApplication: preserve committed v12 state
  UpgradeTest->>FullAbciApplication: retry transition successfully
  FullAbciApplication->>SystemDataContracts: materialize v13 contracts
  UpgradeTest->>DPNS: execute post-upgrade transfer
  DPNS-->>UpgradeTest: return ownership and history results
Loading

Possibly related PRs

Suggested labels: ready for final review

Suggested reviewers: quantumexplorer

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: hardening the Platform v12-to-v13 upgrade path.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch protocol-upgrade-v12-v13-review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thepastaclaw

thepastaclaw commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

✅ Final review complete — no blockers (commit 8b50539)

@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.42105% with 10 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (v4.2-dev@25bbb37). Learn more about missing BASE report.

Files with missing lines Patch % Lines
packages/rs-drive/src/cache/system_contracts.rs 93.42% 10 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             v4.2-dev    #4222   +/-   ##
===========================================
  Coverage            ?   87.54%           
===========================================
  Files               ?     2667           
  Lines               ?   338191           
  Branches            ?        0           
===========================================
  Hits                ?   296055           
  Misses              ?    42136           
  Partials            ?        0           
Components Coverage Δ
dpp 88.49% <0.00%> (?)
drive 86.32% <0.00%> (?)
drive-abci 89.53% <0.00%> (?)
sdk ∅ <0.00%> (?)
dapi-client ∅ <0.00%> (?)
platform-version ∅ <0.00%> (?)
platform-value 92.88% <0.00%> (?)
platform-wallet ∅ <0.00%> (?)
drive-proof-verifier 49.79% <0.00%> (?)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final validation — Codex + Sonnet

This PR fixes a real bug where find_by_id() explicit protocol-version reads could observe a speculative reload_system_contracts() materialization for a different protocol version; the versioned BTreeMap cache and new regression tests (upgrade_fork_tests.rs, dpns.rs) correctly demonstrate and close that gap. Two moderate-confidence, non-blocking correctness gaps remain in the new find_by_id lazy-fill path: it doesn't replicate the DocumentHistory activation-version override used by reload_system_contracts, and it silently swallows load_system_data_contract errors as a plain not-found. Neither is exploitable at the current max protocol version (13), so this is a COMMENT rather than a blocker.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: claude-sonnet-5 — final-verifier
  • Sonnet reviewers: claude-sonnet-5 — general (completed), claude-sonnet-5 — security-auditor (completed), claude-sonnet-5 — ffi-engineer (completed)

🟡 2 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-drive/src/cache/system_contracts.rs`:
- [SUGGESTION] packages/rs-drive/src/cache/system_contracts.rs:260-294: find_by_id lazy-fill doesn't honor the DocumentHistory activation-version override
  reload_system_contracts (lines ~124-135) special-cases SystemDataContract::DocumentHistory: no matter which platform_version the reload is invoked with, the contract is always materialized using PlatformVersion::get(DOCUMENT_HISTORY_ACTIVATION_PROTOCOL_VERSION) (pinned to 13), because its schema uses index-aggregation keywords (`averageable`, `rangeCountable`, ...) that only that exact meta-schema recognizes.

  The new lazy-fill path in find_by_id doesn't apply this override: when a protocol version isn't yet present in contracts_by_protocol_version, line 288 materializes the missing entry with load_system_data_contract(system_contract, platform_version), where platform_version is derived directly from the requested protocol_version (line 260), not from DOCUMENT_HISTORY_ACTIVATION_PROTOCOL_VERSION.

  This is unobservable today because DOCUMENT_HISTORY_ACTIVATION_PROTOCOL_VERSION == 13 == LATEST_PLATFORM_VERSION, so the requested version and the forced override version always coincide. Once protocol v14+ ships (and per the doc comment this contract's schema is not expected to change again), any node that reaches the lazy-fill path for DocumentHistory at v14+ (e.g. a node that only reloaded its current version and is later asked for an older explicit version) will materialize the contract using the requested version's meta-schema instead of the pinned v13 one the normal reload path forces. Two nodes populating the same cache key through the two different code paths (reload vs. lazy-fill) could then diverge on what that key holds, which is exactly the footgun this PR's caching mechanism is meant to eliminate.
- [SUGGESTION] packages/rs-drive/src/cache/system_contracts.rs:288: find_by_id silently treats a load_system_data_contract error as "not a system contract"
  Before this PR, find_by_id could not fail: it returned Arc::clone(&active.load()) for any id matching a known system contract's static id. With the new lazy-fill path, once the id is confirmed to match a known SystemDataContract variant and the activation-version check has passed, a failure from load_system_data_contract(...) (e.g. a bug in a future protocol version's contract definition, or a PlatformVersionError) is swallowed via `.ok()?` and the function returns None.

  The caller, get_system_or_user_contract_with_fee_v0, treats None as "this id is not a cached system contract" and falls through to the normal fee-charged GroveDB contract fetch, which will most likely also fail to find a system contract id, producing a confusing NotFound instead of surfacing the real underlying load error. Distinguish "id doesn't match any system contract" from "id matches but materialization failed" (e.g. log the swallowed error, or return a Result) so a genuine internal defect isn't silently reinterpreted as an ordinary contract-not-found case.

Comment on lines +260 to +294
let platform_version = PlatformVersion::get(protocol_version).ok()?;
let (active, system_contract) = if id == SystemDataContract::Withdrawals.id() {
(&self.withdrawals, SystemDataContract::Withdrawals)
} else if id == SystemDataContract::MasternodeRewards.id() {
&self.masternode_reward_shares
(
&self.masternode_reward_shares,
SystemDataContract::MasternodeRewards,
)
} else if id == SystemDataContract::DPNS.id() {
&self.dpns
(&self.dpns, SystemDataContract::DPNS)
} else if id == SystemDataContract::Dashpay.id() {
&self.dashpay
(&self.dashpay, SystemDataContract::Dashpay)
} else if id == SystemDataContract::TokenHistory.id() {
&self.token_history
(&self.token_history, SystemDataContract::TokenHistory)
} else if id == SystemDataContract::KeywordSearch.id() {
&self.keyword_search
(&self.keyword_search, SystemDataContract::KeywordSearch)
} else if id == SystemDataContract::DocumentHistory.id() {
&self.document_history
(&self.document_history, SystemDataContract::DocumentHistory)
} else {
return None;
};
if active.active_since_protocol_version > protocol_version {
return None;
}
Some(Arc::clone(&active.load()))
if let Some(contract) = active.load_for_protocol_version(protocol_version) {
return Some(contract);
}

let contract = Arc::new(load_system_data_contract(system_contract, platform_version).ok()?);
let mut contracts_by_protocol_version = active.contracts_by_protocol_version.write();
Some(Arc::clone(
contracts_by_protocol_version
.entry(protocol_version)
.or_insert(contract),
))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: find_by_id lazy-fill doesn't honor the DocumentHistory activation-version override

reload_system_contracts (lines ~124-135) special-cases SystemDataContract::DocumentHistory: no matter which platform_version the reload is invoked with, the contract is always materialized using PlatformVersion::get(DOCUMENT_HISTORY_ACTIVATION_PROTOCOL_VERSION) (pinned to 13), because its schema uses index-aggregation keywords (averageable, rangeCountable, ...) that only that exact meta-schema recognizes.

The new lazy-fill path in find_by_id doesn't apply this override: when a protocol version isn't yet present in contracts_by_protocol_version, line 288 materializes the missing entry with load_system_data_contract(system_contract, platform_version), where platform_version is derived directly from the requested protocol_version (line 260), not from DOCUMENT_HISTORY_ACTIVATION_PROTOCOL_VERSION.

This is unobservable today because DOCUMENT_HISTORY_ACTIVATION_PROTOCOL_VERSION == 13 == LATEST_PLATFORM_VERSION, so the requested version and the forced override version always coincide. Once protocol v14+ ships (and per the doc comment this contract's schema is not expected to change again), any node that reaches the lazy-fill path for DocumentHistory at v14+ (e.g. a node that only reloaded its current version and is later asked for an older explicit version) will materialize the contract using the requested version's meta-schema instead of the pinned v13 one the normal reload path forces. Two nodes populating the same cache key through the two different code paths (reload vs. lazy-fill) could then diverge on what that key holds, which is exactly the footgun this PR's caching mechanism is meant to eliminate.

Suggested change
let platform_version = PlatformVersion::get(protocol_version).ok()?;
let (active, system_contract) = if id == SystemDataContract::Withdrawals.id() {
(&self.withdrawals, SystemDataContract::Withdrawals)
} else if id == SystemDataContract::MasternodeRewards.id() {
&self.masternode_reward_shares
(
&self.masternode_reward_shares,
SystemDataContract::MasternodeRewards,
)
} else if id == SystemDataContract::DPNS.id() {
&self.dpns
(&self.dpns, SystemDataContract::DPNS)
} else if id == SystemDataContract::Dashpay.id() {
&self.dashpay
(&self.dashpay, SystemDataContract::Dashpay)
} else if id == SystemDataContract::TokenHistory.id() {
&self.token_history
(&self.token_history, SystemDataContract::TokenHistory)
} else if id == SystemDataContract::KeywordSearch.id() {
&self.keyword_search
(&self.keyword_search, SystemDataContract::KeywordSearch)
} else if id == SystemDataContract::DocumentHistory.id() {
&self.document_history
(&self.document_history, SystemDataContract::DocumentHistory)
} else {
return None;
};
if active.active_since_protocol_version > protocol_version {
return None;
}
Some(Arc::clone(&active.load()))
if let Some(contract) = active.load_for_protocol_version(protocol_version) {
return Some(contract);
}
let contract = Arc::new(load_system_data_contract(system_contract, platform_version).ok()?);
let mut contracts_by_protocol_version = active.contracts_by_protocol_version.write();
Some(Arc::clone(
contracts_by_protocol_version
.entry(protocol_version)
.or_insert(contract),
))
let load_platform_version = if system_contract == SystemDataContract::DocumentHistory {
PlatformVersion::get(DOCUMENT_HISTORY_ACTIVATION_PROTOCOL_VERSION)
.map_err(ProtocolError::from)
.ok()?
} else {
platform_version
};
let contract = Arc::new(load_system_data_contract(system_contract, load_platform_version).ok()?);

source: ['claude']

return Some(contract);
}

let contract = Arc::new(load_system_data_contract(system_contract, platform_version).ok()?);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: find_by_id silently treats a load_system_data_contract error as "not a system contract"

Before this PR, find_by_id could not fail: it returned Arc::clone(&active.load()) for any id matching a known system contract's static id. With the new lazy-fill path, once the id is confirmed to match a known SystemDataContract variant and the activation-version check has passed, a failure from load_system_data_contract(...) (e.g. a bug in a future protocol version's contract definition, or a PlatformVersionError) is swallowed via .ok()? and the function returns None.

The caller, get_system_or_user_contract_with_fee_v0, treats None as "this id is not a cached system contract" and falls through to the normal fee-charged GroveDB contract fetch, which will most likely also fail to find a system contract id, producing a confusing NotFound instead of surfacing the real underlying load error. Distinguish "id doesn't match any system contract" from "id matches but materialization failed" (e.g. log the swallowed error, or return a Result) so a genuine internal defect isn't silently reinterpreted as an ordinary contract-not-found case.

Suggested change
let contract = Arc::new(load_system_data_contract(system_contract, platform_version).ok()?);
let contract = match load_system_data_contract(system_contract, platform_version) {
Ok(contract) => Arc::new(contract),
Err(e) => {
tracing::error!(
?system_contract,
protocol_version,
error = ?e,
"failed to lazily materialize system contract for protocol version"
);
return None;
}
};

source: ['claude']

@QuantumExplorer
QuantumExplorer changed the base branch from v4.1-dev to v4.2-dev July 24, 2026 20:12
@github-actions github-actions Bot modified the milestones: v4.1.0, v4.2.0 Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants