From 2947f8701aa753680118f46d9d531d11fe430133 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 27 Aug 2026 14:03:39 +0200 Subject: [PATCH 1/6] test(cma): port CMA + upgradability specs onto the live harness The branch this work started on predates main's live harness by 90 commits: it carried its own wallet pool, provider builder, vitest config and CI workflow, and main has since grown all of them under contracts/test-utils/harness plus an integration-live vitest project. It also predates the witness-agnostic module refactor, which moved caller identity from the submitting wallet to a witness-supplied secret key. Rebuild the payload on main instead of merging it: * mocks: TestTokenV1/V2 keep their composed-module shape, with the Either arm now Bytes<32> and DEFAULT_ADMIN_ROLE called as a circuit. * identity: an alias is a private state, not a wallet, so one funded deployer pays for every call and the three wit_*SK witnesses answer with the alias' key. * harness: only deploy/providers/cma survive; the wallet pool, network config and private-state store come from main. * livePool: live.setup publishes its pool so a spec needing a raw DeployedContract can borrow the built wallets rather than race a second one on the same seed. Verified against a local node: TestTokenV1 deploys and the rotation spec passes end to end. --- contracts/test-utils/harness/live.setup.ts | 4 + contracts/test-utils/harness/livePool.ts | 36 ++++ contracts/test/integration/_harness/cma.ts | 182 ++++++++++++++++++ contracts/test/integration/_harness/deploy.ts | 63 ++++++ .../test/integration/_harness/identity.ts | 93 +++++++++ .../test/integration/_harness/providers.ts | 54 ++++++ .../integration/_mocks/TestTokenV1.compact | 168 ++++++++++++++++ .../integration/_mocks/TestTokenV2.compact | 168 ++++++++++++++++ .../test/integration/fixtures/testTokenV1.ts | 170 ++++++++++++++++ .../test/integration/fixtures/testTokenV2.ts | 119 ++++++++++++ .../test/integration/specs/cma/freeze.spec.ts | 70 +++++++ .../integration/specs/cma/multiUpdate.spec.ts | 143 ++++++++++++++ .../integration/specs/cma/rotation.spec.ts | 68 +++++++ .../specs/cma/staleCounter.spec.ts | 67 +++++++ 14 files changed, 1405 insertions(+) create mode 100644 contracts/test-utils/harness/livePool.ts create mode 100644 contracts/test/integration/_harness/cma.ts create mode 100644 contracts/test/integration/_harness/deploy.ts create mode 100644 contracts/test/integration/_harness/identity.ts create mode 100644 contracts/test/integration/_harness/providers.ts create mode 100644 contracts/test/integration/_mocks/TestTokenV1.compact create mode 100644 contracts/test/integration/_mocks/TestTokenV2.compact create mode 100644 contracts/test/integration/fixtures/testTokenV1.ts create mode 100644 contracts/test/integration/fixtures/testTokenV2.ts create mode 100644 contracts/test/integration/specs/cma/freeze.spec.ts create mode 100644 contracts/test/integration/specs/cma/multiUpdate.spec.ts create mode 100644 contracts/test/integration/specs/cma/rotation.spec.ts create mode 100644 contracts/test/integration/specs/cma/staleCounter.spec.ts diff --git a/contracts/test-utils/harness/live.setup.ts b/contracts/test-utils/harness/live.setup.ts index fb31cc09c..5c87249e5 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 000000000..939cacd9e --- /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/integration/_harness/cma.ts b/contracts/test/integration/_harness/cma.ts new file mode 100644 index 000000000..713a82401 --- /dev/null +++ b/contracts/test/integration/_harness/cma.ts @@ -0,0 +1,182 @@ +import type { Contract as ContractNs } from '@midnight-ntwrk/compact-js'; +import { + type ContractMaintenanceAuthority, + type ContractState, + sampleSigningKey, + signData, + type SigningKey, +} 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. */ +// biome-ignore lint/suspicious/noExplicitAny: helpers touch only shared plumbing +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; +} + +/** 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 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 000000000..aec5f25bc --- /dev/null +++ b/contracts/test/integration/_harness/deploy.ts @@ -0,0 +1,63 @@ +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. + // biome-ignore lint/suspicious/noExplicitAny: see above + 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 000000000..3315e2504 --- /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 000000000..2d0b2bc1c --- /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 000000000..6c6dbee1e --- /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 000000000..37e44724e --- /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 000000000..9bfbf9d6f --- /dev/null +++ b/contracts/test/integration/fixtures/testTokenV1.ts @@ -0,0 +1,170 @@ +import { CompiledContract } from '@midnight-ntwrk/compact-js'; +import type { Contract as ContractNs } 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 { requireLivePool } from '../../../test-utils/harness/livePool.js'; +import { + Contract as TestTokenV1, + type Ledger as TestTokenV1Ledger, + ledger as testTokenV1Ledger, +} from '../../../artifacts/TestTokenV1/contract/index.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 000000000..19522d625 --- /dev/null +++ b/contracts/test/integration/fixtures/testTokenV2.ts @@ -0,0 +1,119 @@ +import { CompiledContract } from '@midnight-ntwrk/compact-js'; +import type { Contract as ContractNs } 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/cma/freeze.spec.ts b/contracts/test/integration/specs/cma/freeze.spec.ts new file mode 100644 index 000000000..754ef9774 --- /dev/null +++ b/contracts/test/integration/specs/cma/freeze.spec.ts @@ -0,0 +1,70 @@ +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; + let counterBeforeFreeze: bigint; + + 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/multiUpdate.spec.ts b/contracts/test/integration/specs/cma/multiUpdate.spec.ts new file mode 100644 index 000000000..3640237b5 --- /dev/null +++ b/contracts/test/integration/specs/cma/multiUpdate.spec.ts @@ -0,0 +1,143 @@ +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 { + readAuthority, + readCmaCounter, + 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 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 auth = await readAuthority(v1.providers, v1.contractAddress); + expect(auth.committee.length).toBe(1); + }); + }, +); + +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 v1.providers.publicDataProvider.queryContractState( + v1.contractAddress, + ); + expect(stateAfter?.operation('_mint')).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 000000000..f9b74f6a1 --- /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 000000000..f2df0ebbc --- /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); + }); +}); From b3eacc2b9f6cf3b5ff84693420d3920676e4e95c Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 27 Aug 2026 14:13:02 +0200 Subject: [PATCH 2/6] test(cma): port the remaining CMA and upgrade specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every spec is gated on the live backend: the CMA pathway needs a chain, so the dry integration project skips them and stays green. Two specs change shape rather than porting straight across: * the multi-signer AccessControl spec became witnessIdentity — its original subject, that the wallet pool routes callers, no longer carries identity. It now pins that the witness key does. * mixedBundle's two near-identical blocks collapse into one it.each over the ordering. Role assertions read the ledger instead of calling hasRole, which is a full transaction on live for a value already on chain. --- contracts/test/integration/_harness/cma.ts | 3 +- contracts/test/integration/_harness/deploy.ts | 1 - .../test/integration/fixtures/testTokenV1.ts | 4 +- .../test/integration/fixtures/testTokenV2.ts | 2 +- .../accessControl/witnessIdentity.spec.ts | 66 ++++++ .../specs/cma/crossContractReplay.spec.ts | 104 +++++++++ .../specs/cma/emptyCommitteeFreeze.spec.ts | 46 ++++ .../integration/specs/cma/mixedBundle.spec.ts | 86 +++++++ .../specs/cma/multiVkBundle.spec.ts | 138 +++++++++++ .../test/integration/specs/smoke.spec.ts | 50 ++++ .../upgrades/crossModuleIsolation.spec.ts | 70 ++++++ .../upgrades/functionalReverification.spec.ts | 84 +++++++ .../specs/upgrades/stateSurvival.spec.ts | 97 ++++++++ .../specs/upgrades/versionUpgrade.spec.ts | 216 ++++++++++++++++++ .../specs/upgrades/vkCoexistence.spec.ts | 33 +++ 15 files changed, 994 insertions(+), 6 deletions(-) create mode 100644 contracts/test/integration/specs/accessControl/witnessIdentity.spec.ts create mode 100644 contracts/test/integration/specs/cma/crossContractReplay.spec.ts create mode 100644 contracts/test/integration/specs/cma/emptyCommitteeFreeze.spec.ts create mode 100644 contracts/test/integration/specs/cma/mixedBundle.spec.ts create mode 100644 contracts/test/integration/specs/cma/multiVkBundle.spec.ts create mode 100644 contracts/test/integration/specs/smoke.spec.ts create mode 100644 contracts/test/integration/specs/upgrades/crossModuleIsolation.spec.ts create mode 100644 contracts/test/integration/specs/upgrades/functionalReverification.spec.ts create mode 100644 contracts/test/integration/specs/upgrades/stateSurvival.spec.ts create mode 100644 contracts/test/integration/specs/upgrades/versionUpgrade.spec.ts create mode 100644 contracts/test/integration/specs/upgrades/vkCoexistence.spec.ts diff --git a/contracts/test/integration/_harness/cma.ts b/contracts/test/integration/_harness/cma.ts index 713a82401..3c17cdea4 100644 --- a/contracts/test/integration/_harness/cma.ts +++ b/contracts/test/integration/_harness/cma.ts @@ -2,9 +2,9 @@ import type { Contract as ContractNs } from '@midnight-ntwrk/compact-js'; import { type ContractMaintenanceAuthority, type ContractState, + type SigningKey, sampleSigningKey, signData, - type SigningKey, } from '@midnight-ntwrk/compact-runtime'; import { Intent, @@ -32,7 +32,6 @@ import { */ /** Providers for a contract whose concrete type the helper does not care about. */ -// biome-ignore lint/suspicious/noExplicitAny: helpers touch only shared plumbing type AnyProviders = MidnightProviders; /** Either a freshly deployed contract or one rebound via `findDeployedContract`. */ diff --git a/contracts/test/integration/_harness/deploy.ts b/contracts/test/integration/_harness/deploy.ts index aec5f25bc..6e517f278 100644 --- a/contracts/test/integration/_harness/deploy.ts +++ b/contracts/test/integration/_harness/deploy.ts @@ -40,7 +40,6 @@ export async function deployModule( >, // The witnesses generic resolves to `never` for an empty-witness contract; // `any` admits both shapes. - // biome-ignore lint/suspicious/noExplicitAny: see above compiledContract: CompiledContract.CompiledContract< C, ContractNs.PrivateState, diff --git a/contracts/test/integration/fixtures/testTokenV1.ts b/contracts/test/integration/fixtures/testTokenV1.ts index 9bfbf9d6f..262ae3abf 100644 --- a/contracts/test/integration/fixtures/testTokenV1.ts +++ b/contracts/test/integration/fixtures/testTokenV1.ts @@ -1,17 +1,17 @@ -import { CompiledContract } from '@midnight-ntwrk/compact-js'; 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 { requireLivePool } from '../../../test-utils/harness/livePool.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, diff --git a/contracts/test/integration/fixtures/testTokenV2.ts b/contracts/test/integration/fixtures/testTokenV2.ts index 19522d625..4d5b14b43 100644 --- a/contracts/test/integration/fixtures/testTokenV2.ts +++ b/contracts/test/integration/fixtures/testTokenV2.ts @@ -1,5 +1,5 @@ -import { CompiledContract } from '@midnight-ntwrk/compact-js'; import type { Contract as ContractNs } from '@midnight-ntwrk/compact-js'; +import { CompiledContract } from '@midnight-ntwrk/compact-js'; import { createCircuitCallTxInterface, createCircuitMaintenanceTxInterfaces, 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 000000000..e67379393 --- /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 000000000..919350ae1 --- /dev/null +++ b/contracts/test/integration/specs/cma/crossContractReplay.spec.ts @@ -0,0 +1,104 @@ +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, + readAuthority, + 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}`, + ); + } + + // 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 authAfter = await readAuthority( + contractB.providers, + contractB.contractAddress, + ); + expect(authAfter.committee.length).toBe(1); + }); + }, +); 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 000000000..f35796e79 --- /dev/null +++ b/contracts/test/integration/specs/cma/emptyCommitteeFreeze.spec.ts @@ -0,0 +1,46 @@ +import { + ContractMaintenanceAuthority, + ReplaceAuthority, +} from '@midnight-ntwrk/ledger-v8'; +import { isLiveBackend } from '@openzeppelin/compact-simulator'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + readAuthority, + 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 () => { + await expect( + submitRawMaintenanceUpdate(v1.providers, v1.contractAddress, [ + new ReplaceAuthority(new ContractMaintenanceAuthority([], 1)), + ]), + ).rejects.toThrow(/SubmissionError|Transaction submission error/); + + const authAfter = await readAuthority(v1.providers, v1.contractAddress); + expect(authAfter.committee.length).toBe(1); + }); +}); 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 000000000..bac727478 --- /dev/null +++ b/contracts/test/integration/specs/cma/mixedBundle.spec.ts @@ -0,0 +1,86 @@ +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 { + readAuthority, + 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 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 authAfter = await readAuthority(v1.providers, v1.contractAddress); + expect(authAfter.committee.length).toBe(1); + const stateAfter = + await v1.providers.publicDataProvider.queryContractState( + 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 000000000..b716ff7ae --- /dev/null +++ b/contracts/test/integration/specs/cma/multiVkBundle.spec.ts @@ -0,0 +1,138 @@ +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 { 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 v1.providers.publicDataProvider.queryContractState( + 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 v1.providers.publicDataProvider.queryContractState( + 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 v1.providers.publicDataProvider.queryContractState( + v1.contractAddress, + ); + expect(stateAfter?.operation('_mint')).toBeDefined(); + expect(stateAfter?.operation('pause')).toBeUndefined(); + }); + }); + }, +); diff --git a/contracts/test/integration/specs/smoke.spec.ts b/contracts/test/integration/specs/smoke.spec.ts new file mode 100644 index 000000000..11efcb81d --- /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 000000000..88d1df98c --- /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 000000000..1d32aa84d --- /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 000000000..c01b950e3 --- /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 000000000..0309187c9 --- /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 000000000..658318ee1 --- /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/); + }); +}); From f9dbbd2ffacc1e723abc251d9757210b9c086ff2 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 27 Aug 2026 14:14:59 +0200 Subject: [PATCH 3/6] docs(integration): document the CMA suite in the README Records what the specs establish about the maintenance pathway, and the bundle rules the chain enforces, so the next reader does not have to re-derive them by running the suite. Two questions stay open and are marked as such: the counter delta for a multi-update bundle, and whether a maintenance update emits events. --- contracts/test/integration/README.md | 189 +++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) diff --git a/contracts/test/integration/README.md b/contracts/test/integration/README.md index 0979f99f0..fb51ca12d 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. From 9a6f46ccb6049e0fec58bc8d8fadf1fd68213621 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 27 Aug 2026 14:17:10 +0200 Subject: [PATCH 4/6] test(harness): cover the live pool handoff --- .../test-utils/harness/test/livePool.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 contracts/test-utils/harness/test/livePool.test.ts 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 000000000..a95a79888 --- /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(); + }); +}); From a0c934356b004d1781e6ad9930e04d25b4739717 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 27 Aug 2026 14:18:20 +0200 Subject: [PATCH 5/6] docs: point the root README at the live test commands The stack-backed runs were undocumented, so a contributor hitting a skipped spec had no way to find the command that runs it. --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 033bd5b10..58b6a3ef6 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 From 799c7f96bc5bd746d3664c2499ad5dbdea310479 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 27 Aug 2026 15:09:59 +0200 Subject: [PATCH 6/6] test(cma): close the assertions that could pass vacuously Two classes of weak assertion, from review of the pre-port branch: * `state?.operation(op)` is undefined whether the slot is empty or the indexer returned nothing at all, so a spec asserting an empty slot passed either way. requireContractState throws instead. * checking `committee.length === 1` after a rejected update accepts a swap to a different single-key committee, which is precisely what rejection must prevent. readAuthoritySnapshot flattens the authority to plain values so the whole thing compares before and after. Also gives freeze's counter a sentinel: a failure in the first test surfaced as a BigInt TypeError from the second, hiding the cause. --- contracts/test/integration/_harness/cma.ts | 42 +++++++++++++++++++ .../specs/cma/crossContractReplay.spec.ts | 10 +++-- .../specs/cma/emptyCommitteeFreeze.spec.ts | 11 +++-- .../test/integration/specs/cma/freeze.spec.ts | 4 +- .../integration/specs/cma/mixedBundle.spec.ts | 24 +++++++---- .../integration/specs/cma/multiUpdate.spec.ts | 24 +++++++---- .../specs/cma/multiVkBundle.spec.ts | 41 +++++++++--------- 7 files changed, 114 insertions(+), 42 deletions(-) diff --git a/contracts/test/integration/_harness/cma.ts b/contracts/test/integration/_harness/cma.ts index 3c17cdea4..8e22dbc02 100644 --- a/contracts/test/integration/_harness/cma.ts +++ b/contracts/test/integration/_harness/cma.ts @@ -58,6 +58,23 @@ export async function readContractState( 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, @@ -72,6 +89,31 @@ export async function readAuthority( 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, diff --git a/contracts/test/integration/specs/cma/crossContractReplay.spec.ts b/contracts/test/integration/specs/cma/crossContractReplay.spec.ts index 919350ae1..2055e0c90 100644 --- a/contracts/test/integration/specs/cma/crossContractReplay.spec.ts +++ b/contracts/test/integration/specs/cma/crossContractReplay.spec.ts @@ -17,7 +17,7 @@ import { isLiveBackend } from '@openzeppelin/compact-simulator'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { maintenanceTtl, - readAuthority, + readAuthoritySnapshot, readCmaCounter, } from '../../_harness/cma.js'; import { @@ -61,6 +61,10 @@ describe.runIf(isLiveBackend())( ); } + 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( @@ -94,11 +98,11 @@ describe.runIf(isLiveBackend())( }), ).rejects.toThrow(/SubmissionError|Transaction submission error/); - const authAfter = await readAuthority( + const authorityAfter = await readAuthoritySnapshot( contractB.providers, contractB.contractAddress, ); - expect(authAfter.committee.length).toBe(1); + expect(authorityAfter).toStrictEqual(authorityB); }); }, ); diff --git a/contracts/test/integration/specs/cma/emptyCommitteeFreeze.spec.ts b/contracts/test/integration/specs/cma/emptyCommitteeFreeze.spec.ts index f35796e79..6b91d7477 100644 --- a/contracts/test/integration/specs/cma/emptyCommitteeFreeze.spec.ts +++ b/contracts/test/integration/specs/cma/emptyCommitteeFreeze.spec.ts @@ -5,7 +5,7 @@ import { import { isLiveBackend } from '@openzeppelin/compact-simulator'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { - readAuthority, + readAuthoritySnapshot, submitRawMaintenanceUpdate, } from '../../_harness/cma.js'; import { @@ -34,13 +34,18 @@ describe.runIf(isLiveBackend())('TestToken — empty-committee CMA', () => { }); 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 authAfter = await readAuthority(v1.providers, v1.contractAddress); - expect(authAfter.committee.length).toBe(1); + 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 index 754ef9774..36ff6c901 100644 --- a/contracts/test/integration/specs/cma/freeze.spec.ts +++ b/contracts/test/integration/specs/cma/freeze.spec.ts @@ -22,7 +22,9 @@ import { */ describe.runIf(isLiveBackend())('TestToken — freezing the CMA', () => { let v1: TestTokenV1Kit; - let counterBeforeFreeze: bigint; + // 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(); diff --git a/contracts/test/integration/specs/cma/mixedBundle.spec.ts b/contracts/test/integration/specs/cma/mixedBundle.spec.ts index bac727478..b30ea8b3a 100644 --- a/contracts/test/integration/specs/cma/mixedBundle.spec.ts +++ b/contracts/test/integration/specs/cma/mixedBundle.spec.ts @@ -12,7 +12,8 @@ import { import { isLiveBackend } from '@openzeppelin/compact-simulator'; import { afterEach, describe, expect, it } from 'vitest'; import { - readAuthority, + readAuthoritySnapshot, + requireContractState, submitRawMaintenanceUpdate, } from '../../_harness/cma.js'; import { @@ -53,6 +54,10 @@ describe.runIf(isLiveBackend())( { 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())], @@ -74,13 +79,16 @@ describe.runIf(isLiveBackend())( // Neither update took: the authority is the deploy-time one and the // slot is still empty. - const authAfter = await readAuthority(v1.providers, v1.contractAddress); - expect(authAfter.committee.length).toBe(1); - const stateAfter = - await v1.providers.publicDataProvider.queryContractState( - v1.contractAddress, - ); - expect(stateAfter?.operation('_mint')).toBeUndefined(); + 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 index 3640237b5..c5b933ecb 100644 --- a/contracts/test/integration/specs/cma/multiUpdate.spec.ts +++ b/contracts/test/integration/specs/cma/multiUpdate.spec.ts @@ -11,8 +11,9 @@ import { import { isLiveBackend } from '@openzeppelin/compact-simulator'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { - readAuthority, + readAuthoritySnapshot, readCmaCounter, + requireContractState, submitRawMaintenanceUpdate, } from '../../_harness/cma.js'; import { @@ -83,6 +84,10 @@ describe.runIf(isLiveBackend())( }); it('is refused at submission', async () => { + const before = await readAuthoritySnapshot( + v1.providers, + v1.contractAddress, + ); const authFor = (key: ReturnType) => new ContractMaintenanceAuthority([signatureVerifyingKey(key)], 1); @@ -93,8 +98,11 @@ describe.runIf(isLiveBackend())( ]), ).rejects.toThrow(/SubmissionError|Transaction submission error/); - const auth = await readAuthority(v1.providers, v1.contractAddress); - expect(auth.committee.length).toBe(1); + const after = await readAuthoritySnapshot( + v1.providers, + v1.contractAddress, + ); + expect(after).toStrictEqual(before); }); }, ); @@ -133,11 +141,11 @@ describe.runIf(isLiveBackend())( ); expect(result.status).toBe('FailFallible'); - const stateAfter = - await v1.providers.publicDataProvider.queryContractState( - v1.contractAddress, - ); - expect(stateAfter?.operation('_mint')).toBeUndefined(); + 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 index b716ff7ae..2c2827818 100644 --- a/contracts/test/integration/specs/cma/multiVkBundle.spec.ts +++ b/contracts/test/integration/specs/cma/multiVkBundle.spec.ts @@ -6,7 +6,10 @@ import { } from '@midnight-ntwrk/ledger-v8'; import { isLiveBackend } from '@openzeppelin/compact-simulator'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { submitRawMaintenanceUpdate } from '../../_harness/cma.js'; +import { + requireContractState, + submitRawMaintenanceUpdate, +} from '../../_harness/cma.js'; import { deployTestTokenV1, type TestTokenV1Kit, @@ -56,12 +59,12 @@ describe.runIf(isLiveBackend())( ); expect(result.status).toBe('SucceedEntirely'); - const stateAfter = - await v1.providers.publicDataProvider.queryContractState( - v1.contractAddress, - ); - expect(stateAfter?.operation('_mint')).toBeDefined(); - expect(stateAfter?.operation('pause')).toBeDefined(); + const stateAfter = await requireContractState( + v1.providers, + v1.contractAddress, + ); + expect(stateAfter.operation('_mint')).toBeDefined(); + expect(stateAfter.operation('pause')).toBeDefined(); }); }); @@ -89,12 +92,12 @@ describe.runIf(isLiveBackend())( ); expect(result.status).toBe('SucceedEntirely'); - const stateAfter = - await v1.providers.publicDataProvider.queryContractState( - v1.contractAddress, - ); - expect(stateAfter?.operation('_mint')).toBeUndefined(); - expect(stateAfter?.operation('pause')).toBeUndefined(); + const stateAfter = await requireContractState( + v1.providers, + v1.contractAddress, + ); + expect(stateAfter.operation('_mint')).toBeUndefined(); + expect(stateAfter.operation('pause')).toBeUndefined(); }); }); @@ -126,12 +129,12 @@ describe.runIf(isLiveBackend())( ); expect(result.status).toBe('SucceedEntirely'); - const stateAfter = - await v1.providers.publicDataProvider.queryContractState( - v1.contractAddress, - ); - expect(stateAfter?.operation('_mint')).toBeDefined(); - expect(stateAfter?.operation('pause')).toBeUndefined(); + const stateAfter = await requireContractState( + v1.providers, + v1.contractAddress, + ); + expect(stateAfter.operation('_mint')).toBeDefined(); + expect(stateAfter.operation('pause')).toBeUndefined(); }); }); },