Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<category>` script — as its specs are refactored for the live backend.
Every `src/<category>` 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:

Expand Down
65 changes: 42 additions & 23 deletions contracts/src/access/test/ZOwnablePK.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_<ALIAS>_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'));
Expand Down Expand Up @@ -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');
});

Expand All @@ -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())
Expand All @@ -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();
Expand All @@ -273,40 +292,40 @@ 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',
);
});

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');
});
});
Expand All @@ -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 () => {
Expand All @@ -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',
);
});
Expand All @@ -343,7 +362,7 @@ describe('ZOwnablePK', () => {
);

await expect(
ownable.as('UNAUTHORIZED').assertOnlyOwner(),
ownable.as(UNAUTHORIZED).assertOnlyOwner(),
).rejects.toThrow('ZOwnablePK: caller is not the owner');
});

Expand All @@ -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');
});
});
Expand Down Expand Up @@ -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);
});
});
});
Expand Down
4 changes: 2 additions & 2 deletions contracts/src/multisig/test/Forwarder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions contracts/src/multisig/test/ForwarderPrivate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down
23 changes: 10 additions & 13 deletions contracts/src/multisig/test/ShieldedMultiSig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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;
Expand Down Expand Up @@ -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', () => {
Expand Down
6 changes: 3 additions & 3 deletions contracts/src/multisig/test/ShieldedMultiSigV3.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<typeof shieldedTestRecipient>;
let USER_RECIPIENT: ReturnType<typeof shieldedTestKey>;

function makeQualifiedCoin(
color: Uint8Array,
Expand Down Expand Up @@ -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', () => {
Expand Down
6 changes: 3 additions & 3 deletions contracts/src/multisig/test/ShieldedTreasury.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<typeof shieldedTestRecipient>;
let Z_RECIPIENT: ReturnType<typeof shieldedTestKey>;

// 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
Expand Down Expand Up @@ -58,7 +58,7 @@ describe('ShieldedTreasury', () => {
// reuses this shared deploy.
beforeAll(async () => {
treasury = await freshTreasury();
Z_RECIPIENT = shieldedTestRecipient();
Z_RECIPIENT = shieldedTestKey();
});

describe('initial state', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<typeof shieldedTestRecipient>;
let Z_RECIPIENT: ReturnType<typeof shieldedTestKey>;

// 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
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down
Loading