Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
34 changes: 34 additions & 0 deletions craft-nexus-contract/docs/versioned-state-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,40 @@ For every migration version, operators must strictly adhere to the following seq

For a WASM upgrade (as opposed to an in-place storage migration), combine this toolkit with the existing upgrade proposal flow: `propose_upgrade_wasm` (starts the `wasm_upgrade_cooldown` review window) → take a config backup → `execute_upgrade` once the cooldown elapses → run the relevant `migrate_*` functions → verify → only then consider the migration complete. `cancel_upgrade_wasm` remains available up until `execute_upgrade` is called, giving a staged, reviewable rollout instead of an atomic code swap.

## Differential Upgrade Compatibility Gate

An uploaded WASM and a successful unit-test run are not sufficient evidence for
an upgrade. Before execution, run the old and new artifacts against an isolated
fixture containing legacy profiles, active and disputed escrows, recurring
balances, stake queues, pending upgrades, and paused configuration. Compare
read results, authorization decisions, error classifications, invariants, and
events. Commit the pre-migration snapshot returned by
`get_upgrade_state_commitment` and the interface/authentication test results in
an `UpgradeCompatibilityManifest`.

Submit the manifest with `submit_upgrade_compatibility_manifest`. It must:

- identify the exact source and target contract versions;
- commit to storage preconditions, postconditions, interface behavior,
authorization behavior, and rollback limitations;
- include a resumable migration checkpoint;
- report `migration_complete: true` and `manual_records: 0`.

`execute_upgrade` rejects missing, stale, incomplete, or manually unresolved
manifests before calling `update_current_contract_wasm`. On success,
`UpgradeHistory` records the source and target versions, WASM hash, state
commitment, and migration checkpoint. The manifest is removed only after the
upgrade record and version update are written. A migration runner may replace
the manifest for the same hash while it resumes; each execution attempt is
idempotently blocked until the final checkpoint is submitted.

The manifest is an attestation boundary, not a substitute for isolated test
execution. CI and release tooling must fail closed when the differential
fixture, invariant suite, or rollback documentation does not produce all
non-zero commitments required by the on-chain gate. Records that cannot be
automatically migrated must remain outside execution until they are handled
and the manifest is resubmitted with a new checkpoint.

---

