Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::platform_types::platform_state::PlatformState;
use crate::platform_types::platform_state::PlatformStateV0Methods;
use dpp::block::block_info::BlockInfo;
use dpp::dashcore::hashes::Hash;
use dpp::data_contract::accessors::v0::DataContractV0Getters;
use dpp::data_contracts::SystemDataContract;
use dpp::fee::Credits;
use dpp::platform_value::Identifier;
Expand Down Expand Up @@ -688,6 +689,28 @@ impl<C> Platform<C> {
platform_version,
)?;

// CONSENSUS-CRITICAL. Both writes above go straight to state and bypass the drive
// operation batch, whose finalization task is what normally evicts a superseded
// contract from the data contract cache. A migration that rewrites a contract
// that may already be cached must therefore refresh the cache explicitly.
//
// Here that is DPNS, and the consequences of skipping it are severe: the pre-v13
// DPNS was stored with a v0 `DataContractConfig`, the v2 contract written above
// carries a v1 config, and `DocumentV0::serialize` selects `serialize_v0` for
// the former and `serialize_v2` for the latter. A node holding the pre-migration
// DPNS in cache would keep writing DPNS documents with a `00` version prefix
// while a node with a cold cache writes `02` — the same block, two different app
// hashes.
//
// The document history contract needs no refresh: it first comes into existence
// in this transition, and the data contract cache holds no negative entries, so
// no node can have a stale copy.
self.drive.refresh_data_contract_cache_from_state(
dpns_contract.id().to_buffer(),
Some(transaction),
platform_version,
)?;

Ok(())
}
}
Expand Down Expand Up @@ -1014,6 +1037,134 @@ mod tests {
assert!(domain.documents_keep_pricing_history());
}

/// CONSENSUS REGRESSION, reproducing the testnet divergence at block 467,976.
///
/// DPNS has been stored with a **v0** `DataContractConfig` ever since it was
/// registered at genesis under protocol version 1. The v13 transition rewrites it
/// with the v2 schema, which carries a **v1** config. That rewrite goes straight to
/// state and bypasses the drive operation batch, whose finalization task is what
/// normally evicts a superseded contract from the data contract cache — so a node
/// that had been up long enough to have read DPNS once kept serving the v0-config
/// copy for the rest of its process lifetime, while a node whose cache was cold read
/// the migrated one.
///
/// That difference reaches state. `DocumentV0::serialize` selects `serialize_v0` for
/// a contract whose config is v0 and `serialize_v2` otherwise, so the warm node wrote
/// DPNS documents with a leading `00` and the cold node wrote the identical document
/// with a leading `02`. One byte, a different Merk node hash, a different app hash.
#[test]
fn test_transition_to_version_13_refreshes_warm_dpns_contract_cache() {
use dpp::data_contract::accessors::v0::DataContractV0Getters;
use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters;
use dpp::data_contract::document_type::random_document::CreateRandomDocument;
use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0;
use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract};

let platform = TestPlatformBuilder::new()
.with_initial_protocol_version(12)
.build_with_mock_rpc()
.set_genesis_state();

let platform_version_12 = PlatformVersion::get(12).expect("expected platform version 12");
let platform_version = PlatformVersion::get(13).expect("expected platform version 13");

let dpns_id = *SystemDataContract::DPNS.id().as_bytes();

// Put state where testnet actually was: DPNS as it was written at genesis under
// protocol version 1, carrying a v0 config. Nothing had rewritten it since.
let genesis_dpns =
load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::first())
.expect("expected to load the genesis-era DPNS contract");

platform
.drive
.apply_contract(
&genesis_dpns,
BlockInfo::default(),
true,
None,
None,
platform_version_12,
)
.expect("expected to store the genesis-era DPNS contract");

// Warm the global cache the way a long-lived node does: a read with no
// transaction, which is also the path every read-only DAPI query takes.
let warm = platform
.drive
.get_contract_with_fetch_info(dpns_id, true, None, platform_version_12)
.expect("expected to fetch DPNS")
.expect("expected DPNS to exist")
.contract
.clone();

let warm_preorder = warm
.document_type_for_name("preorder")
.expect("expected the preorder document type");
let document = warm_preorder
.random_document(Some(42), platform_version_12)
.expect("expected to build a preorder document");

assert_eq!(
document
.serialize(warm_preorder, &warm, platform_version_12)
.expect("expected to serialize")
.first()
.copied(),
Some(0),
"precondition: the pre-migration contract serializes preorders with the v0 prefix"
);

