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