Skip to content

Commit dd88196

Browse files
test: live-backend harness for the contract suite (core + multisig) (#673)
Signed-off-by: 0xisk <0xisk@proton.me> Co-authored-by: Andrew Fleming <fleming-andrew@protonmail.com>
1 parent 05c3914 commit dd88196

79 files changed

Lines changed: 8901 additions & 1371 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CONTRIBUTING.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ We really appreciate and value contributions to OpenZeppelin Contracts for Compa
2323
* [Pull Requests](#pull-requests)
2424
* [Opening an Issue](#opening-an-issue)
2525

26+
[Running Tests](#running-tests)
27+
28+
* [Unit Tests](#unit-tests)
29+
* [Live Tests](#live-tests)
30+
2631
[Styleguides](#styleguides)
2732

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

153158
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.
154159

160+
## Running Tests
161+
162+
Run all commands from the repository root. Enable Corepack once (`corepack enable`) so `yarn` resolves to the version pinned in `package.json`.
163+
164+
### Unit Tests
165+
166+
Unit tests run against an in-process mock backend (no network, ZK proving skipped):
167+
168+
```bash
169+
yarn test
170+
```
171+
172+
### Live Tests
173+
174+
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`.
175+
176+
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:
177+
178+
```bash
179+
yarn test:live
180+
```
181+
182+
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.
183+
184+
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:
185+
186+
* Fails round 1, passes round 2 → **FLAKY** (exit 0, reported loudly).
187+
* Fails both rounds → **REAL** (exit non-zero).
188+
189+
Scope the same mechanism to one category, or to a subset within it. The first
190+
argument names the category; any further argument is a filename substring
191+
(vitest matches it), so pass a spec name to run every file whose name matches it
192+
on the live backend — the fast loop while iterating on one feature, instead of
193+
waiting for the whole category. The match is a substring, not an exact file, so
194+
a name that prefixes others runs all of them:
195+
196+
```bash
197+
yarn test:live multisig # the whole category
198+
yarn test:live multisig ShieldedTreasury # any file matching "ShieldedTreasury"
199+
```
200+
201+
The two-round flake check still applies to a scoped run, so a green result
202+
means the same thing it does for the full suite.
203+
204+
Stop the network when done: `yarn env:down`. (No manual `env:up` is needed — the runner resets the stack itself.)
205+
206+
> **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:
207+
>
208+
> 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`.
209+
> 2. **One live run at a time.** A pid-stamped lock (`contracts/logs/.live-run.lock`) makes a second concurrent run abort.
210+
211+
Environment knobs:
212+
213+
| Variable | Default | Effect |
214+
| --- | --- | --- |
215+
| `MIDNIGHT_LIVE_WORKERS` | 3 | Parallel spec files (max 3 — one genesis-funded deployer each). |
216+
| `MIDNIGHT_LIVE_ALLOW_DIRTY` | unset | `1` skips the freshness check (run against a dirty node). |
217+
| `MIDNIGHT_LIVE_MAX_COIN_EVENTS` | 0 | Coin events beyond genesis tolerated before "not fresh". |
218+
| `MIDNIGHT_LIVE_MAX_SCAN_BLOCKS` | 3600 | Above this indexer head, the guard asks you to `env:up` rather than scan. |
219+
220+
`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`.
221+
222+
> **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:
223+
>
224+
> ```bash
225+
> FORCE_COLOR=1 yarn test:live multisig 2>&1 | tee logs/live-multisig.ansi
226+
> ```
227+
>
228+
> 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.
229+
155230
## Styleguides
156231
157232
### TypeScript Styleguide

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ SERVICES := proof-server indexer node
66

77
## Start local environment and stream logs to logs/
88
env-up: env-down
9-
docker compose -f $(COMPOSE_FILE) up -d --wait
9+
docker compose -f $(COMPOSE_FILE) up -d
10+
@# proof-server has no healthcheck, so a blanket `--wait` is not portable
11+
@# across Docker Compose versions; wait only on the services that expose one.
12+
docker compose -f $(COMPOSE_FILE) up -d --wait node indexer
1013
@mkdir -p $(LOGS_DIR)
1114
@for svc in $(SERVICES); do \
1215
docker compose -f $(COMPOSE_FILE) logs -f --no-log-prefix $$svc > $(LOGS_DIR)/$$svc.log 2>&1 & \

contracts/package.json

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@
2121
"homepage": "https://docs.openzeppelin.com/contracts-compact/",
2222
"type": "module",
2323
"imports": {
24-
"#test-utils/address.js": "./test-utils/address.js",
25-
"#test-utils/zswap.js": "./test-utils/zswap.js"
24+
"#test-utils/*": "./test-utils/*"
2625
},
2726
"scripts": {
2827
"compile": "compact-compiler --exclude '*/archive/*'",
@@ -33,12 +32,13 @@
3332
"compile:security": "compact-compiler --dir security",
3433
"compile:token": "compact-compiler --dir token",
3534
"compile:utils": "compact-compiler --dir utils",
35+
"compile:integration": "SKIP_ZK=true compact-compiler --src test/integration/_mocks",
3636
"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",
37-
"test": "SKIP_ZK=true yarn run compile && vitest run",
38-
"test:coverage": "SKIP_ZK=true yarn run compile && vitest run --coverage",
39-
"test:live": "yarn run compile && MIDNIGHT_BACKEND=live vitest run --config vitest.live.config.ts",
40-
"compile: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",
41-
"test:integration": "yarn run compile:integration && vitest run --config vitest.integration.config.ts",
37+
"test": "vitest run --project unit",
38+
"test:coverage": "vitest run --project unit --coverage",
39+
"test:integration": "vitest run --project integration",
40+
"test:harness": "vitest run --project harness",
41+
"test:harness:live": "MIDNIGHT_BACKEND=live vitest run --project harness-live",
4242
"types": "tsc -p tsconfig.json --noEmit",
4343
"clean": "git clean -fXd"
4444
},
@@ -49,6 +49,17 @@
4949
"@openzeppelin/compact-cli": "^0.0.2"
5050
},
5151
"devDependencies": {
52+
"@midnight-ntwrk/compact-js": "2.5.1",
53+
"@midnight-ntwrk/compact-runtime": "0.16.0",
54+
"@midnight-ntwrk/ledger-v8": "8.1.0",
55+
"@midnight-ntwrk/midnight-js-contracts": "4.1.1",
56+
"@midnight-ntwrk/midnight-js-http-client-proof-provider": "4.1.1",
57+
"@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.1.1",
58+
"@midnight-ntwrk/midnight-js-network-id": "4.1.1",
59+
"@midnight-ntwrk/midnight-js-node-zk-config-provider": "4.1.1",
60+
"@midnight-ntwrk/midnight-js-protocol": "4.1.1",
61+
"@midnight-ntwrk/midnight-js-types": "4.1.1",
62+
"@midnight-ntwrk/testkit-js": "4.1.1",
5263
"@openzeppelin/compact-simulator": "^0.2.0",
5364
"@tsconfig/node24": "^24.0.4",
5465
"@types/node": "26.1.1",

contracts/src/access/test/AccessControl.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
persistentHash,
66
} from '@midnight-ntwrk/compact-runtime';
77
import { beforeEach, describe, expect, it } from 'vitest';
8-
import * as utils from '#test-utils/address.js';
8+
import * as utils from '#test-utils/fixtures/address.js';
99
import { AccessControlSimulator } from './simulators/AccessControlSimulator.js';
1010

1111
// Helpers

contracts/src/access/test/Ownable.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
persistentHash,
55
} from '@midnight-ntwrk/compact-runtime';
66
import { beforeEach, describe, expect, it } from 'vitest';
7-
import * as utils from '#test-utils/address.js';
7+
import * as utils from '#test-utils/fixtures/address.js';
88
import { OwnableSimulator } from './simulators/OwnableSimulator.js';
99

1010
// Helpers

contracts/src/access/test/ZOwnablePK.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
persistentHash,
66
} from '@midnight-ntwrk/compact-runtime';
77
import { beforeEach, describe, expect, it } from 'vitest';
8-
import * as utils from '#test-utils/address.js';
8+
import * as utils from '#test-utils/fixtures/address.js';
99
import type { ZswapCoinPublicKey } from '../../../artifacts/MockOwnable/contract/index.js';
1010
import { ZOwnablePKSimulator } from './simulators/ZOwnablePKSimulator.js';
1111
import { ZOwnablePKPrivateState } from './witnesses/ZOwnablePKWitnesses.js';

contracts/src/multisig/presets/ShieldedMultiSig.compact

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -187,17 +187,12 @@ export circuit getProposal(id: Uint<64>): Proposal_Proposal {
187187
return Proposal_getProposal(id);
188188
}
189189

190-
export circuit getProposalRecipient(id: Uint<64>): Proposal_Recipient {
191-
return Proposal_getProposalRecipient(id);
192-
}
193-
194-
export circuit getProposalAmount(id: Uint<64>): Uint<128> {
195-
return Proposal_getProposalAmount(id);
196-
}
197-
198-
export circuit getProposalColor(id: Uint<64>): Bytes<32> {
199-
return Proposal_getProposalColor(id);
200-
}
190+
// NOTE: the per-field proposal getters (getProposalRecipient / getProposalAmount
191+
// / getProposalColor) and getReceivedMinusSent were dropped to bring the deploy
192+
// transaction under the block-weight limit (the full 19-circuit deploy is
193+
// rejected with "Transaction would exhaust the block limits"). They are all
194+
// redundant: read the proposal fields via `getProposal(id).to / .amount / .color`,
195+
// and the net balance via `getReceivedTotal(color) - getSentTotal(color)`.
201196

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

220-
export circuit getReceivedMinusSent(color: Bytes<32>): Uint<128> {
221-
return Treasury_getReceivedMinusSent(color);
222-
}
223-
224215
// ISignerManager
225216

226217
export circuit getSignerCount(): Uint<8> {

contracts/src/multisig/test/EmptyWitnesses.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
// OpenZeppelin Compact Contracts v0.3.0-alpha (multisig/test/EmptyWitnesses.ts)
33

44
/**
5-
* Shared empty private state and witnesses for forwarder contracts, none
6-
* of which declare any witnesses. Imported by their simulators in place
7-
* of a per-contract witness module.
5+
* Shared empty private state and witnesses for the multisig test simulators.
6+
* None of the multisig contracts declare witnesses, so every simulator imports
7+
* this in place of a per-contract witness module.
88
*/
99
export type EmptyPrivateState = Record<string, never>;
1010
export const EmptyPrivateState: EmptyPrivateState = {};

contracts/src/multisig/test/Forwarder.test.ts

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1+
import { isLiveBackend } from '@openzeppelin/compact-simulator';
12
import { describe, expect, it } from 'vitest';
2-
import * as utils from '#test-utils/address.js';
3+
import * as utils from '#test-utils/fixtures/address.js';
4+
import {
5+
encodeShieldedCoinInfo,
6+
GENESIS_NATIVE_SHIELDED_TOKEN_COLORS,
7+
} from '#test-utils/fixtures/nativeShieldedToken.js';
8+
import { shieldedTestParentKey } from '#test-utils/fixtures/shieldedKey.js';
39
import { MockForwarderShieldedSimulator } from './simulators/MockForwarderShieldedSimulator.js';
410
import { MockForwarderUnshieldedSimulator } from './simulators/MockForwarderUnshieldedSimulator.js';
511

@@ -11,19 +17,31 @@ import { MockForwarderUnshieldedSimulator } from './simulators/MockForwarderUnsh
1117
// future CMA circuit upgrade can add contract support without a state
1218
// migration; `initialize` stores the supported arm (shielded → `left`,
1319
// unshielded → `right`), which is what `getParent` reads back.
14-
const SHIELDED_PARENT = utils.createEitherTestUser('PARENT').left;
20+
//
21+
// Live: the shielded parent is the deployer's own key (the forward sends the
22+
// coin to it, so its encryption key must resolve on-chain). The unshielded
23+
// parent stays synthetic — an unshielded recipient is a public address, no
24+
// encryption key needed.
25+
const SHIELDED_PARENT = shieldedTestParentKey();
1526
const SHIELDED_ZERO = utils.ZERO_KEY.left;
1627
const UNSHIELDED_PARENT = utils.createEitherTestUserAddress('PARENT').right;
1728
const UNSHIELDED_ZERO = utils.ZERO_USER_ADDRESS.right;
18-
const COLOR = new Uint8Array(32).fill(1);
29+
30+
// Shielded color: genesis-funded (`0x00…01`) so a live forward has funds to
31+
// draw; `fill(1)` would be unfunded on live. Unshielded color: on live the
32+
// deployer wallet only holds the native unshielded token (`0x00…00`), so the
33+
// forward draws that; on dry any color mints freely.
34+
const SHIELDED_COLOR =
35+
GENESIS_NATIVE_SHIELDED_TOKEN_COLORS.nativeShieldedToken1;
36+
const UNSHIELDED_COLOR = isLiveBackend()
37+
? new Uint8Array(32)
38+
: new Uint8Array(32).fill(1);
1939
const AMOUNT = 1000n;
2040

41+
// Live gets a fresh random nonce per run (the node persists nullifiers); dry
42+
// uses zero for reproducibility.
2143
function makeCoin(color: Uint8Array, value: bigint, nonce?: Uint8Array) {
22-
return {
23-
nonce: nonce ?? new Uint8Array(32).fill(0),
24-
color,
25-
value,
26-
};
44+
return encodeShieldedCoinInfo(color, value, nonce);
2745
}
2846

2947
describe('ForwarderShielded module', () => {
@@ -55,9 +73,9 @@ describe('ForwarderShielded module', () => {
5573
SHIELDED_PARENT,
5674
false,
5775
);
58-
await expect(mock.deposit(makeCoin(COLOR, AMOUNT))).rejects.toThrow(
59-
'ForwarderShielded: contract not initialized',
60-
);
76+
await expect(
77+
mock.deposit(makeCoin(SHIELDED_COLOR, AMOUNT)),
78+
).rejects.toThrow('ForwarderShielded: contract not initialized');
6179
});
6280
});
6381

@@ -67,7 +85,7 @@ describe('ForwarderShielded module', () => {
6785
SHIELDED_PARENT,
6886
true,
6987
);
70-
await mock.deposit(makeCoin(COLOR, AMOUNT));
88+
await mock.deposit(makeCoin(SHIELDED_COLOR, AMOUNT));
7189
});
7290
});
7391
});
@@ -101,7 +119,7 @@ describe('ForwarderUnshielded module', () => {
101119
UNSHIELDED_PARENT,
102120
false,
103121
);
104-
await expect(mock.deposit(COLOR, AMOUNT)).rejects.toThrow(
122+
await expect(mock.deposit(UNSHIELDED_COLOR, AMOUNT)).rejects.toThrow(
105123
'ForwarderUnshielded: contract not initialized',
106124
);
107125
});
@@ -113,7 +131,7 @@ describe('ForwarderUnshielded module', () => {
113131
UNSHIELDED_PARENT,
114132
true,
115133
);
116-
await mock.deposit(COLOR, AMOUNT);
134+
await mock.deposit(UNSHIELDED_COLOR, AMOUNT);
117135
});
118136
});
119137
});

0 commit comments

Comments
 (0)