let transaction = platform.drive.grove.start_transaction();

let block_info = BlockInfo {
time_ms: 1_000_000,
height: 100,
core_height: 100,
epoch: Epoch::new(1).expect("expected epoch"),
};

platform
.transition_to_version_13(&block_info, &transaction, platform_version)
.expect("expected the transition to succeed");

// The block execution read path: a transactional fetch, which consults the block
// cache first and then falls back to the global cache. Before the fix this fell
// through to the stale global entry and returned the v0-config contract.
let migrated = platform
.drive
.get_contract_with_fetch_info(dpns_id, false, Some(&transaction), platform_version)
.expect("expected to fetch DPNS")
.expect("expected DPNS to exist after the transition")
.contract
.clone();

assert!(
migrated
.document_type_for_name("domain")
.expect("expected the domain document type")
.documents_keep_transfer_history(),
"a warm node must see the migrated DPNS v2 contract, not its cached copy"
);

// The consensus-visible consequence, asserted directly: the very same preorder
// document must now serialize with the v2 prefix, matching what a node with a
// cold cache writes into the block.
let migrated_preorder = migrated
.document_type_for_name("preorder")
.expect("expected the preorder document type");

assert_eq!(
document
.serialize(migrated_preorder, &migrated, platform_version)
.expect("expected to serialize")
.first()
.copied(),
Some(2),
"a warm node must serialize preorders identically to a cold node after the migration"
);
}

// test_transition_to_version_9 removed: requires prior state from versions 4-8

#[test]
Expand Down
1 change: 1 addition & 0 deletions packages/rs-drive/src/drive/contract/migration/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
mod refresh_contract_cache;
mod strip_unknown_document_schema_properties;
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
use crate::drive::Drive;
use crate::error::Error;
use dpp::version::PlatformVersion;
use grovedb::TransactionArg;

impl Drive {
/// Re-reads a data contract from state and re-seeds the in-memory data contract cache
/// with it. Call this from a protocol-upgrade migration for every contract the
/// migration **rewrites** — i.e. any contract that existed before the migration and
/// so may already sit in a node's cache. A contract the migration introduces for the
/// first time needs no refresh: the cache holds no negative entries, so no node can
/// have a stale copy of a contract that never existed.
///
/// CONSENSUS-CRITICAL. Contracts written by a state transition go through the drive
/// operation batch, whose `RemoveDataContractFromCache` finalization task evicts the
/// superseded copy from the cache. Migrations write contracts directly
/// (`insert_contract` / `apply_contract`) and bypass that machinery entirely, so
/// without this call a node that already holds the pre-migration contract in its
/// global cache keeps serving that copy for the rest of the process lifetime, while a
/// node whose cache is cold (freshly restarted, or the entry was evicted under
/// capacity pressure) reads the migrated one. The two nodes then serialize documents
/// of that contract against different `DataContract`s — a difference that reaches
/// state, because `DocumentV0::serialize` picks its serialization version from the
/// contract's config version — and produce different app hashes from the same block.
///
/// The refreshed contract is placed in the **block** cache, not the global cache: at
/// this point the migration's write is still uncommitted, and the block cache is the
/// only cache that transactional reads consult first. It is promoted to the global
/// cache once the block commits (`merge_and_clear_block_cache`), and dropped at the
/// start of the next block if the block never commits (`clear_block_cache`).
///
/// The read deliberately bypasses both caches rather than going through
/// `get_contract_with_fetch_info`: a concurrent read-only query thread (which reads
/// committed state, with no transaction, and does populate the global cache) could
/// otherwise race a pre-migration copy back into the global cache between the
/// eviction and the re-seed.
///
/// Billing is unaffected. `fetch_contract_v0` computes the cached `OperationCost`
/// with grovedb value caching disabled precisely so the cost of a contract fetch is
/// deterministic, and derives `fee` from that cost only when an epoch is supplied —
/// so a cache hit seeded here bills identically to the cold fetch it replaces.
pub fn refresh_data_contract_cache_from_state(
&self,
contract_id: [u8; 32],
transaction: TransactionArg,
platform_version: &PlatformVersion,
) -> Result<(), Error> {
// Cache-bypassing read: the contract exactly as state now holds it.
let maybe_fetch_info = self.fetch_contract_and_add_operations(
contract_id,
None,
transaction,
&mut vec![],
platform_version,
)?;

// Drop the pre-migration copy from both the block and the global cache.
self.cache.data_contracts.remove(contract_id);

// Re-seed with what state now holds. A migration always writes the contract it
// asks us to refresh, so `None` here means the contract genuinely is not in
// state; leaving the caches empty is then the correct outcome.
if let Some(fetch_info) = maybe_fetch_info {
self.cache
.data_contracts
.insert(fetch_info, transaction.is_some());
Comment on lines +64 to +73

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.

🔴 Blocking: Delayed DAPI cache fill can overwrite migrated DPNS after promotion

The remove-and-reseed sequence is not synchronized with cache-populating non-transactional reads. After remove, a DAPI document query can miss the global cache, fetch the old DPNS from committed GroveDB through get_contract_with_fetch_info_and_add_to_operations_v0, and pause before its unconditional global-cache insertion. The migration meanwhile inserts the new contract into the block cache, and update_drive_cache promotes it through merge_and_clear_block_cache; when the query resumes, its later global-cache insert replaces the migrated entry with the old contract. The query service's committed-height retry does not repair this because the cache mutation occurs before the height recheck and is not rolled back; a retry can then read the same stale cache entry. At the next block, clear_block_cache leaves that global entry intact, so transactional execution can again serialize DPNS documents using the stale 00 format instead of 02, producing node-dependent app hashes. Publication must use per-key synchronization, generation-aware conditional insertion, or equivalent commit/snapshot ordering that prevents a fetch begun against old committed state from overwriting the migrated entry.

source: ['codex']

}

Ok(())
}
}