## Migration 1: UserProfile (v1 -> v2)
Expand Down
200 changes: 200 additions & 0 deletions craft-nexus-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@ pub enum Error {
AlreadyApproved = 43,
/// Token decimal places are outside the supported range (0–18)
InvalidTokenDecimals = 44,
/// No compatibility manifest has been submitted for the upgrade
UpgradeCompatibilityMissing = 45,
/// The compatibility manifest does not describe the current state/version
UpgradeCompatibilityInvalid = 46,
/// The migration report contains records that require manual handling
UpgradeMigrationIncomplete = 47,
/// Persisted storage is on a legacy layout that must be migrated first.
StorageLayoutMismatch = 45,
/// Admin action is in a terminal state (executed or cancelled)
Expand Down Expand Up @@ -544,6 +550,8 @@ pub enum DataKey {
TotalStaked(Address),
/// Bounded log of completed WASM upgrades. Capped at MAX_UPGRADE_HISTORY
UpgradeHistory,
/// Compatibility evidence for completed WASM upgrades.
UpgradeCompatibilityHistory,
/// Key for a recurring escrow by its ID
RecurringEscrow(u64),
/// ID counter for recurring escrows
Expand All @@ -564,6 +572,8 @@ pub enum DataKey {
/// Ledger timestamp (u64) recorded when the last upgrade proposal was
/// cancelled. Used to enforce CANCEL_REPROPOSE_COOLDOWN (Issue #618).
LastUpgradeCancelledAt,
/// Differential compatibility manifest keyed by the proposed WASM hash.
UpgradeCompatibilityManifest(BytesN<32>),
/// Structured evidence log for a disputed escrow order (#927)
EvidenceLog(u32),
/// Submitted evidence hash to prevent reuse across disputes (#927)
Expand Down Expand Up @@ -1201,6 +1211,52 @@ pub struct UpgradeRecord {
pub timestamp: u64,
}

/// Additive audit record for compatibility evidence. Kept separate from
/// `UpgradeRecord` so existing serialized upgrade history remains readable.
#[contracttype]
#[derive(Clone, Eq, PartialEq)]
#[cfg_attr(any(test, feature = "testutils"), derive(Debug))]
pub struct UpgradeCompatibilityRecord {
pub from_version: u32,
pub to_version: u32,
pub wasm_hash: BytesN<32>,
pub state_commitment: BytesN<32>,
pub migration_checkpoint: BytesN<32>,
pub timestamp: u64,
}

/// Evidence produced by an isolated old/new implementation compatibility run.
/// Hash fields commit to the complete manifest and its test evidence; the
/// contract deliberately does not trust an uncommitted human-readable report.
#[contracttype]
#[derive(Clone, Eq, PartialEq)]
#[cfg_attr(any(test, feature = "testutils"), derive(Debug))]
pub struct UpgradeCompatibilityManifest {
pub source_version: u32,
pub target_version: u32,
pub state_commitment: BytesN<32>,
pub interface_commitment: BytesN<32>,
pub authorization_commitment: BytesN<32>,
pub preconditions_commitment: BytesN<32>,
pub postconditions_commitment: BytesN<32>,
pub rollback_limitations_commitment: BytesN<32>,
pub migration_checkpoint: BytesN<32>,
pub migration_complete: bool,
pub manual_records: u32,
}

/// Stable, representative state used by migration tooling when creating a
/// differential snapshot. The resulting hash is supplied in the manifest.
#[contracttype]
#[derive(Clone, Eq, PartialEq)]
#[cfg_attr(any(test, feature = "testutils"), derive(Debug))]
pub struct UpgradeStateSnapshot {
pub contract_version: u32,
pub escrow_count: u32,
pub recurring_escrow_next_id: u64,
pub upgrade_threshold: u32,
pub paused: bool,
pub onboarding_configured: bool,
/// Immutable per-round state for the multi-sig upgrade approval flow.
///
/// Written once on the **first** approval call for a given proposal nonce and
Expand Down Expand Up @@ -5664,6 +5720,105 @@ impl CraftNexusContract {
.unwrap_or_else(|| Vec::new(&env))
}

/// Return the state summary that migration tooling must snapshot in its
/// isolated old/new differential run.
pub fn get_upgrade_state_snapshot(env: Env) -> UpgradeStateSnapshot {
let config = Self::get_platform_config_internal(&env);
UpgradeStateSnapshot {
contract_version: Self::get_version(env.clone()),
escrow_count: env
.storage()
.persistent()
.get(&DataKey::EscrowCount)
.unwrap_or(0),
recurring_escrow_next_id: env
.storage()
.persistent()
.get(&DataKey::NextRecurringEscrowId)
.unwrap_or(0),
upgrade_threshold: Self::get_upgrade_threshold(env.clone()),
paused: config.is_paused,
onboarding_configured: env
.storage()
.persistent()
.has(&DataKey::OnboardingContractAddress),
}
}

/// Hash the current representative state. Tooling should call this before
/// and after its isolated migration and place the pre-migration value in
/// `UpgradeCompatibilityManifest::state_commitment`.
pub fn get_upgrade_state_commitment(env: Env) -> BytesN<32> {
let snapshot = Self::get_upgrade_state_snapshot(env.clone());
env.crypto().sha256(&env.serialize(&snapshot))
}

fn is_zero_commitment(env: &Env, commitment: &BytesN<32>) -> bool {
commitment == &BytesN::<32>::from_array(env, &[0u8; 32])
}

fn validate_compatibility_manifest(
env: &Env,
manifest: &UpgradeCompatibilityManifest,
) -> Result<(), Error> {
let current_version = Self::get_version(env.clone());
if manifest.source_version != current_version
|| manifest.target_version != current_version.saturating_add(1)
|| Self::is_zero_commitment(env, &manifest.state_commitment)
|| Self::is_zero_commitment(env, &manifest.interface_commitment)
|| Self::is_zero_commitment(env, &manifest.authorization_commitment)
|| Self::is_zero_commitment(env, &manifest.preconditions_commitment)
|| Self::is_zero_commitment(env, &manifest.postconditions_commitment)
|| Self::is_zero_commitment(env, &manifest.rollback_limitations_commitment)
|| Self::is_zero_commitment(env, &manifest.migration_checkpoint)
{
return Err(Error::UpgradeCompatibilityInvalid);
}

if !manifest.migration_complete || manifest.manual_records != 0 {
return Err(Error::UpgradeMigrationIncomplete);
}

if manifest.state_commitment != Self::get_upgrade_state_commitment(env.clone()) {
return Err(Error::UpgradeCompatibilityInvalid);
}
Ok(())
}

/// Submit the differential compatibility evidence for a pending upgrade.
/// The manifest is resumable off-chain: a later submission replaces the
/// same hash's report, while execution accepts only a complete report with
/// no records requiring manual intervention.
pub fn submit_upgrade_compatibility_manifest(
env: Env,
wasm_hash: BytesN<32>,
manifest: UpgradeCompatibilityManifest,
) -> Result<(), Error> {
let admin = Self::get_admin(&env)?;
admin.require_auth();
Self::validate_upgrade_hash(&env, &wasm_hash)?;
if manifest.source_version != Self::get_version(env.clone()) {
return Err(Error::UpgradeCompatibilityInvalid);
}
env.storage().persistent().set(
&DataKey::UpgradeCompatibilityManifest(wasm_hash.clone()),
&manifest,
);
Self::extend_persistent(
&env,
&DataKey::UpgradeCompatibilityManifest(wasm_hash),
);
Ok(())
}

/// Read the compatibility evidence associated with a proposed WASM hash.
pub fn get_upgrade_compatibility_manifest(
env: Env,
wasm_hash: BytesN<32>,
) -> Option<UpgradeCompatibilityManifest> {
env.storage()
.persistent()
.get(&DataKey::UpgradeCompatibilityManifest(wasm_hash))
/// Return the persisted storage layout version.
pub fn get_storage_layout_version(env: Env) -> u32 {
env.storage()
Expand Down Expand Up @@ -5746,6 +5901,13 @@ impl CraftNexusContract {
return Err(Error::UpgradeCooldownActive);
}

let manifest: UpgradeCompatibilityManifest = env
.storage()
.persistent()
.get(&DataKey::UpgradeCompatibilityManifest(proposal.wasm_hash.clone()))
.ok_or(Error::UpgradeCompatibilityMissing)?;
Self::validate_compatibility_manifest(&env, &manifest)?;

env.deployer()
.update_current_contract_wasm(proposal.wasm_hash.clone());

Expand All @@ -5772,11 +5934,25 @@ impl CraftNexusContract {
timestamp: env.ledger().timestamp(),
},
);
Self::append_upgrade_compatibility_history(
&env,
UpgradeCompatibilityRecord {
from_version: current_version,
to_version: new_version,
wasm_hash: proposal.wasm_hash.clone(),
state_commitment: manifest.state_commitment,
migration_checkpoint: manifest.migration_checkpoint,
timestamp: env.ledger().timestamp(),
},
);

// Clear proposal
env.storage()
.persistent()
.remove(&DataKey::WasmUpgradeProposal);
env.storage().persistent().remove(&DataKey::UpgradeCompatibilityManifest(
proposal.wasm_hash.clone(),
));

Self::emit_upgrade_event(
&env,
Expand Down Expand Up @@ -5895,6 +6071,22 @@ impl CraftNexusContract {
Self::extend_persistent(env, &DataKey::UpgradeHistory);
}

fn append_upgrade_compatibility_history(env: &Env, record: UpgradeCompatibilityRecord) {
let mut history: Vec<UpgradeCompatibilityRecord> = env
.storage()
.persistent()
.get(&DataKey::UpgradeCompatibilityHistory)
.unwrap_or_else(|| Vec::new(env));
history.push_back(record);
while history.len() > MAX_UPGRADE_HISTORY {
history.pop_front();
}
env.storage()
.persistent()
.set(&DataKey::UpgradeCompatibilityHistory, &history);
Self::extend_persistent(env, &DataKey::UpgradeCompatibilityHistory);
}

/// Returns the bounded log of past contract upgrades (#241).
///
/// Newer entries are at the back. The log is capped at
Expand All @@ -5908,6 +6100,14 @@ impl CraftNexusContract {
.unwrap_or_else(|| Vec::new(&env))
}

/// Returns compatibility evidence for completed upgrades.
pub fn get_upgrade_compatibility_history(env: Env) -> Vec<UpgradeCompatibilityRecord> {
env.storage()
.persistent()
.get(&DataKey::UpgradeCompatibilityHistory)
.unwrap_or_else(|| Vec::new(&env))
}

/// Returns aggregate version + last-upgrade metadata (#241). Pairs the
/// scalar `ContractVersion` with the most recent `UpgradeRecord` so a
/// dashboard or migration script can read everything in one call.
Expand Down
66 changes: 66 additions & 0 deletions craft-nexus-contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2089,6 +2089,72 @@ fn test_contract_upgrade_unauthorized() {
client.execute_upgrade(&dummy_hash);
}

#[test]
fn test_upgrade_requires_compatibility_manifest() {
let env = Env::default();
env.mock_all_auths();
let (client, _, _, _, _, _, admin) = setup_test(&env, true);
let wasm = Bytes::from_array(&env, &[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]);
let wasm_hash = env.deployer().upload_contract_wasm(wasm);

client.propose_upgrade_wasm(&admin, &wasm_hash);
env.ledger().with_mut(|ledger| {
ledger.timestamp += DEFAULT_WASM_UPGRADE_COOLDOWN as u64 + 1;
});

let result = client.try_execute_upgrade(&wasm_hash);
assert!(matches!(result, Err(Ok(Error::UpgradeCompatibilityMissing))));
}

#[test]
fn test_upgrade_manifest_is_recorded_and_consumed() {
let env = Env::default();
env.mock_all_auths();
let (client, _, _, _, _, _, admin) = setup_test(&env, true);
let wasm = Bytes::from_array(&env, &[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]);
let wasm_hash = env.deployer().upload_contract_wasm(wasm);
let commitment = client.get_upgrade_state_commitment();
let nonzero = BytesN::from_array(&env, &[1u8; 32]);
let manifest = UpgradeCompatibilityManifest {
source_version: 1,
target_version: 2,
state_commitment: commitment.clone(),
interface_commitment: nonzero.clone(),
authorization_commitment: nonzero.clone(),
preconditions_commitment: nonzero.clone(),
postconditions_commitment: nonzero.clone(),
rollback_limitations_commitment: nonzero.clone(),
migration_checkpoint: nonzero,
migration_complete: true,
manual_records: 0,
};

client.propose_upgrade_wasm(&admin, &wasm_hash);
client.submit_upgrade_compatibility_manifest(&wasm_hash, &manifest);
assert_eq!(
client
.get_upgrade_compatibility_manifest(&wasm_hash)
.unwrap(),
manifest
);

env.ledger().with_mut(|ledger| {
ledger.timestamp += DEFAULT_WASM_UPGRADE_COOLDOWN as u64 + 1;
});
client.execute_upgrade(&wasm_hash);

let record = client
.get_upgrade_compatibility_history()
.last()
.unwrap();
assert_eq!(record.from_version, 1);
assert_eq!(record.to_version, 2);
assert_eq!(record.state_commitment, commitment);
assert!(client
.get_upgrade_compatibility_manifest(&wasm_hash)
.is_none());
}

#[test]
fn test_get_version_initially() {
let env = Env::default();
Expand Down
Loading