diff --git a/README.md b/README.md
index 033bd5b1..58b6a3ef 100644
--- a/README.md
+++ b/README.md
@@ -219,10 +219,33 @@ yarn compile
### Run tests
+The per-module specs, in the simulator:
+
```bash
yarn test
```
+The integration specs, which compose several modules into one contract:
+
+```bash
+yarn test:integration
+```
+
+Some behaviour has no simulator equivalent — the contract maintenance
+authority, and anything that turns on real proving or finality. Those blocks
+are skipped above and need the local stack:
+
+```bash
+make env-up
+yarn test:live # the per-module specs
+yarn test:integration:live # the integration specs
+make env-down
+```
+
+Expect the live runs to be slow: a fresh deploy plus its wallet sync dominates
+each spec file. See [`contracts/test/integration/README.md`](contracts/test/integration/README.md)
+for what the integration suite covers.
+
### Check/apply Biome formatter
```bash
diff --git a/contracts/test-utils/harness/live.setup.ts b/contracts/test-utils/harness/live.setup.ts
index fb31cc09..5c87249e 100644
--- a/contracts/test-utils/harness/live.setup.ts
+++ b/contracts/test-utils/harness/live.setup.ts
@@ -5,6 +5,7 @@ import { assertFunded } from './dust.js';
import { FundedWallet } from './FundedWallet.js';
import { fundFromDeployer } from './funding.js';
import { LiveSimulatorBackend } from './LiveSimulatorBackend.js';
+import { publishLivePool } from './livePool.js';
import { localEnv } from './network.js';
import {
MAX_LIVE_WORKERS,
@@ -107,6 +108,9 @@ const backend = new LiveSimulatorBackend(pool, env);
backend.register();
await pool.ensureReady();
+// Hand the built pool to specs that deploy outside the simulator (see livePool).
+publishLivePool(pool);
+
// Worker ready: wallets funded, backend registered. Printed after the (slow)
// wallet build, before any spec in this worker runs — a "we're live" pointer.
console.log(
diff --git a/contracts/test-utils/harness/livePool.ts b/contracts/test-utils/harness/livePool.ts
new file mode 100644
index 00000000..939cacd9
--- /dev/null
+++ b/contracts/test-utils/harness/livePool.ts
@@ -0,0 +1,36 @@
+import type { WalletPool } from './WalletPool.js';
+
+/**
+ * The worker's live {@link WalletPool}, published by `live.setup` once its
+ * wallets are built and readable by any spec in the same worker.
+ *
+ * Specs that deploy through the simulator never need this — `Sim.create()`
+ * routes to the pool inside {@link LiveSimulatorBackend}. It exists for the
+ * few that must hold a raw midnight-js `DeployedContract` (CMA maintenance
+ * txs), which the simulator's `LiveContext` does not expose. Borrowing the
+ * built wallets keeps those specs on the same UTXO view as everything else;
+ * a second wallet on the same seed would race it.
+ */
+
+let pool: WalletPool | undefined;
+
+/** Publish the worker's pool. Called by `live.setup` after `ensureReady()`. */
+export function publishLivePool(livePool: WalletPool): void {
+ pool = livePool;
+}
+
+/** Clear the published pool (for the harness' own unit tests). */
+export function clearLivePool(): void {
+ pool = undefined;
+}
+
+/** The worker's pool, or a pointer to the missing live setup. */
+export function requireLivePool(): WalletPool {
+ if (!pool) {
+ throw new Error(
+ 'live wallet pool not published — this spec needs MIDNIGHT_BACKEND=live ' +
+ 'and a project whose setupFiles include live.setup.ts',
+ );
+ }
+ return pool;
+}
diff --git a/contracts/test-utils/harness/test/livePool.test.ts b/contracts/test-utils/harness/test/livePool.test.ts
new file mode 100644
index 00000000..a95a7988
--- /dev/null
+++ b/contracts/test-utils/harness/test/livePool.test.ts
@@ -0,0 +1,29 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import {
+ clearLivePool,
+ publishLivePool,
+ requireLivePool,
+} from '../livePool.js';
+import type { WalletPool } from '../WalletPool.js';
+
+describe('live pool handoff', () => {
+ afterEach(() => {
+ clearLivePool();
+ });
+
+ it('should return the published pool', () => {
+ const pool = {} as WalletPool;
+ publishLivePool(pool);
+ expect(requireLivePool()).toBe(pool);
+ });
+
+ it('should point at the missing live setup when nothing is published', () => {
+ expect(() => requireLivePool()).toThrow(/live wallet pool not published/);
+ });
+
+ it('should forget the pool once cleared', () => {
+ publishLivePool({} as WalletPool);
+ clearLivePool();
+ expect(() => requireLivePool()).toThrow();
+ });
+});
diff --git a/contracts/test/integration/README.md b/contracts/test/integration/README.md
index 0979f99f..fb51ca12 100644
--- a/contracts/test/integration/README.md
+++ b/contracts/test/integration/README.md
@@ -6,3 +6,192 @@ through the simulator, covering interactions the per-module unit tests can't.
```sh
yarn test:integration
```
+
+Some specs need a chain rather than the simulator — the Contract Maintenance
+Authority has no dry equivalent. Those are gated on `isLiveBackend()`, so the
+command above skips them. To run them, bring up the local stack and use the
+live variant:
+
+```sh
+make env-up
+yarn test:integration:live
+```
+
+## Layout
+
+- `specs/` — grouped by the surface under test (`accessControl/`, `cma/`,
+ `upgrades/`, plus a top-level `smoke.spec.ts`).
+- `fixtures/` — per-contract deploy factories. `testTokenV1.ts` deploys and
+ returns a kit; `testTokenV2.ts` supplies V2's verifier keys and a V2-shaped
+ handle on the V1 contract.
+- `_harness/` — the CMA wrappers, the provider builder, and caller identity.
+ Wallets, network config and the live setup come from
+ [`test-utils/harness`](../../test-utils/harness).
+- `_mocks/` — test-only `.compact` contracts.
+
+### Callers
+
+The access modules derive identity from a secret key the caller injects
+through a `wit_*SK` witness, not from the wallet that submits the tx. An alias
+here is therefore a private state: `kit.as('ADMIN')` returns a handle whose
+witnesses answer with `ADMIN`'s key, and every alias pays from the same funded
+deployer wallet. `_harness/identity.ts` owns the alias keys and the account-id
+derivation.
+
+Specs that need a raw `DeployedContract` — the CMA maintenance surface, which
+the simulator's `LiveContext` does not expose — deploy through
+`_harness/deploy.ts` and borrow the worker's wallets via
+`test-utils/harness/livePool.ts`.
+
+## Contract Maintenance Authority
+
+Every deployed contract carries a `ContractMaintenanceAuthority` in its
+`ContractState`:
+
+```
+maintenanceAuthority: {
+ committee: SigningKey[] // signers
+ threshold: bigint // m-of-n
+ counter: bigint // monotonic, replay protection
+}
+```
+
+alongside one verifier-key slot per circuit (`_mint`, `pause`, `grantRole`, …).
+Both are mutated only by a `MaintenanceUpdate` tx, which carries a list of
+`SingleUpdate`s, is signed by the current authority, and is built against the
+current counter.
+
+`SingleUpdate` comes in three shapes, from `@midnight-ntwrk/ledger-v8`:
+
+- `VerifierKeyInsert(op, vk)` — fill an empty slot
+- `VerifierKeyRemove(op, version)` — clear an occupied slot
+- `ReplaceAuthority(authority)` — rotate the authority itself
+
+```mermaid
+flowchart TB
+ SU(["SingleUpdate"])
+ CS["ContractState (per address)"]
+
+ CS --> CMA["maintenanceAuthority
{ committee, threshold, counter }"]
+ CS --> Slots["VK slots, one per circuit"]
+ Slots --> M["_mint: VK"]
+ Slots --> P["pause: VK"]
+ Slots --> G["grantRole: VK"]
+ Slots --> O["...other ops"]
+
+ SU -->|VerifierKeyInsert| Slots
+ SU -->|VerifierKeyRemove| Slots
+ SU -->|ReplaceAuthority| CMA
+ SU -.advances counter.-> CMA
+```
+
+### Two write paths
+
+The SDK wraps exactly one `SingleUpdate` per tx and hides the counter and
+signing:
+
+- `deployed.circuitMaintenanceTx[op].insertVerifierKey(vk)`
+- `deployed.circuitMaintenanceTx[op].removeVerifierKey()`
+- `deployed.contractMaintenanceTx.replaceAuthority(newKey)`
+
+Anything the SDK guards against — multi-update bundles, a forged counter, an
+empty committee, a signature addressed elsewhere — needs the ledger objects
+built and signed by hand. `submitRawMaintenanceUpdate` in
+[`_harness/cma.ts`](_harness/cma.ts) is that path:
+
+```mermaid
+sequenceDiagram
+ participant Spec as Test spec
+ participant H as Harness (cma.ts)
+ participant I as Indexer
+ participant L as ledger-v8
+ participant N as Midnight node
+
+ Spec->>+H: submitRawMaintenanceUpdate(addr, [SU...])
+ H->>+I: queryContractState(addr)
+ I-->>-H: counter
+ H->>L: new MaintenanceUpdate(addr, SU[], counter)
+ L-->>H: mu (with dataToSign)
+ H->>H: signData(authorityKey, mu.dataToSign)
+ H->>L: mu.addSignature(0n, sig)
+ H->>L: Intent.new(ttl).addMaintenanceUpdate(signed)
+ H->>L: Transaction.fromParts(network, _, _, intent)
+ H->>+N: submitTx({ unprovenTx })
+ alt entire bundle applied
+ N-->>H: SucceedEntirely
+ else bundle reverts as a unit
+ N-->>H: FailFallible
+ else refused at submission
+ N--xH: SubmissionError
+ end
+ deactivate N
+ H-->>-Spec: result
+```
+
+### Harness wrappers
+
+- `rotateCircuitVK(providers, deployed, op, newVk?)` — remove then insert, two
+ txs, counter +2
+- `rotateAuthority(deployed, newKey)`
+- `freeze(deployed)` — install a key and discard it
+- `submitRawMaintenanceUpdate(providers, addr, updates, counterOverride?)`
+
+## What the specs establish
+
+### Baseline
+
+- [`smoke`](specs/smoke.spec.ts) — the composed mock deploys and every module's
+ initial ledger reads back.
+- [`accessControl/witnessIdentity`](specs/accessControl/witnessIdentity.spec.ts)
+ — role checks follow the witness key, not the submitting wallet.
+
+### CMA behaviour
+
+- [`cma/rotation`](specs/cma/rotation.spec.ts) — `replaceAuthority` installs a
+ new key and advances the counter; the new key works, the old one does not.
+- [`cma/freeze`](specs/cma/freeze.spec.ts) — rotating to a discarded key ends
+ maintenance for good.
+- [`cma/emptyCommitteeFreeze`](specs/cma/emptyCommitteeFreeze.spec.ts) — an
+ empty committee is refused, so the discarded-key freeze is the only route.
+- [`cma/staleCounter`](specs/cma/staleCounter.spec.ts) — an update signed
+ against a superseded counter is refused.
+- [`cma/crossContractReplay`](specs/cma/crossContractReplay.spec.ts) — a
+ signature is bound to the contract it names.
+- [`cma/multiUpdate`](specs/cma/multiUpdate.spec.ts) — two `ReplaceAuthority`s
+ in one bundle are refused; two inserts on one operation produce a tx the
+ chain accepts and a bundle that reverts whole.
+- [`cma/multiVkBundle`](specs/cma/multiVkBundle.spec.ts) — verifier-key bundles
+ across different operations apply in full, in all three shapes.
+- [`cma/mixedBundle`](specs/cma/mixedBundle.spec.ts) — a `ReplaceAuthority`
+ cannot share a bundle with another kind, in either order.
+
+### Upgrade pathway
+
+- [`upgrades/vkCoexistence`](specs/upgrades/vkCoexistence.spec.ts) — the SDK
+ refuses a second key on an occupied slot, so an upgrade is always a sequenced
+ remove-then-insert.
+- [`upgrades/stateSurvival`](specs/upgrades/stateSurvival.spec.ts) — a rotation
+ leaves a heterogeneous ledger untouched and advances the counter by 2.
+- [`upgrades/functionalReverification`](specs/upgrades/functionalReverification.spec.ts)
+ — every rotated circuit still proves and verifies.
+- [`upgrades/crossModuleIsolation`](specs/upgrades/crossModuleIsolation.spec.ts)
+ — rotating one module's circuit leaves sibling modules' state alone.
+- [`upgrades/versionUpgrade`](specs/upgrades/versionUpgrade.spec.ts) — a V1→V2
+ bump lands a tightened body, a new authorization gate, a relaxed guard, a
+ decommissioned circuit, and a circuit V1 never had.
+
+## Bundle rules, as observed
+
+| Bundle | Outcome |
+|---|---|
+| One `SingleUpdate` | Applied. Counter +1. |
+| Verifier-key updates on different operations | Applied in full (`SucceedEntirely`), in any mix of insert and remove. |
+| Two inserts on the same operation | Tx finalizes `FailFallible`; the bundle reverts whole, so neither insert lands. |
+| More than one `ReplaceAuthority` | Refused at submission. |
+| `ReplaceAuthority` alongside any other kind | Refused at submission, in either order. |
+| `ReplaceAuthority(committee=[])` | Refused at submission. A CMA keeps at least one key. |
+
+Not yet pinned: whether an N-update bundle advances the counter by 1 or by N —
+the bundle specs assert status and slot state, not counter deltas. Single-update
+txs are confirmed at +1. Nor is it known whether a `MaintenanceUpdate` emits
+events.
diff --git a/contracts/test/integration/_harness/cma.ts b/contracts/test/integration/_harness/cma.ts
new file mode 100644
index 00000000..8e22dbc0
--- /dev/null
+++ b/contracts/test/integration/_harness/cma.ts
@@ -0,0 +1,223 @@
+import type { Contract as ContractNs } from '@midnight-ntwrk/compact-js';
+import {
+ type ContractMaintenanceAuthority,
+ type ContractState,
+ type SigningKey,
+ sampleSigningKey,
+ signData,
+} from '@midnight-ntwrk/compact-runtime';
+import {
+ Intent,
+ MaintenanceUpdate,
+ type SingleUpdate,
+ Transaction,
+} from '@midnight-ntwrk/ledger-v8';
+import {
+ type DeployedContract,
+ type FoundContract,
+ submitTx,
+} from '@midnight-ntwrk/midnight-js-contracts';
+import { getNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
+import {
+ asContractAddress,
+ type FinalizedTxData,
+ type MidnightProviders,
+ type VerifierKey,
+} from '@midnight-ntwrk/midnight-js-types';
+
+/**
+ * Query helpers and upgrade-path wrappers over the Contract Maintenance
+ * Authority primitives in `@midnight-ntwrk/midnight-js-contracts`, plus one
+ * raw-ledger escape hatch for the multi-update bundles the SDK cannot express.
+ */
+
+/** Providers for a contract whose concrete type the helper does not care about. */
+type AnyProviders = MidnightProviders;
+
+/** Either a freshly deployed contract or one rebound via `findDeployedContract`. */
+type AnyDeployed =
+ | DeployedContract
+ | FoundContract;
+
+/** A `MaintenanceUpdate` intent stays valid for an hour. */
+const TTL_ONE_HOUR_MS = 60 * 60 * 1000;
+
+/** The expiry every maintenance intent in this suite carries. */
+export const maintenanceTtl = (): Date =>
+ new Date(Date.now() + TTL_ONE_HOUR_MS);
+
+/** Single-signer CMA: the only committee slot a signature can occupy. */
+const SOLE_COMMITTEE_INDEX = 0n;
+
+/** On-chain `ContractState`, or `undefined` while the indexer is still behind. */
+export async function readContractState(
+ providers: AnyProviders,
+ address: string,
+): Promise {
+ const state = await providers.publicDataProvider.queryContractState(address);
+ return state ?? undefined;
+}
+
+/**
+ * On-chain `ContractState`, or a throw.
+ *
+ * A spec asserting that a slot is empty must not pass because the read came
+ * back empty — `state?.operation(op)` is `undefined` either way.
+ */
+export async function requireContractState(
+ providers: AnyProviders,
+ address: string,
+): Promise {
+ const state = await readContractState(providers, address);
+ if (!state) {
+ throw new Error(`requireContractState: no ContractState for ${address}`);
+ }
+ return state;
+}
+
+/** The contract's current maintenance authority. Throws if the indexer has no record. */
+export async function readAuthority(
+ providers: AnyProviders,
+ address: string,
+): Promise {
+ const state = await readContractState(providers, address);
+ if (!state) {
+ throw new Error(
+ `readAuthority: no ContractState available for ${address} yet`,
+ );
+ }
+ return state.maintenanceAuthority;
+}
+
+/** The authority flattened to plain values, so a spec can compare it whole. */
+export interface AuthoritySnapshot {
+ committee: string[];
+ threshold: number;
+ counter: bigint;
+}
+
+/**
+ * The authority as a comparable value.
+ *
+ * Asserting `committee.length` alone would accept a swap to a *different*
+ * single-key committee, which is exactly what a rejected update must not do.
+ */
+export async function readAuthoritySnapshot(
+ providers: AnyProviders,
+ address: string,
+): Promise {
+ const auth = await readAuthority(providers, address);
+ return {
+ committee: [...auth.committee],
+ threshold: auth.threshold,
+ counter: auth.counter,
+ };
+}
+
+/** The replay-protection counter each accepted `MaintenanceUpdate` advances. */
+export async function readCmaCounter(
+ providers: AnyProviders,
+ address: string,
+): Promise {
+ const auth = await readAuthority(providers, address);
+ return auth.counter;
+}
+
+/**
+ * Remove and re-insert one circuit's verifier key, in two txs.
+ *
+ * `newVk` defaults to the circuit's current key — a round-trip that exercises
+ * the pathway without changing behaviour. Pass the other version's key to make
+ * the rotation observable. Advances the CMA counter by 2.
+ */
+export async function rotateCircuitVK(
+ providers: AnyProviders,
+ deployed: AnyDeployed,
+ circuitName: ContractNs.ProvableCircuitId,
+ newVk?: VerifierKey,
+): Promise {
+ const vk =
+ newVk ?? (await providers.zkConfigProvider.getVerifierKey(circuitName));
+ const tx = deployed.circuitMaintenanceTx[circuitName];
+ if (!tx) {
+ throw new Error(
+ `rotateCircuitVK: deployed contract has no circuit named '${String(circuitName)}'`,
+ );
+ }
+ await tx.removeVerifierKey();
+ await tx.insertVerifierKey(vk);
+}
+
+/**
+ * Install `newAuthority` as the contract's maintenance authority, signed by the
+ * key the handle currently holds. The SDK updates that handle's key in place.
+ */
+export async function rotateAuthority(
+ deployed: AnyDeployed,
+ newAuthority: SigningKey,
+): Promise {
+ await deployed.contractMaintenanceTx.replaceAuthority(newAuthority);
+ return newAuthority;
+}
+
+/**
+ * Freeze maintenance by rotating to a key that is generated and immediately
+ * discarded: no caller can sign a further update.
+ *
+ * This is not the protocol's empty-committee authority — `replaceAuthority`
+ * takes a single `SigningKey`, not a full `ContractMaintenanceAuthority`. See
+ * {@link submitRawMaintenanceUpdate} for the ledger-level route.
+ */
+export async function freeze(
+ deployed: AnyDeployed,
+): Promise {
+ await deployed.contractMaintenanceTx.replaceAuthority(sampleSigningKey());
+}
+
+/**
+ * Submit a `MaintenanceUpdate` carrying N `SingleUpdate`s in one tx.
+ *
+ * The SDK's maintenance API wraps exactly one `SingleUpdate` per tx, so probing
+ * bundle semantics (ordering, duplicate operations, mixed update kinds) means
+ * building the ledger objects and signing by hand.
+ *
+ * @param counterOverride Forge a counter the chain will reject, for the
+ * replay-protection specs. Defaults to the current on-chain value.
+ */
+export async function submitRawMaintenanceUpdate(
+ providers: AnyProviders,
+ contractAddress: string,
+ updates: SingleUpdate[],
+ counterOverride?: bigint,
+): Promise {
+ const [signingKey, freshCounter] = await Promise.all([
+ providers.privateStateProvider.getSigningKey(contractAddress),
+ readCmaCounter(providers, contractAddress),
+ ]);
+ if (!signingKey) {
+ throw new Error(
+ `submitRawMaintenanceUpdate: no signing key for ${contractAddress} in the private-state provider`,
+ );
+ }
+
+ const update = new MaintenanceUpdate(
+ asContractAddress(contractAddress),
+ updates,
+ counterOverride ?? freshCounter,
+ );
+ const signed = update.addSignature(
+ SOLE_COMMITTEE_INDEX,
+ signData(signingKey, update.dataToSign),
+ );
+
+ const intent = Intent.new(maintenanceTtl());
+ const unprovenTx = Transaction.fromParts(
+ getNetworkId(),
+ undefined,
+ undefined,
+ intent.addMaintenanceUpdate(signed),
+ );
+ // `submitTx` is generic over a contract type but only reads provider plumbing
+ // that is identical for any contract; the cast unifies the generic.
+ return submitTx(providers as Parameters[0], { unprovenTx });
+}
diff --git a/contracts/test/integration/_harness/deploy.ts b/contracts/test/integration/_harness/deploy.ts
new file mode 100644
index 00000000..6e517f27
--- /dev/null
+++ b/contracts/test/integration/_harness/deploy.ts
@@ -0,0 +1,62 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import type {
+ CompiledContract,
+ Contract as ContractNs,
+} from '@midnight-ntwrk/compact-js';
+import {
+ type DeployContractOptionsWithPrivateState,
+ type DeployedContract,
+ deployContract,
+} from '@midnight-ntwrk/midnight-js-contracts';
+import type { MidnightProviders } from '@midnight-ntwrk/midnight-js-types';
+
+const currentDir = path.dirname(fileURLToPath(import.meta.url));
+
+/** Absolute path to `contracts/artifacts//` — the ZK keys + zkir root. */
+export function moduleRootPath(name: string): string {
+ // this harness lives at contracts/test/integration/_harness/;
+ // artifacts live at contracts/artifacts//
+ return path.resolve(currentDir, '..', '..', '..', 'artifacts', name);
+}
+
+/** Absolute path to `contracts/artifacts//contract/` — the compiled JS. */
+export function contractAssetsPath(name: string): string {
+ return path.join(moduleRootPath(name), 'contract');
+}
+
+/**
+ * Deploy one compiled contract and return the raw midnight-js handle.
+ *
+ * The simulator's live backend keeps only the deployed address, so CMA specs —
+ * which need `deployTxData.private.signingKey` and the maintenance-tx surface —
+ * deploy through here instead.
+ */
+export async function deployModule(
+ providers: MidnightProviders<
+ ContractNs.ProvableCircuitId,
+ string,
+ ContractNs.PrivateState
+ >,
+ // The witnesses generic resolves to `never` for an empty-witness contract;
+ // `any` admits both shapes.
+ compiledContract: CompiledContract.CompiledContract<
+ C,
+ ContractNs.PrivateState,
+ any
+ >,
+ privateStateId: string,
+ initialPrivateState: ContractNs.PrivateState,
+ args: ContractNs.InitializeParameters,
+): Promise> {
+ // `DeployContractOptionsWithPrivateState` is conditional on whether
+ // `InitializeParameters` is empty, which TS cannot reduce under an
+ // unbounded `C`. Shape the literal once and assert it here.
+ const options = {
+ compiledContract,
+ privateStateId,
+ initialPrivateState,
+ args,
+ } as unknown as DeployContractOptionsWithPrivateState;
+ return deployContract(providers, options);
+}
diff --git a/contracts/test/integration/_harness/identity.ts b/contracts/test/integration/_harness/identity.ts
new file mode 100644
index 00000000..3315e250
--- /dev/null
+++ b/contracts/test/integration/_harness/identity.ts
@@ -0,0 +1,93 @@
+import {
+ CompactTypeBytes,
+ CompactTypeVector,
+ persistentHash,
+ type WitnessContext,
+} from '@midnight-ntwrk/compact-runtime';
+
+/**
+ * Caller identity for the witness-based access modules.
+ *
+ * Authorization is proved by the secret key a caller injects through its
+ * `wit_*SK` witness, not by the wallet that submits the tx. So an "alias" here
+ * is a private state, and every alias of one deployment can share the single
+ * funded deployer wallet.
+ */
+
+/** The private state every module in the TestToken mock reads its identity from. */
+export type TestTokenPrivateState = { secretKey: Uint8Array };
+
+/** The three `wit_*SK` witnesses the composed modules declare. */
+export interface TestTokenWitnesses {
+ wit_AccessControlSK(
+ context: WitnessContext,
+ ): [TestTokenPrivateState, Uint8Array];
+ wit_OwnableSK(
+ context: WitnessContext,
+ ): [TestTokenPrivateState, Uint8Array];
+ wit_FungibleTokenSK(
+ context: WitnessContext,
+ ): [TestTokenPrivateState, Uint8Array];
+}
+
+/**
+ * All three witnesses answer with the same key, so one alias is one identity
+ * across AccessControl, Ownable and FungibleToken.
+ */
+export const testTokenWitnesses = (): TestTokenWitnesses => {
+ const sk = (context: WitnessContext) =>
+ [context.privateState, Uint8Array.from(context.privateState.secretKey)] as [
+ TestTokenPrivateState,
+ Uint8Array,
+ ];
+ return {
+ wit_AccessControlSK: sk,
+ wit_OwnableSK: sk,
+ wit_FungibleTokenSK: sk,
+ };
+};
+
+/** The aliases the CMA specs call as. */
+export const ALIASES = ['deployer', 'ADMIN', 'ALICE', 'BOB'] as const;
+export type Alias = (typeof ALIASES)[number];
+
+/** A deterministic 32-byte secret key for `alias`. Stable across runs. */
+export function secretKeyFor(alias: string): Uint8Array {
+ const sk = new Uint8Array(32);
+ sk.set(new TextEncoder().encode(alias).slice(0, 32));
+ return sk;
+}
+
+/**
+ * The account identifier `alias` presents on chain, mirroring the modules'
+ * `Utils_computeAccountId`. The one place the derivation is written down.
+ */
+export function accountIdFor(alias: string): Uint8Array {
+ return persistentHash(new CompactTypeVector(1, new CompactTypeBytes(32)), [
+ secretKeyFor(alias),
+ ]);
+}
+
+const ZERO_BYTES = new Uint8Array(32);
+
+/** `alias` as the account-id arm of the modules' `Either`. */
+export function eitherFor(alias: string) {
+ return {
+ is_left: true,
+ left: accountIdFor(alias),
+ right: { bytes: ZERO_BYTES },
+ };
+}
+
+/** A stable, unique `ContractAddress` arm derived from `label`. Nothing is deployed there. */
+export function eitherContractAddress(label: string) {
+ const bytes = new Uint8Array(32);
+ const seed = new TextEncoder().encode(label);
+ for (let i = 0; i < bytes.length; i++) {
+ bytes[i] = seed[i % seed.length] ?? 0;
+ }
+ return { is_left: false, left: ZERO_BYTES, right: { bytes } };
+}
+
+/** `AccessControl.DEFAULT_ADMIN_ROLE` — zero bytes. */
+export const DEFAULT_ADMIN_ROLE = ZERO_BYTES;
diff --git a/contracts/test/integration/_harness/providers.ts b/contracts/test/integration/_harness/providers.ts
new file mode 100644
index 00000000..2d0b2bc1
--- /dev/null
+++ b/contracts/test/integration/_harness/providers.ts
@@ -0,0 +1,54 @@
+import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
+import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
+import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
+import type { MidnightProviders } from '@midnight-ntwrk/midnight-js-types';
+import {
+ inMemoryPrivateStateProvider,
+ type MidnightWalletProvider,
+} from '@midnight-ntwrk/testkit-js';
+import { localEnv } from '../../../test-utils/harness/network.js';
+import { moduleRootPath } from './deploy.js';
+
+/**
+ * A provider bundle for one deployment of `artifactName`, paying from `wallet`.
+ *
+ * The private-state provider is in-memory and passed in by the caller, so every
+ * alias of one deployment shares a store: `deployContract` writes the CMA
+ * signing key there and the maintenance helpers read it back. testkit's on-disk
+ * default cannot serve that — it scopes state by the wallet's coin public key
+ * and allows a single handle on the directory.
+ */
+export function buildProviders<
+ CircuitKey extends string,
+ PrivateStateId extends string,
+ PrivateState,
+>(
+ wallet: MidnightWalletProvider,
+ artifactName: string,
+ privateStateProvider: MidnightProviders<
+ CircuitKey,
+ PrivateStateId,
+ PrivateState
+ >['privateStateProvider'],
+): MidnightProviders {
+ const env = localEnv();
+ const zkConfigProvider = new NodeZkConfigProvider(
+ moduleRootPath(artifactName),
+ );
+ return {
+ privateStateProvider,
+ publicDataProvider: indexerPublicDataProvider(env.indexer, env.indexerWS),
+ zkConfigProvider,
+ proofProvider: httpClientProofProvider(env.proofServer, zkConfigProvider),
+ walletProvider: wallet,
+ midnightProvider: wallet,
+ };
+}
+
+/** The shared in-memory private-state store for one deployment. */
+export function makePrivateStateProvider<
+ PrivateStateId extends string,
+ PrivateState,
+>() {
+ return inMemoryPrivateStateProvider();
+}
diff --git a/contracts/test/integration/_mocks/TestTokenV1.compact b/contracts/test/integration/_mocks/TestTokenV1.compact
new file mode 100644
index 00000000..6c6dbee1
--- /dev/null
+++ b/contracts/test/integration/_mocks/TestTokenV1.compact
@@ -0,0 +1,168 @@
+// SPDX-License-Identifier: MIT
+//
+// WARNING: FOR TESTING PURPOSES ONLY.
+// Composite mock used exclusively by the CMA integration test suite to
+// exercise the upgrade pathway against a realistic ERC20-shaped contract.
+// Combines AccessControl + FungibleToken + Pausable + Initializable + Utils
+// in a single compilation unit so that one deploy + one suite of CMA specs
+// can probe heterogeneous-ledger preservation, cross-module isolation, and
+// post-rotation functional re-verification.
+//
+// DO NOT deploy or use this contract in any production application.
+
+pragma language_version >= 0.23.0;
+
+import CompactStandardLibrary;
+
+// Path: from contracts/test/integration/_mocks/ up three levels to contracts/, then into src//.
+import "../../../src/security/Initializable" prefix Initializable_;
+import "../../../src/security/Pausable" prefix Pausable_;
+import "../../../src/utils/Utils" prefix Utils_;
+import "../../../src/access/AccessControl" prefix AccessControl_;
+import "../../../src/access/Ownable" prefix Ownable_;
+import "../../../src/token/FungibleToken" prefix FungibleToken_;
+
+export {
+ ContractAddress,
+ Either,
+ Maybe,
+ AccessControl_DEFAULT_ADMIN_ROLE,
+ AccessControl__operatorRoles,
+ Initializable__isInitialized,
+ Ownable__owner,
+ Pausable__isPaused,
+ FungibleToken__totalSupply,
+ FungibleToken__name,
+ FungibleToken__symbol,
+ FungibleToken__decimals,
+ FungibleToken__balances,
+};
+
+/**
+ * @description Initializes the FungibleToken (name/symbol/decimals) and the
+ * Initializable module. Admin-role bootstrap intentionally happens post-deploy
+ * via `_grantRole(DEFAULT_ADMIN_ROLE, admin)` — keeps the constructor simple
+ * and avoids discarding the `_grantRole` Boolean return inside it.
+ */
+constructor(
+ _name: Opaque<"string">,
+ _symbol: Opaque<"string">,
+ _decimals: Uint<8>,
+ _initialOwner: Either, ContractAddress>,
+) {
+ Initializable_initialize();
+ FungibleToken_initialize(_name, _symbol, _decimals);
+ Ownable_initialize(_initialOwner);
+}
+
+// ──────────────────────────────────────────────────────────────────────
+// Surface deliberately pruned for block-limit fit on the local node:
+// asserts, renounceRole, _setRoleAdmin, allowance/approve/transferFrom,
+// and _burn are dropped from the wrapper layer. Specs assert state via
+// the exposed ledger fields (e.g. Pausable__isPaused) and read circuits
+// (hasRole, balanceOf, etc.). Add wrappers back as needed if a future
+// spec genuinely requires them and the deploy still fits.
+// ──────────────────────────────────────────────────────────────────────
+
+// ─── AccessControl public surface ───
+
+export circuit hasRole(
+ roleId: Bytes<32>,
+ account: Either, ContractAddress>,
+): Boolean {
+ return AccessControl_hasRole(roleId, account);
+}
+
+export circuit grantRole(
+ roleId: Bytes<32>,
+ account: Either, ContractAddress>,
+): [] {
+ AccessControl_grantRole(roleId, account);
+}
+
+export circuit revokeRole(
+ roleId: Bytes<32>,
+ account: Either, ContractAddress>,
+): [] {
+ AccessControl_revokeRole(roleId, account);
+}
+
+export circuit getRoleAdmin(roleId: Bytes<32>): Bytes<32> {
+ return AccessControl_getRoleAdmin(roleId);
+}
+
+// ─── AccessControl unsafe surface (for test setup) ───
+
+export circuit _grantRole(
+ roleId: Bytes<32>,
+ account: Either, ContractAddress>,
+): Boolean {
+ return AccessControl__grantRole(roleId, account);
+}
+
+// ─── Pausable public surface ───
+
+export circuit isPaused(): Boolean {
+ return Pausable_isPaused();
+}
+
+export circuit pause(): [] {
+ Pausable__pause();
+}
+
+export circuit unpause(): [] {
+ Pausable__unpause();
+}
+
+// ─── FungibleToken public surface ───
+// `name` / `symbol` / `decimals` / `totalSupply` wrappers omitted — no spec
+// calls them as circuits. The underlying ledger fields are still written by
+// `FungibleToken_initialize` and remain readable via `readLedger()`.
+
+export circuit balanceOf(
+ account: Either, ContractAddress>,
+): Uint<128> {
+ return FungibleToken_balanceOf(account);
+}
+
+export circuit transfer(
+ to: Either, ContractAddress>,
+ value: Uint<128>,
+): Boolean {
+ return FungibleToken_transfer(to, value);
+}
+
+// ─── FungibleToken unsafe surface (for test setup) ───
+// Unsafe `_mint` exposed directly — production contracts gate via MINTER_ROLE,
+// but the CMA test focus is VK rotation and authority, not role-gating mint.
+// Role-gating itself is exercised on grantRole / revokeRole.
+
+export circuit _mint(
+ account: Either, ContractAddress>,
+ value: Uint<128>,
+): [] {
+ FungibleToken__mint(account, value);
+}
+
+// ─── Ownable public surface ───
+// V1 mirrors today's Ownable: `transferOwnership` rejects ContractAddress,
+// and the unsafe escape hatch is a separate circuit. The upgrade specs
+// rotate `transferOwnership`'s VK to V2's (which lifts the ContractAddress
+// guard, simulating the post-C2C implementation) and `removeVerifierKey`
+// the unsafe slot (simulating its deletion).
+
+export circuit owner(): Either, ContractAddress> {
+ return Ownable_owner();
+}
+
+export circuit transferOwnership(
+ newOwner: Either, ContractAddress>,
+): [] {
+ Ownable_transferOwnership(newOwner);
+}
+
+export circuit _unsafeTransferOwnership(
+ newOwner: Either, ContractAddress>,
+): [] {
+ Ownable__unsafeTransferOwnership(newOwner);
+}
diff --git a/contracts/test/integration/_mocks/TestTokenV2.compact b/contracts/test/integration/_mocks/TestTokenV2.compact
new file mode 100644
index 00000000..37e44724
--- /dev/null
+++ b/contracts/test/integration/_mocks/TestTokenV2.compact
@@ -0,0 +1,168 @@
+// SPDX-License-Identifier: MIT
+//
+// WARNING: FOR TESTING PURPOSES ONLY.
+// V2 of TestToken — same composite-module layout as V1, with three changes:
+// - `_mint(account, amount)` enforces a per-tx mint cap (assertion).
+// - `pause()` requires DEFAULT_ADMIN_ROLE on the caller.
+// - `unpause()` matches `pause()` for symmetry.
+// - `mintBatch(account, amount)` is a NEW circuit not present in V1 — mints
+// `3 × amount` to `account` in one tx (a fixed-arity unrolled batch).
+//
+// Used by the upgrade specs to prove that, after rotating the relevant VKs
+// from V1 to V2:
+// - rotating `_mint`'s VK enforces the new per-tx cap on subsequent calls,
+// - rotating `pause`'s VK adds the admin gate,
+// - inserting `mintBatch`'s VK adds a brand-new operation NAME to the
+// contract's VK table (open question per the upgradability research).
+//
+// Same constructor + ledger layout as V1 (Compact's CMA upgrade pathway only
+// supports VK changes, not state-shape changes).
+
+pragma language_version >= 0.23.0;
+
+import CompactStandardLibrary;
+
+import "../../../src/security/Initializable" prefix Initializable_;
+import "../../../src/security/Pausable" prefix Pausable_;
+import "../../../src/utils/Utils" prefix Utils_;
+import "../../../src/access/AccessControl" prefix AccessControl_;
+import "../../../src/access/Ownable" prefix Ownable_;
+import "../../../src/token/FungibleToken" prefix FungibleToken_;
+
+export {
+ ContractAddress,
+ Either,
+ Maybe,
+ AccessControl_DEFAULT_ADMIN_ROLE,
+ AccessControl__operatorRoles,
+ Initializable__isInitialized,
+ Ownable__owner,
+ Pausable__isPaused,
+ FungibleToken__totalSupply,
+ FungibleToken__name,
+ FungibleToken__symbol,
+ FungibleToken__decimals,
+ FungibleToken__balances,
+};
+
+constructor(
+ _name: Opaque<"string">,
+ _symbol: Opaque<"string">,
+ _decimals: Uint<8>,
+ _initialOwner: Either, ContractAddress>,
+) {
+ Initializable_initialize();
+ FungibleToken_initialize(_name, _symbol, _decimals);
+ Ownable_initialize(_initialOwner);
+}
+
+// ─── AccessControl public surface (unchanged from V1) ───
+
+export circuit hasRole(
+ roleId: Bytes<32>,
+ account: Either, ContractAddress>,
+): Boolean {
+ return AccessControl_hasRole(roleId, account);
+}
+
+export circuit grantRole(
+ roleId: Bytes<32>,
+ account: Either, ContractAddress>,
+): [] {
+ AccessControl_grantRole(roleId, account);
+}
+
+export circuit revokeRole(
+ roleId: Bytes<32>,
+ account: Either, ContractAddress>,
+): [] {
+ AccessControl_revokeRole(roleId, account);
+}
+
+export circuit getRoleAdmin(roleId: Bytes<32>): Bytes<32> {
+ return AccessControl_getRoleAdmin(roleId);
+}
+
+export circuit _grantRole(
+ roleId: Bytes<32>,
+ account: Either, ContractAddress>,
+): Boolean {
+ return AccessControl__grantRole(roleId, account);
+}
+
+// ─── Pausable public surface — CHANGED: pause/unpause require admin ───
+
+export circuit isPaused(): Boolean {
+ return Pausable_isPaused();
+}
+
+export circuit pause(): [] {
+ AccessControl_assertOnlyRole(AccessControl_DEFAULT_ADMIN_ROLE());
+ Pausable__pause();
+}
+
+export circuit unpause(): [] {
+ AccessControl_assertOnlyRole(AccessControl_DEFAULT_ADMIN_ROLE());
+ Pausable__unpause();
+}
+
+// ─── FungibleToken read surface (unchanged) ───
+// `name` / `symbol` / `decimals` / `totalSupply` wrappers dropped to mirror
+// V1 — keeping the wrapper sets identical preserves cross-binding. The
+// ledger fields are still on chain and readable via `readLedger()`.
+
+export circuit balanceOf(
+ account: Either, ContractAddress>,
+): Uint<128> {
+ return FungibleToken_balanceOf(account);
+}
+
+export circuit transfer(
+ to: Either, ContractAddress>,
+ value: Uint<128>,
+): Boolean {
+ return FungibleToken_transfer(to, value);
+}
+
+// ─── FungibleToken unsafe surface — CHANGED: _mint enforces per-tx cap ───
+
+export circuit _mint(
+ account: Either, ContractAddress>,
+ value: Uint<128>,
+): [] {
+ assert(value <= 1000000, "TestTokenV2: _mint amount over per-tx cap");
+ FungibleToken__mint(account, value);
+}
+
+// ─── NEW (V2 only): mintBatch — fixed batch of 3 mints in one tx ───
+// `mintBatch(account, value)` mints `3 × value` to `account`. Unrolled
+// because Compact circuits don't have unbounded loops. Per-mint cap still
+// applies — same constant as `_mint` above.
+
+export circuit mintBatch(
+ account: Either, ContractAddress>,
+ value: Uint<128>,
+): [] {
+ assert(value <= 1000000, "TestTokenV2: mintBatch per-call amount over cap");
+ FungibleToken__mint(account, value);
+ FungibleToken__mint(account, value);
+ FungibleToken__mint(account, value);
+}
+
+// ─── Ownable public surface — CHANGED: transferOwnership lifts the
+// ContractAddress guard (post-C2C semantics) and the `_unsafe…` wrapper
+// is intentionally OMITTED to simulate its deletion in the upgrade.
+// Specs that rotate `transferOwnership`'s VK V1→V2 will see calls with a
+// ContractAddress destination start succeeding; calls to V1's
+// `_unsafeTransferOwnership` slot are expected to fail after its VK is
+// removed on-chain (no replacement available because V2 dropped it).
+
+export circuit owner(): Either, ContractAddress> {
+ return Ownable_owner();
+}
+
+export circuit transferOwnership(
+ newOwner: Either, ContractAddress>,
+): [] {
+ Ownable__unsafeTransferOwnership(newOwner);
+}
diff --git a/contracts/test/integration/fixtures/testTokenV1.ts b/contracts/test/integration/fixtures/testTokenV1.ts
new file mode 100644
index 00000000..262ae3ab
--- /dev/null
+++ b/contracts/test/integration/fixtures/testTokenV1.ts
@@ -0,0 +1,170 @@
+import type { Contract as ContractNs } from '@midnight-ntwrk/compact-js';
+import { CompiledContract } from '@midnight-ntwrk/compact-js';
+import {
+ type FoundContract,
+ findDeployedContract,
+} from '@midnight-ntwrk/midnight-js-contracts';
+import type { MidnightProviders } from '@midnight-ntwrk/midnight-js-types';
+import type { MidnightWalletProvider } from '@midnight-ntwrk/testkit-js';
+import {
+ Contract as TestTokenV1,
+ type Ledger as TestTokenV1Ledger,
+ ledger as testTokenV1Ledger,
+} from '../../../artifacts/TestTokenV1/contract/index.js';
+import { requireLivePool } from '../../../test-utils/harness/livePool.js';
+import { contractAssetsPath, deployModule } from '../_harness/deploy.js';
+import {
+ DEFAULT_ADMIN_ROLE,
+ eitherFor,
+ secretKeyFor,
+ type TestTokenPrivateState,
+ testTokenWitnesses,
+} from '../_harness/identity.js';
+import {
+ buildProviders,
+ makePrivateStateProvider,
+} from '../_harness/providers.js';
+
+export const TESTTOKEN_V1_ARTIFACT = 'TestTokenV1';
+
+/** The private-state slot holding `alias`' identity for this contract. */
+export const privateStateIdFor = (alias: string): string =>
+ `${TESTTOKEN_V1_ARTIFACT}-${alias.toLowerCase()}`;
+
+/** `alias`' private state — the secret key its witnesses answer with. */
+export const privateStateFor = (alias: string): TestTokenPrivateState => ({
+ secretKey: secretKeyFor(alias),
+});
+
+export type TestTokenV1Contract = TestTokenV1;
+export type TestTokenV1CircuitKeys =
+ ContractNs.ProvableCircuitId;
+export type TestTokenV1Providers = MidnightProviders<
+ TestTokenV1CircuitKeys,
+ string,
+ TestTokenPrivateState
+>;
+export type TestTokenV1Handle =
+ | Awaited>>
+ | FoundContract;
+
+export const compiledTestTokenV1 = CompiledContract.make(
+ TESTTOKEN_V1_ARTIFACT,
+ TestTokenV1,
+).pipe(
+ CompiledContract.withWitnesses(testTokenWitnesses() as never),
+ CompiledContract.withCompiledFileAssets(
+ contractAssetsPath(TESTTOKEN_V1_ARTIFACT),
+ ),
+);
+
+export interface DeployTestTokenV1Opts {
+ name?: string;
+ symbol?: string;
+ decimals?: number;
+ /** Grant `DEFAULT_ADMIN_ROLE` to the `ADMIN` alias after deploy. Default `true`. */
+ bootstrapAdmin?: boolean;
+}
+
+export interface TestTokenV1Kit {
+ /** The deploying handle, holding the CMA signing key. */
+ deployed: Awaited>>;
+ /** The provider bundle every alias and maintenance helper shares. */
+ providers: TestTokenV1Providers;
+ /** The funded wallet paying for every call, borrowed from the worker's pool. */
+ wallet: MidnightWalletProvider;
+ readonly contractAddress: string;
+ /** Latest public ledger, read through the indexer. */
+ readLedger(): Promise;
+ /**
+ * A handle whose witnesses answer with `alias`' secret key, so circuits see
+ * `alias` as the caller. Every alias submits from the same funded wallet.
+ */
+ as(alias: string): Promise;
+ teardown(): Promise;
+}
+
+/** Deploy a fresh TestTokenV1 to the local node. */
+export async function deployTestTokenV1(
+ opts: DeployTestTokenV1Opts = {},
+): Promise {
+ const wallet = requireLivePool().walletFor('deployer');
+
+ // One store per deployment, shared by every alias: `deployContract` files the
+ // CMA signing key here under the contract address, and the maintenance
+ // helpers read it back.
+ const privateStateProvider = makePrivateStateProvider<
+ string,
+ TestTokenPrivateState
+ >();
+ // Every alias shares one provider bundle — they differ only in the private
+ // state `findDeployedContract` binds, and all pay from the same wallet.
+ const providers = buildProviders<
+ TestTokenV1CircuitKeys,
+ string,
+ TestTokenPrivateState
+ >(wallet, TESTTOKEN_V1_ARTIFACT, privateStateProvider);
+
+ const deployed = await deployModule(
+ providers,
+ compiledTestTokenV1,
+ privateStateIdFor('deployer'),
+ privateStateFor('deployer'),
+ [
+ opts.name ?? 'TestToken',
+ opts.symbol ?? 'TT',
+ BigInt(opts.decimals ?? 6),
+ eitherFor('deployer'),
+ ] as ContractNs.InitializeParameters,
+ );
+
+ const contractAddress = deployed.deployTxData.public.contractAddress;
+
+ // Deduped per alias so parallel `as(alias)` calls share one lookup.
+ const handles = new Map>();
+ const findAs = (alias: string) =>
+ // No `signingKey`: passing one would overwrite the CMA key `deployContract`
+ // stored, and the rotation specs assert on that key.
+ findDeployedContract(providers, {
+ compiledContract: compiledTestTokenV1,
+ contractAddress,
+ privateStateId: privateStateIdFor(alias),
+ initialPrivateState: privateStateFor(alias),
+ });
+
+ const kit: TestTokenV1Kit = {
+ deployed,
+ providers,
+ wallet,
+ contractAddress,
+
+ async readLedger(): Promise {
+ const state =
+ await providers.publicDataProvider.queryContractState(contractAddress);
+ if (!state) {
+ throw new Error(`readLedger: no ContractState for ${contractAddress}`);
+ }
+ return testTokenV1Ledger(state.data);
+ },
+
+ as(alias: string): Promise {
+ let handle = handles.get(alias);
+ if (!handle) {
+ handle = findAs(alias);
+ handles.set(alias, handle);
+ }
+ return handle;
+ },
+
+ async teardown(): Promise {
+ // The wallet belongs to the worker's pool; `live.globalSetup` stops it.
+ handles.clear();
+ },
+ };
+
+ if (opts.bootstrapAdmin !== false) {
+ await deployed.callTx._grantRole(DEFAULT_ADMIN_ROLE, eitherFor('ADMIN'));
+ }
+
+ return kit;
+}
diff --git a/contracts/test/integration/fixtures/testTokenV2.ts b/contracts/test/integration/fixtures/testTokenV2.ts
new file mode 100644
index 00000000..4d5b14b4
--- /dev/null
+++ b/contracts/test/integration/fixtures/testTokenV2.ts
@@ -0,0 +1,119 @@
+import type { Contract as ContractNs } from '@midnight-ntwrk/compact-js';
+import { CompiledContract } from '@midnight-ntwrk/compact-js';
+import {
+ createCircuitCallTxInterface,
+ createCircuitMaintenanceTxInterfaces,
+ createContractMaintenanceTxInterface,
+ type FoundContract,
+} from '@midnight-ntwrk/midnight-js-contracts';
+import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
+import type {
+ MidnightProviders,
+ VerifierKey,
+} from '@midnight-ntwrk/midnight-js-types';
+import {
+ Contract as TestTokenV2,
+ type Ledger as TestTokenV2Ledger,
+} from '../../../artifacts/TestTokenV2/contract/index.js';
+import { contractAssetsPath, moduleRootPath } from '../_harness/deploy.js';
+import {
+ type TestTokenPrivateState,
+ testTokenWitnesses,
+} from '../_harness/identity.js';
+import { buildProviders } from '../_harness/providers.js';
+import {
+ privateStateFor,
+ privateStateIdFor,
+ type TestTokenV1Kit,
+} from './testTokenV1.js';
+
+/**
+ * V2 is never deployed. The upgrade specs deploy V1 and rotate individual
+ * verifier keys to V2's, so this module only supplies V2's keys and a
+ * V2-shaped handle on the V1 contract.
+ *
+ * V2 keeps V1's ledger layout and private state — CMA can change verifier
+ * keys, not state shape.
+ */
+
+export const TESTTOKEN_V2_ARTIFACT = 'TestTokenV2';
+
+export type TestTokenV2Contract = TestTokenV2;
+export type TestTokenV2CircuitKeys =
+ ContractNs.ProvableCircuitId;
+export type TestTokenV2Providers = MidnightProviders<
+ TestTokenV2CircuitKeys,
+ string,
+ TestTokenPrivateState
+>;
+export type TestTokenV2Handle = FoundContract;
+export type { TestTokenV2Ledger };
+
+export const compiledTestTokenV2 = CompiledContract.make(
+ TESTTOKEN_V2_ARTIFACT,
+ TestTokenV2,
+).pipe(
+ CompiledContract.withWitnesses(testTokenWitnesses() as never),
+ CompiledContract.withCompiledFileAssets(
+ contractAssetsPath(TESTTOKEN_V2_ARTIFACT),
+ ),
+);
+
+/** V2's verifier key for `circuitName`, to feed `insertVerifierKey`. */
+export async function v2VerifierKey(
+ circuitName: TestTokenV2CircuitKeys,
+): Promise {
+ return new NodeZkConfigProvider(
+ moduleRootPath(TESTTOKEN_V2_ARTIFACT),
+ ).getVerifierKey(circuitName);
+}
+
+/**
+ * A V2-typed handle on the V1-deployed contract, bound as `alias`.
+ *
+ * `findDeployedContract` validates V2's whole verifier-key set against
+ * the chain, which would force a spec to rotate every V2-divergent circuit
+ * before binding — including ones it does not exercise. This assembles the
+ * same surface without that check, so each spec rotates only what it tests
+ * and a mismatched key surfaces at the call it belongs to.
+ */
+export async function bindAsV2(
+ kit: TestTokenV1Kit,
+ alias: string,
+): Promise {
+ // V2's own proving keys, over the deployment's shared private state.
+ const providers = buildProviders<
+ TestTokenV2CircuitKeys,
+ string,
+ TestTokenPrivateState
+ >(kit.wallet, TESTTOKEN_V2_ARTIFACT, kit.providers.privateStateProvider);
+
+ // The side effects `findDeployedContract` would have applied: the call and
+ // maintenance interfaces read the address, the alias' private state, and the
+ // authority signing key straight off the provider.
+ providers.privateStateProvider.setContractAddress(kit.contractAddress);
+ await providers.privateStateProvider.set(
+ privateStateIdFor(alias),
+ privateStateFor(alias),
+ );
+
+ return {
+ deployTxData: {} as TestTokenV2Handle['deployTxData'],
+ callTx: createCircuitCallTxInterface(
+ providers,
+ compiledTestTokenV2,
+ kit.contractAddress,
+ privateStateIdFor(alias),
+ ),
+ circuitMaintenanceTx: createCircuitMaintenanceTxInterfaces(
+ providers,
+ compiledTestTokenV2,
+ kit.contractAddress,
+ ),
+ contractMaintenanceTx: createContractMaintenanceTxInterface(
+ providers,
+ compiledTestTokenV2,
+ kit.contractAddress,
+ ),
+ };
+}
diff --git a/contracts/test/integration/specs/accessControl/witnessIdentity.spec.ts b/contracts/test/integration/specs/accessControl/witnessIdentity.spec.ts
new file mode 100644
index 00000000..e6737939
--- /dev/null
+++ b/contracts/test/integration/specs/accessControl/witnessIdentity.spec.ts
@@ -0,0 +1,66 @@
+import { isLiveBackend } from '@openzeppelin/compact-simulator';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { DEFAULT_ADMIN_ROLE, eitherFor } from '../../_harness/identity.js';
+import {
+ deployTestTokenV1,
+ type TestTokenV1Kit,
+} from '../../fixtures/testTokenV1.js';
+
+/**
+ * Role checks follow the secret key a caller injects through its witness, not
+ * the wallet that submits the tx — every alias here pays from the same wallet.
+ * The upgrade specs rely on that to vary the caller, so it is pinned once here
+ * rather than assumed.
+ *
+ * Assertions read the ledger instead of calling `hasRole`, which would be a
+ * full transaction for a value already on chain.
+ */
+const MINTER_ROLE = new Uint8Array(32);
+MINTER_ROLE.set(new TextEncoder().encode('MINTER'));
+
+const ALICE = eitherFor('ALICE');
+
+describe.runIf(isLiveBackend())(
+ 'AccessControl — witness-derived caller identity',
+ () => {
+ let v1: TestTokenV1Kit;
+
+ const hasRole = async (roleId: Uint8Array, account: typeof ALICE) => {
+ const roles = (await v1.readLedger()).AccessControl__operatorRoles;
+ return (
+ roles.member(roleId) &&
+ roles.lookup(roleId).member(account) &&
+ roles.lookup(roleId).lookup(account)
+ );
+ };
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1();
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it('grants the admin role to ADMIN during deploy', async () => {
+ expect(await hasRole(DEFAULT_ADMIN_ROLE, eitherFor('ADMIN'))).toBe(true);
+ });
+
+ it('lets ADMIN grant and revoke a role', async () => {
+ const admin = await v1.as('ADMIN');
+
+ await admin.callTx.grantRole(MINTER_ROLE, ALICE);
+ expect(await hasRole(MINTER_ROLE, ALICE)).toBe(true);
+
+ await admin.callTx.revokeRole(MINTER_ROLE, ALICE);
+ expect(await hasRole(MINTER_ROLE, ALICE)).toBe(false);
+ });
+
+ it('rejects a caller whose witness key holds no admin role', async () => {
+ const bob = await v1.as('BOB');
+ await expect(bob.callTx.grantRole(MINTER_ROLE, ALICE)).rejects.toThrow(
+ 'AccessControl: unauthorized account',
+ );
+ });
+ },
+);
diff --git a/contracts/test/integration/specs/cma/crossContractReplay.spec.ts b/contracts/test/integration/specs/cma/crossContractReplay.spec.ts
new file mode 100644
index 00000000..2055e0c9
--- /dev/null
+++ b/contracts/test/integration/specs/cma/crossContractReplay.spec.ts
@@ -0,0 +1,108 @@
+import {
+ sampleSigningKey,
+ signatureVerifyingKey,
+ signData,
+} from '@midnight-ntwrk/compact-runtime';
+import {
+ ContractMaintenanceAuthority,
+ Intent,
+ MaintenanceUpdate,
+ ReplaceAuthority,
+ Transaction,
+} from '@midnight-ntwrk/ledger-v8';
+import { submitTx } from '@midnight-ntwrk/midnight-js-contracts';
+import { getNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
+import { asContractAddress } from '@midnight-ntwrk/midnight-js-types';
+import { isLiveBackend } from '@openzeppelin/compact-simulator';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import {
+ maintenanceTtl,
+ readAuthoritySnapshot,
+ readCmaCounter,
+} from '../../_harness/cma.js';
+import {
+ deployTestTokenV1,
+ type TestTokenV1Kit,
+} from '../../fixtures/testTokenV1.js';
+
+/**
+ * A maintenance signature is bound to the contract it names. Without that,
+ * capturing one signature from any contract would compromise every other
+ * contract whose counter happened to line up.
+ *
+ * The update is built and signed inline rather than through
+ * `submitRawMaintenanceUpdate`: that helper looks the key up by the address it
+ * is given, so it would fetch B's key and prove nothing.
+ */
+describe.runIf(isLiveBackend())(
+ "TestToken — one contract's signature on another's update",
+ () => {
+ let contractA: TestTokenV1Kit;
+ let contractB: TestTokenV1Kit;
+
+ beforeAll(async () => {
+ contractA = await deployTestTokenV1();
+ contractB = await deployTestTokenV1();
+ });
+
+ afterAll(async () => {
+ await contractA?.teardown();
+ await contractB?.teardown();
+ });
+
+ it('is rejected when addressed to B and signed with A’s key', async () => {
+ const signingKeyA =
+ await contractA.providers.privateStateProvider.getSigningKey(
+ contractA.contractAddress,
+ );
+ if (!signingKeyA) {
+ throw new Error(
+ `crossContractReplay setup: no signing key for ${contractA.contractAddress}`,
+ );
+ }
+
+ const authorityB = await readAuthoritySnapshot(
+ contractB.providers,
+ contractB.contractAddress,
+ );
+ // Match B's counter, or a stale-counter rejection would mask the one
+ // under test.
+ const counterB = await readCmaCounter(
+ contractB.providers,
+ contractB.contractAddress,
+ );
+ const decoyAuth = new ContractMaintenanceAuthority(
+ [signatureVerifyingKey(sampleSigningKey())],
+ 1,
+ );
+
+ const update = new MaintenanceUpdate(
+ asContractAddress(contractB.contractAddress),
+ [new ReplaceAuthority(decoyAuth)],
+ counterB,
+ );
+ const signed = update.addSignature(
+ 0n,
+ signData(signingKeyA, update.dataToSign),
+ );
+ const unprovenTx = Transaction.fromParts(
+ getNetworkId(),
+ undefined,
+ undefined,
+ Intent.new(maintenanceTtl()).addMaintenanceUpdate(signed),
+ );
+
+ await expect(
+ submitTx(contractB.providers as Parameters[0], {
+ unprovenTx,
+ }),
+ ).rejects.toThrow(/SubmissionError|Transaction submission error/);
+
+ const authorityAfter = await readAuthoritySnapshot(
+ contractB.providers,
+ contractB.contractAddress,
+ );
+ expect(authorityAfter).toStrictEqual(authorityB);
+ });
+ },
+);
diff --git a/contracts/test/integration/specs/cma/emptyCommitteeFreeze.spec.ts b/contracts/test/integration/specs/cma/emptyCommitteeFreeze.spec.ts
new file mode 100644
index 00000000..6b91d747
--- /dev/null
+++ b/contracts/test/integration/specs/cma/emptyCommitteeFreeze.spec.ts
@@ -0,0 +1,51 @@
+import {
+ ContractMaintenanceAuthority,
+ ReplaceAuthority,
+} from '@midnight-ntwrk/ledger-v8';
+import { isLiveBackend } from '@openzeppelin/compact-simulator';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import {
+ readAuthoritySnapshot,
+ submitRawMaintenanceUpdate,
+} from '../../_harness/cma.js';
+import {
+ deployTestTokenV1,
+ type TestTokenV1Kit,
+} from '../../fixtures/testTokenV1.js';
+
+/**
+ * An empty committee is described as the canonical frozen authority, but the
+ * chain refuses it: a CMA must keep at least one committee key. That makes the
+ * discard-the-key freeze in `freeze.spec.ts` the only way to reach the state,
+ * not merely the most convenient one.
+ *
+ * The SDK's `replaceAuthority` takes a single signing key and cannot express
+ * an empty committee at all, so this goes through the raw ledger path.
+ */
+describe.runIf(isLiveBackend())('TestToken — empty-committee CMA', () => {
+ let v1: TestTokenV1Kit;
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1();
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it('is refused at submission', async () => {
+ const before = await readAuthoritySnapshot(
+ v1.providers,
+ v1.contractAddress,
+ );
+
+ await expect(
+ submitRawMaintenanceUpdate(v1.providers, v1.contractAddress, [
+ new ReplaceAuthority(new ContractMaintenanceAuthority([], 1)),
+ ]),
+ ).rejects.toThrow(/SubmissionError|Transaction submission error/);
+
+ const after = await readAuthoritySnapshot(v1.providers, v1.contractAddress);
+ expect(after).toStrictEqual(before);
+ });
+});
diff --git a/contracts/test/integration/specs/cma/freeze.spec.ts b/contracts/test/integration/specs/cma/freeze.spec.ts
new file mode 100644
index 00000000..36ff6c90
--- /dev/null
+++ b/contracts/test/integration/specs/cma/freeze.spec.ts
@@ -0,0 +1,72 @@
+import { sampleSigningKey } from '@midnight-ntwrk/compact-runtime';
+import { findDeployedContract } from '@midnight-ntwrk/midnight-js-contracts';
+import { isLiveBackend } from '@openzeppelin/compact-simulator';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { freeze, readCmaCounter } from '../../_harness/cma.js';
+import {
+ compiledTestTokenV1,
+ deployTestTokenV1,
+ privateStateFor,
+ privateStateIdFor,
+ type TestTokenV1Contract,
+ type TestTokenV1Kit,
+} from '../../fixtures/testTokenV1.js';
+
+/**
+ * Freezing terminates all further maintenance.
+ *
+ * `freeze()` rotates to a key nobody retains. The deploying handle cannot show
+ * that: `replaceAuthority` silently installs the new key in it, so it would
+ * keep succeeding. The last test re-binds with a key that is definitely not
+ * on chain, which is the state every caller is in after a freeze.
+ */
+describe.runIf(isLiveBackend())('TestToken — freezing the CMA', () => {
+ let v1: TestTokenV1Kit;
+ // Sentinel: a failure in the first test would otherwise resurface here as
+ // a BigInt TypeError, hiding the real cause.
+ let counterBeforeFreeze = 0n;
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1();
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it('accepts a maintenance update before freezing', async () => {
+ const before = await readCmaCounter(v1.providers, v1.contractAddress);
+ const vk = await v1.providers.zkConfigProvider.getVerifierKey('pause');
+ await v1.deployed.circuitMaintenanceTx.pause.removeVerifierKey();
+ await v1.deployed.circuitMaintenanceTx.pause.insertVerifierKey(vk);
+ counterBeforeFreeze = await readCmaCounter(
+ v1.providers,
+ v1.contractAddress,
+ );
+ expect(counterBeforeFreeze).toBe(before + 2n);
+ });
+
+ it('advances the counter by 1 when freeze succeeds', async () => {
+ await freeze(v1.deployed);
+ const after = await readCmaCounter(v1.providers, v1.contractAddress);
+ expect(after).toBe(counterBeforeFreeze + 1n);
+ });
+
+ it('rejects every maintenance update signed by a wrong key after freeze', async () => {
+ const reFound = await findDeployedContract(
+ v1.providers,
+ {
+ compiledContract: compiledTestTokenV1,
+ contractAddress: v1.contractAddress,
+ privateStateId: privateStateIdFor('deployer'),
+ initialPrivateState: privateStateFor('deployer'),
+ signingKey: sampleSigningKey(),
+ },
+ );
+ // The chain rejects the unauthorized signature, and the SDK surfaces that
+ // as Effect's `SubmissionError` rather than the typed maintenance error.
+ await expect(
+ reFound.circuitMaintenanceTx.pause.removeVerifierKey(),
+ ).rejects.toThrow(/SubmissionError|Transaction submission error/);
+ });
+});
diff --git a/contracts/test/integration/specs/cma/mixedBundle.spec.ts b/contracts/test/integration/specs/cma/mixedBundle.spec.ts
new file mode 100644
index 00000000..b30ea8b3
--- /dev/null
+++ b/contracts/test/integration/specs/cma/mixedBundle.spec.ts
@@ -0,0 +1,94 @@
+import {
+ sampleSigningKey,
+ signatureVerifyingKey,
+} from '@midnight-ntwrk/compact-runtime';
+import {
+ ContractMaintenanceAuthority,
+ ContractOperationVersionedVerifierKey,
+ ReplaceAuthority,
+ type SingleUpdate,
+ VerifierKeyInsert,
+} from '@midnight-ntwrk/ledger-v8';
+import { isLiveBackend } from '@openzeppelin/compact-simulator';
+import { afterEach, describe, expect, it } from 'vitest';
+import {
+ readAuthoritySnapshot,
+ requireContractState,
+ submitRawMaintenanceUpdate,
+} from '../../_harness/cma.js';
+import {
+ deployTestTokenV1,
+ type TestTokenV1Kit,
+} from '../../fixtures/testTokenV1.js';
+
+/**
+ * A `ReplaceAuthority` cannot share a bundle with another kind of update: the
+ * chain refuses the tx at submission, in either order. Together with the
+ * two-`ReplaceAuthority` case in `multiUpdate`, that makes the rule structural
+ * rather than about ordering or content.
+ *
+ * Note this is a different rule from the atomic revert two same-operation
+ * inserts get — those produce a tx the chain accepts.
+ */
+const OPERATION_VERSION = 'v3';
+
+/** Deploy, then empty `_mint` so the bundle's insert has a free slot. */
+async function deployWithEmptyMintSlot(): Promise {
+ const v1 = await deployTestTokenV1();
+ await v1.deployed.circuitMaintenanceTx._mint.removeVerifierKey();
+ return v1;
+}
+
+describe.runIf(isLiveBackend())(
+ 'TestToken — bundling ReplaceAuthority with another update kind',
+ () => {
+ let v1: TestTokenV1Kit | undefined;
+
+ afterEach(async () => {
+ await v1?.teardown();
+ v1 = undefined;
+ });
+
+ it.each([
+ { order: 'ReplaceAuthority first', authorityFirst: true },
+ { order: 'ReplaceAuthority last', authorityFirst: false },
+ ])('is refused at submission with $order', async ({ authorityFirst }) => {
+ v1 = await deployWithEmptyMintSlot();
+ const authorityBefore = await readAuthoritySnapshot(
+ v1.providers,
+ v1.contractAddress,
+ );
+
+ const newAuth = new ContractMaintenanceAuthority(
+ [signatureVerifyingKey(sampleSigningKey())],
+ 1,
+ );
+ const mintVk =
+ await v1.providers.zkConfigProvider.getVerifierKey('_mint');
+ const insert = new VerifierKeyInsert(
+ '_mint',
+ new ContractOperationVersionedVerifierKey(OPERATION_VERSION, mintVk),
+ );
+ const updates: SingleUpdate[] = authorityFirst
+ ? [new ReplaceAuthority(newAuth), insert]
+ : [insert, new ReplaceAuthority(newAuth)];
+
+ await expect(
+ submitRawMaintenanceUpdate(v1.providers, v1.contractAddress, updates),
+ ).rejects.toThrow(/SubmissionError|Transaction submission error/);
+
+ // Neither update took: the authority is the deploy-time one and the
+ // slot is still empty.
+ const authorityAfter = await readAuthoritySnapshot(
+ v1.providers,
+ v1.contractAddress,
+ );
+ expect(authorityAfter).toStrictEqual(authorityBefore);
+ const stateAfter = await requireContractState(
+ v1.providers,
+ v1.contractAddress,
+ );
+ expect(stateAfter.operation('_mint')).toBeUndefined();
+ });
+ },
+);
diff --git a/contracts/test/integration/specs/cma/multiUpdate.spec.ts b/contracts/test/integration/specs/cma/multiUpdate.spec.ts
new file mode 100644
index 00000000..c5b933ec
--- /dev/null
+++ b/contracts/test/integration/specs/cma/multiUpdate.spec.ts
@@ -0,0 +1,151 @@
+import {
+ sampleSigningKey,
+ signatureVerifyingKey,
+} from '@midnight-ntwrk/compact-runtime';
+import {
+ ContractMaintenanceAuthority,
+ ContractOperationVersionedVerifierKey,
+ ReplaceAuthority,
+ VerifierKeyInsert,
+} from '@midnight-ntwrk/ledger-v8';
+import { isLiveBackend } from '@openzeppelin/compact-simulator';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import {
+ readAuthoritySnapshot,
+ readCmaCounter,
+ requireContractState,
+ submitRawMaintenanceUpdate,
+} from '../../_harness/cma.js';
+import {
+ deployTestTokenV1,
+ type TestTokenV1Kit,
+} from '../../fixtures/testTokenV1.js';
+
+/**
+ * How the chain treats a `MaintenanceUpdate` carrying several `SingleUpdate`s,
+ * which the SDK's one-update-per-tx surface cannot build.
+ *
+ * Two rules fall out, and they differ:
+ *
+ * - Two `ReplaceAuthority`s in one bundle are refused at submission, so the
+ * tx never reaches a block.
+ * - Two inserts on the same operation produce a tx the chain accepts, whose
+ * bundle then reverts as a unit: the status is `FailFallible` and the slot
+ * is left as it was. Reverting is per bundle, not per `SingleUpdate`.
+ */
+const OPERATION_VERSION = 'v3';
+
+describe.runIf(isLiveBackend())(
+ 'TestToken — one bundle of [remove, insert] for `_mint`',
+ () => {
+ let v1: TestTokenV1Kit;
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1();
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it('accepts the bundle and advances the counter once per update', async () => {
+ const before = await readCmaCounter(v1.providers, v1.contractAddress);
+ const mintVk =
+ await v1.providers.zkConfigProvider.getVerifierKey('_mint');
+ const versionedVk = new ContractOperationVersionedVerifierKey(
+ OPERATION_VERSION,
+ mintVk,
+ );
+
+ // The slot holds the deploy-time key. Remove through the SDK, then
+ // re-insert through the raw path — the helper takes no remove update.
+ await v1.deployed.circuitMaintenanceTx._mint.removeVerifierKey();
+ await submitRawMaintenanceUpdate(v1.providers, v1.contractAddress, [
+ new VerifierKeyInsert('_mint', versionedVk),
+ ]);
+
+ const after = await readCmaCounter(v1.providers, v1.contractAddress);
+ expect(after).toBe(before + 2n);
+ });
+ },
+);
+
+describe.runIf(isLiveBackend())(
+ 'TestToken — two `ReplaceAuthority` in one bundle',
+ () => {
+ let v1: TestTokenV1Kit;
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1();
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it('is refused at submission', async () => {
+ const before = await readAuthoritySnapshot(
+ v1.providers,
+ v1.contractAddress,
+ );
+ const authFor = (key: ReturnType) =>
+ new ContractMaintenanceAuthority([signatureVerifyingKey(key)], 1);
+
+ await expect(
+ submitRawMaintenanceUpdate(v1.providers, v1.contractAddress, [
+ new ReplaceAuthority(authFor(sampleSigningKey())),
+ new ReplaceAuthority(authFor(sampleSigningKey())),
+ ]),
+ ).rejects.toThrow(/SubmissionError|Transaction submission error/);
+
+ const after = await readAuthoritySnapshot(
+ v1.providers,
+ v1.contractAddress,
+ );
+ expect(after).toStrictEqual(before);
+ });
+ },
+);
+
+describe.runIf(isLiveBackend())(
+ 'TestToken — two `VerifierKeyInsert` on the same operation',
+ () => {
+ let v1: TestTokenV1Kit;
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1();
+ // Empty the slot first, so this is a two-insert case rather than the
+ // insert-on-occupied one `vkCoexistence` already covers.
+ await v1.deployed.circuitMaintenanceTx._mint.removeVerifierKey();
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it('finalizes the tx but reverts the bundle, leaving the slot empty', async () => {
+ const mintVk =
+ await v1.providers.zkConfigProvider.getVerifierKey('_mint');
+ const versionedVk = new ContractOperationVersionedVerifierKey(
+ OPERATION_VERSION,
+ mintVk,
+ );
+
+ const result = await submitRawMaintenanceUpdate(
+ v1.providers,
+ v1.contractAddress,
+ [
+ new VerifierKeyInsert('_mint', versionedVk),
+ new VerifierKeyInsert('_mint', versionedVk),
+ ],
+ );
+ expect(result.status).toBe('FailFallible');
+
+ const stateAfter = await requireContractState(
+ v1.providers,
+ v1.contractAddress,
+ );
+ expect(stateAfter.operation('_mint')).toBeUndefined();
+ });
+ },
+);
diff --git a/contracts/test/integration/specs/cma/multiVkBundle.spec.ts b/contracts/test/integration/specs/cma/multiVkBundle.spec.ts
new file mode 100644
index 00000000..2c282781
--- /dev/null
+++ b/contracts/test/integration/specs/cma/multiVkBundle.spec.ts
@@ -0,0 +1,141 @@
+import {
+ ContractOperationVersion,
+ ContractOperationVersionedVerifierKey,
+ VerifierKeyInsert,
+ VerifierKeyRemove,
+} from '@midnight-ntwrk/ledger-v8';
+import { isLiveBackend } from '@openzeppelin/compact-simulator';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import {
+ requireContractState,
+ submitRawMaintenanceUpdate,
+} from '../../_harness/cma.js';
+import {
+ deployTestTokenV1,
+ type TestTokenV1Kit,
+} from '../../fixtures/testTokenV1.js';
+
+/**
+ * Verifier-key bundles that touch *different* operations — the shape a real
+ * multi-circuit version bump takes.
+ *
+ * The neighbouring specs pin what the chain refuses: bundles with more than
+ * one `ReplaceAuthority`, bundles mixing `ReplaceAuthority` with another kind,
+ * and two inserts on one operation. These three are the remainder, and they
+ * all apply in full.
+ */
+const OPERATION_VERSION = 'v3';
+
+const versionedKey = async (kit: TestTokenV1Kit, circuit: '_mint' | 'pause') =>
+ new ContractOperationVersionedVerifierKey(
+ OPERATION_VERSION,
+ await kit.providers.zkConfigProvider.getVerifierKey(circuit),
+ );
+
+describe.runIf(isLiveBackend())(
+ 'TestToken — VK bundles across different operations',
+ () => {
+ describe('two inserts into empty slots', () => {
+ let v1: TestTokenV1Kit;
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1();
+ await v1.deployed.circuitMaintenanceTx._mint.removeVerifierKey();
+ await v1.deployed.circuitMaintenanceTx.pause.removeVerifierKey();
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it('applies both, occupying each slot', async () => {
+ const result = await submitRawMaintenanceUpdate(
+ v1.providers,
+ v1.contractAddress,
+ [
+ new VerifierKeyInsert('_mint', await versionedKey(v1, '_mint')),
+ new VerifierKeyInsert('pause', await versionedKey(v1, 'pause')),
+ ],
+ );
+ expect(result.status).toBe('SucceedEntirely');
+
+ const stateAfter = await requireContractState(
+ v1.providers,
+ v1.contractAddress,
+ );
+ expect(stateAfter.operation('_mint')).toBeDefined();
+ expect(stateAfter.operation('pause')).toBeDefined();
+ });
+ });
+
+ describe('two removes from occupied slots', () => {
+ let v1: TestTokenV1Kit;
+
+ beforeAll(async () => {
+ // A fresh deploy leaves both slots holding their original keys.
+ v1 = await deployTestTokenV1();
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it('applies both, emptying each slot', async () => {
+ const version = new ContractOperationVersion(OPERATION_VERSION);
+ const result = await submitRawMaintenanceUpdate(
+ v1.providers,
+ v1.contractAddress,
+ [
+ new VerifierKeyRemove('_mint', version),
+ new VerifierKeyRemove('pause', version),
+ ],
+ );
+ expect(result.status).toBe('SucceedEntirely');
+
+ const stateAfter = await requireContractState(
+ v1.providers,
+ v1.contractAddress,
+ );
+ expect(stateAfter.operation('_mint')).toBeUndefined();
+ expect(stateAfter.operation('pause')).toBeUndefined();
+ });
+ });
+
+ describe('an insert and a remove', () => {
+ let v1: TestTokenV1Kit;
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1();
+ // `_mint` empty for the insert to land in; `pause` left occupied for
+ // the remove to take.
+ await v1.deployed.circuitMaintenanceTx._mint.removeVerifierKey();
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it('applies both, so mixing update kinds is allowed', async () => {
+ const result = await submitRawMaintenanceUpdate(
+ v1.providers,
+ v1.contractAddress,
+ [
+ new VerifierKeyInsert('_mint', await versionedKey(v1, '_mint')),
+ new VerifierKeyRemove(
+ 'pause',
+ new ContractOperationVersion(OPERATION_VERSION),
+ ),
+ ],
+ );
+ expect(result.status).toBe('SucceedEntirely');
+
+ const stateAfter = await requireContractState(
+ v1.providers,
+ v1.contractAddress,
+ );
+ expect(stateAfter.operation('_mint')).toBeDefined();
+ expect(stateAfter.operation('pause')).toBeUndefined();
+ });
+ });
+ },
+);
diff --git a/contracts/test/integration/specs/cma/rotation.spec.ts b/contracts/test/integration/specs/cma/rotation.spec.ts
new file mode 100644
index 00000000..f9b74f6a
--- /dev/null
+++ b/contracts/test/integration/specs/cma/rotation.spec.ts
@@ -0,0 +1,68 @@
+import { sampleSigningKey } from '@midnight-ntwrk/compact-runtime';
+import { findDeployedContract } from '@midnight-ntwrk/midnight-js-contracts';
+import { isLiveBackend } from '@openzeppelin/compact-simulator';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { readCmaCounter, rotateAuthority } from '../../_harness/cma.js';
+import {
+ compiledTestTokenV1,
+ deployTestTokenV1,
+ type TestTokenV1Contract,
+ type TestTokenV1Kit,
+} from '../../fixtures/testTokenV1.js';
+
+/**
+ * `replaceAuthority` rotates the on-chain maintenance authority: the counter
+ * advances, the new key authorizes further updates, and the old key does not.
+ *
+ * Ordering matters. The SDK caches one signing key per contract address, and
+ * `findDeployedContract({ signingKey })` overwrites it — so the old-key test
+ * runs last, after the tests that need the handle's own key intact.
+ */
+describe.runIf(isLiveBackend())('TestToken — CMA rotation', () => {
+ let v1: TestTokenV1Kit;
+ let originalKey: ReturnType;
+ let counterBeforeRotation: bigint;
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1();
+ originalKey = v1.deployed.deployTxData.private.signingKey;
+ counterBeforeRotation = await readCmaCounter(
+ v1.providers,
+ v1.contractAddress,
+ );
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it('installs a new signing key and advances the counter by 1', async () => {
+ await rotateAuthority(v1.deployed, sampleSigningKey());
+ const counterAfter = await readCmaCounter(v1.providers, v1.contractAddress);
+ expect(counterAfter).toBe(counterBeforeRotation + 1n);
+ });
+
+ it('authorizes further maintenance updates with the rotated key', async () => {
+ const before = await readCmaCounter(v1.providers, v1.contractAddress);
+ await rotateAuthority(v1.deployed, sampleSigningKey());
+ const after = await readCmaCounter(v1.providers, v1.contractAddress);
+ expect(after).toBe(before + 1n);
+ });
+
+ it('rejects a maintenance tx signed by the pre-rotation key', async () => {
+ const reFound = await findDeployedContract(
+ v1.providers,
+ {
+ compiledContract: compiledTestTokenV1,
+ contractAddress: v1.contractAddress,
+ signingKey: originalKey,
+ },
+ );
+ const before = await readCmaCounter(v1.providers, v1.contractAddress);
+ await expect(
+ reFound.contractMaintenanceTx.replaceAuthority(sampleSigningKey()),
+ ).rejects.toThrow();
+ const after = await readCmaCounter(v1.providers, v1.contractAddress);
+ expect(after).toBe(before);
+ });
+});
diff --git a/contracts/test/integration/specs/cma/staleCounter.spec.ts b/contracts/test/integration/specs/cma/staleCounter.spec.ts
new file mode 100644
index 00000000..f2df0ebb
--- /dev/null
+++ b/contracts/test/integration/specs/cma/staleCounter.spec.ts
@@ -0,0 +1,67 @@
+import {
+ sampleSigningKey,
+ signatureVerifyingKey,
+} from '@midnight-ntwrk/compact-runtime';
+import {
+ ContractMaintenanceAuthority,
+ ReplaceAuthority,
+} from '@midnight-ntwrk/ledger-v8';
+import { isLiveBackend } from '@openzeppelin/compact-simulator';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import {
+ readCmaCounter,
+ submitRawMaintenanceUpdate,
+} from '../../_harness/cma.js';
+import {
+ deployTestTokenV1,
+ type TestTokenV1Kit,
+} from '../../fixtures/testTokenV1.js';
+
+/**
+ * The CMA counter is replay protection: it is part of the signed payload, so a
+ * signature captured at counter C must not apply once the chain has moved on.
+ * The setup lands one real update to advance the chain, then submits an update
+ * signed against the now-stale counter.
+ */
+describe.runIf(isLiveBackend())('TestToken — stale-counter update', () => {
+ let v1: TestTokenV1Kit;
+ let staleCounter: bigint;
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1();
+ staleCounter = await readCmaCounter(v1.providers, v1.contractAddress);
+ await v1.deployed.circuitMaintenanceTx._mint.removeVerifierKey();
+
+ const fresh = await readCmaCounter(v1.providers, v1.contractAddress);
+ if (fresh !== staleCounter + 1n) {
+ throw new Error(
+ `staleCounter setup: expected the counter to advance from ${staleCounter} to ${staleCounter + 1n}, got ${fresh}`,
+ );
+ }
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it('rejects an update built against a counter the chain has moved past', async () => {
+ // The payload is incidental — a fresh authority is structurally valid and
+ // does not depend on slot occupancy. The counter is what is under test.
+ const newAuth = new ContractMaintenanceAuthority(
+ [signatureVerifyingKey(sampleSigningKey())],
+ 1,
+ );
+
+ await expect(
+ submitRawMaintenanceUpdate(
+ v1.providers,
+ v1.contractAddress,
+ [new ReplaceAuthority(newAuth)],
+ staleCounter,
+ ),
+ ).rejects.toThrow(/SubmissionError|Transaction submission error/);
+
+ const counterAfter = await readCmaCounter(v1.providers, v1.contractAddress);
+ expect(counterAfter).toBe(staleCounter + 1n);
+ });
+});
diff --git a/contracts/test/integration/specs/smoke.spec.ts b/contracts/test/integration/specs/smoke.spec.ts
new file mode 100644
index 00000000..11efcb81
--- /dev/null
+++ b/contracts/test/integration/specs/smoke.spec.ts
@@ -0,0 +1,50 @@
+import { isLiveBackend } from '@openzeppelin/compact-simulator';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import {
+ deployTestTokenV1,
+ type TestTokenV1Kit,
+} from '../fixtures/testTokenV1.js';
+
+/**
+ * The composed mock deploys and every module's ledger reads back. The CMA and
+ * upgrade specs all build on this, so a failure here means the harness is
+ * wrong rather than the upgrade path.
+ */
+describe.runIf(isLiveBackend())('Smoke — TestToken deploy', () => {
+ let v1: TestTokenV1Kit;
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1({
+ name: 'TestToken',
+ symbol: 'TT',
+ decimals: 6,
+ });
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it('deploys to the local node', () => {
+ expect(v1.contractAddress).toMatch(/^[0-9a-f]+$/);
+ });
+
+ it('reads back every composed module’s initial ledger', async () => {
+ const ledger = await v1.readLedger();
+ expect({
+ initialized: ledger.Initializable__isInitialized,
+ paused: ledger.Pausable__isPaused,
+ name: ledger.FungibleToken__name,
+ symbol: ledger.FungibleToken__symbol,
+ decimals: ledger.FungibleToken__decimals,
+ totalSupply: ledger.FungibleToken__totalSupply,
+ }).toStrictEqual({
+ initialized: true,
+ paused: false,
+ name: 'TestToken',
+ symbol: 'TT',
+ decimals: 6n,
+ totalSupply: 0n,
+ });
+ });
+});
diff --git a/contracts/test/integration/specs/upgrades/crossModuleIsolation.spec.ts b/contracts/test/integration/specs/upgrades/crossModuleIsolation.spec.ts
new file mode 100644
index 00000000..88d1df98
--- /dev/null
+++ b/contracts/test/integration/specs/upgrades/crossModuleIsolation.spec.ts
@@ -0,0 +1,70 @@
+import { isLiveBackend } from '@openzeppelin/compact-simulator';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { rotateCircuitVK } from '../../_harness/cma.js';
+import { eitherFor } from '../../_harness/identity.js';
+import {
+ deployTestTokenV1,
+ type TestTokenV1Kit,
+} from '../../fixtures/testTokenV1.js';
+
+/**
+ * Rotating one module's circuit leaves the other modules' state alone — the
+ * property only a composed contract can show. Each test writes state in one
+ * module, rotates a key in another, and reads the first back.
+ */
+const MINTER_ROLE = new Uint8Array(32);
+MINTER_ROLE.set(new TextEncoder().encode('MINTER'));
+
+const ALICE = eitherFor('ALICE');
+const BOB = eitherFor('BOB');
+
+describe.runIf(isLiveBackend())(
+ 'TestToken — cross-module isolation under VK rotation',
+ () => {
+ let v1: TestTokenV1Kit;
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1();
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it("keeps BOB's balance when the AccessControl `grantRole` VK rotates", async () => {
+ await v1.deployed.callTx._mint(BOB, 50n);
+ const before = (await v1.readLedger()).FungibleToken__balances.lookup(
+ BOB,
+ );
+
+ await rotateCircuitVK(v1.providers, v1.deployed, 'grantRole');
+
+ const after = (await v1.readLedger()).FungibleToken__balances.lookup(BOB);
+ expect(after).toBe(before);
+ });
+
+ it("keeps ALICE's MINTER role when the FungibleToken `_mint` VK rotates", async () => {
+ const admin = await v1.as('ADMIN');
+ await admin.callTx.grantRole(MINTER_ROLE, ALICE);
+
+ await rotateCircuitVK(v1.providers, v1.deployed, '_mint');
+
+ const roles = (await v1.readLedger()).AccessControl__operatorRoles;
+ expect(roles.lookup(MINTER_ROLE).lookup(ALICE)).toBe(true);
+ });
+
+ it('keeps the contract paused when the FungibleToken `_mint` VK rotates', async () => {
+ if (!(await v1.readLedger()).Pausable__isPaused) {
+ await v1.deployed.callTx.pause();
+ }
+ await rotateCircuitVK(v1.providers, v1.deployed, '_mint');
+ expect((await v1.readLedger()).Pausable__isPaused).toBe(true);
+ });
+
+ it('keeps the Initializable flag set when the Pausable `pause` VK rotates', async () => {
+ expect((await v1.readLedger()).Initializable__isInitialized).toBe(true);
+ await rotateCircuitVK(v1.providers, v1.deployed, 'pause');
+ expect((await v1.readLedger()).Initializable__isInitialized).toBe(true);
+ });
+ },
+);
diff --git a/contracts/test/integration/specs/upgrades/functionalReverification.spec.ts b/contracts/test/integration/specs/upgrades/functionalReverification.spec.ts
new file mode 100644
index 00000000..1d32aa84
--- /dev/null
+++ b/contracts/test/integration/specs/upgrades/functionalReverification.spec.ts
@@ -0,0 +1,84 @@
+import { isLiveBackend } from '@openzeppelin/compact-simulator';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { rotateCircuitVK } from '../../_harness/cma.js';
+import { eitherFor } from '../../_harness/identity.js';
+import {
+ deployTestTokenV1,
+ type TestTokenV1Kit,
+} from '../../fixtures/testTokenV1.js';
+
+/**
+ * A rotated circuit still proves and verifies. `stateSurvival` shows the ledger
+ * is untouched; this calls each rotated circuit afterwards, so a rotation that
+ * broke the prove-verify-apply loop fails at the call rather than silently.
+ */
+const MINTER_ROLE = new Uint8Array(32);
+MINTER_ROLE.set(new TextEncoder().encode('MINTER'));
+
+const ALICE = eitherFor('ALICE');
+const BOB = eitherFor('BOB');
+
+describe.runIf(isLiveBackend())('TestToken — calls after VK rotation', () => {
+ let v1: TestTokenV1Kit;
+
+ const balanceOf = async (account: typeof ALICE) => {
+ const balances = (await v1.readLedger()).FungibleToken__balances;
+ return balances.member(account) ? balances.lookup(account) : 0n;
+ };
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1();
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it('mints after the `_mint` VK rotates', async () => {
+ const before = await balanceOf(ALICE);
+
+ await rotateCircuitVK(v1.providers, v1.deployed, '_mint');
+ await v1.deployed.callTx._mint(ALICE, 75n);
+
+ expect(await balanceOf(ALICE)).toBe(before + 75n);
+ });
+
+ it('pauses after the `pause` VK rotates', async () => {
+ if ((await v1.readLedger()).Pausable__isPaused) {
+ await v1.deployed.callTx.unpause();
+ }
+ await rotateCircuitVK(v1.providers, v1.deployed, 'pause');
+ await v1.deployed.callTx.pause();
+ expect((await v1.readLedger()).Pausable__isPaused).toBe(true);
+ });
+
+ it('grants a role after the `grantRole` VK rotates', async () => {
+ const admin = await v1.as('ADMIN');
+ await rotateCircuitVK(v1.providers, v1.deployed, 'grantRole');
+ await admin.callTx.grantRole(MINTER_ROLE, ALICE);
+
+ const roles = (await v1.readLedger()).AccessControl__operatorRoles;
+ expect(roles.lookup(MINTER_ROLE).lookup(ALICE)).toBe(true);
+ });
+
+ it('transfers after the `transfer` VK rotates', async () => {
+ const aliceStart = await balanceOf(ALICE);
+ if (aliceStart < 50n) {
+ await v1.deployed.callTx._mint(ALICE, 50n - aliceStart);
+ }
+ if ((await v1.readLedger()).Pausable__isPaused) {
+ await v1.deployed.callTx.unpause();
+ }
+
+ const aliceBefore = await balanceOf(ALICE);
+ const bobBefore = await balanceOf(BOB);
+
+ await rotateCircuitVK(v1.providers, v1.deployed, 'transfer');
+
+ const alice = await v1.as('ALICE');
+ await alice.callTx.transfer(BOB, 25n);
+
+ expect(await balanceOf(ALICE)).toBe(aliceBefore - 25n);
+ expect(await balanceOf(BOB)).toBe(bobBefore + 25n);
+ });
+});
diff --git a/contracts/test/integration/specs/upgrades/stateSurvival.spec.ts b/contracts/test/integration/specs/upgrades/stateSurvival.spec.ts
new file mode 100644
index 00000000..c01b950e
--- /dev/null
+++ b/contracts/test/integration/specs/upgrades/stateSurvival.spec.ts
@@ -0,0 +1,97 @@
+import type { Contract as ContractNs } from '@midnight-ntwrk/compact-js';
+import { isLiveBackend } from '@openzeppelin/compact-simulator';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { readCmaCounter, rotateCircuitVK } from '../../_harness/cma.js';
+import { eitherFor } from '../../_harness/identity.js';
+import {
+ deployTestTokenV1,
+ type TestTokenV1Contract,
+ type TestTokenV1Kit,
+} from '../../fixtures/testTokenV1.js';
+
+/**
+ * A verifier-key round-trip touches the key table and nothing else. The
+ * contract carries a deliberately heterogeneous ledger — a constructor-set
+ * flag, a toggled flag, a scalar, a balance map and a nested role map — and
+ * every rotation must leave all of it intact while advancing the counter once
+ * per update.
+ */
+const MINTER_ROLE = new Uint8Array(32);
+MINTER_ROLE.set(new TextEncoder().encode('MINTER'));
+
+const ALICE = eitherFor('ALICE');
+const BOB = eitherFor('BOB');
+
+interface Snapshot {
+ initialized: boolean;
+ paused: boolean;
+ totalSupply: bigint;
+ bobBalance: bigint;
+ aliceHasMinter: boolean;
+ counter: bigint;
+}
+
+describe.runIf(isLiveBackend())(
+ 'TestToken — ledger state across VK rotation',
+ () => {
+ let v1: TestTokenV1Kit;
+
+ async function snapshot(): Promise {
+ const ledger = await v1.readLedger();
+ const roles = ledger.AccessControl__operatorRoles;
+ const balances = ledger.FungibleToken__balances;
+ return {
+ initialized: ledger.Initializable__isInitialized,
+ paused: ledger.Pausable__isPaused,
+ totalSupply: ledger.FungibleToken__totalSupply,
+ bobBalance: balances.member(BOB) ? balances.lookup(BOB) : 0n,
+ aliceHasMinter:
+ roles.member(MINTER_ROLE) &&
+ roles.lookup(MINTER_ROLE).member(ALICE) &&
+ roles.lookup(MINTER_ROLE).lookup(ALICE),
+ counter: await readCmaCounter(v1.providers, v1.contractAddress),
+ };
+ }
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1();
+
+ const admin = await v1.as('ADMIN');
+ await admin.callTx.grantRole(MINTER_ROLE, ALICE);
+ await v1.deployed.callTx._mint(BOB, 100n);
+ await v1.deployed.callTx.pause();
+
+ // A failure here is a broken setup, not a broken upgrade path.
+ expect(await snapshot()).toMatchObject({
+ initialized: true,
+ paused: true,
+ totalSupply: 100n,
+ bobBalance: 100n,
+ aliceHasMinter: true,
+ });
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ async function expectStatePreserved(
+ circuitName: ContractNs.ProvableCircuitId,
+ ) {
+ const before = await snapshot();
+ await rotateCircuitVK(v1.providers, v1.deployed, circuitName);
+ const after = await snapshot();
+ expect(after).toStrictEqual({
+ ...before,
+ counter: before.counter + 2n,
+ });
+ }
+
+ it.each(['pause', '_mint', 'grantRole', 'transfer'] as const)(
+ 'preserves every ledger field when rotating the `%s` VK',
+ async (circuitName) => {
+ await expectStatePreserved(circuitName);
+ },
+ );
+ },
+);
diff --git a/contracts/test/integration/specs/upgrades/versionUpgrade.spec.ts b/contracts/test/integration/specs/upgrades/versionUpgrade.spec.ts
new file mode 100644
index 00000000..0309187c
--- /dev/null
+++ b/contracts/test/integration/specs/upgrades/versionUpgrade.spec.ts
@@ -0,0 +1,216 @@
+import { isLiveBackend } from '@openzeppelin/compact-simulator';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { eitherContractAddress, eitherFor } from '../../_harness/identity.js';
+import {
+ deployTestTokenV1,
+ type TestTokenV1Kit,
+} from '../../fixtures/testTokenV1.js';
+import { bindAsV2, v2VerifierKey } from '../../fixtures/testTokenV2.js';
+
+/**
+ * A version bump done the way a real one would be: deploy V1, rotate the
+ * verifier keys of exactly the circuits whose behaviour changes, and call
+ * them. Five kinds of change are covered — a tightened body, a new
+ * authorization gate, a relaxed guard, a decommissioned circuit, and a
+ * circuit V1 never had.
+ *
+ * `bindAsV2` deliberately skips the SDK's whole-key-set check, so each block
+ * must rotate every circuit it calls through the V2 handle. Anything it does
+ * not rotate stays on V1's key.
+ */
+const ALICE = eitherFor('ALICE');
+const BOB = eitherFor('BOB');
+
+/** Swap one circuit's on-chain key from V1's to V2's. */
+async function rotateToV2(
+ v1: TestTokenV1Kit,
+ circuit: 'pause' | 'unpause' | '_mint' | 'transferOwnership',
+): Promise {
+ const vk = await v2VerifierKey(circuit);
+ await v1.deployed.circuitMaintenanceTx[circuit].removeVerifierKey();
+ await v1.deployed.circuitMaintenanceTx[circuit].insertVerifierKey(vk);
+}
+
+const balanceOf = async (v1: TestTokenV1Kit, account: typeof ALICE) => {
+ const balances = (await v1.readLedger()).FungibleToken__balances;
+ return balances.member(account) ? balances.lookup(account) : 0n;
+};
+
+describe.runIf(isLiveBackend())(
+ 'TestToken upgrade — `_mint` gains a per-tx cap',
+ () => {
+ let v1: TestTokenV1Kit;
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1();
+ await rotateToV2(v1, '_mint');
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it('mints an amount within the cap', async () => {
+ const v2 = await bindAsV2(v1, 'deployer');
+ const before = await balanceOf(v1, ALICE);
+ await v2.callTx._mint(ALICE, 1000n);
+ expect(await balanceOf(v1, ALICE)).toBe(before + 1000n);
+ });
+
+ it('rejects an amount over the cap, which V1 would have minted', async () => {
+ const v2 = await bindAsV2(v1, 'deployer');
+ await expect(v2.callTx._mint(BOB, 2_000_000n)).rejects.toThrow(
+ 'TestTokenV2: _mint amount over per-tx cap',
+ );
+ });
+ },
+);
+
+describe.runIf(isLiveBackend())(
+ 'TestToken upgrade — `pause` gains an admin gate',
+ () => {
+ let v1: TestTokenV1Kit;
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1();
+ await rotateToV2(v1, 'pause');
+ await rotateToV2(v1, 'unpause');
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it('lets the admin pause and unpause', async () => {
+ const admin = await bindAsV2(v1, 'ADMIN');
+ await admin.callTx.pause();
+ expect((await v1.readLedger()).Pausable__isPaused).toBe(true);
+ await admin.callTx.unpause();
+ expect((await v1.readLedger()).Pausable__isPaused).toBe(false);
+ });
+
+ it('rejects a caller without the admin role', async () => {
+ const bob = await bindAsV2(v1, 'BOB');
+ await expect(bob.callTx.pause()).rejects.toThrow(
+ 'AccessControl: unauthorized account',
+ );
+ });
+ },
+);
+
+describe.runIf(isLiveBackend())(
+ 'TestToken upgrade — `transferOwnership` drops the ContractAddress guard',
+ () => {
+ let v1: TestTokenV1Kit;
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1();
+ await rotateToV2(v1, 'transferOwnership');
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it('accepts a ContractAddress destination, which V1 rejected', async () => {
+ const v2 = await bindAsV2(v1, 'deployer');
+ const contractDest = eitherContractAddress('upgrade-test-contract');
+
+ await v2.callTx.transferOwnership(contractDest);
+
+ const ownerNow = (await v1.readLedger()).Ownable__owner;
+ expect(ownerNow.is_left).toBe(false);
+ expect(ownerNow.right.bytes).toEqual(contractDest.right.bytes);
+ });
+
+ it('still accepts an account destination', async () => {
+ // Its own deploy: the test above hands ownership to a contract address,
+ // and the module cannot authenticate one as a caller.
+ const fresh = await deployTestTokenV1();
+ try {
+ await rotateToV2(fresh, 'transferOwnership');
+ const v2 = await bindAsV2(fresh, 'deployer');
+
+ await v2.callTx.transferOwnership(ALICE);
+
+ expect((await fresh.readLedger()).Ownable__owner.left).toEqual(
+ ALICE.left,
+ );
+ } finally {
+ await fresh.teardown();
+ }
+ });
+ },
+);
+
+describe.runIf(isLiveBackend())(
+ 'TestToken upgrade — `_unsafeTransferOwnership` is decommissioned',
+ () => {
+ let v1: TestTokenV1Kit;
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1();
+ // V2 dropped the circuit, so there is no key to rotate to; removing it
+ // outright is the whole upgrade.
+ await v1.deployed.circuitMaintenanceTx._unsafeTransferOwnership.removeVerifierKey();
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it('rejects the call through the V1 handle once the key is gone', async () => {
+ // V1's compiled contract still carries the circuit, but the on-chain
+ // state no longer lists the operation, so the SDK aborts before
+ // submitting and the caller learns the circuit is gone.
+ await expect(
+ v1.deployed.callTx._unsafeTransferOwnership(ALICE),
+ ).rejects.toThrow(/Operation '_unsafeTransferOwnership' is undefined/);
+ });
+
+ it('does not expose the circuit on the V2 handle at all', async () => {
+ const v2 = await bindAsV2(v1, 'deployer');
+ const callTx = v2.callTx as Record;
+ expect(callTx._unsafeTransferOwnership).toBeUndefined();
+ });
+ },
+);
+
+describe.runIf(isLiveBackend())(
+ 'TestToken upgrade — `mintBatch` is a circuit V1 never had',
+ () => {
+ let v1: TestTokenV1Kit;
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1();
+ // V1's maintenance interface has no `mintBatch` key. Re-binding the same
+ // contract as V2 yields one, and its insert carries an operation name the
+ // deployed key table has never seen.
+ const v2 = await bindAsV2(v1, 'deployer');
+ await v2.circuitMaintenanceTx.mintBatch.insertVerifierKey(
+ await v2VerifierKey('mintBatch'),
+ );
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it('mints three times the amount in one call', async () => {
+ const v2 = await bindAsV2(v1, 'deployer');
+ const before = await balanceOf(v1, ALICE);
+
+ await v2.callTx.mintBatch(ALICE, 1000n);
+
+ expect(await balanceOf(v1, ALICE)).toBe(before + 3000n);
+ });
+
+ it('leaves `_mint` on its V1 key', async () => {
+ // Only `mintBatch` was inserted. V1's handle still holds the matching
+ // prover key; V2's `_mint` body differs, so its proof would not verify.
+ const before = await balanceOf(v1, BOB);
+ await v1.deployed.callTx._mint(BOB, 50n);
+ expect(await balanceOf(v1, BOB)).toBe(before + 50n);
+ });
+ },
+);
diff --git a/contracts/test/integration/specs/upgrades/vkCoexistence.spec.ts b/contracts/test/integration/specs/upgrades/vkCoexistence.spec.ts
new file mode 100644
index 00000000..658318ee
--- /dev/null
+++ b/contracts/test/integration/specs/upgrades/vkCoexistence.spec.ts
@@ -0,0 +1,33 @@
+import { isLiveBackend } from '@openzeppelin/compact-simulator';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import {
+ deployTestTokenV1,
+ type TestTokenV1Kit,
+} from '../../fixtures/testTokenV1.js';
+
+/**
+ * Two verifier keys cannot share a circuit slot. The SDK checks the slot, not
+ * the key, and refuses before building the tx — so an upgrade is a sequenced
+ * remove-then-insert, never a side-by-side install followed by a cleanup.
+ */
+describe.runIf(isLiveBackend())('TestToken — VK coexistence', () => {
+ let v1: TestTokenV1Kit;
+
+ beforeAll(async () => {
+ v1 = await deployTestTokenV1();
+ });
+
+ afterAll(async () => {
+ await v1?.teardown();
+ });
+
+ it('rejects inserting into a slot that already holds a key', async () => {
+ // Re-inserting the deploy-time key is enough: the guard reads the slot.
+ const currentMintVk =
+ await v1.providers.zkConfigProvider.getVerifierKey('_mint');
+
+ await expect(
+ v1.deployed.circuitMaintenanceTx._mint.insertVerifierKey(currentMintVk),
+ ).rejects.toThrow(/Circuit '_mint' is already defined/);
+ });
+});