Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
f922716
test(multisig): add live-backend harness for shielded specs
0xisk Jul 9, 2026
0808b8a
refactor(multisig): rename shielded presets by capability
0xisk Jul 9, 2026
ad579cf
test(multisig): fund fourth signer on live harness
0xisk Jul 9, 2026
cc12307
fix(harness): share private state across signer aliases
0xisk Jul 9, 2026
c13ac96
fix(harness): re-sync wallet before balancing tx
0xisk Jul 10, 2026
e7f7bb2
test(multisig): raise live timeout for lifecycle specs
0xisk Jul 10, 2026
3d6074d
chore(harness): drop WIP coin-tracker plan doc
0xisk Jul 10, 2026
8419346
ci: align workflows with renamed compile/check scripts
0xisk Jul 10, 2026
fb9210d
refactor(multisig): use one empty witnesses for test sims
0xisk Jul 10, 2026
0d520c4
fix(harness): hex-pad the pooled wallet seed helper
0xisk Jul 10, 2026
73f5b26
feat(harness): per-worker wallet pools for parallel live runs
0xisk Jul 10, 2026
9441e39
fix(harness): retry deploy once on submission error
0xisk Jul 10, 2026
24db6cb
test(multisig): share deploys in read-only groups
0xisk Jul 10, 2026
1f9e26c
feat(harness): tag live-test output per worker
0xisk Jul 13, 2026
11573db
docs(contributing): document running live tests
0xisk Jul 13, 2026
2dc45b1
fix(harness): don't retry 1010 node rejections
0xisk Jul 13, 2026
abe952d
feat(harness): guard live runs, fresh node + lock
0xisk Jul 13, 2026
d5431d1
feat(harness): two-round live flake checker
0xisk Jul 13, 2026
fdab257
docs(contributing): live-run guard and verify flow
0xisk Jul 13, 2026
671ef2b
feat(harness): tag live result lines per worker
0xisk Jul 13, 2026
f751932
feat(harness): unify live runs into one runner
0xisk Jul 13, 2026
1829c56
docs(contributing): document unified live runner
0xisk Jul 13, 2026
f13f9b6
docs(multisig): fix stale beforeEach reference in test comment
0xisk Jul 14, 2026
3978492
fix(multisig): await treasury total getters sequentially
0xisk Jul 14, 2026
e673759
fix(harness): stop provider when wallet init fails
0xisk Jul 14, 2026
2ae6ca6
fix(harness): validate MIDNIGHT_*_PORT overrides
0xisk Jul 14, 2026
9444562
test(harness): isolate default-port test from env
0xisk Jul 14, 2026
8767a13
fix(harness): build pooled wallets sequentially
0xisk Jul 14, 2026
0ae9c1c
build(turbo): add compile:archive to compile aggregate
0xisk Jul 14, 2026
2bdab60
docs(contributing): document single-file live runs
0xisk Jul 14, 2026
b870c2e
ci(live): add separate live test suite workflow
0xisk Jul 14, 2026
e5254d1
docs(contributing): document live tests in CI
0xisk Jul 14, 2026
2d97de7
ci(live): make dispatch category a choice input
0xisk Jul 14, 2026
d4b4da3
feat(harness): add --list flag printing live-ready categories
0xisk Jul 14, 2026
4037703
ci(live): fan out one parallel job per category
0xisk Jul 14, 2026
3c87232
docs(contributing): note per-category live CI jobs
0xisk Jul 14, 2026
aee64a8
feat(harness): surface live verdict in CI job summaries
0xisk Jul 14, 2026
11901fb
ci(live): split artifacts and track nightly failures
0xisk Jul 14, 2026
fa33ad1
docs(contributing): note live CI summaries and artifacts
0xisk Jul 14, 2026
1bc30b3
build(turbo): drop unused test:live task and script
0xisk Jul 14, 2026
6980a49
docs(harness): explain why the live runner is not turbo
0xisk Jul 14, 2026
ced2478
build(harness): drop bundled dev-api rename
0xisk Jul 15, 2026
5189289
chore: update docs
0xisk Jul 15, 2026
6a9c9f8
Merge branch 'main' into test/multisig-live-harness
0xisk Jul 15, 2026
d78dd1a
ci: defer live-test workflow to a follow-up PR
0xisk Jul 15, 2026
9d02eac
ci: drop the live-CI subsection from CONTRIBUTING
0xisk Jul 15, 2026
221692d
fix(turbo): drop archive from aggregate compile
0xisk Jul 15, 2026
8cce705
fix(make): scope env-up --wait to node+indexer
0xisk Jul 15, 2026
aa84ca1
refactor(multisig): revert preset rename
0xisk Jul 15, 2026
44aef93
chore: drop corepack prefix from yarn commands
0xisk Jul 15, 2026
e8e490b
chore: drop redundant test:live:multisig script
0xisk Jul 15, 2026
d4afe49
fix(harness): reset node before each round-2 file
0xisk Jul 15, 2026
0283ba1
test(multisig): group dry-only burn tests
0xisk Jul 16, 2026
1e7b929
refactor(test-utils): remove createEitherTestUser
0xisk Jul 16, 2026
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
75 changes: 75 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ We really appreciate and value contributions to OpenZeppelin Contracts for Compa
* [Pull Requests](#pull-requests)
* [Opening an Issue](#opening-an-issue)

[Running Tests](#running-tests)

* [Unit Tests](#unit-tests)
* [Live Tests](#live-tests)

[Styleguides](#styleguides)

* [Git Commit Messages](#git-commit-messages)
Expand Down Expand Up @@ -152,6 +157,76 @@ A maintainer will re-run the status check for you. If we conclude that the failu

While the prerequisites above must be satisfied prior to having your pull request reviewed, the reviewer(s) may ask you to complete additional design work, tests, or other changes before your pull request can be ultimately accepted.

## Running Tests

Run all commands from the repository root. Enable Corepack once (`corepack enable`) so `yarn` resolves to the version pinned in `package.json`.

### Unit Tests

Unit tests run against an in-process mock backend (no network, ZK proving skipped):

```bash
yarn test
```

### Live Tests

Live tests run against a local Midnight network (node, indexer, and proof server) defined in [`local-env.yml`](./local-env.yml). They require [Docker](https://docs.docker.com/get-docker/) and a completed `yarn install`.

One command runs everything — it compiles, resets the stack, runs a quick harness smoke, then each live-ready category sequentially on a freshly reset node:

```bash
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.

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:

* Fails round 1, passes round 2 → **FLAKY** (exit 0, reported loudly).
* Fails both rounds → **REAL** (exit non-zero).

Scope the same mechanism to one category, or to a single file within it. The
first argument names the category; any further argument is a filename substring
(vitest matches it), so pass a spec name to run just that file on the live
backend — the fast loop while iterating on one feature, instead of waiting for
the whole category:

```bash
yarn test:live multisig # the whole category
yarn test:live multisig ShieldedTreasury # just that one file
yarn test:live multisig Forwarder # any file matching "Forwarder"
```

The two-round flake check still applies to a single-file run, so a green result
means the same thing it does for the full suite.

Stop the network when done: `yarn env:down`. (No manual `env:up` is needed — the runner resets the stack itself.)

> **Note:** The live tests all run against one shared node, so state left by an earlier run can make a later one fail. Two rules keep them reliable, both enforced by a guard that fails fast, before any wallet build:
>
> 1. **Start from a fresh node.** State left by a previous run makes shielded spends fail with node `Custom error: 103`. The guard aborts if it finds any shielded coin event beyond genesis. The `test:live*` runner resets for you; reset manually with `yarn env:up`.
> 2. **One live run at a time.** A pid-stamped lock (`contracts/logs/.live-run.lock`) makes a second concurrent run abort.

Environment knobs:

| Variable | Default | Effect |
| --- | --- | --- |
| `MIDNIGHT_LIVE_WORKERS` | 3 | Parallel spec files (max 3 — one genesis-funded deployer each). |
| `MIDNIGHT_LIVE_ALLOW_DIRTY` | unset | `1` skips the freshness check (run against a dirty node). |
| `MIDNIGHT_LIVE_MAX_COIN_EVENTS` | 0 | Coin events beyond genesis tolerated before "not fresh". |
| `MIDNIGHT_LIVE_MAX_SCAN_BLOCKS` | 3600 | Above this indexer head, the guard asks you to `env:up` rather than scan. |

`unit-live` runs up to 3 workers in parallel, so their output interleaves. It is tagged per worker: a `▶ live worker N/3 ready` banner when a worker's wallets are funded, a `[wN] ❯ <file>` line as each spec file starts, and a `[wN] ✓ <test> (<ms>) [done/total]` line per test — showing the worker, the result, and overall progress through the run. Each worker also writes a detailed log to `logs/live-harness-wN.log`.

> **Tip:** to save the run to a colored, readable log, force color and pipe to `tee`. Piping (stdout is no longer a TTY) makes vitest print one clean line per result instead of an animated spinner, and `FORCE_COLOR=1` keeps the color. Write it to a `.ansi` file:
>
> ```bash
> FORCE_COLOR=1 yarn test:live multisig 2>&1 | tee logs/live-multisig.ansi
> ```
>
> The file stores ANSI color codes, so render them rather than reading them raw. In VS Code, an ANSI extension such as [`iliazeus.vscode-ansi`](https://marketplace.visualstudio.com/items?itemName=iliazeus.vscode-ansi) renders a `.ansi` file via **"ANSI Text: Open Preview"**. In a terminal, use `less -R logs/live-multisig.ansi`. On Linux, prefix `systemd-inhibit --why="live tests"` for a long run.

## Styleguides

### TypeScript Styleguide
Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ SERVICES := proof-server indexer node

## Start local environment and stream logs to logs/
env-up: env-down
docker compose -f $(COMPOSE_FILE) up -d --wait
docker compose -f $(COMPOSE_FILE) up -d
@# proof-server has no healthcheck, so a blanket `--wait` is not portable
@# across Docker Compose versions; wait only on the services that expose one.
docker compose -f $(COMPOSE_FILE) up -d --wait node indexer
@mkdir -p $(LOGS_DIR)
@for svc in $(SERVICES); do \
docker compose -f $(COMPOSE_FILE) logs -f --no-log-prefix $$svc > $(LOGS_DIR)/$$svc.log 2>&1 & \
Expand Down
26 changes: 18 additions & 8 deletions contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,23 @@
"homepage": "https://docs.openzeppelin.com/contracts-compact/",
"type": "module",
"imports": {
"#test-utils/address.js": "./test-utils/address.js",
"#test-utils/zswap.js": "./test-utils/zswap.js"
"#test-utils/*": "./test-utils/*"
},
"scripts": {
"compact": "compact-compiler --exclude '*/archive/*'",
"compact:access": "compact-compiler --dir access",
"compact:archive": "compact-compiler --dir archive",
"compact:crypto": "compact-compiler --dir crypto",
"compact:multisig": "compact-compiler --dir multisig",
"compact:security": "compact-compiler --dir security",
"compact:token": "compact-compiler --dir token",
"compact:utils": "compact-compiler --dir utils",
"compact:integration": "SKIP_ZK=true compact-compiler --src test/integration/_mocks",
"build": "compact-builder --hierarchical --out dist --clean-dist --exclude '*/archive/*' --exclude 'Mock*' --exclude '*.mock.compact' --copy package.json --copy ../README.md && find dist -type d -empty -delete",
"test": "SKIP_ZK=true yarn run compact && vitest run",
"test:coverage": "SKIP_ZK=true yarn run compact && vitest run --coverage",
"test:live": "yarn run compact && MIDNIGHT_BACKEND=live vitest run --config vitest.live.config.ts",
"compact:integration": "SKIP_ZK=true compact compile test/integration/_mocks/SharedInitCollision.compact artifacts/SharedInitCollision && SKIP_ZK=true compact compile test/integration/_mocks/ComposedTokens.compact artifacts/ComposedTokens",
"test:integration": "yarn run compact:integration && vitest run --config vitest.integration.config.ts",
"test": "vitest run --project unit",
"test:coverage": "vitest run --project unit --coverage",
"test:integration": "vitest run --project integration",
"test:harness": "vitest run --project harness",
"test:harness:live": "MIDNIGHT_BACKEND=live vitest run --project harness-live",
"types": "tsc -p tsconfig.json --noEmit",
"clean": "git clean -fXd"
},
Expand All @@ -49,6 +48,17 @@
"@openzeppelin/compact-cli": "^0.0.2"
},
"devDependencies": {
"@midnight-ntwrk/compact-js": "2.5.1",
"@midnight-ntwrk/compact-runtime": "0.16.0",
"@midnight-ntwrk/ledger-v8": "8.1.0",
"@midnight-ntwrk/midnight-js-contracts": "4.1.1",
"@midnight-ntwrk/midnight-js-http-client-proof-provider": "4.1.1",
"@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.1.1",
"@midnight-ntwrk/midnight-js-network-id": "4.1.1",
"@midnight-ntwrk/midnight-js-node-zk-config-provider": "4.1.1",
"@midnight-ntwrk/midnight-js-protocol": "4.1.1",
"@midnight-ntwrk/midnight-js-types": "4.1.1",
"@midnight-ntwrk/testkit-js": "4.1.1",
"@openzeppelin/compact-simulator": "^0.2.0",
"@tsconfig/node24": "^24.0.4",
"@types/node": "25.9.3",
Expand Down
2 changes: 1 addition & 1 deletion contracts/src/access/test/AccessControl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
persistentHash,
} from '@midnight-ntwrk/compact-runtime';
import { beforeEach, describe, expect, it } from 'vitest';
import * as utils from '#test-utils/address.js';
import * as utils from '#test-utils/fixtures/address.js';
import { AccessControlSimulator } from './simulators/AccessControlSimulator.js';

// Helpers
Expand Down
2 changes: 1 addition & 1 deletion contracts/src/access/test/Ownable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
persistentHash,
} from '@midnight-ntwrk/compact-runtime';
import { beforeEach, describe, expect, it } from 'vitest';
import * as utils from '#test-utils/address.js';
import * as utils from '#test-utils/fixtures/address.js';
import { OwnableSimulator } from './simulators/OwnableSimulator.js';

// Helpers
Expand Down
18 changes: 13 additions & 5 deletions contracts/src/access/test/ZOwnablePK.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
persistentHash,
} from '@midnight-ntwrk/compact-runtime';
import { beforeEach, describe, expect, it } from 'vitest';
import * as utils from '#test-utils/address.js';
import * as utils from '#test-utils/fixtures/address.js';
import type { ZswapCoinPublicKey } from '../../../artifacts/MockOwnable/contract/index.js';
import { ZOwnablePKSimulator } from './simulators/ZOwnablePKSimulator.js';
import { ZOwnablePKPrivateState } from './witnesses/ZOwnablePKWitnesses.js';
Expand Down Expand Up @@ -133,7 +133,9 @@ describe('ZOwnablePK', () => {
});

it('should allow pure computeOwnerId', async () => {
const eitherOwner = utils.createEitherTestUser('OWNER');
const eitherOwner = utils.eitherUserFromCoinPublicKey(
utils.toHexPadded('OWNER'),
);

const ownerId = await ownable._computeOwnerId(
eitherOwner,
Expand Down Expand Up @@ -418,17 +420,23 @@ describe('ZOwnablePK', () => {
const testCases = [
...Array.from({ length: 10 }, (_, i) => ({
label: `User${i}`,
eitherOwner: utils.createEitherTestUser(`User${i}`),
eitherOwner: utils.eitherUserFromCoinPublicKey(
utils.toHexPadded(`User${i}`),
),
nonce: new Uint8Array(32).fill(i),
})),
{
label: 'All-zero nonce',
eitherOwner: utils.createEitherTestUser('ZeroUser'),
eitherOwner: utils.eitherUserFromCoinPublicKey(
utils.toHexPadded('ZeroUser'),
),
nonce: new Uint8Array(32).fill(0),
},
{
label: 'Max nonce',
eitherOwner: utils.createEitherTestUser('MaxUser'),
eitherOwner: utils.eitherUserFromCoinPublicKey(
utils.toHexPadded('MaxUser'),
),
nonce: new Uint8Array(32).fill(255),
},
];
Expand Down
21 changes: 6 additions & 15 deletions contracts/src/multisig/presets/ShieldedMultiSig.compact
Original file line number Diff line number Diff line change
Expand Up @@ -187,17 +187,12 @@ export circuit getProposal(id: Uint<64>): Proposal_Proposal {
return Proposal_getProposal(id);
}

export circuit getProposalRecipient(id: Uint<64>): Proposal_Recipient {
return Proposal_getProposalRecipient(id);
}

export circuit getProposalAmount(id: Uint<64>): Uint<128> {
return Proposal_getProposalAmount(id);
}

export circuit getProposalColor(id: Uint<64>): Bytes<32> {
return Proposal_getProposalColor(id);
}
// NOTE: the per-field proposal getters (getProposalRecipient / getProposalAmount
// / getProposalColor) and getReceivedMinusSent were dropped to bring the deploy
// transaction under the block-weight limit (the full 19-circuit deploy is
// rejected with "Transaction would exhaust the block limits"). They are all
// redundant: read the proposal fields via `getProposal(id).to / .amount / .color`,
// and the net balance via `getReceivedTotal(color) - getSentTotal(color)`.

export circuit getProposalStatus(id: Uint<64>): Proposal_ProposalStatus {
return Proposal_getProposalStatus(id);
Expand All @@ -217,10 +212,6 @@ export circuit getSentTotal(color: Bytes<32>): Uint<128> {
return Treasury_getSentTotal(color);
}

export circuit getReceivedMinusSent(color: Bytes<32>): Uint<128> {
return Treasury_getReceivedMinusSent(color);
}

// ISignerManager

export circuit getSignerCount(): Uint<8> {
Expand Down
6 changes: 3 additions & 3 deletions contracts/src/multisig/test/EmptyWitnesses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
// OpenZeppelin Compact Contracts v0.3.0-alpha (multisig/test/EmptyWitnesses.ts)

/**
* Shared empty private state and witnesses for forwarder contracts, none
* of which declare any witnesses. Imported by their simulators in place
* of a per-contract witness module.
* Shared empty private state and witnesses for the multisig test simulators.
* None of the multisig contracts declare witnesses, so every simulator imports
* this in place of a per-contract witness module.
*/
export type EmptyPrivateState = Record<string, never>;
export const EmptyPrivateState: EmptyPrivateState = {};
Expand Down
46 changes: 32 additions & 14 deletions contracts/src/multisig/test/Forwarder.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { isLiveBackend } from '@openzeppelin/compact-simulator';
import { describe, expect, it } from 'vitest';
import * as utils from '#test-utils/address.js';
import * as utils from '#test-utils/fixtures/address.js';
import {
encodeShieldedCoinInfo,
GENESIS_NATIVE_SHIELDED_TOKEN_COLORS,
} from '#test-utils/fixtures/nativeShieldedToken.js';
import { shieldedTestParentKey } from '#test-utils/fixtures/shieldedKey.js';
import { MockForwarderShieldedSimulator } from './simulators/MockForwarderShieldedSimulator.js';
import { MockForwarderUnshieldedSimulator } from './simulators/MockForwarderUnshieldedSimulator.js';

Expand All @@ -11,19 +17,31 @@ import { MockForwarderUnshieldedSimulator } from './simulators/MockForwarderUnsh
// future CMA circuit upgrade can add contract support without a state
// migration; `initialize` stores the supported arm (shielded → `left`,
// unshielded → `right`), which is what `getParent` reads back.
const SHIELDED_PARENT = utils.createEitherTestUser('PARENT').left;
//
// Live: the shielded parent is the deployer's own key (the forward sends the
// 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_ZERO = utils.ZERO_KEY.left;
const UNSHIELDED_PARENT = utils.createEitherTestUserAddress('PARENT').right;
const UNSHIELDED_ZERO = utils.ZERO_USER_ADDRESS.right;
const COLOR = new Uint8Array(32).fill(1);

// Shielded color: genesis-funded (`0x00…01`) so a live forward has funds to
// draw; `fill(1)` would be unfunded on live. Unshielded color: on live the
// deployer wallet only holds the native unshielded token (`0x00…00`), so the
// forward draws that; on dry any color mints freely.
const SHIELDED_COLOR =
GENESIS_NATIVE_SHIELDED_TOKEN_COLORS.nativeShieldedToken1;
const UNSHIELDED_COLOR = isLiveBackend()
? new Uint8Array(32)
: new Uint8Array(32).fill(1);
const AMOUNT = 1000n;

// Live gets a fresh random nonce per run (the node persists nullifiers); dry
// uses zero for reproducibility.
function makeCoin(color: Uint8Array, value: bigint, nonce?: Uint8Array) {
return {
nonce: nonce ?? new Uint8Array(32).fill(0),
color,
value,
};
return encodeShieldedCoinInfo(color, value, nonce);
}

describe('ForwarderShielded module', () => {
Expand Down Expand Up @@ -55,9 +73,9 @@ describe('ForwarderShielded module', () => {
SHIELDED_PARENT,
false,
);
await expect(mock.deposit(makeCoin(COLOR, AMOUNT))).rejects.toThrow(
'ForwarderShielded: contract not initialized',
);
await expect(
mock.deposit(makeCoin(SHIELDED_COLOR, AMOUNT)),
).rejects.toThrow('ForwarderShielded: contract not initialized');
});
});

Expand All @@ -67,7 +85,7 @@ describe('ForwarderShielded module', () => {
SHIELDED_PARENT,
true,
);
await mock.deposit(makeCoin(COLOR, AMOUNT));
await mock.deposit(makeCoin(SHIELDED_COLOR, AMOUNT));
});
});
});
Expand Down Expand Up @@ -101,7 +119,7 @@ describe('ForwarderUnshielded module', () => {
UNSHIELDED_PARENT,
false,
);
await expect(mock.deposit(COLOR, AMOUNT)).rejects.toThrow(
await expect(mock.deposit(UNSHIELDED_COLOR, AMOUNT)).rejects.toThrow(
'ForwarderUnshielded: contract not initialized',
);
});
Expand All @@ -113,7 +131,7 @@ describe('ForwarderUnshielded module', () => {
UNSHIELDED_PARENT,
true,
);
await mock.deposit(COLOR, AMOUNT);
await mock.deposit(UNSHIELDED_COLOR, AMOUNT);
});
});
});
Loading
Loading