#[cfg(test)]
mod tests {
use crate::drive::contract::DataContractFetchInfo;
use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure;
use dpp::block::block_info::BlockInfo;
use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters};
use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract};
use dpp::version::PlatformVersion;
use std::sync::Arc;

/// A contract written directly to state must replace a stale cached copy, so that a
/// warm node and a cold node read the same contract afterwards.
#[test]
fn test_refresh_replaces_stale_cached_contract() {
let drive = setup_drive_with_initial_state_structure(None);
let platform_version = PlatformVersion::latest();
let transaction = drive.grove.start_transaction();

let dpns = load_system_data_contract(SystemDataContract::DPNS, platform_version)
.expect("expected to load DPNS");
let contract_id = dpns.id().to_buffer();

// Warm the global cache with a contract that does NOT match state: a distinct
// version stands in for the pre-migration copy a long-lived node would hold.
let mut stale = dpns.clone();
stale.set_version(u32::MAX);
drive.cache.data_contracts.insert(
Arc::new(DataContractFetchInfo {
contract: stale,
storage_flags: None,
cost: Default::default(),
fee: None,
}),
false,
);

drive
.apply_contract(
&dpns,
BlockInfo::default(),
true,
None,
Some(&transaction),
platform_version,
)
.expect("expected to apply contract");

// Without the refresh this still reads the stale copy out of the global cache.
drive
.refresh_data_contract_cache_from_state(
contract_id,
Some(&transaction),
platform_version,
)
.expect("expected to refresh cache");

let refreshed = drive
.get_contract_with_fetch_info(contract_id, false, Some(&transaction), platform_version)
.expect("expected to fetch contract")
.expect("expected the contract to be present");

assert_eq!(refreshed.contract.version(), dpns.version());
}

/// The refresh must seed the block cache, not the global cache: the migration's write
/// is still uncommitted, so a non-transactional reader must not see it yet.
#[test]
fn test_refresh_seeds_block_cache_not_global_cache() {
let drive = setup_drive_with_initial_state_structure(None);
let platform_version = PlatformVersion::latest();
let transaction = drive.grove.start_transaction();

let dpns = load_system_data_contract(SystemDataContract::DPNS, platform_version)
.expect("expected to load DPNS");
let contract_id = dpns.id().to_buffer();

drive
.apply_contract(
&dpns,
BlockInfo::default(),
true,
None,
Some(&transaction),
platform_version,
)
.expect("expected to apply contract");

drive
.refresh_data_contract_cache_from_state(
contract_id,
Some(&transaction),
platform_version,
)
.expect("expected to refresh cache");

assert!(
drive.cache.data_contracts.get(contract_id, true).is_some(),
"transactional reads must see the refreshed contract"
);
assert!(
drive.cache.data_contracts.get(contract_id, false).is_none(),
"the uncommitted contract must not be visible in the global cache"
);
}
}
Loading