diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c93db8ece..bda2795c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -179,7 +179,7 @@ One command runs everything — it compiles, resets the stack, runs a quick harn yarn test:live ``` -Currently `multisig` is the only live-ready category; the others still assume dry-only semantics and are skipped (listed in the run banner). Each category joins the run — with its own `test:live:` script — as its specs are refactored for the live backend. +Every `src/` with tests runs live: `multisig`, `token`, `access`, and `security` carry backend-aware specs (real coins and on-chain identity threaded through the wallet pool; coin-flow assertions split into dry and live blocks), and `crypto` and `utils` — pure hashing / commitment / encoding primitives — run their computation through a real deploy on the node too. There is no separate live-ready allowlist; the runner discovers categories automatically (CI reads the list via `--list`). A category that must not run live is an explicit opt-out (`EXCLUDED_CATEGORIES` in [`scripts/test-live.ts`](./scripts/test-live.ts) — only legacy `archive` today). If any files fail, a second round re-runs just those files on a fresh node with one worker, to separate a real failure from an environment flake: diff --git a/contracts/src/access/test/ZOwnablePK.test.ts b/contracts/src/access/test/ZOwnablePK.test.ts index e5a1f144e..330e6fb33 100644 --- a/contracts/src/access/test/ZOwnablePK.test.ts +++ b/contracts/src/access/test/ZOwnablePK.test.ts @@ -6,13 +6,32 @@ import { } from '@midnight-ntwrk/compact-runtime'; import { beforeEach, describe, expect, it } from 'vitest'; import * as utils from '#test-utils/fixtures/address.js'; +import { shieldedTestKey } from '#test-utils/fixtures/shieldedKey.js'; import type { ZswapCoinPublicKey } from '../../../artifacts/MockOwnable/contract/index.js'; import { ZOwnablePKSimulator } from './simulators/ZOwnablePKSimulator.js'; import { ZOwnablePKPrivateState } from './witnesses/ZOwnablePKWitnesses.js'; -// PKs -const [, Z_OWNER] = utils.generatePubKeyPair('OWNER'); -const [, Z_NEW_OWNER] = utils.generatePubKeyPair('NEW_OWNER'); +// The three owner identities this spec authorizes by. ZOwnablePK is the one +// access contract that authorizes by the caller's REAL coin public key: +// `assertOnlyOwner` recomputes `id = SHA256(ownPublicKey(), nonce)` and matches +// its commitment against the stored one. So the PK used to build the expected +// commitment off-chain MUST equal the key `.as(alias)` makes `ownPublicKey()` +// return, and the three roles must resolve to distinct wallets or the negative +// "caller is not the owner" checks false-pass. The live wallet pool exposes +// fixed slots (`SIGNER1`–`SIGNER3`, within the 3-signer cap); any other alias +// collapses to the deployer wallet. So bind each role to its own slot here and +// refer to it by these names throughout. +const OWNER = 'SIGNER1'; +const NEW_OWNER = 'SIGNER2'; +const UNAUTHORIZED = 'SIGNER3'; + +// The owner / new-owner coin public keys — the `.left` (user) arm of the shared +// signer fixture, i.e. exactly what `ownPublicKey()` returns for that alias. On +// live each is the pooled wallet's own key (published as +// `MIDNIGHT__COIN_PK`); on dry it is the deterministic synthetic key the +// dry backend also derives for `.as(alias)`. +const Z_OWNER = shieldedTestKey(OWNER).left; +const Z_NEW_OWNER = shieldedTestKey(NEW_OWNER).left; const INSTANCE_SALT = new Uint8Array(32).fill(8675309); const BAD_NONCE = Buffer.from(Buffer.alloc(32, 'BAD_NONCE')); @@ -196,36 +215,36 @@ describe('ZOwnablePK', () => { }); it('should transfer ownership', async () => { - await ownable.as('OWNER').transferOwnership(newIdHash); + await ownable.as(OWNER).transferOwnership(newIdHash); expect(await ownable.owner()).toEqual(newOwnerCommitment); // Old owner - await expect(ownable.as('OWNER').assertOnlyOwner()).rejects.toThrow( + await expect(ownable.as(OWNER).assertOnlyOwner()).rejects.toThrow( 'ZOwnablePK: caller is not the owner', ); // Unauthorized await expect( - ownable.as('UNAUTHORIZED').assertOnlyOwner(), + ownable.as(UNAUTHORIZED).assertOnlyOwner(), ).rejects.toThrow('ZOwnablePK: caller is not the owner'); // New owner await ownable.privateState.injectSecretNonce( Buffer.from(newOwnerNonce), ); - await ownable.as('NEW_OWNER').assertOnlyOwner(); + await ownable.as(NEW_OWNER).assertOnlyOwner(); }); it('should fail when transferring to id zero', async () => { const badId = new Uint8Array(32).fill(0); await expect( - ownable.as('OWNER').transferOwnership(badId), + ownable.as(OWNER).transferOwnership(badId), ).rejects.toThrow('ZOwnablePK: invalid id'); }); it('should fail when unauthorized transfers ownership', async () => { await expect( - ownable.as('UNAUTHORIZED').transferOwnership(newIdHash), + ownable.as(UNAUTHORIZED).transferOwnership(newIdHash), ).rejects.toThrow('ZOwnablePK: caller is not the owner'); }); @@ -237,7 +256,7 @@ describe('ZOwnablePK', () => { .ZOwnablePK__counter; // Transfer - await ownable.as('OWNER').transferOwnership(newIdHash); + await ownable.as(OWNER).transferOwnership(newIdHash); // Check counter const afterInstance = (await ownable.getPublicState()) @@ -257,7 +276,7 @@ describe('ZOwnablePK', () => { expect(initCommitment).toEqual(expInitCommitment); // Transfer ownership to self with the same id -> `H(pk, nonce)` - await ownable.as('OWNER').transferOwnership(repeatedId); + await ownable.as(OWNER).transferOwnership(repeatedId); // Check commitments don't match const newCommitment = await ownable.owner(); @@ -273,32 +292,32 @@ describe('ZOwnablePK', () => { expect(newCommitment).toEqual(expNewCommitment); // Check same owner maintains permissions after transfer - await ownable.as('OWNER').assertOnlyOwner(); + await ownable.as(OWNER).assertOnlyOwner(); }); }); describe('renounceOwnership', () => { it('should renounce ownership', async () => { - await ownable.as('OWNER').renounceOwnership(); + await ownable.as(OWNER).renounceOwnership(); // Check owner is reset expect(await ownable.owner()).toEqual(new Uint8Array(32).fill(0)); // Check revoked permissions - await expect(ownable.as('OWNER').assertOnlyOwner()).rejects.toThrow( + await expect(ownable.as(OWNER).assertOnlyOwner()).rejects.toThrow( 'ZOwnablePK: caller is not the owner', ); }); it('should fail when renouncing from unauthorized', async () => { await expect( - ownable.as('UNAUTHORIZED').renounceOwnership(), + ownable.as(UNAUTHORIZED).renounceOwnership(), ).rejects.toThrow('ZOwnablePK: caller is not the owner'); }); it('should fail when renouncing from authorized with bad nonce', async () => { await ownable.privateState.injectSecretNonce(BAD_NONCE); - await expect(ownable.as('OWNER').renounceOwnership()).rejects.toThrow( + await expect(ownable.as(OWNER).renounceOwnership()).rejects.toThrow( 'ZOwnablePK: caller is not the owner', ); }); @@ -306,7 +325,7 @@ describe('ZOwnablePK', () => { it('should fail when renouncing from unauthorized with bad nonce', async () => { await ownable.privateState.injectSecretNonce(BAD_NONCE); await expect( - ownable.as('UNAUTHORIZED').renounceOwnership(), + ownable.as(UNAUTHORIZED).renounceOwnership(), ).rejects.toThrow('ZOwnablePK: caller is not the owner'); }); }); @@ -318,7 +337,7 @@ describe('ZOwnablePK', () => { secretNonce, ); - await ownable.as('OWNER').assertOnlyOwner(); + await ownable.as(OWNER).assertOnlyOwner(); }); it('should fail when the authorized caller has the wrong nonce', async () => { @@ -331,7 +350,7 @@ describe('ZOwnablePK', () => { ); // Set caller and call circuit - await expect(ownable.as('OWNER').assertOnlyOwner()).rejects.toThrow( + await expect(ownable.as(OWNER).assertOnlyOwner()).rejects.toThrow( 'ZOwnablePK: caller is not the owner', ); }); @@ -343,7 +362,7 @@ describe('ZOwnablePK', () => { ); await expect( - ownable.as('UNAUTHORIZED').assertOnlyOwner(), + ownable.as(UNAUTHORIZED).assertOnlyOwner(), ).rejects.toThrow('ZOwnablePK: caller is not the owner'); }); @@ -358,7 +377,7 @@ describe('ZOwnablePK', () => { // Set unauthorized caller and call circuit await expect( - ownable.as('UNAUTHORIZED').assertOnlyOwner(), + ownable.as(UNAUTHORIZED).assertOnlyOwner(), ).rejects.toThrow('ZOwnablePK: caller is not the owner'); }); }); @@ -495,9 +514,9 @@ describe('ZOwnablePK', () => { it('should allow anyone to transfer', async () => { const id = createIdHash(Z_OWNER, secretNonce); - await ownable.as('OWNER')._transferOwnership(id); + await ownable.as(OWNER)._transferOwnership(id); - await ownable.as('UNAUTHORIZED')._transferOwnership(id); + await ownable.as(UNAUTHORIZED)._transferOwnership(id); }); }); }); diff --git a/contracts/src/multisig/test/Forwarder.test.ts b/contracts/src/multisig/test/Forwarder.test.ts index 5679a277b..f33a0f11f 100644 --- a/contracts/src/multisig/test/Forwarder.test.ts +++ b/contracts/src/multisig/test/Forwarder.test.ts @@ -5,7 +5,7 @@ import { encodeShieldedCoinInfo, GENESIS_NATIVE_SHIELDED_TOKEN_COLORS, } from '#test-utils/fixtures/nativeShieldedToken.js'; -import { shieldedTestParentKey } from '#test-utils/fixtures/shieldedKey.js'; +import { shieldedTestKey } from '#test-utils/fixtures/shieldedKey.js'; import { MockForwarderShieldedSimulator } from './simulators/MockForwarderShieldedSimulator.js'; import { MockForwarderUnshieldedSimulator } from './simulators/MockForwarderUnshieldedSimulator.js'; @@ -22,7 +22,7 @@ import { MockForwarderUnshieldedSimulator } from './simulators/MockForwarderUnsh // coin to it, so its encryption key must resolve on-chain). The unshielded // parent stays synthetic — an unshielded recipient is a public address, no // encryption key needed. -const SHIELDED_PARENT = shieldedTestParentKey(); +const SHIELDED_PARENT = shieldedTestKey().left; const SHIELDED_ZERO = utils.ZERO_KEY.left; const UNSHIELDED_PARENT = utils.createEitherTestUserAddress('PARENT').right; const UNSHIELDED_ZERO = utils.ZERO_USER_ADDRESS.right; diff --git a/contracts/src/multisig/test/ForwarderPrivate.test.ts b/contracts/src/multisig/test/ForwarderPrivate.test.ts index e9f8d68ca..ca4bb151a 100644 --- a/contracts/src/multisig/test/ForwarderPrivate.test.ts +++ b/contracts/src/multisig/test/ForwarderPrivate.test.ts @@ -7,7 +7,7 @@ import { encodeShieldedCoinInfo, GENESIS_NATIVE_SHIELDED_TOKEN_COLORS, } from '#test-utils/fixtures/nativeShieldedToken.js'; -import { shieldedTestParentKey } from '#test-utils/fixtures/shieldedKey.js'; +import { shieldedTestKey } from '#test-utils/fixtures/shieldedKey.js'; import { bytesToHex, isNonceSpent, @@ -31,7 +31,7 @@ import { MockForwarderPrivateSimulator } from './simulators/MockForwarderPrivate // none). Published by the live setup, so safe to read at module scope. `WRONG` // stays a synthetic key: it only exercises the commitment gate, which rejects it // before any send. -const PARENT_BYTES = shieldedTestParentKey().bytes; +const PARENT_BYTES = shieldedTestKey().left.bytes; const WRONG_BYTES = utils.createEitherTestUser('WRONG').left.bytes; const OP_SECRET = new Uint8Array(32).fill(0xaa); const WRONG_OP_SECRET = new Uint8Array(32).fill(0xbb); diff --git a/contracts/src/multisig/test/ShieldedMultiSig.test.ts b/contracts/src/multisig/test/ShieldedMultiSig.test.ts index 0cb8295b2..845815a22 100644 --- a/contracts/src/multisig/test/ShieldedMultiSig.test.ts +++ b/contracts/src/multisig/test/ShieldedMultiSig.test.ts @@ -3,10 +3,7 @@ import { encodeShieldedCoinInfo, GENESIS_NATIVE_SHIELDED_TOKEN_COLORS, } from '#test-utils/fixtures/nativeShieldedToken.js'; -import { - shieldedTestParentKey, - shieldedTestSigner, -} from '#test-utils/fixtures/shieldedKey.js'; +import { shieldedTestKey } from '#test-utils/fixtures/shieldedKey.js'; import { ShieldedMultiSigSimulator } from './simulators/ShieldedMultiSigSimulator.js'; const ProposalStatus = { Inactive: 0, Active: 1, Executed: 2, Cancelled: 3 }; @@ -24,16 +21,16 @@ const PROPOSAL_AMOUNT = 400n; // `ownPublicKey()` matches — the only way to exercise multi-signer authorization // on a real node. On dry these are the deterministic synthetic keys `.as(...)` // resolves to. `OTHER` is not a pooled wallet, so it acts as a non-signer. -const Z_SIGNER1 = shieldedTestSigner('SIGNER1'); -const Z_SIGNER2 = shieldedTestSigner('SIGNER2'); -const Z_SIGNER3 = shieldedTestSigner('SIGNER3'); +const Z_SIGNER1 = shieldedTestKey('SIGNER1'); +const Z_SIGNER2 = shieldedTestKey('SIGNER2'); +const Z_SIGNER3 = shieldedTestKey('SIGNER3'); const SIGNERS = [Z_SIGNER1, Z_SIGNER2, Z_SIGNER3]; -const Z_NON_SIGNER = shieldedTestSigner('OTHER'); -// Proposal payout recipient. On live `executeShieldedProposal` sends the treasury -// coins here, so it must be a node-resolvable key (the deployer's own); on dry a -// synthetic key. -const Z_RECIPIENT_PK = shieldedTestParentKey('RECIPIENT'); +const Z_NON_SIGNER = shieldedTestKey('OTHER'); +// Proposal payout recipient (bare coin public key). On live +// `executeShieldedProposal` sends the treasury coins here, so it must be a +// node-resolvable key (the deployer's own); on dry a synthetic key. +const Z_RECIPIENT_PK = shieldedTestKey().left; function makeRecipient(pk: { bytes: Uint8Array }): { kind: number; @@ -172,7 +169,7 @@ describe('ShieldedMultiSig', () => { // witness the dry sim sets from `.as('SIGNER1')`). On live each alias is // backed by its own prefunded wallet (the harness pool), so `.as('SIGNER1')` // submits from that wallet and `ownPublicKey()` is that signer's key — the - // synthetic set is replaced by the pooled wallets' keys (see `shieldedTestSigner`), + // synthetic set is replaced by the pooled wallets' keys (see `shieldedTestKey`), // so these run on both backends. describe('caller-gated proposal flows', () => { describe('createShieldedProposal', () => { diff --git a/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts b/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts index fded63c35..d15df11be 100644 --- a/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts +++ b/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts @@ -1,7 +1,7 @@ import { isLiveBackend } from '@openzeppelin/compact-simulator'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; import * as utils from '#test-utils/fixtures/address.js'; -import { shieldedTestRecipient } from '#test-utils/fixtures/shieldedKey.js'; +import { shieldedTestKey } from '#test-utils/fixtures/shieldedKey.js'; import { calculateSignerId, ShieldedMultiSigV3Simulator, @@ -39,7 +39,7 @@ const CONTRACT_RECIPIENT = utils.createEitherTestContractAddress('TARGET'); // live it resolves to the deployer's own coin public key (whose encryption key // the node can resolve), so the minted coin is deliverable; dry → a synthetic // user. -let USER_RECIPIENT: ReturnType; +let USER_RECIPIENT: ReturnType; function makeQualifiedCoin( color: Uint8Array, @@ -141,7 +141,7 @@ describe('ShieldedMultiSigV3', () => { // mutating groups below build their own fresh instance per test. beforeAll(async () => { multisig = await freshMultisig(); - USER_RECIPIENT = shieldedTestRecipient(); + USER_RECIPIENT = shieldedTestKey(); }); describe('view', () => { diff --git a/contracts/src/multisig/test/ShieldedTreasury.test.ts b/contracts/src/multisig/test/ShieldedTreasury.test.ts index 37a1e5adb..2bb39ab74 100644 --- a/contracts/src/multisig/test/ShieldedTreasury.test.ts +++ b/contracts/src/multisig/test/ShieldedTreasury.test.ts @@ -4,7 +4,7 @@ import { encodeShieldedCoinInfo, GENESIS_NATIVE_SHIELDED_TOKEN_COLORS, } from '#test-utils/fixtures/nativeShieldedToken.js'; -import { shieldedTestRecipient } from '#test-utils/fixtures/shieldedKey.js'; +import { shieldedTestKey } from '#test-utils/fixtures/shieldedKey.js'; import { bytesToHex, isNonceSpent, @@ -30,7 +30,7 @@ const TREASURY_ADDRESS = '7a'.repeat(32); // Assigned in `beforeAll` after `create()` syncs the wallet: on live the harness // then publishes MIDNIGHT_DEPLOYER_COIN_PK, so this resolves to the deployer's own // coin public key (an encryption key the node can resolve); dry → a synthetic user. -let Z_RECIPIENT: ReturnType; +let Z_RECIPIENT: ReturnType; // Delegates to the backend-aware builder: on live every coin gets a fresh random // nonce (the local node persists nullifiers across runs, so a fixed nonce would @@ -58,7 +58,7 @@ describe('ShieldedTreasury', () => { // reuses this shared deploy. beforeAll(async () => { treasury = await freshTreasury(); - Z_RECIPIENT = shieldedTestRecipient(); + Z_RECIPIENT = shieldedTestKey(); }); describe('initial state', () => { diff --git a/contracts/src/multisig/test/ShieldedTreasuryStateless.test.ts b/contracts/src/multisig/test/ShieldedTreasuryStateless.test.ts index 4767bc1b3..ff038e1f0 100644 --- a/contracts/src/multisig/test/ShieldedTreasuryStateless.test.ts +++ b/contracts/src/multisig/test/ShieldedTreasuryStateless.test.ts @@ -6,7 +6,7 @@ import { encodeShieldedCoinInfo, GENESIS_NATIVE_SHIELDED_TOKEN_COLORS, } from '#test-utils/fixtures/nativeShieldedToken.js'; -import { shieldedTestRecipient } from '#test-utils/fixtures/shieldedKey.js'; +import { shieldedTestKey } from '#test-utils/fixtures/shieldedKey.js'; import { bytesToHex, isNonceSpent, @@ -31,7 +31,7 @@ const TREASURY_ADDRESS = '5c'.repeat(32); // Assigned in `beforeEach` after `create()` syncs the wallet: on live this // resolves to the deployer's own coin public key (an encryption key the node can // resolve); dry → a synthetic user. -let Z_RECIPIENT: ReturnType; +let Z_RECIPIENT: ReturnType; // Delegates to the backend-aware builder: live gets a fresh random nonce per run // (the local node persists nullifiers, so a fixed nonce would replay a spent @@ -51,7 +51,7 @@ describe('ShieldedTreasuryStateless', () => { treasury = await MockShieldedTreasuryStatelessSimulator.create({ contractAddress: TREASURY_ADDRESS, }); - Z_RECIPIENT = shieldedTestRecipient(); + Z_RECIPIENT = shieldedTestKey(); const deposited = makeCoin(COLOR, AMOUNT); await treasury._deposit(deposited); coin = await getQualifiedShieldedCoinInfo( @@ -257,7 +257,7 @@ describe('ShieldedTreasuryStateless', () => { '_send — implementing contract routes the change onward on live', () => { it('should let the caller send the change to a different recipient using the send result', async () => { - const changeDest = shieldedTestRecipient(); + const changeDest = shieldedTestKey(); const routed = await treasury._sendAndRouteChange( coin, Z_RECIPIENT, diff --git a/contracts/src/multisig/test/presets/ForwarderPrivate.test.ts b/contracts/src/multisig/test/presets/ForwarderPrivate.test.ts index 245c436a2..7ee212a10 100644 --- a/contracts/src/multisig/test/presets/ForwarderPrivate.test.ts +++ b/contracts/src/multisig/test/presets/ForwarderPrivate.test.ts @@ -3,7 +3,7 @@ import { GENESIS_NATIVE_SHIELDED_TOKEN_COLORS, encodeShieldedCoinInfo as makeCoin, } from '#test-utils/fixtures/nativeShieldedToken.js'; -import { shieldedTestParentKey } from '#test-utils/fixtures/shieldedKey.js'; +import { shieldedTestKey } from '#test-utils/fixtures/shieldedKey.js'; import { contractOwner, getQualifiedShieldedCoinInfo, @@ -14,7 +14,7 @@ import { ForwarderPrivateSimulator } from '../simulators/presets/ForwarderPrivat // over its raw 32 bytes (`calculateParentCommitment(parent.bytes, opSecret)`). // On live it is the deployer's own key (the drain sends the note to it, so its // encryption key must resolve on-chain). -const PARENT_BYTES = shieldedTestParentKey().bytes; +const PARENT_BYTES = shieldedTestKey().left.bytes; const OP_SECRET = new Uint8Array(32).fill(0xaa); // A shielded token type the deployer wallet holds on live (genesis-minted). const COLOR = GENESIS_NATIVE_SHIELDED_TOKEN_COLORS.nativeShieldedToken1; diff --git a/contracts/src/multisig/test/presets/ForwarderShielded.test.ts b/contracts/src/multisig/test/presets/ForwarderShielded.test.ts index 533dd1612..dce0bac87 100644 --- a/contracts/src/multisig/test/presets/ForwarderShielded.test.ts +++ b/contracts/src/multisig/test/presets/ForwarderShielded.test.ts @@ -4,7 +4,7 @@ import { GENESIS_NATIVE_SHIELDED_TOKEN_COLORS, encodeShieldedCoinInfo as makeCoin, } from '#test-utils/fixtures/nativeShieldedToken.js'; -import { shieldedTestParentKey } from '#test-utils/fixtures/shieldedKey.js'; +import { shieldedTestKey } from '#test-utils/fixtures/shieldedKey.js'; import { ForwarderShieldedSimulator } from '../simulators/presets/ForwarderShieldedSimulator.js'; // The constructor takes a `ZswapCoinPublicKey` (the supported arm). The @@ -14,7 +14,7 @@ import { ForwarderShieldedSimulator } from '../simulators/presets/ForwarderShiel // // Live: the parent is the deployer's own key (the deposit forwards the coin to // it, so its encryption key must resolve on-chain). -const PARENT = shieldedTestParentKey(); +const PARENT = shieldedTestKey().left; const ZERO_KEY = utils.ZERO_KEY.left; // A shielded token type the deployer wallet holds on live (genesis-minted). const COLOR = GENESIS_NATIVE_SHIELDED_TOKEN_COLORS.nativeShieldedToken1; diff --git a/contracts/src/token/test/NativeShieldedToken.test.ts b/contracts/src/token/test/NativeShieldedToken.test.ts index b31dbc3f4..1bb7f6d8e 100644 --- a/contracts/src/token/test/NativeShieldedToken.test.ts +++ b/contracts/src/token/test/NativeShieldedToken.test.ts @@ -1,5 +1,8 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { isLiveBackend } from '@openzeppelin/compact-simulator'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; import * as utils from '#test-utils/fixtures/address.js'; +import { encodeShieldedCoinInfo } from '#test-utils/fixtures/nativeShieldedToken.js'; +import { shieldedTestKey } from '#test-utils/fixtures/shieldedKey.js'; import { NativeShieldedTokenSimulator, type NativeShieldedTokenSimulator as Sim, @@ -34,7 +37,29 @@ const deploy = (init = INIT): Promise => let token: NativeShieldedTokenSimulator; +// Resolved once in `beforeAll` after the first `create()`: on live the harness +// then publishes MIDNIGHT_DEPLOYER_COIN_PK, so this is the deployer wallet's own +// coin public key (an encryption key the node can resolve as a mint recipient / +// refund target); on dry it is the synthetic `createEitherTestUser('RECIPIENT')`, +// identical to the un-gated tests' `RECIPIENT`. +let Z_RECIPIENT: ReturnType; + +// A backend-aware mint nonce, delegated to the shared coin builder: on live every +// coin gets a fresh random nonce (the local node persists nullifiers/commitments +// across runs, so a fixed nonce would replay a spent coin — Custom error 103); on +// dry it is the passed seed (else zero) so `coin.nonce` assertions stay reproducible. +const mintNonce = (seed?: Uint8Array): Uint8Array => + encodeShieldedCoinInfo(new Uint8Array(32), 0n, seed).nonce; + describe('NativeShieldedToken (Fungible profile)', () => { + // Resolve the shared recipient once: on live it needs a prior `create()` (which + // triggers the wallet sync that publishes the deployer key); on dry it is + // synthetic. Mutating groups still deploy a fresh token per test in `beforeEach`. + beforeAll(async () => { + token = await deploy(INIT); + Z_RECIPIENT = shieldedTestKey(); + }); + describe('initialization', () => { beforeEach(async () => { token = await deploy(INIT); @@ -107,16 +132,29 @@ describe('NativeShieldedToken (Fungible profile)', () => { token = await deploy(INIT); }); + // `_mint` creates a NEW coin (`mintShieldedToken`, no input coin to receive), + // so it runs on both backends: recipient → the node-resolvable `Z_RECIPIENT`, + // nonce → a backend-aware value captured locally (a fixed nonce would replay a + // prior run's commitment on live). The mint echoes the nonce back, so assert + // against the one we passed, never a hardcoded value. it('should return a coin with color = tokenColor, value = amount, nonce = arg', async () => { - const nonce = b32('mint-nonce-1'); - const coin = await token._mint(RECIPIENT, AMOUNT, nonce); + const nonce = mintNonce(b32('mint-nonce-1')); + const coin = await token._mint(Z_RECIPIENT, AMOUNT, nonce); expect(coin.value).toBe(AMOUNT); expect(coin.nonce).toEqual(nonce); expect(coin.color).toEqual(await token.tokenColor()); }); + // Minting to a contract-address recipient: a mint only creates a commitment + // (no ciphertext / no encryption-key resolution needed), so a synthetic + // contract address is fine on both backends; only the nonce must be + // backend-aware to avoid replaying a prior run's commitment on live. it('should mint to a contract-address recipient', async () => { - const coin = await token._mint(RECIPIENT_CONTRACT, AMOUNT, b32('mint-c')); + const coin = await token._mint( + RECIPIENT_CONTRACT, + AMOUNT, + mintNonce(b32('mint-c')), + ); expect(coin.value).toBe(AMOUNT); expect(coin.color).toEqual(await token.tokenColor()); }); @@ -147,6 +185,8 @@ describe('NativeShieldedToken (Fungible profile)', () => { value, }); + // Reverts assert-and-throw on the guards BEFORE the coin is received/spent, so + // the fabricated coin never reaches Zswap — valid on both backends, un-gated. it('should revert on a wrong-color coin', async () => { await expect( token._burn(coinOf(AMOUNT, b32('wrong')), AMOUNT, REFUND_TO), @@ -168,15 +208,43 @@ describe('NativeShieldedToken (Fungible profile)', () => { ).rejects.toThrow('NativeShieldedToken: invalid refund target'); }); - it('should return none on a full burn (amount == coin.value)', async () => { - const res = await token._burn(coinOf(AMOUNT), AMOUNT, REFUND_TO); - expect(res.is_some).toBe(false); + // The happy paths actually receive and spend the coin. A fabricated coin has + // no on-chain existence on live (`receiveShielded`/`sendImmediateShielded` + // reverts), so split like ShieldedTreasury: keep the fabricated-coin assertions + // as dry coverage, and mint→burn a real coin on live. + describe.skipIf(isLiveBackend())('happy paths (dry only)', () => { + it('should return none on a full burn (amount == coin.value)', async () => { + const res = await token._burn(coinOf(AMOUNT), AMOUNT, REFUND_TO); + expect(res.is_some).toBe(false); + }); + + it('should return some(refund) with refund.value == coin.value - amount on a partial burn', async () => { + const res = await token._burn(coinOf(AMOUNT), 600n, REFUND_TO); + expect(res.is_some).toBe(true); + expect(res.value.value).toBe(AMOUNT - 600n); + }); }); - it('should return some(refund) with refund.value == coin.value - amount on a partial burn', async () => { - const res = await token._burn(coinOf(AMOUNT), 600n, REFUND_TO); - expect(res.is_some).toBe(true); - expect(res.value.value).toBe(AMOUNT - 600n); + describe.runIf(isLiveBackend())('happy paths on live', () => { + // LIVE: `_burn` receives a same-tx coin and spends it via + // `sendImmediateShielded`. The coin's color is this token's `tokenColor()` + // (contract-derived, NOT a genesis color), so it must come from a prior + // `_mint` this run — a fabricated coin can't be received. `refundTo` receives + // the partial-burn change, so it must be node-resolvable (`Z_RECIPIENT`). + // Confirm against a node that the minted coin is spendable in the burn tx + // (the wallet paying it into the contract). + it('should return none on a full burn (amount == coin.value)', async () => { + const coin = await token._mint(Z_RECIPIENT, AMOUNT, mintNonce()); + const res = await token._burn(coin, AMOUNT, Z_RECIPIENT); + expect(res.is_some).toBe(false); + }); + + it('should return some(refund) with refund.value == coin.value - amount on a partial burn', async () => { + const coin = await token._mint(Z_RECIPIENT, AMOUNT, mintNonce()); + const res = await token._burn(coin, 600n, Z_RECIPIENT); + expect(res.is_some).toBe(true); + expect(res.value.value).toBe(AMOUNT - 600n); + }); }); }); @@ -194,6 +262,8 @@ describe('NativeShieldedToken (Fungible profile)', () => { mt_index: 0n, }); + // Reverts assert-and-throw on the guards BEFORE the `sendShielded` spend, so + // the fabricated coin never reaches Zswap — valid on both backends, un-gated. it('should revert on a wrong-color coin', async () => { await expect( token._burnFromSelf(qCoinOf(AMOUNT, b32('wrong')), AMOUNT), @@ -206,14 +276,44 @@ describe('NativeShieldedToken (Fungible profile)', () => { ).rejects.toThrow('NativeShieldedToken: insufficient coin value'); }); - it('should return change on a partial burn', async () => { - const res = await token._burnFromSelf(qCoinOf(AMOUNT), 600n); - expect(res.is_some).toBe(true); + // The happy paths spend a coin the contract already holds. On live that means + // a real Merkle-tree entry with a valid `mt_index`; a fabricated one reverts. + // Split like ShieldedTreasury: keep the fabricated-coin assertions as dry + // coverage, and mint→capture→burn on live. + describe.skipIf(isLiveBackend())('happy paths (dry only)', () => { + it('should return change on a partial burn', async () => { + const res = await token._burnFromSelf(qCoinOf(AMOUNT), 600n); + expect(res.is_some).toBe(true); + }); + + it('should return none on a full burn', async () => { + const res = await token._burnFromSelf(qCoinOf(AMOUNT), AMOUNT); + expect(res.is_some).toBe(false); + }); }); - it('should return none on a full burn', async () => { - const res = await token._burnFromSelf(qCoinOf(AMOUNT), AMOUNT); - expect(res.is_some).toBe(false); + // Skipped, not `runIf(isLiveBackend())`: `_burnFromSelf` spends a coin the + // CONTRACT already holds (a Merkle-tree entry with a valid `mt_index`). Such + // a coin must be minted to this contract and its `mt_index` recovered from + // the global zswap ledger-events stream (ShieldedCoinTracker) once the mint + // finalizes — it cannot be known from the spec alone. The mint-to-deployer + + // `mt_index: 0n` below is a placeholder that reverts on a real node; unskip + // once the tracker capture is wired in. + describe.skip('happy paths on live (pending real mt_index capture)', () => { + it('should return change on a partial burn', async () => { + const coin = await token._mint(Z_RECIPIENT, AMOUNT, mintNonce()); + const res = await token._burnFromSelf({ ...coin, mt_index: 0n }, 600n); + expect(res.is_some).toBe(true); + }); + + it('should return none on a full burn', async () => { + const coin = await token._mint(Z_RECIPIENT, AMOUNT, mintNonce()); + const res = await token._burnFromSelf( + { ...coin, mt_index: 0n }, + AMOUNT, + ); + expect(res.is_some).toBe(false); + }); }); }); }); diff --git a/contracts/src/token/test/NativeShieldedTokenCore.test.ts b/contracts/src/token/test/NativeShieldedTokenCore.test.ts index 83ed6314e..fc680746d 100644 --- a/contracts/src/token/test/NativeShieldedTokenCore.test.ts +++ b/contracts/src/token/test/NativeShieldedTokenCore.test.ts @@ -1,5 +1,8 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { isLiveBackend } from '@openzeppelin/compact-simulator'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; import * as utils from '#test-utils/fixtures/address.js'; +import { encodeShieldedCoinInfo } from '#test-utils/fixtures/nativeShieldedToken.js'; +import { shieldedTestKey } from '#test-utils/fixtures/shieldedKey.js'; import { NativeShieldedTokenCoreSimulator, type NativeShieldedTokenCoreSimulator as Sim, @@ -29,7 +32,29 @@ const deploy = (init = INIT): Promise => let token: NativeShieldedTokenCoreSimulator; +// Resolved once in `beforeAll` after the first `create()`: on live the harness +// then publishes MIDNIGHT_DEPLOYER_COIN_PK, so this is the deployer wallet's own +// coin public key (an encryption key the node can resolve as a mint recipient / +// refund target); on dry it is the synthetic `createEitherTestUser('RECIPIENT')`, +// identical to the un-gated tests' `RECIPIENT`. +let Z_RECIPIENT: ReturnType; + +// A backend-aware mint nonce, delegated to the shared coin builder: on live every +// coin gets a fresh random nonce (the local node persists nullifiers/commitments +// across runs, so a fixed nonce would replay a spent coin — Custom error 103); on +// dry it is the passed seed (else zero) so `coin.nonce` assertions stay reproducible. +const mintNonce = (seed?: Uint8Array): Uint8Array => + encodeShieldedCoinInfo(new Uint8Array(32), 0n, seed).nonce; + describe('NativeShieldedTokenCore (bare base)', () => { + // Resolve the shared recipient once: on live it needs a prior `create()` (which + // triggers the wallet sync that publishes the deployer key); on dry it is + // synthetic. Mutating groups still deploy a fresh token per test in `beforeEach`. + beforeAll(async () => { + token = await deploy(INIT); + Z_RECIPIENT = shieldedTestKey(); + }); + describe('initialization', () => { beforeEach(async () => { token = await deploy(INIT); @@ -150,9 +175,14 @@ describe('NativeShieldedTokenCore (bare base)', () => { token = await deploy(INIT); }); + // `_mint` creates a NEW coin (`mintShieldedToken`, no input coin to receive), + // so it runs on both backends: recipient → the node-resolvable `Z_RECIPIENT`, + // nonce → a backend-aware value captured locally (a fixed nonce would replay a + // prior run's commitment on live). The mint echoes the nonce back, so assert + // against the one we passed, never a hardcoded value. it('should return a coin with color = tokenColor(domain), value, nonce', async () => { - const nonce = b32('m-a'); - const coin = await token._mint(DOMAIN_A, RECIPIENT, AMOUNT, nonce); + const nonce = mintNonce(b32('m-a')); + const coin = await token._mint(DOMAIN_A, Z_RECIPIENT, AMOUNT, nonce); expect(coin.value).toBe(AMOUNT); expect(coin.nonce).toEqual(nonce); expect(coin.color).toEqual(await token.tokenColor(DOMAIN_A)); @@ -205,6 +235,8 @@ describe('NativeShieldedTokenCore (bare base)', () => { value, }); + // Reverts assert-and-throw on the guards BEFORE the coin is received/spent, so + // the fabricated coin never reaches Zswap — valid on both backends, un-gated. it('should revert when amount > coin.value', async () => { await expect( token._burn(DOMAIN_A, coinOf(AMOUNT), AMOUNT + 1n, REFUND_TO), @@ -217,19 +249,61 @@ describe('NativeShieldedTokenCore (bare base)', () => { ).rejects.toThrow('NativeShieldedToken: invalid refund target'); }); - it('should return none on a full burn and some(refund) on a partial burn', async () => { - expect( - (await token._burn(DOMAIN_A, coinOf(AMOUNT), AMOUNT, REFUND_TO)) - .is_some, - ).toBe(false); - const partial = await token._burn( - DOMAIN_A, - coinOf(AMOUNT), - 600n, - REFUND_TO, - ); - expect(partial.is_some).toBe(true); - expect(partial.value.value).toBe(AMOUNT - 600n); + // The happy path actually receives and spends the coin. A fabricated coin has + // no on-chain existence on live (`receiveShielded`/`sendImmediateShielded` + // reverts), so split like ShieldedTreasury: keep the fabricated-coin assertions + // as dry coverage, and mint→burn a real coin on live. + describe.skipIf(isLiveBackend())('happy path (dry only)', () => { + it('should return none on a full burn and some(refund) on a partial burn', async () => { + expect( + (await token._burn(DOMAIN_A, coinOf(AMOUNT), AMOUNT, REFUND_TO)) + .is_some, + ).toBe(false); + const partial = await token._burn( + DOMAIN_A, + coinOf(AMOUNT), + 600n, + REFUND_TO, + ); + expect(partial.is_some).toBe(true); + expect(partial.value.value).toBe(AMOUNT - 600n); + }); + }); + + describe.runIf(isLiveBackend())('happy path on live', () => { + it('should return none on a full burn and some(refund) on a partial burn', async () => { + // LIVE: `_burn` receives a same-tx coin and spends it via + // `sendImmediateShielded`. The coin's color is this contract's + // `tokenColor(DOMAIN_A)` (contract-derived, NOT a genesis color), so it + // must come from a prior `_mint` this run — a fabricated coin can't be + // received. `refundTo` receives the partial-burn change, so it must be + // node-resolvable (`Z_RECIPIENT`). Confirm against a node that the minted + // coin is spendable in the burn tx (the wallet paying it into the contract). + const fullCoin = await token._mint( + DOMAIN_A, + Z_RECIPIENT, + AMOUNT, + mintNonce(), + ); + expect( + (await token._burn(DOMAIN_A, fullCoin, AMOUNT, Z_RECIPIENT)).is_some, + ).toBe(false); + + const partialCoin = await token._mint( + DOMAIN_A, + Z_RECIPIENT, + AMOUNT, + mintNonce(), + ); + const partial = await token._burn( + DOMAIN_A, + partialCoin, + 600n, + Z_RECIPIENT, + ); + expect(partial.is_some).toBe(true); + expect(partial.value.value).toBe(AMOUNT - 600n); + }); }); }); @@ -247,19 +321,71 @@ describe('NativeShieldedTokenCore (bare base)', () => { mt_index: 0n, }); - it('should return change on a partial burn and none on a full burn', async () => { - expect( - (await token._burnFromSelf(DOMAIN_A, qCoinOf(AMOUNT), 600n)).is_some, - ).toBe(true); - expect( - (await token._burnFromSelf(DOMAIN_A, qCoinOf(AMOUNT), AMOUNT)).is_some, - ).toBe(false); - }); - + // Wrong-color revert asserts BEFORE the `sendShielded` spend, so the fabricated + // coin never reaches Zswap — valid on both backends, un-gated. it('should reject a wrong-color coin', async () => { await expect( token._burnFromSelf(DOMAIN_A, qCoinOf(AMOUNT, b32('wrong')), AMOUNT), ).rejects.toThrow('NativeShieldedToken: wrong token'); }); + + // The happy path spends a coin the contract already holds. On live that means + // a real Merkle-tree entry with a valid `mt_index`; a fabricated one reverts. + // Split like ShieldedTreasury: keep the fabricated-coin assertions as dry + // coverage, and mint→capture→burn on live. + describe.skipIf(isLiveBackend())('happy path (dry only)', () => { + it('should return change on a partial burn and none on a full burn', async () => { + expect( + (await token._burnFromSelf(DOMAIN_A, qCoinOf(AMOUNT), 600n)).is_some, + ).toBe(true); + expect( + (await token._burnFromSelf(DOMAIN_A, qCoinOf(AMOUNT), AMOUNT)) + .is_some, + ).toBe(false); + }); + }); + + // Skipped, not `runIf(isLiveBackend())`: `_burnFromSelf` spends a coin the + // CONTRACT already holds (a Merkle-tree entry with a valid `mt_index`). Such + // a coin must be minted to this contract and its `mt_index` recovered from + // the global zswap ledger-events stream (ShieldedCoinTracker) once the mint + // finalizes — it cannot be known from the spec alone. The mint-to-deployer + + // `mt_index: 0n` below is a placeholder that reverts on a real node; unskip + // once the tracker capture is wired in. + describe.skip('happy path on live (pending real mt_index capture)', () => { + it('should return change on a partial burn and none on a full burn', async () => { + const partialCoin = await token._mint( + DOMAIN_A, + Z_RECIPIENT, + AMOUNT, + mintNonce(), + ); + expect( + ( + await token._burnFromSelf( + DOMAIN_A, + { ...partialCoin, mt_index: 0n }, + 600n, + ) + ).is_some, + ).toBe(true); + + const fullCoin = await token._mint( + DOMAIN_A, + Z_RECIPIENT, + AMOUNT, + mintNonce(), + ); + expect( + ( + await token._burnFromSelf( + DOMAIN_A, + { ...fullCoin, mt_index: 0n }, + AMOUNT, + ) + ).is_some, + ).toBe(false); + }); + }); }); }); diff --git a/contracts/src/token/test/NativeShieldedTokenFamily.test.ts b/contracts/src/token/test/NativeShieldedTokenFamily.test.ts index 8242c75c1..18eaed1b5 100644 --- a/contracts/src/token/test/NativeShieldedTokenFamily.test.ts +++ b/contracts/src/token/test/NativeShieldedTokenFamily.test.ts @@ -1,5 +1,8 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { isLiveBackend } from '@openzeppelin/compact-simulator'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; import * as utils from '#test-utils/fixtures/address.js'; +import { encodeShieldedCoinInfo } from '#test-utils/fixtures/nativeShieldedToken.js'; +import { shieldedTestKey } from '#test-utils/fixtures/shieldedKey.js'; import { NativeShieldedTokenFamilySimulator, type NativeShieldedTokenFamilySimulator as Sim, @@ -29,7 +32,29 @@ const deploy = (init = INIT): Promise => let token: NativeShieldedTokenFamilySimulator; +// Resolved once in `beforeAll` after the first `create()`: on live the harness +// then publishes MIDNIGHT_DEPLOYER_COIN_PK, so this is the deployer wallet's own +// coin public key (an encryption key the node can resolve as a mint recipient / +// refund target); on dry it is the synthetic `createEitherTestUser('RECIPIENT')`, +// identical to the un-gated tests' `RECIPIENT`. +let Z_RECIPIENT: ReturnType; + +// A backend-aware mint nonce, delegated to the shared coin builder: on live every +// coin gets a fresh random nonce (the local node persists nullifiers/commitments +// across runs, so a fixed nonce would replay a spent coin — Custom error 103); on +// dry it is the passed seed (else zero) so `coin.nonce` assertions stay reproducible. +const mintNonce = (seed?: Uint8Array): Uint8Array => + encodeShieldedCoinInfo(new Uint8Array(32), 0n, seed).nonce; + describe('NativeShieldedTokenFamily (Family profile)', () => { + // Resolve the shared recipient once: on live it needs a prior `create()` (which + // triggers the wallet sync that publishes the deployer key); on dry it is + // synthetic. Mutating groups still deploy a fresh token per test in `beforeEach`. + beforeAll(async () => { + token = await deploy(INIT); + Z_RECIPIENT = shieldedTestKey(); + }); + describe('initialization', () => { beforeEach(async () => { token = await deploy(INIT); @@ -93,9 +118,14 @@ describe('NativeShieldedTokenFamily (Family profile)', () => { token = await deploy(INIT); }); + // `_mint` creates a NEW coin (`mintShieldedToken`, no input coin to receive), + // so it runs on both backends: recipient → the node-resolvable `Z_RECIPIENT`, + // nonce → a backend-aware value captured locally (a fixed nonce would replay a + // prior run's commitment on live). The mint echoes the nonce back, so assert + // against the one we passed, never a hardcoded value. it('should return a coin with color = tokenColor(domain), value, nonce', async () => { - const nonce = b32('m-a'); - const coin = await token._mint(DOMAIN_A, RECIPIENT, AMOUNT, nonce); + const nonce = mintNonce(b32('m-a')); + const coin = await token._mint(DOMAIN_A, Z_RECIPIENT, AMOUNT, nonce); expect(coin.value).toBe(AMOUNT); expect(coin.nonce).toEqual(nonce); expect(coin.color).toEqual(await token.tokenColor(DOMAIN_A)); @@ -148,6 +178,8 @@ describe('NativeShieldedTokenFamily (Family profile)', () => { value, }); + // Reverts assert-and-throw on the guards BEFORE the coin is received/spent, so + // the fabricated coin never reaches Zswap — valid on both backends, un-gated. it('should revert when amount > coin.value', async () => { await expect( token._burn(DOMAIN_A, coinOf(AMOUNT), AMOUNT + 1n, REFUND_TO), @@ -160,19 +192,61 @@ describe('NativeShieldedTokenFamily (Family profile)', () => { ).rejects.toThrow('NativeShieldedToken: invalid refund target'); }); - it('should return none on a full burn and some(refund) on a partial burn', async () => { - expect( - (await token._burn(DOMAIN_A, coinOf(AMOUNT), AMOUNT, REFUND_TO)) - .is_some, - ).toBe(false); - const partial = await token._burn( - DOMAIN_A, - coinOf(AMOUNT), - 600n, - REFUND_TO, - ); - expect(partial.is_some).toBe(true); - expect(partial.value.value).toBe(AMOUNT - 600n); + // The happy path actually receives and spends the coin. A fabricated coin has + // no on-chain existence on live (`receiveShielded`/`sendImmediateShielded` + // reverts), so split like ShieldedTreasury: keep the fabricated-coin assertions + // as dry coverage, and mint→burn a real coin on live. + describe.skipIf(isLiveBackend())('happy path (dry only)', () => { + it('should return none on a full burn and some(refund) on a partial burn', async () => { + expect( + (await token._burn(DOMAIN_A, coinOf(AMOUNT), AMOUNT, REFUND_TO)) + .is_some, + ).toBe(false); + const partial = await token._burn( + DOMAIN_A, + coinOf(AMOUNT), + 600n, + REFUND_TO, + ); + expect(partial.is_some).toBe(true); + expect(partial.value.value).toBe(AMOUNT - 600n); + }); + }); + + describe.runIf(isLiveBackend())('happy path on live', () => { + it('should return none on a full burn and some(refund) on a partial burn', async () => { + // LIVE: `_burn` receives a same-tx coin and spends it via + // `sendImmediateShielded`. The coin's color is this contract's + // `tokenColor(DOMAIN_A)` (contract-derived, NOT a genesis color), so it + // must come from a prior `_mint` this run — a fabricated coin can't be + // received. `refundTo` receives the partial-burn change, so it must be + // node-resolvable (`Z_RECIPIENT`). Confirm against a node that the minted + // coin is spendable in the burn tx (the wallet paying it into the contract). + const fullCoin = await token._mint( + DOMAIN_A, + Z_RECIPIENT, + AMOUNT, + mintNonce(), + ); + expect( + (await token._burn(DOMAIN_A, fullCoin, AMOUNT, Z_RECIPIENT)).is_some, + ).toBe(false); + + const partialCoin = await token._mint( + DOMAIN_A, + Z_RECIPIENT, + AMOUNT, + mintNonce(), + ); + const partial = await token._burn( + DOMAIN_A, + partialCoin, + 600n, + Z_RECIPIENT, + ); + expect(partial.is_some).toBe(true); + expect(partial.value.value).toBe(AMOUNT - 600n); + }); }); }); @@ -190,19 +264,71 @@ describe('NativeShieldedTokenFamily (Family profile)', () => { mt_index: 0n, }); - it('should return change on a partial burn and none on a full burn', async () => { - expect( - (await token._burnFromSelf(DOMAIN_A, qCoinOf(AMOUNT), 600n)).is_some, - ).toBe(true); - expect( - (await token._burnFromSelf(DOMAIN_A, qCoinOf(AMOUNT), AMOUNT)).is_some, - ).toBe(false); - }); - + // Wrong-color revert asserts BEFORE the `sendShielded` spend, so the fabricated + // coin never reaches Zswap — valid on both backends, un-gated. it('should reject a wrong-color coin', async () => { await expect( token._burnFromSelf(DOMAIN_A, qCoinOf(AMOUNT, b32('wrong')), AMOUNT), ).rejects.toThrow('NativeShieldedToken: wrong token'); }); + + // The happy path spends a coin the contract already holds. On live that means + // a real Merkle-tree entry with a valid `mt_index`; a fabricated one reverts. + // Split like ShieldedTreasury: keep the fabricated-coin assertions as dry + // coverage, and mint→capture→burn on live. + describe.skipIf(isLiveBackend())('happy path (dry only)', () => { + it('should return change on a partial burn and none on a full burn', async () => { + expect( + (await token._burnFromSelf(DOMAIN_A, qCoinOf(AMOUNT), 600n)).is_some, + ).toBe(true); + expect( + (await token._burnFromSelf(DOMAIN_A, qCoinOf(AMOUNT), AMOUNT)) + .is_some, + ).toBe(false); + }); + }); + + // Skipped, not `runIf(isLiveBackend())`: `_burnFromSelf` spends a coin the + // CONTRACT already holds (a Merkle-tree entry with a valid `mt_index`). Such + // a coin must be minted to this contract and its `mt_index` recovered from + // the global zswap ledger-events stream (ShieldedCoinTracker) once the mint + // finalizes — it cannot be known from the spec alone. The mint-to-deployer + + // `mt_index: 0n` below is a placeholder that reverts on a real node; unskip + // once the tracker capture is wired in. + describe.skip('happy path on live (pending real mt_index capture)', () => { + it('should return change on a partial burn and none on a full burn', async () => { + const partialCoin = await token._mint( + DOMAIN_A, + Z_RECIPIENT, + AMOUNT, + mintNonce(), + ); + expect( + ( + await token._burnFromSelf( + DOMAIN_A, + { ...partialCoin, mt_index: 0n }, + 600n, + ) + ).is_some, + ).toBe(true); + + const fullCoin = await token._mint( + DOMAIN_A, + Z_RECIPIENT, + AMOUNT, + mintNonce(), + ); + expect( + ( + await token._burnFromSelf( + DOMAIN_A, + { ...fullCoin, mt_index: 0n }, + AMOUNT, + ) + ).is_some, + ).toBe(false); + }); + }); }); }); diff --git a/contracts/src/token/test/extensions/NativeShieldedTokenPublicSupply.property.test.ts b/contracts/src/token/test/extensions/NativeShieldedTokenPublicSupply.property.test.ts index 4b48d58ce..c8b2c4a2c 100644 --- a/contracts/src/token/test/extensions/NativeShieldedTokenPublicSupply.property.test.ts +++ b/contracts/src/token/test/extensions/NativeShieldedTokenPublicSupply.property.test.ts @@ -1,3 +1,4 @@ +import { isLiveBackend } from '@openzeppelin/compact-simulator'; import fc from 'fast-check'; import { describe, expect, it } from 'vitest'; import { NativeShieldedTokenFamilyPublicSupplySimulator } from '../simulators/NativeShieldedTokenFamilyPublicSupplySimulator.js'; @@ -24,81 +25,91 @@ const opArb = fc.record({ burnPct: fc.integer({ min: 0, max: 100 }), }); -describe('NativeShieldedTokenPublicSupply(Core/Family) — property: supply invariants under random op sequences', () => { - it('should keep totalMinted exact, totalSupply = minted - burned, and burned <= minted (scalar)', async () => { - await fc.assert( - fc.asyncProperty( - fc.array(opArb, { minLength: 1, maxLength: 6 }), - async (ops) => { - const supply = - await NativeShieldedTokenPublicSupplySimulator.create(); - let expectedMinted = 0n; - let expectedBurned = 0n; +// Dry-only on a feasibility ground, not a coverage one: fast-check drives 15 +// randomized runs of up to 6–8 mint/burn ops each, and every op is its own +// deploy/tx on the live backend (~18s apiece → hours for this one file). The +// random op sequences are also non-deterministic, so the two-round flake +// classifier in `scripts/test-live.ts` (re-run a failed file to tell a real +// failure from an env flake) cannot reproduce a failing case. The accounting +// itself is exercised live by the (deterministic) scalar/family supply specs. +describe.skipIf(isLiveBackend())( + 'NativeShieldedTokenPublicSupply(Core/Family) — property: supply invariants under random op sequences', + () => { + it('should keep totalMinted exact, totalSupply = minted - burned, and burned <= minted (scalar)', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(opArb, { minLength: 1, maxLength: 6 }), + async (ops) => { + const supply = + await NativeShieldedTokenPublicSupplySimulator.create(); + let expectedMinted = 0n; + let expectedBurned = 0n; - for (const op of ops) { - await supply._addMinted(op.mint); - expectedMinted += op.mint; - const burn = (op.mint * BigInt(op.burnPct)) / 100n; - if (burn > 0n) { - await supply._addBurned(burn); - expectedBurned += burn; + for (const op of ops) { + await supply._addMinted(op.mint); + expectedMinted += op.mint; + const burn = (op.mint * BigInt(op.burnPct)) / 100n; + if (burn > 0n) { + await supply._addBurned(burn); + expectedBurned += burn; + } } - } - expect(await supply.totalMinted()).toBe(expectedMinted); - expect(await supply.totalBurned()).toBe(expectedBurned); - expect(await supply.totalSupply()).toBe( - expectedMinted - expectedBurned, - ); - expect(expectedBurned <= expectedMinted).toBe(true); - }, - ), - { numRuns: 15 }, - ); - }, 120_000); + expect(await supply.totalMinted()).toBe(expectedMinted); + expect(await supply.totalBurned()).toBe(expectedBurned); + expect(await supply.totalSupply()).toBe( + expectedMinted - expectedBurned, + ); + expect(expectedBurned <= expectedMinted).toBe(true); + }, + ), + { numRuns: 15 }, + ); + }, 120_000); - it('should keep those same invariants per domain across interleaved domains (family)', async () => { - const DOMAINS = [b32('domain-A'), b32('domain-B'), b32('domain-C')]; + it('should keep those same invariants per domain across interleaved domains (family)', async () => { + const DOMAINS = [b32('domain-A'), b32('domain-B'), b32('domain-C')]; - await fc.assert( - fc.asyncProperty( - fc.array( - fc.record({ - domainIdx: fc.integer({ min: 0, max: DOMAINS.length - 1 }), - mint: fc.bigInt({ min: 1n, max: 1_000_000n }), - burnPct: fc.integer({ min: 0, max: 100 }), - }), - { minLength: 1, maxLength: 8 }, - ), - async (ops) => { - const supply = - await NativeShieldedTokenFamilyPublicSupplySimulator.create(); - const expectedMinted = DOMAINS.map(() => 0n); - const expectedBurned = DOMAINS.map(() => 0n); + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + domainIdx: fc.integer({ min: 0, max: DOMAINS.length - 1 }), + mint: fc.bigInt({ min: 1n, max: 1_000_000n }), + burnPct: fc.integer({ min: 0, max: 100 }), + }), + { minLength: 1, maxLength: 8 }, + ), + async (ops) => { + const supply = + await NativeShieldedTokenFamilyPublicSupplySimulator.create(); + const expectedMinted = DOMAINS.map(() => 0n); + const expectedBurned = DOMAINS.map(() => 0n); - for (const op of ops) { - const domain = DOMAINS[op.domainIdx]; - await supply._addMinted(domain, op.mint); - expectedMinted[op.domainIdx] += op.mint; - const burn = (op.mint * BigInt(op.burnPct)) / 100n; - if (burn > 0n) { - await supply._addBurned(domain, burn); - expectedBurned[op.domainIdx] += burn; + for (const op of ops) { + const domain = DOMAINS[op.domainIdx]; + await supply._addMinted(domain, op.mint); + expectedMinted[op.domainIdx] += op.mint; + const burn = (op.mint * BigInt(op.burnPct)) / 100n; + if (burn > 0n) { + await supply._addBurned(domain, burn); + expectedBurned[op.domainIdx] += burn; + } } - } - for (let i = 0; i < DOMAINS.length; i++) { - const domain = DOMAINS[i]; - expect(await supply.totalMinted(domain)).toBe(expectedMinted[i]); - expect(await supply.totalBurned(domain)).toBe(expectedBurned[i]); - expect(await supply.totalSupply(domain)).toBe( - expectedMinted[i] - expectedBurned[i], - ); - expect(expectedBurned[i] <= expectedMinted[i]).toBe(true); - } - }, - ), - { numRuns: 15 }, - ); - }, 120_000); -}); + for (let i = 0; i < DOMAINS.length; i++) { + const domain = DOMAINS[i]; + expect(await supply.totalMinted(domain)).toBe(expectedMinted[i]); + expect(await supply.totalBurned(domain)).toBe(expectedBurned[i]); + expect(await supply.totalSupply(domain)).toBe( + expectedMinted[i] - expectedBurned[i], + ); + expect(expectedBurned[i] <= expectedMinted[i]).toBe(true); + } + }, + ), + { numRuns: 15 }, + ); + }, 120_000); + }, +); diff --git a/contracts/test-utils/fixtures/shieldedKey.ts b/contracts/test-utils/fixtures/shieldedKey.ts index 9e50bcd9a..5dd8748f8 100644 --- a/contracts/test-utils/fixtures/shieldedKey.ts +++ b/contracts/test-utils/fixtures/shieldedKey.ts @@ -1,77 +1,53 @@ // Compact's byte-encoded recipient representation (the one the compiled circuits // accept) — distinct from the runtime `Recipient`, whose fields are // `type`/`string`. `EncodedRecipient` is exactly -// `Either`. -import { - type EncodedRecipient, - encodeCoinPublicKey, -} from '@midnight-ntwrk/compact-runtime'; +// `Either`; its `.left` is the coin public +// key (`EncodedCoinPublicKey`) that callers needing a bare key read directly. +import type { EncodedRecipient } from '@midnight-ntwrk/compact-runtime'; import { createEitherTestUser, eitherUserFromCoinPublicKey, - encodeToPK, } from './address.js'; /** - * Backend-aware shielded recipient/signer-key fixtures, so one spec runs - * unchanged on both `MIDNIGHT_BACKEND=dry` and `=live`. The live keys are - * threaded in WITHOUT importing the live harness (which pulls in testkit / - * midnight-js): each key arrives through a `MIDNIGHT_*_COIN_PK` env var the - * harness publishes once the wallet has synced. So a dry spec importing this - * stays lean. See {@link nativeShieldedToken} for the matching coin fixtures. + * Backend-aware shielded coin-public-key fixture, so one spec runs unchanged on + * both `MIDNIGHT_BACKEND=dry` and `=live`. The live keys are threaded in WITHOUT + * importing the live harness (which pulls in testkit / midnight-js): each key + * arrives through a `MIDNIGHT_*_COIN_PK` env var the harness publishes once the + * wallet has synced. So a dry spec importing this stays lean. See + * {@link nativeShieldedToken} for the matching coin fixtures. */ /** - * The recipient for a shielded send in a spec that runs on both backends. On - * live it must be a key whose encryption key the node can resolve, so it is the - * deployer wallet's own coin public key — published by the live harness as - * `MIDNIGHT_DEPLOYER_COIN_PK` once the wallet has synced. Call this AFTER - * `Sim.create()` (e.g. in `beforeEach`), since that is what triggers the sync. - * On dry a fabricated key works, so `label` is hashed into a synthetic recipient. - * - * @param label Distinguishes synthetic recipients on the dry backend. + * The env var carrying a wallet alias's coin public key, mirroring the harness's + * `coinPkEnv` (`deployer` → `MIDNIGHT_DEPLOYER_COIN_PK`). Inlined so this fixture + * does not import the harness. Uppercases uniformly, so it stays in lockstep with + * the harness for any alias casing (a divergence would silently resolve a live + * alias to a dry synthetic key); keep the two in sync. */ -export const shieldedTestRecipient = ( - label = 'RECIPIENT', -): EncodedRecipient => { - const deployerPk = process.env.MIDNIGHT_DEPLOYER_COIN_PK; - return deployerPk - ? eitherUserFromCoinPublicKey(deployerPk) - : createEitherTestUser(label); -}; +const coinPkEnvVar = (alias: string): string => + `MIDNIGHT_${alias.toUpperCase()}_COIN_PK`; /** - * A bare `ZswapCoinPublicKey` for a spec that targets a coin public key directly - * (not an `Either`) — e.g. a private forwarder's drain parent. Like - * {@link shieldedTestRecipient}, on live it is the deployer wallet's own key - * (whose encryption key the node can resolve; a fabricated key has none) and on - * dry a synthetic key. The deployer key is published by the live setup before - * any spec loads, so this is safe to read at module scope. + * The `Either` for a shielded identity — a + * send recipient, a drain parent, or a named multisig signer — that runs on both + * backends. On live it resolves to the named wallet's own coin public key + * (published by the harness as `MIDNIGHT__COIN_PK`), so `.as(alias)` + * submits from that wallet and the circuit's `ownPublicKey()` matches it; on dry + * it is the deterministic synthetic key `createEitherTestUser(alias)`, exactly + * what the dry backend resolves `.as(alias)` to. Callers needing a bare + * `EncodedCoinPublicKey` (not an `Either`) read `.left`. * - * @param label Distinguishes synthetic keys on the dry backend. - */ -export const shieldedTestParentKey = ( - label = 'PARENT', -): { bytes: Uint8Array } => { - const deployerPk = process.env.MIDNIGHT_DEPLOYER_COIN_PK; - return deployerPk - ? { bytes: encodeCoinPublicKey(deployerPk) } - : encodeToPK(label); -}; - -/** - * The `Either` for a named signer alias, for - * specs that register a multisig signer set. On live it resolves to the pooled - * wallet's own coin public key — published by the harness as - * `MIDNIGHT__COIN_PK` — so `.as(alias)` submits from that wallet and the - * circuit's `ownPublicKey()` matches this key. On dry it is the deterministic - * synthetic key `createEitherTestUser(alias)`, which is exactly what the dry - * backend resolves `.as(alias)` to. Read at module scope: the keys are published - * by the live setup before any spec loads. + * The `deployer` default suits a send recipient / drain parent: on live that key + * must be one whose encryption key the node can resolve, which only the deployer + * wallet's own key is. With the default, call AFTER `Sim.create()` (which + * triggers the wallet sync that publishes the deployer key); pooled signer + * aliases are published before any spec loads, so those are safe at module scope. * - * @param alias The signer alias (e.g. `SIGNER1`); must be a pooled wallet on live. + * @param alias `'deployer'` (default) or a pooled signer alias like `'SIGNER1'`; + * must be a pooled wallet on live. */ -export const shieldedTestSigner = (alias: string): EncodedRecipient => { - const pk = process.env[`MIDNIGHT_${alias}_COIN_PK`]; +export const shieldedTestKey = (alias = 'deployer'): EncodedRecipient => { + const pk = process.env[coinPkEnvVar(alias)]; return pk ? eitherUserFromCoinPublicKey(pk) : createEitherTestUser(alias); }; diff --git a/contracts/test-utils/fixtures/test/shieldedKey.test.ts b/contracts/test-utils/fixtures/test/shieldedKey.test.ts index 46a550a38..afbd08b63 100644 --- a/contracts/test-utils/fixtures/test/shieldedKey.test.ts +++ b/contracts/test-utils/fixtures/test/shieldedKey.test.ts @@ -1,77 +1,60 @@ -import { encodeCoinPublicKey } from '@midnight-ntwrk/compact-runtime'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createEitherTestUser, eitherUserFromCoinPublicKey, - encodeToPK, } from '../address.js'; -import { - shieldedTestParentKey, - shieldedTestRecipient, - shieldedTestSigner, -} from '../shieldedKey.js'; +import { shieldedTestKey } from '../shieldedKey.js'; /** - * The backend-aware shielded recipient/signer-key fixtures. Each helper branches - * on a published `MIDNIGHT_*_COIN_PK` env var, so the tests drive both arms by - * stubbing the environment. The dry arm is asserted against the pure builders it - * delegates to; the live arm (real wallet keys) is exercised by the live path, so - * here we only assert the branch selection. + * The backend-aware shielded key fixture. It branches on a published + * `MIDNIGHT_*_COIN_PK` env var, so the tests drive both arms by stubbing the + * environment. The dry arm is asserted against the pure builder it delegates to; + * the live arm (real wallet keys) is exercised by the live path, so here we only + * assert the branch selection and the env-var mapping. */ /** A valid 64-hex coin public key (32 bytes of 0xab). */ const PK = 'ab'.repeat(32); -describe('shielded key fixtures', () => { +describe('shieldedTestKey', () => { afterEach(() => { vi.unstubAllEnvs(); }); - describe('shieldedTestRecipient', () => { - it('should build a synthetic recipient when no deployer key is published', () => { + describe('deployer (default)', () => { + it('should build a synthetic deployer key when none is published', () => { vi.stubEnv('MIDNIGHT_DEPLOYER_COIN_PK', undefined); - expect(shieldedTestRecipient('BOB')).toStrictEqual( - createEitherTestUser('BOB'), - ); + expect(shieldedTestKey()).toStrictEqual(createEitherTestUser('deployer')); }); - it('should bind to the deployer coin public key when published', () => { + it('should bind to the published deployer coin public key', () => { vi.stubEnv('MIDNIGHT_DEPLOYER_COIN_PK', PK); - expect(shieldedTestRecipient('BOB')).toStrictEqual( - eitherUserFromCoinPublicKey(PK), - ); + expect(shieldedTestKey()).toStrictEqual(eitherUserFromCoinPublicKey(PK)); }); }); - describe('shieldedTestParentKey', () => { - it('should build a synthetic key when no deployer key is published', () => { - vi.stubEnv('MIDNIGHT_DEPLOYER_COIN_PK', undefined); - expect(shieldedTestParentKey('PARENT')).toStrictEqual( - encodeToPK('PARENT'), - ); - }); - - it('should bind to the deployer coin public key when published', () => { - vi.stubEnv('MIDNIGHT_DEPLOYER_COIN_PK', PK); - expect(shieldedTestParentKey('PARENT')).toStrictEqual({ - bytes: encodeCoinPublicKey(PK), - }); - }); - }); - - describe('shieldedTestSigner', () => { + describe('signer alias', () => { it("should build a synthetic key from the alias when the signer's key is unpublished", () => { vi.stubEnv('MIDNIGHT_SIGNER1_COIN_PK', undefined); - expect(shieldedTestSigner('SIGNER1')).toStrictEqual( + expect(shieldedTestKey('SIGNER1')).toStrictEqual( createEitherTestUser('SIGNER1'), ); }); it("should bind to the signer's published coin public key", () => { vi.stubEnv('MIDNIGHT_SIGNER1_COIN_PK', PK); - expect(shieldedTestSigner('SIGNER1')).toStrictEqual( + expect(shieldedTestKey('SIGNER1')).toStrictEqual( eitherUserFromCoinPublicKey(PK), ); }); }); + + describe('.left (bare coin public key)', () => { + it('should expose the bare coin public key as the Either left arm', () => { + vi.stubEnv('MIDNIGHT_DEPLOYER_COIN_PK', PK); + expect(shieldedTestKey().left).toStrictEqual( + eitherUserFromCoinPublicKey(PK).left, + ); + }); + }); }); diff --git a/contracts/test-utils/harness/WalletPool.ts b/contracts/test-utils/harness/WalletPool.ts index c21a73fbe..a7b240fb2 100644 --- a/contracts/test-utils/harness/WalletPool.ts +++ b/contracts/test-utils/harness/WalletPool.ts @@ -79,9 +79,11 @@ export function walletSeedsFor( export const WALLET_SEEDS: Readonly> = walletSeedsFor(1); /** The env var carrying an alias's coin public key (e.g. `MIDNIGHT_SIGNER1_COIN_PK`, - * `MIDNIGHT_DEPLOYER_COIN_PK`). Specs read these to build a live signer set. */ + * `MIDNIGHT_DEPLOYER_COIN_PK`). Specs read these to build a live signer set. + * Uppercases uniformly so publication and lookup agree for any alias casing; + * the `shieldedKey` fixture inlines the same mapping and must stay in sync. */ export const coinPkEnv = (alias: string): string => - `MIDNIGHT_${alias === 'deployer' ? 'DEPLOYER' : alias}_COIN_PK`; + `MIDNIGHT_${alias.toUpperCase()}_COIN_PK`; /** * Owns the pooled wallets for a worker. Builds each seed once (serially) via diff --git a/contracts/vitest.config.ts b/contracts/vitest.config.ts index cc0bb332f..a96fe19bb 100644 --- a/contracts/vitest.config.ts +++ b/contracts/vitest.config.ts @@ -19,6 +19,13 @@ import { configDefaults, defineConfig } from 'vitest/config'; const NODE = { globals: true, environment: 'node' as const }; const ARCHIVE_EXCLUDE = [...configDefaults.exclude, 'src/archive/**']; +// `unit-live` additionally drops the `test/witnesses/**` specs: they build a +// fabricated `WitnessContext` and assert on the private-state / `wit_*` +// helpers directly — no simulator, no deploy, no backend surface — so +// `MIDNIGHT_BACKEND=live` changes nothing about them. Running them on the node +// would only burn a worker slot; the dry `unit` project still covers them. +const LIVE_EXCLUDE = [...ARCHIVE_EXCLUDE, 'src/**/test/witnesses/**']; + // Generous timeouts every live project shares (real proofs + on-chain finality // are slow). Split out from the sequential/parallel knobs below. const LIVE_TIMEOUTS = { @@ -105,7 +112,7 @@ export default defineConfig({ ...LIVE_TIMEOUTS, name: 'unit-live', include: ['src/**/*.test.ts'], - exclude: ARCHIVE_EXCLUDE, + exclude: LIVE_EXCLUDE, // Fail fast (before any wallet build) if the node is dirty or another // live run holds the lock. See `live.globalSetup`. globalSetup: ['./test-utils/harness/live.globalSetup.ts'], diff --git a/scripts/test-live.ts b/scripts/test-live.ts index 4267e6e42..1cbcdeb18 100644 --- a/scripts/test-live.ts +++ b/scripts/test-live.ts @@ -69,15 +69,13 @@ const PROGRESS_REPORTER = path.join( ); const VERIFY_LOCK = path.join(LOGS, '.live-verify.lock'); -// `archive` is excluded from the unit/unit-live projects (see vitest.config). +// The live suite runs every `src/` that has tests (see +// `liveCategories`) — there is no separate live-ready allowlist to keep in sync, +// since all current categories are backend-aware. A category that must NOT run +// live is an explicit opt-out here (only legacy `archive` today, which the +// unit/unit-live vitest projects also exclude — see vitest.config). const EXCLUDED_CATEGORIES = new Set(['archive']); -// Categories whose specs have been refactored for the live backend. The others -// still assume dry-only semantics (e.g. `.as()` identities derived from alias -// labels, which the live wallet pool cannot impersonate) and join this list as -// they are refactored, PR by PR. -const LIVE_READY = new Set(['multisig']); - interface JsonTestResult { readonly name: string; readonly status: string; @@ -326,31 +324,32 @@ function reportVerdict(flaky: string[], real: string[]): number { } async function main(): Promise { - // `--list` prints the live-ready categories as JSON and exits — CI derives - // its per-category matrix from this, so LIVE_READY stays the single source - // of truth. + // `--list` prints the live categories as JSON and exits — CI derives its + // per-category matrix from this, so `liveCategories()` is the single source + // of truth (every category with tests, minus the excluded ones). if (process.argv.includes('--list')) { - console.log( - JSON.stringify(liveCategories().filter((c) => LIVE_READY.has(c))), - ); + console.log(JSON.stringify(liveCategories())); return 0; } const args = process.argv.slice(2).filter((a) => a !== '--'); const allCategories = liveCategories(); - // First arg naming a category (the test:live: scripts pass one) - // scopes the run; everything else is a vitest file filter. + // A first arg naming a category scopes the run to it; everything else is a + // vitest file filter. const scoped = args.length > 0 && allCategories.includes(args[0]); - if (scoped && !LIVE_READY.has(args[0])) { + // The first positional arg always names the category (CONTRIBUTING.md). If it + // is present but not an active live category — an excluded one like `archive`, + // or a typo — fail fast with the valid set, BEFORE the expensive compile / + // env-up / harness-smoke setup below (no args = every category, the default). + if (args.length > 0 && !scoped) { + const reason = EXCLUDED_CATEGORIES.has(args[0]) + ? `'${args[0]}' is excluded from live runs (see EXCLUDED_CATEGORIES)` + : `'${args[0]}' is not a live category`; console.log( - `'${args[0]}' is not live-ready yet — its specs still assume dry-only ` + - `semantics. Ready categories: ${[...LIVE_READY].join(', ')}.`, + `${reason}.\nLive-runnable categories: ${allCategories.join(', ')}`, ); return 2; } - const categories = scoped - ? [args[0]] - : allCategories.filter((c) => LIVE_READY.has(c)); - const skipped = scoped ? [] : allCategories.filter((c) => !LIVE_READY.has(c)); + const categories = scoped ? [args[0]] : allCategories; const fileFilters = scoped ? args.slice(1) : args; acquireVerifyLock(); @@ -368,9 +367,6 @@ async function main(): Promise { `ROUND 1 — categories: ${categories.join(', ')}` + (fileFilters.length ? ` (filter: ${fileFilters.join(' ')})` : ''), ); - if (skipped.length > 0) { - console.log(`skipped (not yet live-ready): ${skipped.join(', ')}`); - } if (!compileVerified()) return 2; if (run('make', ['env-up']) !== 0) { console.log('env-up failed — cannot start the live stack.');