This document defines the formal invariant set that every contract migration (upgrade + state migration) must preserve. These invariants guard against common upgrade failures: dropped storage keys, orphaned cross-contract references, stale caches, and silent data corruption.
Monitoring: The Backup, DR & Migration dashboard (
monitoring/grafana/dashboards/backup-and-migration.json) surfaces the metrics that reflect migration health in real time:quorumproof_migration_status,quorumproof_migration_cursor,quorumproof_migration_last_progress_timestamp, andquorumproof_state_versioncardinality. Use it as a live view while verifying the invariants below during a migration run.
Every SoulboundToken in sbt_registry must reference an existing, un-revoked Credential in quorum_proof.
∀ token_id ∈ sbt_registry.Tokens:
∃ credential ∈ quorum_proof.Credentials
where credential.id == token.credential_idCheck: Enumerate all SBTs via sbt_registry.sbt_count() + sbt_registry.owner_of(). For each, call quorum_proof.credential_exists(token.credential_id).
Rationale: The cross-contract call map (docs/architecture.md) shows sbt_registry calls quorum_proof.get_credential during mint. An upgrade that renumbers credential IDs or drops credentials without updating SBTs produces unreachable SBTs — tokens that can never be verified.
For every QuorumSlice, the on-chain weight cache DataKey2::SliceTotalWeight(slice_id) must equal the sum of the slice's individual attestor weights.
∀ slice_id ∈ [1..SliceCount]:
stored_total_weight(slice_id) == Σ weights[0..n]
Check: Read each slice via quorum_proof.get_slice(id), sum weights, compare to the pre-migration value (captured before migration). After migration, re-read each slice and confirm the sum equals the pre-migration sum.
Rationale: A migration that accidentally drops or resets the SliceTotalWeight cache breaks any code that relies on it (e.g., threshold enforcement, weight-based voting). The observable invariant is that the weights remain correct; the cache is an implementation detail.
Credential IDs, Slice IDs, and Token IDs are assigned sequentially by monotonic counters (CredentialCount, SliceCount, TokenCount). After migration:
∀ id ∈ [1..CredentialCount] : credential_exists(id) == true∀ id ∈ [1..SliceCount] : slice_exists(id) == true∀ id ∈ [1..TokenCount] : owner_of(id) != error
No duplicate or gap-skipping IDs may be introduced.
Check: Iterate all IDs from 1 to each count and confirm existence. Confirm count is unchanged from pre-migration.
Rationale: A migration that resets counters or re-assigns IDs breaks off-chain indexing, audit trails, and any external references to specific credential/SBT IDs.
For every credential that existed before migration, after migration:
revokedflag is identicalsuspendedflag is identical (if applicable)expires_attimestamp is identicalis_revoked()returns the same value
∀ credential ∈ Credentials_before:
revoked_after(credential.id) == revoked_before(credential.id) &&
expires_at_after(credential.id) == expires_at_before(credential.id)Check: Before migration, iterate all credentials and capture revoked, suspended, expires_at. After migration, compare field-by-field.
Rationale: A migration that accidentally clears revocation state permits revoked credentials to pass verification, which is a security vulnerability.
sbt_registry may cache credential revocation status per CredentialCache(credential_id). After migration, every cache entry must match quorum_proof.is_revoked(credential_id).
∀ (credential_id, cached_revoked) ∈ sbt_registry.CredentialCache:
cached_revoked == quorum_proof.is_revoked(credential_id)Check: For each credential referenced by an SBT, compare the cached value against the live revocation state in quorum_proof.
Rationale: A stale cache can allow minting SBTs against newly revoked credentials, or falsely prevent minting against reinstated ones.
The DataKey::Admin address must be unchanged after migration.
admin_after == admin_before
Check: Verify that the original admin address can still perform admin-only operations (pause, unpause, migrate_state) after upgrade.
Rationale: An upgrade that drops or resets the admin storage key locks the contract permanently.
If the contract was paused before migration began, it must remain paused after migration completes. If unpaused, it must remain unpaused.
paused_after == paused_before
Check: quorum_proof.is_paused() returns same value before and after migration.
Rationale: An unexpected state change could allow operations during a maintenance window or block operations after a routine upgrade.
The StateVersion stored at DataKey::StateVersion must never regress across a migration.
state_version_after >= state_version_before
Check: quorum_proof.get_state_version().
Rationale: Version regressions indicate a migration was applied out of order or a rollback happened incorrectly, which can confuse lazy-migration logic that checks the current version.
| ID | Invariant | Scope | Enforced by |
|---|---|---|---|
| I1 | No orphaned SBT-to-credential references | Cross-contract | Migration harness |
| I2 | Slice weight cache matches live weights | quorum_proof | Migration harness |
| I3 | No ID collisions post-migration | quorum_proof + sbt_registry | Migration harness |
| I4 | Revocation/expiry state preserved | quorum_proof | Migration harness |
| I5 | Cross-contract credential cache consistent | quorum_proof ↔ sbt_registry | Migration harness |
| I6 | Admin identity preserved | quorum_proof | Migration harness |
| I7 | Paused state preserved | quorum_proof | Migration harness |
| I8 | StateVersion monotonically non-decreasing | quorum_proof | Migration harness |
The migration verification harness (see contracts/integration_tests/src/migration_verification.rs) guarantees:
- Before/after semantic equivalence: All observable contract state captured through the public API is preserved across a migration.
- Cross-contract reference integrity: No orphaned links exist between
sbt_registryandquorum_proofafter migration. - Cache/state consistency: Cached values have not drifted from their source-of-truth values.
- Administrative continuity: The contract remains controllable by the original admin.
- Arbitrary code correctness: The harness only checks state before vs. after. It does not prove that the new WASM bytecode is free of logic bugs.
- Storage-level bitwise equivalence: The harness checks semantic preservation through the public API, not raw storage key equality. Cache reorganizations or key renames that preserve the public contract are acceptable.
- Performance characteristics: Migration time, gas costs, and storage footprint are outside scope.
- Off-chain indexer compatibility: The harness does not test external databases or event indexers that may depend on specific storage layouts.
- Concurrent migration safety: The harness assumes single-threaded, single-admin migration execution.
- Future invariant coverage: New invariants may need to be added for new features. This set covers the core protocol.
Add a new invariant when:
- A new storage key or cache is introduced that has a source-of-truth dependency
- A new cross-contract reference is added to the call map (see
docs/architecture.md) - A new field is added to an existing struct and the migration preserves it