diff --git a/.env.example b/.env.example index a74ae48..2fb4ba7 100644 --- a/.env.example +++ b/.env.example @@ -16,11 +16,34 @@ MN_SEED= # MN_SEED. The file is read locally and never copied or printed. MN_WALLET_ENV_FILE= -# Required by the v2 stagenet deployer: absolute path to a durable private -# maintenance signing-key file. The deployer creates/reuses it at mode 0600 -# before wallet startup. Back it up; never commit or print its contents. +# Required by the v2 deployer on MN_ENV=stagenet: absolute path to a durable +# private maintenance signing-key file. The deployer creates/reuses it at mode +# 0600 before wallet startup. Back it up; never commit or print its contents. +# On MN_ENV=undeployed this is optional - see the local 2.x block below. MN_MAINTENANCE_KEY_FILE= # Optional v2 wallet synchronization timeout in milliseconds. The deployer # defaults to 300000 and accepts values from 30000 through 900000. MN_WALLET_SYNC_TIMEOUT_MS= + +# --- Local Midnight 2.x devnet: the v2 scripts on MN_ENV=undeployed --------- +# The v2 commands (deploy:v2, verify:deployment:v2, test:v2:external) default to +# MN_ENV=stagenet. Set undeployed to point them at a throwaway local ledger-v9 +# chain instead; MN_ENV is shared with the 1.x scripts, which accept the same +# value. The endpoints below are the defaults, so uncomment only the ones your +# stack moves (compose service hostnames, a non-default port block, ...). +# MN_ENV=undeployed +# MN_NODE_URL=http://127.0.0.1:9944 +# MN_NODE_WS_URL=ws://127.0.0.1:9944 +# MN_INDEXER_URL=http://127.0.0.1:8088/api/v4/graphql +# MN_INDEXER_WS_URL=ws://127.0.0.1:8088/api/v4/graphql/ws +# MN_PROOF_SERVER_URL=http://127.0.0.1:6300 +# +# Optional on undeployed: with MN_MAINTENANCE_KEY_FILE unset the deployer writes +# .local/private-state/v2-undeployed/maintenance-key.json at mode 0600 (a +# relative path is accepted here and resolved against the working directory). +# MN_MAINTENANCE_KEY_FILE= +# +# With no MN_SEED/MN_MNEMONIC the undeployed deployer falls back to the shared +# genesis-1 devnet seed and warns; set MN_SEED above to a dedicated devnet seed +# for anything you keep. Never put a seed you care about in this template. diff --git a/README.md b/README.md index cd01839..191d457 100644 --- a/README.md +++ b/README.md @@ -67,12 +67,18 @@ The frontend is a Vite + React app that connects to any `window.midnight` wallet ```bash bun install --frozen-lockfile -bun --cwd frontend install --frozen-lockfile +bun --cwd=frontend install --frozen-lockfile npm --prefix frontend/protocols/v1 ci npm --prefix frontend/protocols/v2 ci -bun --cwd frontend run dev # http://localhost:5173 (uses the committed .env) +bun --cwd=frontend run dev # http://localhost:5173 (uses the committed .env) ``` +Needs **bun 1.4 or newer**: the committed `bun.lock` files are lockfile v2, which +older bun refuses to read (`Unknown lockfile version`), and bun 1.4 wants the +`--cwd=` spelling — with a space it prints its usage text and exits 0 +without running anything. CI installs the latest bun, so the pinned form above +is what CI runs. + Needs a Midnight wallet extension, the v1 artifacts in `src/managed/`, and the v2 artifacts in `contracts/v2/managed/`. The two protocol installs remain separate because their ledger/runtime WASM generations cannot share class identities. Deploy details and the wallet-proving model are in [frontend/README.md](frontend/README.md). Deploy a contract (needs a funded, DUST-registered wallet). Put the deployer @@ -93,6 +99,27 @@ Two `.env` files, opposite policies: the root `.env` holds **secrets** and is gitignored; [frontend/.env](frontend/.env) holds only **public contract addresses** and is committed (the deployed address lives in git history). +### Which lane runs where + +Two contract trees, two compilers, two sets of scripts. Which one a network uses +is decided by the ledger generation that network runs — except `undeployed`, +which is not a chain but whichever devnet is on the other end of the URLs you +hand it: + +| Network | Ledger generation | Contract tree | Deploy / verify | The page | +| --- | --- | --- | --- | --- | +| `preview`, `preprod` | Midnight 1.x | root `src/` (compactc 0.31.1 → `src/managed/`) | `MN_ENV=preview bun run scripts/deploy.ts`, `bun run verify:deployment` | pinned 1.x | +| `stagenet` | Midnight 2.x | `contracts/v2/` (compactc 0.34.0) | `MN_ENV=stagenet bun run deploy:v2`, `bun run verify:deployment:v2` — durable `MN_MAINTENANCE_KEY_FILE` mandatory | pinned 2.x | +| `undeployed` (local devnet) | Midnight 1.x — **the default** | root `src/` | `MN_ENV=undeployed bun run scripts/deploy.ts`, `bun run verify:deployment` | `Local (undeployed)`, no setting needed | +| `undeployed` (local devnet) | Midnight 2.x — **opt-in** | `contracts/v2/` | `MN_ENV=undeployed bun run deploy:v2`, `bun run verify:deployment:v2` — maintenance key optional | `UNDEPLOYED_PROTOCOL=midnight-2.x` → `Local (undeployed · 2.x)` | + +Nothing here changes by default: with no `MN_ENV` the v2 scripts still target +`stagenet`, and with no `UNDEPLOYED_PROTOCOL` the page still treats the local +network as Midnight 1.x. The 2.x local lane is opt-in on both sides, and both +sides must agree — a page told `midnight-2.x` while the devnet is ledger-v8 (or +the reverse) only fails when the adapter runs, and the activity log then names +the configured family and what to change. + ### Deploying the 2.x contract to Stagenet Contract deployment is a local-host operation; the GitHub workflow only builds and uploads the static website. Install the isolated v2 dependency tree, reproduce the compiler 0.34.0 artifacts, and run the deployer with a funded, DUST-registered Stagenet wallet and a compatible proof server (the default proof-server URL is `http://127.0.0.1:6300`): @@ -128,7 +155,7 @@ docker run --rm \ Wallet synchronization uses a five-minute timeout by default and then requires positive NIGHT and DUST balances before provider initialization or transaction construction. Set `MN_WALLET_SYNC_TIMEOUT_MS` to an integer from `30000` through `900000` when a slower indexer needs a different bounded wait. -The deployer closes and read-back validates the key file before wallet startup, passes that exact key to Midnight.js, and verifies that its derived public key is the 1-of-1 on-chain maintenance authority. It then confirms the transaction, compares the circuit set and verifier keys with `contracts/v2/managed`, reports the maintenance-authority state, and writes a private record to `.local/deployments/v2-stagenet-
.json` by default. It does not lock the maintenance authority. Preserve the printed `STAGENET_ADDRESS` as public data in `frontend/.env`, then independently repeat the read-only verification: +The deployer closes and read-back validates the key file before wallet startup, passes that exact key to Midnight.js, and verifies that its derived public key is the 1-of-1 on-chain maintenance authority. It then confirms the transaction, compares the circuit set and verifier keys with `contracts/v2/managed`, reports the maintenance-authority state, and writes a private record to `.local/deployments/v2--
.json` by default — `v2-stagenet-
.json` here, `v2-undeployed-
.json` for the local 2.x lane below (`DEPLOY_OUT=` overrides the whole path). It does not lock the maintenance authority. The address is also printed as `_ADDRESS=
`, so this run prints `STAGENET_ADDRESS=…`; preserve it as public data in `frontend/.env`, then independently repeat the read-only verification: ```bash MN_ENV=stagenet CV_ADDRESS= bun run verify:deployment:v2 @@ -142,15 +169,16 @@ Everything above assumes the local devnet is on this host's loopback and that a human pastes the new address into `frontend/.env`. A deployment that brings up its OWN chain — a compose stack that deploys this contract once per bring-up and serves the dApp from an image built long before — needs neither assumption, and -four opt-in knobs cover it. All default to today's behaviour, so nothing changes +five opt-in knobs cover it. All default to today's behaviour, so nothing changes for an existing deploy, build or CI run. | Knob | Where | What it does | | --- | --- | --- | -| `MN_INDEXER_URL`, `MN_INDEXER_WS_URL`, `MN_NODE_URL`, `MN_PROOF_SERVER_URL` | deploy / lock / verify scripts and the integration suite | dial a stack that is not on `127.0.0.1` — e.g. compose service hostnames from inside the same docker network. `undeployed` honours all four; hosted envs honour `MN_PROOF_SERVER_URL` only ([TESTING.md](TESTING.md)) | -| `DEPLOY_OUT=` | `scripts/deploy.ts`, `scripts/deploy-and-lock.ts` | also write the deploy as JSON — `{address, networkId, name, symbol, decimals, deployedAt, commit, locked}` — published atomically, so an automated deployment reads DATA instead of scraping stdout ([scripts/deploy-record.ts](scripts/deploy-record.ts)) | -| `window.SHIELDED_NIGHT = { UNDEPLOYED_ADDRESS: "…" }` | the SPA — overwrite the built `dist/config.js`, which `index.html` already loads before the bundle | override the built-in contract address at RUNTIME, so one image serves any stack; nothing else in the build is touched ([frontend/README.md](frontend/README.md#runtime-address-override-windowshielded_night)) | -| `MN_EXTERNAL_STACK=1` | the integration suite | run the suite against that already-running stack instead of booting one with testcontainers — the strongest e2e gate a packaging of this dApp can have ([TESTING.md](TESTING.md)) | +| `MN_INDEXER_URL`, `MN_INDEXER_WS_URL`, `MN_NODE_URL`, `MN_PROOF_SERVER_URL` — plus `MN_NODE_WS_URL` on the v2 lane | deploy / lock / verify scripts and the integration suite; the same names on the v2 scripts (`deploy:v2`, `verify:deployment:v2`) and the 2.x external-stack suite | dial a stack that is not on `127.0.0.1` — e.g. compose service hostnames from inside the same docker network. `undeployed` honours all four (five on the v2 lane); hosted envs honour `MN_PROOF_SERVER_URL` only ([TESTING.md](TESTING.md)) | +| `DEPLOY_OUT=` | `scripts/deploy.ts`, `scripts/deploy-and-lock.ts`; the v2 scripts honour it too, for their own provenance record | also write the deploy as JSON — `{address, networkId, name, symbol, decimals, deployedAt, commit, locked}` — published atomically, so an automated deployment reads DATA instead of scraping stdout ([scripts/deploy-record.ts](scripts/deploy-record.ts)) | +| `window.SHIELDED_NIGHT = { UNDEPLOYED_ADDRESS: "…" }` | the SPA — overwrite the built `dist/config.js`, which `index.html` already loads before the bundle | override the built-in contract address at RUNTIME, so one image serves any stack; nothing else in the build is touched ([frontend/README.md](frontend/README.md#runtime-configuration-override)) | +| `UNDEPLOYED_PROTOCOL` | the SPA — build-time env at `vite build`, or `window.SHIELDED_NIGHT.UNDEPLOYED_PROTOCOL` in that same `dist/config.js` (a non-blank runtime value wins) | which ledger generation the local `undeployed` network runs: `midnight-1.x` (the default, today's behaviour) or `midnight-2.x`, which loads the v2 (ledger-v9) adapter and labels the row `Local (undeployed · 2.x)`. Any other value is a visible configuration error on the page and Connect stays disabled — never a silent fallback ([frontend/README.md](frontend/README.md#local-protocol-selection-undeployed_protocol)) | +| `MN_EXTERNAL_STACK=1` | the 1.x integration suite, and the 2.x round-trip suite (`bun run test:v2:external`, where it is mandatory) | run the suite against that already-running stack instead of booting one with testcontainers — the strongest e2e gate a packaging of this dApp can have ([TESTING.md](TESTING.md)) | ```bash # deploy into a compose stack, from a container on its network @@ -167,6 +195,74 @@ On `undeployed` the deployer seed defaults to the genesis seed (`…0001`). Set `MN_SEED` to a dedicated one whenever anything else on that stack uses genesis — two facades on one wallet knock each other offline. +#### When that local devnet is a Midnight 2.x chain + +Everything above is the 1.x lane. If the stack you brought up runs **Midnight +2.x** (ledger-v9), use the v2 tree's commands instead — same knobs, same URLs, +and `MN_ENV=undeployed` is what selects the lane (the v2 scripts default to +`stagenet`, so nothing changes for the Stagenet deployment): + +```bash +# 1 — deploy the compiler-0.34.0 contract onto the local 2.x chain +MN_ENV=undeployed MN_SEED= \ + MN_NODE_URL=http://127.0.0.1:9944 \ + MN_NODE_WS_URL=ws://127.0.0.1:9944 \ + MN_INDEXER_URL=http://127.0.0.1:8088/api/v4/graphql \ + MN_INDEXER_WS_URL=ws://127.0.0.1:8088/api/v4/graphql/ws \ + MN_PROOF_SERVER_URL=http://127.0.0.1:6300 \ + bun run deploy:v2 + +# 2 — verify what landed, read-only, from the address step 1 printed +MN_ENV=undeployed CV_ADDRESS=
\ + MN_INDEXER_URL=http://127.0.0.1:8088/api/v4/graphql \ + MN_INDEXER_WS_URL=ws://127.0.0.1:8088/api/v4/graphql/ws \ + MN_NODE_URL=http://127.0.0.1:9944 \ + bun run verify:deployment:v2 + +# 3 — optional: drive the full round trip through that chain and contract +MN_EXTERNAL_STACK=1 MN_ENV=undeployed MN_SEED= \ + CV_ADDRESS=
\ + MN_NODE_URL=http://127.0.0.1:9944 \ + MN_NODE_WS_URL=ws://127.0.0.1:9944 \ + MN_INDEXER_URL=http://127.0.0.1:8088/api/v4/graphql \ + MN_INDEXER_WS_URL=ws://127.0.0.1:8088/api/v4/graphql/ws \ + MN_PROOF_SERVER_URL=http://127.0.0.1:6300 \ + bun run test:v2:external +``` + +The loopback values above are the defaults; pass only the ones your stack moves +(compose service hostnames from inside the network, a `pick-ports` block from +outside). What the three steps leave behind: + +- Step 1 prints `UNDEPLOYED_ADDRESS=
` (the printed variable is + `_ADDRESS`, so `STAGENET_ADDRESS=` on the Stagenet lane) and writes + `.local/deployments/v2-undeployed-
.json`. `DEPLOY_OUT=` + redirects that record, exactly as on the 1.x lane. +- The maintenance signing key is **optional here**. With no + `MN_MAINTENANCE_KEY_FILE` the deployer creates + `.local/private-state/v2-undeployed/maintenance-key.json` (mode 0600, recorded + `network: "undeployed"`); with the variable set, a relative path is accepted + and resolved against the working directory. A key created for one network is + refused on the other, and `stagenet` keeps its mandatory absolute path on + durable storage. `.local/` is gitignored in full. +- Private state is per network (`shielded-night-v2-undeployed`), so a machine + that also deploys to Stagenet never mixes the two. +- **`deploy:v2` always deploys a NEW contract.** There is no resume or join path + in it — exactly like the 1.x `scripts/deploy.ts`. Re-running it hands you a + second contract (harmless on a throwaway devnet, and on Stagenet it is why the + operator does not run it twice); what a re-run *does* reuse is the maintenance + key file. Resumability belongs to the caller: a compose entrypoint that reads + its own `contract.json` and joins the address already there, or + `verify:deployment:v2` to confirm an address you already have. +- Seed order is `MN_MNEMONIC`, then `MN_SEED`, then — on `undeployed` only — the + genesis-1 devnet seed `…0001` with a printed warning. That seed is the local + stack's funding faucet and is shared with every other facade deployed there, + so pass `MN_SEED` for anything you keep. Step 3 spends from the seed it is + given and deploys contracts of its own: point it only at a throwaway devnet. +- The page needs to be told too: `UNDEPLOYED_PROTOCOL=midnight-2.x` next to + `UNDEPLOYED_ADDRESS`, either baked in at `vite build` or injected into + `dist/config.js` at container start. + ## Locking a 1.x contract Every Midnight contract has a **maintenance authority** - a committee of keys allowed to change its rules (e.g. swap out a circuit's verifier key). On a fresh deploy that committee is just the deployer (1-of-1), so the deployer can still alter the contract after the fact. For a trustless release you remove that power. @@ -279,6 +375,21 @@ bun run compact && bun run test:integration # docker stack: node + indexer + p Unit tests run every circuit against an in-memory context, including security and border cases for both the atomic and two-step paths. Integration tests deploy to a local stack and cover the full round trip both directions (atomic and two-step), negative paths, on-chain attack vectors (forged, inflated, and double-spent coins; nonce-replay minting; the solvency invariant), multi-party circulation, and the maintenance-authority lock. +The 2.x tree has its own two tiers: + +```bash +bun run test:v2 # chain-free unit tier for contracts/v2 (what CI runs) +bun run test:v2:external # opt-in round trip against a Midnight 2.x stack you already started +``` + +`test:v2:external` never boots or tears down a stack: `MN_EXTERNAL_STACK=1` is +mandatory and its global setup refuses to run without it, then preflights the +indexer, node and proof server and names the URL that is wrong. Give it the +`MN_*_URL` block and `MN_ENV=undeployed` (its own default) as in the recipe +above; `CV_ADDRESS` is optional and joins an existing deployment instead of +deploying a fresh one. It asserts the 11-circuit verifier-key set plus the +atomic and two-step round trips with exact balances ([TESTING.md](TESTING.md)). + ## CI / CD [`.github/workflows/ci.yml`](.github/workflows/ci.yml) runs on every push to `main`, every PR, and on demand: diff --git a/TESTING.md b/TESTING.md index 76cd727..1f114d3 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,11 +8,20 @@ reference suites: | Unit (simulator) | Every circuit against an in-memory `CircuitContext` | none | seconds | | Integration (docker) | Real deploys + balanced transactions via a genesis wallet | docker: node, indexer, proof server | minutes | +The compiler-0.34.0 / ledger-v9 tree in `contracts/v2` mirrors both, one tier at +a time: `bun run test:v2` is its chain-free unit tier (what CI runs) and +`bun run test:v2:external` is its round-trip suite against a Midnight 2.x stack +you already started — see [the 2.x lane's external-stack suite](#the-2x-lanes-external-stack-suite-contractsv2) below. + ## Prerequisites - Node 22+ (vitest runs under Node; bun is the package manager) +- bun 1.4 or newer — the committed `bun.lock` files are lockfile v2 and older + bun cannot read them, and `--cwd` needs its `=` form (`bun --cwd=frontend …`) + or bun 1.4 prints usage and exits 0 without running the command - `bun install` -- The `compact` CLI (the scripts pin compiler `0.31.1`) +- The `compact` CLI (the root scripts pin compiler `0.31.1`; the `contracts/v2` + tree pins `0.34.0`) - Docker running (integration tier only) ## Unit tests @@ -158,6 +167,63 @@ locks the transaction structure so the wallet can't attach the unshielded UTXO input that `receiveUnshielded` needs — deposits would fail with `BalanceCheckOverspend` (see README, "The balancing fix"). +### The 2.x lane's external-stack suite (`contracts/v2`) + +The compiler-0.34.0 / ledger-v9 tree has its own round-trip suite. It is the +2.x counterpart of `MN_EXTERNAL_STACK=1` above, with one difference: there is no +self-booting mode. This tree carries no 2.x compose file, so the suite **only** +ever joins a stack you already started, and its global setup refuses to run +without `MN_EXTERNAL_STACK`, naming the variable. Nothing is ever torn down. + +```bash +MN_EXTERNAL_STACK=1 MN_ENV=undeployed \ + MN_SEED= \ + MN_INDEXER_URL=http://127.0.0.1:8088/api/v4/graphql \ + MN_INDEXER_WS_URL=ws://127.0.0.1:8088/api/v4/graphql/ws \ + MN_NODE_URL=http://127.0.0.1:9944 \ + MN_NODE_WS_URL=ws://127.0.0.1:9944 \ + MN_PROOF_SERVER_URL=http://127.0.0.1:6300 \ + bun run test:v2:external # = npm --prefix contracts/v2 run test:external +``` + +- `MN_ENV` defaults to `undeployed` **here** (the deploy/verify scripts default + to `stagenet`); `stagenet` is accepted but then `MN_SEED` is mandatory. +- `MN_SEED` is optional on `undeployed` only: without it the suite uses the + shared genesis-1 devnet seed and says so loudly. Pass a dedicated seed for + anything you keep. +- `CV_ADDRESS` is optional. Set, the suite joins that deployment; unset, it + deploys a fresh contract with a maintenance key sampled for the run. +- Global setup preflights the indexer/node/proof-server with a 10-second fetch + and fails immediately naming the URL that is wrong. +- The same five `MN_*_URL` variables steer `bun run deploy:v2` and + `bun run verify:deployment:v2` when `MN_ENV=undeployed`, so the suite and the + deployment it drives share one env block — the README's + [local 2.x recipe](README.md#when-that-local-devnet-is-a-midnight-2x-chain) + runs all three commands in order. + +What it asserts, against the chain: + +1. the deployed (or joined) contract serves the 11-circuit set with verifier + keys byte-equal to `contracts/v2/managed/keys/`, the release metadata + (`Shielded Night` / `sNight` / 6), and — for a contract it deployed itself — + the run's sampled key as the sole maintenance authority; +2. the two-step round trip `depositUnshielded → withdrawShielded → + depositShielded → withdrawUnshielded`, with EXACT NIGHT and wrapper balances + after every step; +3. the atomic pair `convertToShielded` / `convertToUnshielded` (the circuits the + SPA drives), again with exact balances; +4. a wrong-secret withdrawal is refused with `no balance for this secret`, and + the rightful secret still redeems the credit. + +The suite deploys contracts and spends from the seed it is given: **point it +only at a throwaway devnet.** + +The unit tier is unaffected. `bun run test:v2` runs +`vitest --config contracts/v2/vitest.config.ts`, whose include pattern and +exclude list both keep `test/external/**` out, so CI's `unit-v2` job never +collects a test that needs a chain. There is no CI job for the 2.x external +suite, exactly as there is none for the 1.x external mode. + ## CI `.github/workflows/ci.yml`: diff --git a/contracts/v2/package.json b/contracts/v2/package.json index b21f343..c14c5d8 100644 --- a/contracts/v2/package.json +++ b/contracts/v2/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "typecheck": "tsc --noEmit", - "test:unit": "vitest run", + "test:unit": "vitest run --config vitest.config.ts", + "test:external": "vitest run --config vitest.external.config.ts", "deploy": "node --import tsx scripts/deploy.ts", "verify:deployment": "node --import tsx scripts/verify-deployment.ts" }, diff --git a/contracts/v2/scripts/deploy.ts b/contracts/v2/scripts/deploy.ts index a97d48c..91d0e89 100644 --- a/contracts/v2/scripts/deploy.ts +++ b/contracts/v2/scripts/deploy.ts @@ -1,4 +1,4 @@ -/** Local-host deployment for the compiler 0.34.0 stagenet profile. */ +/** Local-host deployment for the compiler 0.34.0 profiles (MN_ENV=stagenet | undeployed). */ import '../../../scripts/load-env.js'; import { CompiledContract } from '@midnight-ntwrk/compact-js'; import { deployContract } from '@midnight-ntwrk/midnight-js-contracts'; @@ -12,28 +12,56 @@ import { assertMaintenanceAuthorityKey, COMPATIBILITY, createWalletLogger, + GENESIS_MINT_SEED, MANAGED_DIRECTORY, mergeVerificationRecord, preflightRecordOutput, + privateStateStoreName, + profileFor, reportConfirmedDeployment, + requestedEnv, sourceCommit, - stagenet, verifyAddress, + WALLET_NETWORK_IDS, + walletNetworkIdFor, withDurableMaintenanceKey, withSyncedDeploymentWallet, writeRecord, + type V2EnvName, } from './profile.js'; import { loadWalletEnvFile } from './load-wallet-env.js'; loadWalletEnvFile(); -function deploymentSeed(): string { +/** + * `WALLET_NETWORK_IDS` is written as plain literals in profile.ts so the unit + * tier can import that module without the WASM-bearing wallet barrel. This + * declaration is the compile-time pin that keeps those literals equal to the + * SDK's own constants: it stops type-checking if either name or value moves. + */ +const _walletNetworkIdsMatchSdk: { + readonly stagenet: typeof NetworkId.NetworkId.StageNet; + readonly undeployed: typeof NetworkId.NetworkId.Undeployed; +} = WALLET_NETWORK_IDS; +void _walletNetworkIdsMatchSdk; + +function deploymentSeed(env: V2EnvName): string { const mnemonic = process.env.MN_MNEMONIC?.trim().replace(/\s+/g, ' '); if (mnemonic) { if (!validateMnemonic(mnemonic)) throw new Error('MN_MNEMONIC is not a valid BIP-39 phrase.'); return Buffer.from(mnemonicToSeedSync(mnemonic)).toString('hex'); } const seed = process.env.MN_SEED?.trim(); + if (!seed && env === 'undeployed') { + // Only ever on a throwaway devnet, and never silently: genesis-1 is the + // funding faucet on a local stack and is shared with every other facade + // deployed there. + console.warn( + '[deploy] WARNING: no MN_MNEMONIC/MN_SEED set; falling back to the shared genesis-1 devnet seed. ' + + 'It funds every other facade on a local stack — set MN_SEED to a dedicated seed for anything you keep.', + ); + return GENESIS_MINT_SEED; + } if (!seed || !/^[0-9a-f]+$/i.test(seed) || seed.length % 2 !== 0) { throw new Error('Set MN_MNEMONIC or an even-length hexadecimal MN_SEED in the repo-root .env or shell.'); } @@ -41,16 +69,15 @@ function deploymentSeed(): string { } async function main() { - const requested = process.env.MN_ENV?.trim() || 'stagenet'; - if (requested !== 'stagenet') throw new Error('The v2 deployment command only supports MN_ENV=stagenet.'); - const profile = stagenet(); - const environment = { ...profile, walletNetworkId: NetworkId.NetworkId.StageNet }; + const env = requestedEnv(); + const profile = profileFor(env); + const environment = { ...profile, walletNetworkId: walletNetworkIdFor(env) }; // All provenance and output checks happen before wallet startup or a funded // transaction. After deployContract resolves, the address is printed before // any record or indexer operation that can fail. const deploymentSourceCommit = sourceCommit(); const deploymentArtifactSha256 = artifactSha256(); - preflightRecordOutput(); + preflightRecordOutput(env); await withDurableMaintenanceKey({ sourceCommit: deploymentSourceCommit, artifactSha256: deploymentArtifactSha256, @@ -59,10 +86,10 @@ async function main() { // testkit-js logs the seed at info level; deployment logging must remain // silent because structured redaction cannot remove an interpolated secret. - const wallet = await MidnightWalletProvider.build(createWalletLogger(), environment, deploymentSeed()); + const wallet = await MidnightWalletProvider.build(createWalletLogger(), environment, deploymentSeed(env)); await withSyncedDeploymentWallet(wallet, syncWallet, async () => { const providers = initializeMidnightProviders(wallet, environment, { - privateStateStoreName: 'shielded-night-v2-stagenet', + privateStateStoreName: privateStateStoreName(env), zkConfigPath: MANAGED_DIRECTORY, }); const compiled = CompiledContract.make('shielded-night-v2', Contract).pipe( @@ -78,12 +105,12 @@ async function main() { signingKey: maintenanceKey.signingKey, } as never); const transaction = deployed.deployTxData.public; - const address = reportConfirmedDeployment(transaction); + const address = reportConfirmedDeployment(transaction, console.log, env); const provenance = { schemaVersion: 2, recordKind: 'deployment', network: { - name: 'stagenet', + name: env, networkId: profile.networkId, node: profile.node, indexer: profile.indexer, @@ -108,7 +135,7 @@ async function main() { }, recordedAt: new Date().toISOString(), }; - const pendingPath = writeRecord(pendingRecord, address); + const pendingPath = writeRecord(pendingRecord, address, env); console.log(`[deploy] confirmation record ${pendingPath}`); const verified = await verifyAddress(providers.publicDataProvider, address); @@ -122,8 +149,8 @@ async function main() { verificationArtifactSha256: deploymentArtifactSha256, verifiedAt, }); - const recordPath = writeRecord(record, verified.address); - console.log(`[deploy] verified stagenet contract ${verified.address}`); + const recordPath = writeRecord(record, verified.address, env); + console.log(`[deploy] verified ${env} contract ${verified.address}`); console.log(`[deploy] maintenance authority locked=${verified.authority.locked}`); console.log(`[deploy] record ${recordPath}`); }); diff --git a/contracts/v2/scripts/profile.ts b/contracts/v2/scripts/profile.ts index 6cbd26b..ac4371e 100644 --- a/contracts/v2/scripts/profile.ts +++ b/contracts/v2/scripts/profile.ts @@ -40,6 +40,54 @@ export const COMPATIBILITY = { export const REPOSITORY_ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..', '..', '..'); export const MANAGED_DIRECTORY = path.resolve(REPOSITORY_ROOT, 'contracts', 'v2', 'managed'); + +/** + * The environments the v2 (compiler 0.34.0 / ledger-v9) scripts can target. + * `stagenet` is the default and keeps every rule it had before `undeployed` + * existed: a durable, absolute, operator-supplied maintenance key. + * `undeployed` is a throwaway local devnet — same code path, relaxed custody. + */ +export const V2_ENV_NAMES = ['stagenet', 'undeployed'] as const; +export type V2EnvName = (typeof V2_ENV_NAMES)[number]; + +export const isV2EnvName = (value: string): value is V2EnvName => + (V2_ENV_NAMES as readonly string[]).includes(value); + +/** `MN_ENV`, defaulting to `stagenet` so nothing changes for today's callers. */ +export function requestedEnv(): V2EnvName { + const requested = process.env.MN_ENV?.trim() || 'stagenet'; + if (!isV2EnvName(requested)) { + throw new Error(`Invalid MN_ENV "${requested}". The v2 commands support ${V2_ENV_NAMES.join(' | ')}.`); + } + return requested; +} + +/** + * The wallet network id per env. These are exactly `NetworkId.NetworkId.StageNet` + * and `NetworkId.NetworkId.Undeployed` from `@midnightntwrk/wallet-sdk` + * 2.0.0-beta.2; the literals live here so the unit tier can import this module + * without loading the WASM-bearing wallet barrel. `deploy.ts` pins the mapping + * to the SDK's own constants at typecheck time. + */ +export const WALLET_NETWORK_IDS = { + stagenet: 'stagenet', + undeployed: 'undeployed', +} as const satisfies Record; + +export const walletNetworkIdFor = (env: V2EnvName): (typeof WALLET_NETWORK_IDS)[V2EnvName] => + WALLET_NETWORK_IDS[env]; + +/** + * Genesis-block-funded devnet seed — the SAME constant the 1.x lane uses + * (`test/support/network.ts` GENESIS_MINT_SEED). Only ever valid on + * `undeployed`: on a local devnet it is the funding faucet and is shared with + * every other facade on that stack, so callers that matter pass `MN_SEED`. + */ +export const GENESIS_MINT_SEED = '0000000000000000000000000000000000000000000000000000000000000001'; + +/** Level DB store name for an env's private state (never shared between envs). */ +export const privateStateStoreName = (env: V2EnvName, suffix = ''): string => + `shielded-night-v2-${env}${suffix}`; export const DEFAULT_WALLET_SYNC_TIMEOUT_MS = 300_000; const MIN_WALLET_SYNC_TIMEOUT_MS = 30_000; const MAX_WALLET_SYNC_TIMEOUT_MS = 900_000; @@ -95,7 +143,7 @@ export async function withSyncedDeploymentWallet( interface MaintenanceKeyRecord { readonly schemaVersion: 1; readonly recordKind: 'shielded-night-maintenance-signing-key'; - readonly network: 'stagenet'; + readonly network: V2EnvName; readonly signingKey: SigningKey; readonly verifyingKey: SignatureVerifyingKey; readonly createdAt: string; @@ -136,8 +184,33 @@ function normalizeVerifyingKey(value: unknown): SignatureVerifyingKey { const sameVerifyingKey = (left: SignatureVerifyingKey, right: SignatureVerifyingKey): boolean => left.tag === right.tag && left.value.toLowerCase() === right.value.toLowerCase(); -export function maintenanceKeyPath(): string { +/** Default `undeployed` key location: the repo's gitignored private-state area. */ +export const UNDEPLOYED_MAINTENANCE_KEY_PATH = path.resolve( + REPOSITORY_ROOT, + '.local', + 'private-state', + 'v2-undeployed', + 'maintenance-key.json', +); + +/** + * Where the maintenance signing key lives. + * + * `stagenet` is unchanged: an operator must name an absolute path on durable + * storage, because losing that key locks the funded deployment out of every + * future maintenance transaction. + * + * `undeployed` is a throwaway devnet, so the file is optional and defaults into + * the repository's gitignored private-state directory; a relative + * `MN_MAINTENANCE_KEY_FILE` is accepted there and resolved against the cwd. + * Mode 0600 and the write/read-back check still apply on both — that is + * secret hygiene, not durability. + */ +export function maintenanceKeyPath(env: V2EnvName = requestedEnv()): string { const configured = process.env.MN_MAINTENANCE_KEY_FILE?.trim(); + if (env === 'undeployed') { + return configured ? path.resolve(configured) : UNDEPLOYED_MAINTENANCE_KEY_PATH; + } if (!configured) { throw new Error('Set MN_MAINTENANCE_KEY_FILE to an explicit durable, private maintenance-key file path.'); } @@ -147,7 +220,7 @@ export function maintenanceKeyPath(): string { return configured; } -function readMaintenanceKeyRecord(destination: string): MaintenanceKeyRecord { +function readMaintenanceKeyRecord(destination: string, expectedNetwork: V2EnvName): MaintenanceKeyRecord { let file: number | undefined; try { file = openSync(destination, constants.O_RDONLY | constants.O_NOFOLLOW); @@ -161,9 +234,22 @@ function readMaintenanceKeyRecord(destination: string): MaintenanceKeyRecord { throw new Error('Maintenance key file must contain a JSON object.'); } const record = parsed as Partial; - if (record.schemaVersion !== 1 || record.recordKind !== 'shielded-night-maintenance-signing-key' || record.network !== 'stagenet') { + if ( + record.schemaVersion !== 1 || + record.recordKind !== 'shielded-night-maintenance-signing-key' || + typeof record.network !== 'string' || + !isV2EnvName(record.network) + ) { throw new Error('Maintenance key file has an unsupported schema or network.'); } + // A key belongs to exactly one network: reusing a stagenet key on a + // throwaway devnet (or the reverse) would put the funded deployment's + // maintenance identity on a chain nobody controls. + if (record.network !== expectedNetwork) { + throw new Error( + `Maintenance key file was created for network "${record.network}" but MN_ENV is "${expectedNetwork}".`, + ); + } if (typeof record.createdAt !== 'string' || !record.createdFor || !/^[0-9a-f]{40}$/i.test(record.createdFor.sourceCommit ?? '') || !/^[0-9a-f]{64}$/i.test(record.createdFor.artifactSha256 ?? '')) { @@ -183,7 +269,7 @@ function readMaintenanceKeyRecord(destination: string): MaintenanceKeyRecord { return { schemaVersion: 1, recordKind: 'shielded-night-maintenance-signing-key', - network: 'stagenet', + network: record.network, signingKey, verifyingKey, createdAt: record.createdAt, @@ -203,10 +289,10 @@ function readMaintenanceKeyRecord(destination: string): MaintenanceKeyRecord { export function prepareMaintenanceSigningKey(input: { sourceCommit: string; artifactSha256: string; -}): PreparedMaintenanceKey { - const destination = maintenanceKeyPath(); +}, env: V2EnvName = requestedEnv()): PreparedMaintenanceKey { + const destination = maintenanceKeyPath(env); if (existsSync(destination)) { - const existing = readMaintenanceKeyRecord(destination); + const existing = readMaintenanceKeyRecord(destination, env); return { path: destination, signingKey: existing.signingKey, verifyingKey: existing.verifyingKey, created: false }; } @@ -216,7 +302,7 @@ export function prepareMaintenanceSigningKey(input: { const record: MaintenanceKeyRecord = { schemaVersion: 1, recordKind: 'shielded-night-maintenance-signing-key', - network: 'stagenet', + network: env, signingKey, verifyingKey, createdAt: new Date().toISOString(), @@ -250,7 +336,7 @@ export function prepareMaintenanceSigningKey(input: { // The deployer never trusts its own write. Close and reopen the durable file, // enforce its permissions, and derive the public identity again before any // wallet is started or transaction can be submitted. - const persisted = readMaintenanceKeyRecord(destination); + const persisted = readMaintenanceKeyRecord(destination, env); if (!sameVerifyingKey(persisted.verifyingKey, verifyingKey) || persisted.signingKey.tag !== signingKey.tag || persisted.signingKey.value !== signingKey.value) { throw new Error('Maintenance signing key did not survive durable write/read-back validation.'); @@ -258,12 +344,13 @@ export function prepareMaintenanceSigningKey(input: { return { path: destination, signingKey: persisted.signingKey, verifyingKey: persisted.verifyingKey, created: true }; } -/** Run wallet startup/submission only after durable key creation and read-back. */ +/** Run wallet startup/submission only after key creation and read-back. */ export async function withDurableMaintenanceKey( input: { sourceCommit: string; artifactSha256: string }, action: (key: PreparedMaintenanceKey) => Promise, + env: V2EnvName = requestedEnv(), ): Promise { - const key = prepareMaintenanceSigningKey(input); + const key = prepareMaintenanceSigningKey(input, env); return await action(key); } @@ -292,6 +379,30 @@ export const stagenet = () => ({ faucet: undefined, }); +/** + * A local ledger-v9 devnet. The loopback defaults are the 1.x lane's + * (`test/support/network.ts` UndeployedNetwork): indexer 8088, node 9944, + * proof server 6300. Every endpoint is overridable because a caller inside the + * stack's docker network must dial service hostnames instead. + */ +export const undeployed = () => ({ + walletNetworkId: 'undeployed' as const, + networkId: 'undeployed', + indexer: envUrl('MN_INDEXER_URL', 'http://127.0.0.1:8088/api/v4/graphql'), + indexerWS: envUrl('MN_INDEXER_WS_URL', 'ws://127.0.0.1:8088/api/v4/graphql/ws'), + node: envUrl('MN_NODE_URL', 'http://127.0.0.1:9944'), + nodeWS: envUrl('MN_NODE_WS_URL', 'ws://127.0.0.1:9944'), + proofServer: envUrl('MN_PROOF_SERVER_URL', 'http://127.0.0.1:6300'), + faucet: undefined, +}); + +export type V2Profile = ReturnType | ReturnType; + +/** Resolved per CALL, so endpoint overrides set after import still apply. */ +export function profileFor(env: V2EnvName): V2Profile { + return env === 'undeployed' ? undeployed() : stagenet(); +} + const bytesEqual = (left: Uint8Array, right: Uint8Array): boolean => left.length === right.length && left.every((value, index) => value === right[index]); @@ -399,16 +510,16 @@ export function assertMaintenanceAuthorityKey( } } -export function recordPath(address: string): string { +export function recordPath(address: string, env: V2EnvName = requestedEnv()): string { const configured = process.env.DEPLOY_OUT?.trim(); return configured ? path.resolve(configured) - : path.resolve(REPOSITORY_ROOT, '.local', 'deployments', `v2-stagenet-${address}.json`); + : path.resolve(REPOSITORY_ROOT, '.local', 'deployments', `v2-${env}-${address}.json`); } /** Prove the record directory is writable before a deployment can spend funds. */ -export function preflightRecordOutput(): void { - const destination = recordPath('preflight'); +export function preflightRecordOutput(env: V2EnvName = requestedEnv()): void { + const destination = recordPath('preflight', env); if (existsSync(destination) && statSync(destination).isDirectory()) { throw new Error(`Deployment record output is a directory: ${destination}`); } @@ -433,11 +544,12 @@ export interface ConfirmedDeploymentTransaction { export function reportConfirmedDeployment( transaction: ConfirmedDeploymentTransaction, log: (message: string) => void = console.log, + env: V2EnvName = requestedEnv(), ): string { const address = transaction.contractAddress; - log(`[deploy] confirmed stagenet contract ${address}`); + log(`[deploy] confirmed ${env} contract ${address}`); log(`[deploy] confirmed transaction ${transaction.txId}`); - log(`STAGENET_ADDRESS=${address}`); + log(`${env.toUpperCase()}_ADDRESS=${address}`); return address; } @@ -518,8 +630,8 @@ export function mergeVerificationRecord(input: { }; } -export function readRecord(address: string): Record | undefined { - const destination = recordPath(address); +export function readRecord(address: string, env: V2EnvName = requestedEnv()): Record | undefined { + const destination = recordPath(address, env); if (!existsSync(destination)) return undefined; const value: unknown = JSON.parse(readFileSync(destination, 'utf8')); return value != null && typeof value === 'object' && !Array.isArray(value) @@ -527,8 +639,8 @@ export function readRecord(address: string): Record | undefined : undefined; } -export function writeRecord(record: Record, address: string): string { - const destination = recordPath(address); +export function writeRecord(record: Record, address: string, env: V2EnvName = requestedEnv()): string { + const destination = recordPath(address, env); mkdirSync(path.dirname(destination), { recursive: true }); const temporary = `${destination}.tmp.${process.pid}`; writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); diff --git a/contracts/v2/scripts/verify-deployment.ts b/contracts/v2/scripts/verify-deployment.ts index 514f955..8651b8f 100644 --- a/contracts/v2/scripts/verify-deployment.ts +++ b/contracts/v2/scripts/verify-deployment.ts @@ -1,23 +1,23 @@ -/** Read-only stagenet verifier for a compiler 0.34.0 Shielded NIGHT deployment. */ +/** Read-only verifier for a compiler 0.34.0 Shielded NIGHT deployment (MN_ENV=stagenet | undeployed). */ import '../../../scripts/load-env.js'; import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider'; import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id'; import { artifactSha256, mergeVerificationRecord, + profileFor, readRecord, + requestedEnv, sourceCommit, - stagenet, verifyAddress, writeRecord, } from './profile.js'; async function main() { - const requested = process.env.MN_ENV?.trim() || 'stagenet'; - if (requested !== 'stagenet') throw new Error('The v2 verifier only supports MN_ENV=stagenet.'); + const env = requestedEnv(); const address = process.env.CV_ADDRESS?.trim(); - if (!address) throw new Error('Set CV_ADDRESS to the stagenet Shielded NIGHT contract address.'); - const profile = stagenet(); + if (!address) throw new Error(`Set CV_ADDRESS to the ${env} Shielded NIGHT contract address.`); + const profile = profileFor(env); setNetworkId(profile.networkId); const publicDataProvider = indexerPublicDataProvider({ queryURL: profile.indexer, @@ -25,11 +25,11 @@ async function main() { }); try { const verified = await verifyAddress(publicDataProvider, address); - const existing = readRecord(verified.address) ?? {}; + const existing = readRecord(verified.address, env) ?? {}; const record = mergeVerificationRecord({ existing, network: { - name: 'stagenet', + name: env, networkId: profile.networkId, node: profile.node, indexer: profile.indexer, @@ -39,7 +39,7 @@ async function main() { verificationArtifactSha256: artifactSha256(), verifiedAt: new Date().toISOString(), }); - const recordPath = writeRecord(record, verified.address); + const recordPath = writeRecord(record, verified.address, env); console.log(`[verify:v2] code and metadata match ${verified.address}`); console.log(`[verify:v2] maintenance authority locked=${verified.authority.locked}`); console.log(`[verify:v2] record ${recordPath}`); diff --git a/contracts/v2/test/deployment-record.unit.test.ts b/contracts/v2/test/deployment-record.unit.test.ts index 00a0dd9..286d39d 100644 --- a/contracts/v2/test/deployment-record.unit.test.ts +++ b/contracts/v2/test/deployment-record.unit.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { Writable } from 'node:stream'; import { pathToFileURL } from 'node:url'; -import { afterEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { unshieldedToken } from '@midnightntwrk/ledger-v9'; import { assertMaintenanceAuthorityKey, @@ -38,8 +38,17 @@ const originalDeployOut = process.env.DEPLOY_OUT; const originalSourceCommit = process.env.SHIELDED_NIGHT_COMMIT; const originalMaintenanceKeyFile = process.env.MN_MAINTENANCE_KEY_FILE; const originalWalletSyncTimeout = process.env.MN_WALLET_SYNC_TIMEOUT_MS; +const originalEnv = process.env.MN_ENV; + +// Every assertion in this file pins the DEFAULT (stagenet) lane, so the +// `undeployed` lane cannot change it by being exported in the caller's shell. +beforeEach(() => { + delete process.env.MN_ENV; +}); afterEach(() => { + if (originalEnv === undefined) delete process.env.MN_ENV; + else process.env.MN_ENV = originalEnv; if (originalDeployOut === undefined) delete process.env.DEPLOY_OUT; else process.env.DEPLOY_OUT = originalDeployOut; if (originalSourceCommit === undefined) delete process.env.SHIELDED_NIGHT_COMMIT; diff --git a/contracts/v2/test/external/global-setup.ts b/contracts/v2/test/external/global-setup.ts new file mode 100644 index 0000000..a8c750e --- /dev/null +++ b/contracts/v2/test/external/global-setup.ts @@ -0,0 +1,104 @@ +import { isV2EnvName, profileFor, V2_ENV_NAMES, type V2EnvName, type V2Profile } from '../../scripts/profile.js'; + +/** + * Global setup for the 2.x external-stack suite. + * + * Unlike the 1.x integration suite, this one NEVER starts a stack: there is no + * testcontainers path and no compose file in this tree (project 00008, Q2). It + * only ever joins a chain somebody else is running, so `MN_EXTERNAL_STACK` is + * mandatory and the setup refuses without it. Nothing is ever torn down. + * + * MN_EXTERNAL_STACK=1 MN_ENV=undeployed \ + * MN_INDEXER_URL=http://127.0.0.1:8088/api/v4/graphql \ + * MN_INDEXER_WS_URL=ws://127.0.0.1:8088/api/v4/graphql/ws \ + * MN_NODE_URL=http://127.0.0.1:9944 \ + * MN_NODE_WS_URL=ws://127.0.0.1:9944 \ + * MN_PROOF_SERVER_URL=http://127.0.0.1:6300 \ + * MN_SEED= \ + * npm --prefix contracts/v2 run test:external + * + * The suite deploys contracts and spends from the seed it is given: point it + * only at a throwaway devnet. + */ +export const EXTERNAL_STACK_ENV_VAR = 'MN_EXTERNAL_STACK'; +const EXTERNAL_STACK_TRUTHY = ['1', 'true', 'yes', 'on']; + +/** Env keys the setup hands to the (forked) test workers. */ +export const RESOLVED_ENV_VAR = '__MN_V2_ENV__'; +export const RESOLVED_PROFILE_VAR = '__MN_V2_CFG__'; + +const useExternalStack = (): boolean => + EXTERNAL_STACK_TRUTHY.includes((process.env[EXTERNAL_STACK_ENV_VAR] ?? '').trim().toLowerCase()); + +/** + * Any HTTP answer — including 400/404/405 — proves the endpoint is listening; + * only a transport error (nothing there, DNS miss, refused) is a miss. Fails + * fast with the URL that is wrong instead of a wallet sync that times out ten + * minutes later inside a test. + */ +const unreachable = async (url: string): Promise => { + try { + await fetch(url, { signal: AbortSignal.timeout(10_000) }); + return undefined; + } catch (error) { + return `${url} (${error instanceof Error ? error.message : String(error)})`; + } +}; + +/** Preflight the stack's endpoints; each WS URL is covered by its HTTP sibling. */ +const assertStackReachable = async (profile: V2Profile): Promise => { + const probes: ReadonlyArray = [ + ['indexer', profile.indexer], + ['node', profile.node], + ['proof server', profile.proofServer], + ]; + const misses = ( + await Promise.all(probes.map(async ([label, url]) => ({ label, miss: await unreachable(url) }))) + ).filter((probe) => probe.miss !== undefined); + if (misses.length > 0) { + throw new Error( + `${EXTERNAL_STACK_ENV_VAR} is set but ${misses.length} endpoint(s) are unreachable:\n` + + misses.map((probe) => ` - ${probe.label}: ${probe.miss}`).join('\n') + + '\nStart the stack, or point the suite at it with ' + + 'MN_INDEXER_URL / MN_INDEXER_WS_URL / MN_NODE_URL / MN_NODE_WS_URL / MN_PROOF_SERVER_URL.', + ); + } +}; + +export default async function setup(): Promise<() => Promise> { + if (!useExternalStack()) { + throw new Error( + `The 2.x round-trip suite only runs against a stack you already started. ` + + `Set ${EXTERNAL_STACK_ENV_VAR}=1 (accepted: ${EXTERNAL_STACK_TRUTHY.join(' | ')}) and point it at that stack ` + + 'with MN_INDEXER_URL / MN_INDEXER_WS_URL / MN_NODE_URL / MN_NODE_WS_URL / MN_PROOF_SERVER_URL. ' + + 'This suite never starts or stops a stack.', + ); + } + + // The suite is written for a throwaway devnet, so `undeployed` is the default + // here even though the deploy/verify scripts default to `stagenet`. + const requested = process.env.MN_ENV?.trim() || 'undeployed'; + if (!isV2EnvName(requested)) { + throw new Error(`Invalid MN_ENV "${requested}". The v2 commands support ${V2_ENV_NAMES.join(' | ')}.`); + } + const env: V2EnvName = requested; + if (env !== 'undeployed' && !process.env.MN_SEED?.trim()) { + throw new Error(`MN_SEED is required for MN_ENV=${env}: there is no genesis-funded seed outside a local devnet.`); + } + + const profile = profileFor(env); + console.log( + `[vitest] ${EXTERNAL_STACK_ENV_VAR}: joining the running ${env} stack ` + + `(node=${profile.node} indexer=${profile.indexer} proof=${profile.proofServer})`, + ); + await assertStackReachable(profile); + + process.env[RESOLVED_ENV_VAR] = env; + process.env[RESOLVED_PROFILE_VAR] = JSON.stringify(profile); + + return async () => { + // Nothing was started here, so nothing is torn down: never stop a stack we + // do not own. + console.log('[vitest] external stack: leaving it running'); + }; +} diff --git a/contracts/v2/test/external/shielded-night.external.test.ts b/contracts/v2/test/external/shielded-night.external.test.ts new file mode 100644 index 0000000..de33b25 --- /dev/null +++ b/contracts/v2/test/external/shielded-night.external.test.ts @@ -0,0 +1,406 @@ +import { CompiledContract } from '@midnight-ntwrk/compact-js'; +import { deployContract, findDeployedContract } from '@midnight-ntwrk/midnight-js-contracts'; +import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id'; +import { initializeMidnightProviders, MidnightWalletProvider, syncWallet } from '@midnight-ntwrk/testkit-js'; +import { encodeUserAddress, rawTokenType, unshieldedToken } from '@midnightntwrk/ledger-v9'; +import { sampleSigningKey, signatureVerifyingKey } from '@midnightntwrk/onchain-runtime-v4'; +import { type WalletFacade } from '@midnightntwrk/wallet-sdk'; +import * as Rx from 'rxjs'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { Contract } from '../../managed/contract/index.js'; +import { + assertDeploymentWalletFunded, + assertMaintenanceAuthorityKey, + createWalletLogger, + deploymentWalletSyncTimeoutMs, + GENESIS_MINT_SEED, + MANAGED_DIRECTORY, + privateStateStoreName, + profileFor, + verifyAddress, + walletNetworkIdFor, + type V2EnvName, + type V2Profile, +} from '../../scripts/profile.js'; +import { RESOLVED_ENV_VAR, RESOLVED_PROFILE_VAR } from './global-setup.js'; + +/** 10 sNight at 6 decimals — the same unit the 1.x round-trip suite moves. */ +const N = 10_000_000n; + +/** Hex key of native NIGHT in the wallet's unshielded balance map. */ +const NIGHT_HEX = unshieldedToken().raw; + +/** The compiler 0.34.0 circuit set; `verifyAddress` compares it against the chain. */ +const CIRCUITS = [ + 'convertToShielded', + 'convertToUnshielded', + 'decimals', + 'depositShielded', + 'depositUnshielded', + 'getBalance', + 'name', + 'symbol', + 'tokenColor', + 'withdrawShielded', + 'withdrawUnshielded', +] as const; + +const DEPLOY_ARGS = ['Shielded Night', 'sNight', 6n] as const; + +/** The contract's wrapper domain separator (`pad(32, "shielded-night:wrapper")`). */ +const WRAP_DOMAIN = 'shielded-night:wrapper'; + +type Coin = { nonce: Uint8Array; color: Uint8Array; value: bigint }; +type CoinPublicKey = { bytes: Uint8Array }; +type EitherAddress = { + is_left: boolean; + left: { bytes: Uint8Array }; + right: { bytes: Uint8Array }; +}; + +interface CallOutcome { + readonly public: { readonly txId: string }; + readonly private: { readonly result: TResult }; +} + +/** + * The circuit surface of `managed/contract/index.d.ts`, restated at the SDK + * seam. `deployContract` / `findDeployedContract` are called through the same + * `as never` seam `scripts/deploy.ts` uses, so their nominal Compact types + * cannot pull a second runtime identity into this package; the handle below is + * what the suite actually needs from the result. + */ +interface ShieldedNight { + readonly deployTxData: { readonly public: { readonly contractAddress: string } }; + readonly callTx: { + name(): Promise>; + symbol(): Promise>; + decimals(): Promise>; + tokenColor(): Promise>; + getBalance(secret: Uint8Array): Promise>; + depositUnshielded(secret: Uint8Array, amount: bigint): Promise>; + depositShielded(secret: Uint8Array, coin: Coin): Promise>; + withdrawUnshielded(secret: Uint8Array, amount: bigint, recipient: EitherAddress): Promise>; + withdrawShielded( + secret: Uint8Array, + amount: bigint, + recipient: CoinPublicKey, + nonce: Uint8Array, + ): Promise>; + convertToShielded(amount: bigint, recipient: CoinPublicKey, nonce: Uint8Array): Promise>; + convertToUnshielded(coin: Coin, recipient: EitherAddress): Promise>; + }; +} + +const bytesToHex = (bytes: Uint8Array): string => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + +/** Right-pad a UTF-8 string into a `Bytes` domain separator. */ +const padDomain = (value: string, length = 32): Uint8Array => { + const buffer = new Uint8Array(length); + const encoded = new TextEncoder().encode(value); + if (encoded.length > length) throw new Error(`Domain "${value}" is ${encoded.length} bytes; max ${length}.`); + buffer.set(encoded); + return buffer; +}; + +const randomBytes32 = (): Uint8Array => { + const buffer = new Uint8Array(32); + globalThis.crypto.getRandomValues(buffer); + return buffer; +}; + +/** `Either` with the user (right) branch populated. */ +const rightUserAddress = (bytes: Uint8Array): EitherAddress => ({ + is_left: false, + left: { bytes: new Uint8Array(32) }, + right: { bytes }, +}); + +/** Flatten an error chain (Effect wrappers, causes) into searchable text. */ +const errorText = (error: unknown): string => { + const parts: string[] = []; + let current: unknown = error; + for (let depth = 0; current != null && depth < 10; depth += 1) { + if (current instanceof Error) { + parts.push(current.message, String(current)); + current = current.cause; + } else { + parts.push(typeof current === 'string' ? current : JSON.stringify(current)); + break; + } + } + return parts.join('\n'); +}; + +const WAIT_TIMEOUT_MS = 5 * 60_000; + +const waitForBalance = ( + wallet: WalletFacade, + bucket: 'unshielded' | 'shielded', + tokenHex: string, + predicate: (balance: bigint) => boolean, +): Promise => + Rx.firstValueFrom( + wallet.state().pipe( + Rx.throttleTime(2_000), + Rx.filter((state) => state.isSynced), + Rx.map((state) => state[bucket].balances[tokenHex] ?? 0n), + Rx.filter(predicate), + Rx.timeout({ + each: WAIT_TIMEOUT_MS, + with: () => + Rx.throwError( + () => new Error(`waitForBalance(${bucket}, ${tokenHex}) timed out after ${WAIT_TIMEOUT_MS}ms`), + ), + }), + ), + ); + +const firstSyncedState = (wallet: WalletFacade) => + Rx.firstValueFrom(wallet.state().pipe(Rx.filter((state) => state.isSynced))); + +/** The resolved env/profile the global setup probed, or a clear refusal. */ +const resolvedEnv = (): V2EnvName => { + const value = process.env[RESOLVED_ENV_VAR]; + if (value !== 'stagenet' && value !== 'undeployed') { + throw new Error(`${RESOLVED_ENV_VAR} was not set by the global setup; run this suite through its own config.`); + } + return value; +}; + +const resolvedProfile = (env: V2EnvName): V2Profile => { + const raw = process.env[RESOLVED_PROFILE_VAR]; + return raw ? (JSON.parse(raw) as V2Profile) : profileFor(env); +}; + +/** + * Driver seed. Genesis-1 is the default only on a local devnet — it is that + * stack's funding faucet and is shared with every other facade deployed there, + * so a run that matters passes MN_SEED. + */ +const driverSeed = (env: V2EnvName): string => { + const seed = process.env.MN_SEED?.trim(); + if (seed) { + if (!/^[0-9a-f]+$/i.test(seed) || seed.length % 2 !== 0) { + throw new Error('MN_SEED must be an even-length hexadecimal string.'); + } + return seed; + } + if (env !== 'undeployed') throw new Error(`MN_SEED is required for MN_ENV=${env}.`); + console.warn( + '[external] WARNING: no MN_SEED set; using the shared genesis-1 devnet seed. ' + + 'It funds every other facade on a local stack — pass MN_SEED for anything but a throwaway devnet.', + ); + return GENESIS_MINT_SEED; +}; + +describe('shielded-night 2.x — external stack', () => { + const env = resolvedEnv(); + const profile = resolvedProfile(env); + const environment = { ...profile, walletNetworkId: walletNetworkIdFor(env) }; + + let walletProvider: MidnightWalletProvider; + let providers: ReturnType; + let contract: ShieldedNight; + let address: string; + let wrapperColorHex: string; + /** Undefined when the suite joined an existing deployment via CV_ADDRESS. */ + let deployedMaintenanceKey: ReturnType | undefined; + + beforeAll(async () => { + setNetworkId(profile.networkId); + + // testkit-js logs the seed at info level, so the wallet logger stays silent + // for the same reason scripts/deploy.ts keeps it silent. + walletProvider = await MidnightWalletProvider.build(createWalletLogger(), environment, driverSeed(env)); + + // `withSyncedDeploymentWallet` unrolled across vitest's lifecycle: the same + // start -> bounded sync -> funding assertion, but the wallet has to outlive + // one action here, and the stop moves to afterAll. + await walletProvider.start(false); + const state = await syncWallet(walletProvider.wallet, 2_000, deploymentWalletSyncTimeoutMs()); + assertDeploymentWalletFunded(state); + + providers = initializeMidnightProviders(walletProvider, environment, { + privateStateStoreName: privateStateStoreName(env, '-test'), + zkConfigPath: MANAGED_DIRECTORY, + }); + + const compiled = CompiledContract.make('shielded-night-v2', Contract).pipe( + CompiledContract.withVacantWitnesses, + CompiledContract.withCompiledFileAssets(MANAGED_DIRECTORY), + ); + + const joinAddress = process.env.CV_ADDRESS?.trim(); + if (joinAddress) { + contract = (await findDeployedContract(providers as never, { + compiledContract: compiled, + contractAddress: joinAddress.replace(/^0x/i, '').toLowerCase(), + } as never)) as unknown as ShieldedNight; + console.log(`[external] joined ${env} contract ${contract.deployTxData.public.contractAddress}`); + } else { + // Test-only maintenance key: sampled per run and never written to a + // maintenance-key file, because a throwaway contract needs no custody. + deployedMaintenanceKey = sampleSigningKey('schnorr'); + contract = (await deployContract(providers as never, { + compiledContract: compiled, + args: [...DEPLOY_ARGS], + signingKey: deployedMaintenanceKey, + } as never)) as unknown as ShieldedNight; + console.log(`[external] deployed ${env} contract ${contract.deployTxData.public.contractAddress}`); + } + + address = contract.deployTxData.public.contractAddress.replace(/^0x/i, '').toLowerCase(); + wrapperColorHex = bytesToHex((await contract.callTx.tokenColor()).private.result); + }, 20 * 60_000); + + afterAll(async () => { + const disposable = providers?.publicDataProvider as { dispose?: () => Promise } | undefined; + await disposable?.dispose?.().catch(() => undefined); + await walletProvider?.stop().catch(() => undefined); + }); + + test( + 'serves the 11-circuit set, matching verifier keys, and the release metadata', + async () => { + const verified = await verifyAddress(providers.publicDataProvider, address); + + expect(verified.address).toBe(address); + expect(Object.keys(verified.verifierKeys).sort()).toEqual([...CIRCUITS]); + expect(verified.metadata).toEqual({ name: 'Shielded Night', symbol: 'sNight', decimals: 6 }); + + if (deployedMaintenanceKey) { + // A contract this suite deployed must carry this run's sampled key as + // its sole maintenance authority. + expect(() => + assertMaintenanceAuthorityKey(verified, signatureVerifyingKey(deployedMaintenanceKey!)), + ).not.toThrow(); + } + + expect((await contract.callTx.name()).private.result).toBe('Shielded Night'); + expect((await contract.callTx.symbol()).private.result).toBe('sNight'); + expect((await contract.callTx.decimals()).private.result).toBe(6n); + + // The wrapper colour is `tokenType(pad(32,"shielded-night:wrapper"), self())` + // on chain; the same value has to be derivable off chain from the address. + expect(wrapperColorHex).toHaveLength(64); + expect(wrapperColorHex).toBe(rawTokenType(padDomain(WRAP_DOMAIN), address)); + }, + 10 * 60_000, + ); + + test( + 'two-step round trip: NIGHT -> credit -> sNight -> credit -> NIGHT', + async () => { + const secret = randomBytes32(); + const night0 = (await firstSyncedState(walletProvider.wallet)).unshielded.balances[NIGHT_HEX] ?? 0n; + expect(night0).toBeGreaterThanOrEqual(N); + const wrapped0 = (await firstSyncedState(walletProvider.wallet)).shielded.balances[wrapperColorHex] ?? 0n; + + // 1. Lock native NIGHT, credit hash(secret). + await contract.callTx.depositUnshielded(secret, N); + expect((await contract.callTx.getBalance(secret)).private.result).toBe(N); + expect( + await waitForBalance(walletProvider.wallet, 'unshielded', NIGHT_HEX, (balance) => balance <= night0 - N), + ).toBe(night0 - N); + + // 2. Mint the shielded wrapper against the credit. + const coinPublicKey: CoinPublicKey = { + bytes: (await firstSyncedState(walletProvider.wallet)).shielded.coinPublicKey.data, + }; + const coin = (await contract.callTx.withdrawShielded(secret, N, coinPublicKey, randomBytes32())).private.result; + expect(coin.value).toBe(N); + expect(bytesToHex(coin.color)).toBe(wrapperColorHex); + expect((await contract.callTx.getBalance(secret)).private.result).toBe(0n); + expect( + await waitForBalance( + walletProvider.wallet, + 'shielded', + wrapperColorHex, + (balance) => balance >= wrapped0 + N, + ), + ).toBe(wrapped0 + N); + + // 3. Burn that exact wrapper coin, credit hash(secret) again. + await contract.callTx.depositShielded(secret, coin); + expect((await contract.callTx.getBalance(secret)).private.result).toBe(N); + expect( + await waitForBalance(walletProvider.wallet, 'shielded', wrapperColorHex, (balance) => balance <= wrapped0), + ).toBe(wrapped0); + + // 4. Release the locked NIGHT back to the caller. + const me = rightUserAddress(encodeUserAddress(walletProvider.unshieldedKeystore.getAddress())); + await contract.callTx.withdrawUnshielded(secret, N, me); + expect((await contract.callTx.getBalance(secret)).private.result).toBe(0n); + expect( + await waitForBalance(walletProvider.wallet, 'unshielded', NIGHT_HEX, (balance) => balance >= night0), + ).toBe(night0); + }, + 10 * 60_000, + ); + + test( + 'atomic round trip: convertToShielded then convertToUnshielded', + async () => { + const night0 = (await firstSyncedState(walletProvider.wallet)).unshielded.balances[NIGHT_HEX] ?? 0n; + expect(night0).toBeGreaterThanOrEqual(N); + const wrapped0 = (await firstSyncedState(walletProvider.wallet)).shielded.balances[wrapperColorHex] ?? 0n; + const coinPublicKey: CoinPublicKey = { + bytes: (await firstSyncedState(walletProvider.wallet)).shielded.coinPublicKey.data, + }; + + // One transaction, one approval: lock NIGHT and mint the wrapper. + const coin = (await contract.callTx.convertToShielded(N, coinPublicKey, randomBytes32())).private.result; + expect(coin.value).toBe(N); + expect(bytesToHex(coin.color)).toBe(wrapperColorHex); + expect( + await waitForBalance(walletProvider.wallet, 'unshielded', NIGHT_HEX, (balance) => balance <= night0 - N), + ).toBe(night0 - N); + expect( + await waitForBalance( + walletProvider.wallet, + 'shielded', + wrapperColorHex, + (balance) => balance >= wrapped0 + N, + ), + ).toBe(wrapped0 + N); + + // One transaction back: burn the wrapper and release the NIGHT. + const me = rightUserAddress(encodeUserAddress(walletProvider.unshieldedKeystore.getAddress())); + await contract.callTx.convertToUnshielded(coin, me); + expect( + await waitForBalance(walletProvider.wallet, 'shielded', wrapperColorHex, (balance) => balance <= wrapped0), + ).toBe(wrapped0); + expect( + await waitForBalance(walletProvider.wallet, 'unshielded', NIGHT_HEX, (balance) => balance >= night0), + ).toBe(night0); + }, + 10 * 60_000, + ); + + test( + 'a withdrawal with the wrong secret is refused', + async () => { + const secret = randomBytes32(); + await contract.callTx.depositUnshielded(secret, N); + expect((await contract.callTx.getBalance(secret)).private.result).toBe(N); + + const me = rightUserAddress(encodeUserAddress(walletProvider.unshieldedKeystore.getAddress())); + let failure: unknown; + try { + await contract.callTx.withdrawUnshielded(randomBytes32(), N, me); + } catch (error) { + failure = error; + } + expect(failure, 'expected the wrong-secret withdrawal to fail').toBeDefined(); + expect(errorText(failure)).toContain('no balance for this secret'); + + // The credit is untouched, and the rightful secret still redeems it. + expect((await contract.callTx.getBalance(secret)).private.result).toBe(N); + await contract.callTx.withdrawUnshielded(secret, N, me); + expect((await contract.callTx.getBalance(secret)).private.result).toBe(0n); + }, + 10 * 60_000, + ); +}); diff --git a/contracts/v2/test/profile-env.unit.test.ts b/contracts/v2/test/profile-env.unit.test.ts new file mode 100644 index 0000000..9569972 --- /dev/null +++ b/contracts/v2/test/profile-env.unit.test.ts @@ -0,0 +1,284 @@ +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, test } from 'vitest'; +import { + GENESIS_MINT_SEED, + isV2EnvName, + maintenanceKeyPath, + prepareMaintenanceSigningKey, + privateStateStoreName, + profileFor, + recordPath, + reportConfirmedDeployment, + REPOSITORY_ROOT, + requestedEnv, + stagenet, + undeployed, + UNDEPLOYED_MAINTENANCE_KEY_PATH, + V2_ENV_NAMES, + WALLET_NETWORK_IDS, + walletNetworkIdFor, +} from '../scripts/profile.js'; + +const ADDRESS = 'a'.repeat(64); +const URL_VARS = [ + 'MN_INDEXER_URL', + 'MN_INDEXER_WS_URL', + 'MN_NODE_URL', + 'MN_NODE_WS_URL', + 'MN_PROOF_SERVER_URL', +] as const; +const MANAGED_VARS = ['MN_ENV', 'MN_MAINTENANCE_KEY_FILE', 'DEPLOY_OUT', ...URL_VARS] as const; + +const original = new Map(MANAGED_VARS.map((name) => [name, process.env[name]])); + +afterEach(() => { + for (const [name, value] of original) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } +}); + +const clearUrlOverrides = (): void => { + for (const name of URL_VARS) delete process.env[name]; +}; + +describe('v2 environment selection', () => { + test('MN_ENV defaults to stagenet, accepts undeployed, and names both on anything else', () => { + expect(V2_ENV_NAMES).toEqual(['stagenet', 'undeployed']); + + delete process.env.MN_ENV; + expect(requestedEnv()).toBe('stagenet'); + + process.env.MN_ENV = ' '; + expect(requestedEnv()).toBe('stagenet'); + + process.env.MN_ENV = ' undeployed '; + expect(requestedEnv()).toBe('undeployed'); + + process.env.MN_ENV = 'stagenet'; + expect(requestedEnv()).toBe('stagenet'); + + for (const rejected of ['preview', 'preprod', 'Undeployed', 'ledger9']) { + process.env.MN_ENV = rejected; + expect(() => requestedEnv()).toThrow(`Invalid MN_ENV "${rejected}"`); + expect(() => requestedEnv()).toThrow('stagenet | undeployed'); + } + + expect(isV2EnvName('undeployed')).toBe(true); + expect(isV2EnvName('preview')).toBe(false); + }); + + test('wallet network ids match the SDK constants and the store name carries the env', () => { + // Pinned to NetworkId.NetworkId.StageNet / .Undeployed by the compile-time + // declaration in scripts/deploy.ts; asserted here as values. + expect(WALLET_NETWORK_IDS).toEqual({ stagenet: 'stagenet', undeployed: 'undeployed' }); + expect(walletNetworkIdFor('stagenet')).toBe('stagenet'); + expect(walletNetworkIdFor('undeployed')).toBe('undeployed'); + + expect(privateStateStoreName('stagenet')).toBe('shielded-night-v2-stagenet'); + expect(privateStateStoreName('undeployed')).toBe('shielded-night-v2-undeployed'); + expect(privateStateStoreName('undeployed', '-test')).toBe('shielded-night-v2-undeployed-test'); + }); +}); + +describe('undeployed profile', () => { + test('defaults to the 1.x lane loopback endpoints', () => { + clearUrlOverrides(); + expect(undeployed()).toEqual({ + walletNetworkId: 'undeployed', + networkId: 'undeployed', + indexer: 'http://127.0.0.1:8088/api/v4/graphql', + indexerWS: 'ws://127.0.0.1:8088/api/v4/graphql/ws', + node: 'http://127.0.0.1:9944', + nodeWS: 'ws://127.0.0.1:9944', + proofServer: 'http://127.0.0.1:6300', + faucet: undefined, + }); + expect(GENESIS_MINT_SEED).toBe(`${'0'.repeat(63)}1`); + }); + + test('every endpoint is overridable, per call, and a blank override is ignored', () => { + clearUrlOverrides(); + process.env.MN_INDEXER_URL = 'http://indexer:8088/api/v4/graphql'; + process.env.MN_INDEXER_WS_URL = 'ws://indexer:8088/api/v4/graphql/ws'; + process.env.MN_NODE_URL = 'http://node:9944'; + process.env.MN_NODE_WS_URL = 'ws://node:9944'; + process.env.MN_PROOF_SERVER_URL = 'http://proof-server:6300'; + + expect(undeployed()).toMatchObject({ + indexer: 'http://indexer:8088/api/v4/graphql', + indexerWS: 'ws://indexer:8088/api/v4/graphql/ws', + node: 'http://node:9944', + nodeWS: 'ws://node:9944', + proofServer: 'http://proof-server:6300', + }); + + process.env.MN_NODE_URL = ' '; + expect(undeployed().node).toBe('http://127.0.0.1:9944'); + }); + + test('profileFor picks the profile without disturbing stagenet defaults', () => { + clearUrlOverrides(); + expect(profileFor('undeployed')).toEqual(undeployed()); + expect(profileFor('stagenet')).toEqual(stagenet()); + expect(profileFor('stagenet')).toEqual({ + walletNetworkId: 'stagenet', + networkId: 'stagenet', + indexer: 'https://indexer.stagenet.shielded.tools/api/v4/graphql', + indexerWS: 'wss://indexer.stagenet.shielded.tools/api/v4/graphql/ws', + node: 'https://rpc.stagenet.shielded.tools', + nodeWS: 'wss://rpc.stagenet.shielded.tools', + proofServer: 'http://127.0.0.1:6300', + faucet: undefined, + }); + }); +}); + +describe('maintenance key custody per env', () => { + test('stagenet still demands an explicit absolute durable path', () => { + delete process.env.MN_MAINTENANCE_KEY_FILE; + expect(() => maintenanceKeyPath('stagenet')).toThrow('Set MN_MAINTENANCE_KEY_FILE'); + process.env.MN_MAINTENANCE_KEY_FILE = 'relative-key.json'; + expect(() => maintenanceKeyPath('stagenet')).toThrow('must be an absolute path'); + + // The default env is stagenet, so an unparameterized call is unchanged. + delete process.env.MN_ENV; + delete process.env.MN_MAINTENANCE_KEY_FILE; + expect(() => maintenanceKeyPath()).toThrow('Set MN_MAINTENANCE_KEY_FILE'); + }); + + test('undeployed makes the key file optional and accepts a relative override', () => { + delete process.env.MN_MAINTENANCE_KEY_FILE; + expect(maintenanceKeyPath('undeployed')).toBe(UNDEPLOYED_MAINTENANCE_KEY_PATH); + expect(UNDEPLOYED_MAINTENANCE_KEY_PATH).toBe( + path.join(REPOSITORY_ROOT, '.local', 'private-state', 'v2-undeployed', 'maintenance-key.json'), + ); + + process.env.MN_ENV = 'undeployed'; + expect(maintenanceKeyPath()).toBe(UNDEPLOYED_MAINTENANCE_KEY_PATH); + + process.env.MN_MAINTENANCE_KEY_FILE = 'relative-key.json'; + expect(maintenanceKeyPath('undeployed')).toBe(path.resolve(process.cwd(), 'relative-key.json')); + + const absolute = path.join(tmpdir(), 'explicit-undeployed-key.json'); + process.env.MN_MAINTENANCE_KEY_FILE = absolute; + expect(maintenanceKeyPath('undeployed')).toBe(absolute); + }); + + test('an undeployed key is written 0600 and records network "undeployed"', () => { + const directory = mkdtempSync(path.join(tmpdir(), 'shielded-night-v2-undeployed-key-')); + const destination = path.join(directory, 'nested', 'maintenance-key.json'); + process.env.MN_MAINTENANCE_KEY_FILE = destination; + try { + const created = prepareMaintenanceSigningKey( + { sourceCommit: '1'.repeat(40), artifactSha256: '2'.repeat(64) }, + 'undeployed', + ); + expect(created.created).toBe(true); + expect(created.path).toBe(destination); + expect(statSync(destination).mode & 0o777).toBe(0o600); + + const record = JSON.parse(readFileSync(destination, 'utf8')); + expect(record.network).toBe('undeployed'); + expect(record.recordKind).toBe('shielded-night-maintenance-signing-key'); + + const reused = prepareMaintenanceSigningKey( + { sourceCommit: '3'.repeat(40), artifactSha256: '4'.repeat(64) }, + 'undeployed', + ); + expect(reused.created).toBe(false); + expect(reused.verifyingKey).toEqual(created.verifyingKey); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + test('a key file belonging to the other network is refused in both directions', () => { + const directory = mkdtempSync(path.join(tmpdir(), 'shielded-night-v2-key-network-')); + const destination = path.join(directory, 'maintenance-key.json'); + process.env.MN_MAINTENANCE_KEY_FILE = destination; + try { + prepareMaintenanceSigningKey( + { sourceCommit: '1'.repeat(40), artifactSha256: '2'.repeat(64) }, + 'stagenet', + ); + expect(() => prepareMaintenanceSigningKey( + { sourceCommit: '1'.repeat(40), artifactSha256: '2'.repeat(64) }, + 'undeployed', + )).toThrow('created for network "stagenet" but MN_ENV is "undeployed"'); + + const stagenetRecord = JSON.parse(readFileSync(destination, 'utf8')); + writeFileSync(destination, `${JSON.stringify({ ...stagenetRecord, network: 'undeployed' }, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + expect(() => prepareMaintenanceSigningKey( + { sourceCommit: '1'.repeat(40), artifactSha256: '2'.repeat(64) }, + 'stagenet', + )).toThrow('created for network "undeployed" but MN_ENV is "stagenet"'); + expect(prepareMaintenanceSigningKey( + { sourceCommit: '1'.repeat(40), artifactSha256: '2'.repeat(64) }, + 'undeployed', + ).created).toBe(false); + + writeFileSync(destination, `${JSON.stringify({ ...stagenetRecord, network: 'preview' }, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + expect(() => prepareMaintenanceSigningKey( + { sourceCommit: '1'.repeat(40), artifactSha256: '2'.repeat(64) }, + 'undeployed', + )).toThrow('unsupported schema or network'); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); + +describe('records and printed identity per env', () => { + test('the record file name carries the env, and DEPLOY_OUT still wins', () => { + delete process.env.DEPLOY_OUT; + expect(recordPath(ADDRESS, 'stagenet')).toBe( + path.join(REPOSITORY_ROOT, '.local', 'deployments', `v2-stagenet-${ADDRESS}.json`), + ); + expect(recordPath(ADDRESS, 'undeployed')).toBe( + path.join(REPOSITORY_ROOT, '.local', 'deployments', `v2-undeployed-${ADDRESS}.json`), + ); + + delete process.env.MN_ENV; + expect(recordPath(ADDRESS)).toBe(recordPath(ADDRESS, 'stagenet')); + process.env.MN_ENV = 'undeployed'; + expect(recordPath(ADDRESS)).toBe(recordPath(ADDRESS, 'undeployed')); + + process.env.DEPLOY_OUT = path.join(tmpdir(), 'explicit-record.json'); + expect(recordPath(ADDRESS, 'undeployed')).toBe(process.env.DEPLOY_OUT); + }); + + test('stagenet keeps its exact three lines; undeployed prints UNDEPLOYED_ADDRESS=', () => { + const stagenetMessages: string[] = []; + delete process.env.MN_ENV; + expect(reportConfirmedDeployment( + { contractAddress: ADDRESS, txId: 'synthetic-confirmed-tx' }, + (message) => stagenetMessages.push(message), + )).toBe(ADDRESS); + expect(stagenetMessages).toEqual([ + `[deploy] confirmed stagenet contract ${ADDRESS}`, + '[deploy] confirmed transaction synthetic-confirmed-tx', + `STAGENET_ADDRESS=${ADDRESS}`, + ]); + + const undeployedMessages: string[] = []; + expect(reportConfirmedDeployment( + { contractAddress: ADDRESS, txId: 'synthetic-confirmed-tx' }, + (message) => undeployedMessages.push(message), + 'undeployed', + )).toBe(ADDRESS); + expect(undeployedMessages).toEqual([ + `[deploy] confirmed undeployed contract ${ADDRESS}`, + '[deploy] confirmed transaction synthetic-confirmed-tx', + `UNDEPLOYED_ADDRESS=${ADDRESS}`, + ]); + }); +}); diff --git a/contracts/v2/vitest.config.ts b/contracts/v2/vitest.config.ts new file mode 100644 index 0000000..37219c9 --- /dev/null +++ b/contracts/v2/vitest.config.ts @@ -0,0 +1,19 @@ +import { configDefaults, defineConfig } from 'vitest/config'; + +/** + * The v2 unit tier: no chain, no docker, no network. `test/external/**` is the + * opt-in round-trip suite against a running 2.x stack and is excluded here by + * both the include pattern and the exclude list, so `npm run test:unit` (and + * the repo's `bun run test:v2`, and CI's `unit-v2` job) can never collect it. + * Run that suite with `npm run test:external` / `bun run test:v2:external`. + */ +export default defineConfig({ + test: { + include: ['test/**/*.unit.test.ts'], + exclude: [...configDefaults.exclude, 'test/external/**'], + reporters: [ + 'default', + ...(process.env.GITHUB_ACTIONS === 'true' ? (['github-actions'] as const) : []), + ], + }, +}); diff --git a/contracts/v2/vitest.external.config.ts b/contracts/v2/vitest.external.config.ts new file mode 100644 index 0000000..c0cafa9 --- /dev/null +++ b/contracts/v2/vitest.external.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vitest/config'; + +/** + * The 2.x round-trip suite. Needs a running `undeployed` (or `stagenet`) stack + * and refuses to start without `MN_EXTERNAL_STACK` — see + * `test/external/global-setup.ts`. + * + * Retries default to 0, unlike the 1.x integration config: every case here + * asserts EXACT balances, and a retry of a half-completed round trip would + * assert against balances the first attempt already moved. Set MN_TEST_RETRY + * only when chasing a transport flake. + */ +export default defineConfig({ + test: { + include: ['test/external/**/*.external.test.ts'], + globalSetup: ['./test/external/global-setup.ts'], + testTimeout: 10 * 60_000, + hookTimeout: 20 * 60_000, + retry: Number(process.env.MN_TEST_RETRY ?? 0), + // One chain, one wallet: run files serially in forked workers. + pool: 'forks', + fileParallelism: false, + reporters: [ + 'default', + ...(process.env.GITHUB_ACTIONS === 'true' ? (['github-actions'] as const) : []), + ], + }, +}); diff --git a/frontend/README.md b/frontend/README.md index 919ffe1..3f21099 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -8,12 +8,14 @@ Install the app and both isolated protocol trees from the repository root: ```bash bun install --frozen-lockfile -bun --cwd frontend install --frozen-lockfile +bun --cwd=frontend install --frozen-lockfile npm --prefix frontend/protocols/v1 ci npm --prefix frontend/protocols/v2 ci -bun --cwd frontend run dev +bun --cwd=frontend run dev ``` +Use **bun 1.4 or newer**: the committed `bun.lock` files are lockfile v2, which older bun cannot read (`Unknown lockfile version`), and bun 1.4 requires the `--cwd=` spelling — written with a space it prints its usage text and exits 0 without installing or running anything. CI installs the latest bun. + The development server listens on `http://localhost:5173`. The repository must contain the full v1 artifacts in `src/managed` and v2 artifacts in `contracts/v2/managed`. ## Network configuration @@ -25,23 +27,45 @@ The development server listens on `http://localhost:5173`. The repository must c | `PREVIEW_ADDRESS` | Preview | Midnight 1.x | | `PREPROD_ADDRESS` | Preprod | Midnight 1.x | | `STAGENET_ADDRESS` | Stagenet | Midnight 2.x | -| `UNDEPLOYED_ADDRESS` | Local (development only) | Midnight 1.x | +| `UNDEPLOYED_ADDRESS` | Local (development only) | Midnight 1.x by default, 2.x with `UNDEPLOYED_PROTOCOL` | Preview, Preprod and Stagenet always appear. A missing or malformed address displays an unavailable state and blocks wallet connection and transactions. Local appears in development or when its address is explicitly configured. The wallet supplies its network, indexer and proving capabilities. The app verifies the wallet network throughout the operation and delegates proving, transaction balancing and submission to the wallet. It submits the exact balanced bytes returned by the wallet. -### Runtime address override +### Local protocol selection (`UNDEPLOYED_PROTOCOL`) + +Preview, Preprod and Stagenet are pinned to the ledger generation their chain runs. `undeployed` is not a chain but whichever devnet is on the other end, so its protocol family is a setting: + +| Value | Effect | +| --- | --- | +| unset (default) | `Local (undeployed)`, Midnight 1.x — today's behavior | +| `midnight-1.x` | the same, stated explicitly | +| `midnight-2.x` | `Local (undeployed · 2.x)`, the v2 (ledger-v9) adapter | +| anything else | the page reports the invalid value and blocks connecting; no silent fallback | + +It is read exactly like the contract addresses: baked in at `vite build` from the environment (the `UNDEPLOYED_` prefix in `vite.config.ts` exposes it), and overridable at runtime by `window.SHIELDED_NIGHT.UNDEPLOYED_PROTOCOL` (a non-blank runtime value wins; a blank one falls through to the build-time value). Values are trimmed and case-sensitive. + +```bash +UNDEPLOYED_PROTOCOL=midnight-2.x bun --cwd=frontend run build # build-time, from the repository root +``` + +The page is only one half of the switch: the contract it talks to has to come from the matching lane too, which is `MN_ENV=undeployed bun run deploy:v2` for `midnight-2.x` and `MN_ENV=undeployed bun run scripts/deploy.ts` for the 1.x default. The root [README](../README.md#which-lane-runs-where) has the table and the local 2.x recipe. + +The wallet never announces its own ledger generation, so a mismatch (a Midnight 1.x wallet on a `midnight-2.x` local network, or the reverse) can only fail once the adapter runs; the activity log then names the configured family and what to change. + +### Runtime configuration override `index.html` loads `public/config.js` before the module bundle. A stack may replace its no-op value at container startup: ```js window.SHIELDED_NIGHT = { + UNDEPLOYED_PROTOCOL: "midnight-2.x", UNDEPLOYED_ADDRESS: "0123…", }; ``` -The supported keys are `PREVIEW_ADDRESS`, `PREPROD_ADDRESS`, `STAGENET_ADDRESS` and `UNDEPLOYED_ADDRESS`. A non-blank runtime value wins over its build-time value. Only public addresses are injectable; secrets remain outside the frontend. +The supported keys are `PREVIEW_ADDRESS`, `PREPROD_ADDRESS`, `STAGENET_ADDRESS`, `UNDEPLOYED_ADDRESS` and `UNDEPLOYED_PROTOCOL`. A non-blank runtime value wins over its build-time value; a blank one falls through to the build-time value. Only public addresses and that protocol switch are injectable; secrets remain outside the frontend. ## Protocol isolation @@ -79,9 +103,9 @@ The browser coin store is **not** the set of reversible coins. It records what t ## Validation ```bash -bun --cwd frontend run typecheck -bun --cwd frontend run build -bun run test:unit -- test/unit/frontend-wallet-boundary.unit.test.ts test/unit/runtime-config.unit.test.ts +bun --cwd=frontend run typecheck +bun --cwd=frontend run build +bun run test:unit -- test/unit/frontend-wallet-boundary.unit.test.ts test/unit/networks-protocol.unit.test.ts test/unit/runtime-config.unit.test.ts ``` CI additionally installs both protocol lockfiles on Linux, rebuilds both contract artifact trees with their pinned Compact compilers, checks byte-exact output and runs the Docker integration suite. diff --git a/frontend/public/config.js b/frontend/public/config.js index 94ce74a..d542461 100644 --- a/frontend/public/config.js +++ b/frontend/public/config.js @@ -11,8 +11,19 @@ // // window.SHIELDED_NIGHT = { UNDEPLOYED_ADDRESS: "0123…" }; // -// Per network, an injected address wins over the one baked in at build time -// from frontend/.env; a blank or absent value falls through to the build-time -// value. Keys: PREVIEW_ADDRESS, PREPROD_ADDRESS, STAGENET_ADDRESS, -// UNDEPLOYED_ADDRESS. See src/lib/runtime-config.ts. +// A stack whose local chain is a Midnight 2.x devnet also declares the ledger +// generation the "Local (undeployed)" network runs, so the page loads the v2 +// adapter instead of the default v1 one: +// +// window.SHIELDED_NIGHT = { +// UNDEPLOYED_PROTOCOL: "midnight-2.x", +// UNDEPLOYED_ADDRESS: "0123…", +// }; +// +// Per key, an injected value wins over the one baked in at build time from +// frontend/.env; a blank or absent value falls through to the build-time value. +// Keys: PREVIEW_ADDRESS, PREPROD_ADDRESS, STAGENET_ADDRESS, UNDEPLOYED_ADDRESS +// and UNDEPLOYED_PROTOCOL ("midnight-1.x" — the default — or "midnight-2.x"; +// any other value is reported on the page instead of being guessed at). +// See src/lib/runtime-config.ts. window.SHIELDED_NIGHT = window.SHIELDED_NIGHT || {}; diff --git a/frontend/src/hooks/useShieldedNight.ts b/frontend/src/hooks/useShieldedNight.ts index 7150d6f..5204665 100644 --- a/frontend/src/hooks/useShieldedNight.ts +++ b/frontend/src/hooks/useShieldedNight.ts @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ConnectedAPI, InitialAPI } from '@midnight-ntwrk/dapp-connector-api'; import { connectWallet, findInitialAPIs, isCompatibleApiVersion } from '../lib/connector'; import { contractAddressFor, contractConfigurationError, - NETWORKS, + protocolMismatchHint, + resolveNetwork, type NetworkOption, } from '../lib/networks'; import type { @@ -94,7 +95,10 @@ export function useShieldedNight(): ShieldedNightState { const sessionRef = useRef(); const generation = useRef(0); - const network = NETWORKS.find((item) => item.key === networkKey)!; + // Resolved (not static) so `undeployed` carries the protocol the + // UNDEPLOYED_PROTOCOL setting selects; memoised to keep a stable identity for + // the callbacks that depend on it. + const network = useMemo(() => resolveNetwork(networkKey), [networkKey]); const contractAddress = contractAddressFor(networkKey); const configurationError = contractConfigurationError(networkKey); @@ -137,7 +141,7 @@ export function useShieldedNight(): ShieldedNightState { resetConnection(); setError(undefined); setNetworkKeyState(key); - appendLog(`Selected ${NETWORKS.find((item) => item.key === key)?.label ?? key}; reconnect required.`); + appendLog(`Selected ${resolveNetwork(key).label}; reconnect required.`); }, [appendLog, networkKey, resetConnection]); const connect = useCallback(async (api: InitialAPI) => { @@ -153,6 +157,9 @@ export function useShieldedNight(): ShieldedNightState { resetConnection(); const requestGeneration = generation.current; let created: ProtocolSession | undefined; + // Set once the wallet is accepted and the ledger-generation-specific code + // starts: a failure from here on is the one a protocol mismatch produces. + let adapterReached = false; setConnecting(true); setError(undefined); appendLog(`Connecting ${api.name} to ${network.label} (${network.protocolFamily})…`); @@ -165,6 +172,7 @@ export function useShieldedNight(): ShieldedNightState { throw new Error(`Wallet connected to ${configuration.networkId}; select ${network.networkId} in the wallet.`); } + adapterReached = true; created = network.protocolFamily === 'midnight-1.x' ? await import('../../protocols/v1/src/adapter').then((module) => module.createV1Adapter(apiConnection, network.networkId, contractAddress)) @@ -198,6 +206,10 @@ export function useShieldedNight(): ShieldedNightState { setWalletName(undefined); setError(errMsg(caught)); appendLog(`Connection failed: ${errMsg(caught)}`); + // The wallet never announces its ledger generation, so a 1.x wallet on + // a midnight-2.x local network (or the reverse) can only surface here. + const hint = adapterReached ? protocolMismatchHint(network.key) : undefined; + if (hint) appendLog(hint); } } finally { if (generation.current === requestGeneration) setConnecting(false); diff --git a/frontend/src/lib/networks.ts b/frontend/src/lib/networks.ts index 7e52fd6..7dc066c 100644 --- a/frontend/src/lib/networks.ts +++ b/frontend/src/lib/networks.ts @@ -7,17 +7,34 @@ * (see runtime-config.ts) for deployments that deploy their own contract after * the bundle was built. The wrapper (sNight) token type is always derived from * the address. + * + * The three public networks are pinned to the ledger generation their chain + * runs. `undeployed` is not a chain but whichever devnet is on the other end, + * so its protocol family is a SETTING (`UNDEPLOYED_PROTOCOL`, build-time env or + * runtime config, default `midnight-1.x`) resolved by `resolveNetwork()`. */ /// -import { resolveContractAddress, type ContractAddressVar } from './runtime-config.js'; +import { + DEFAULT_UNDEPLOYED_PROTOCOL, + resolveContractAddress, + resolveUndeployedProtocol, + type ContractAddressVar, + type ProtocolFamilyName, +} from './runtime-config.js'; export interface NetworkOption { key: 'preview' | 'preprod' | 'stagenet' | 'undeployed'; label: string; networkId: string; - protocolFamily: 'midnight-1.x' | 'midnight-2.x'; + protocolFamily: ProtocolFamilyName; } +/** + * The static rows. `undeployed.protocolFamily` here is the BUILD DEFAULT, not + * the resolved one: read a row through `resolveNetwork()` (or + * `configuredNetworks()`) wherever the setting must be honoured. This array is + * never mutated — a resolved row is a fresh object. + */ export const NETWORKS: NetworkOption[] = [ { key: 'preview', label: 'Preview', networkId: 'preview', protocolFamily: 'midnight-1.x' }, { key: 'preprod', label: 'Preprod', networkId: 'preprod', protocolFamily: 'midnight-1.x' }, @@ -25,6 +42,59 @@ export const NETWORKS: NetworkOption[] = [ { key: 'undeployed', label: 'Local (undeployed)', networkId: 'undeployed', protocolFamily: 'midnight-1.x' }, ]; +/** The local row's label per resolved generation (the selector shows which one is live). */ +const UNDEPLOYED_LABEL: Record = { + 'midnight-1.x': 'Local (undeployed)', + 'midnight-2.x': 'Local (undeployed · 2.x)', +}; + +/** + * Build-time protocol switch, baked from the env at `vite build` (envPrefix + * `UNDEPLOYED_`). Read per CALL, like the addresses, so a runtime config that + * lands after module evaluation still wins. + */ +const buildTimeUndeployedProtocol = (): string | undefined => import.meta.env.UNDEPLOYED_PROTOCOL; + +/** + * The `undeployed` row's ledger generation, or the error explaining a rejected + * `UNDEPLOYED_PROTOCOL` value. Only `undeployed` has a switch; the public rows + * always resolve clean. + */ +const undeployedProtocol = () => resolveUndeployedProtocol(buildTimeUndeployedProtocol()); + +/** + * A network row with the settings applied: for `undeployed` that is the + * resolved protocol family and its label, for every other key the static row. + * On an invalid `UNDEPLOYED_PROTOCOL` the row falls back to the default family + * for display only — `contractConfigurationError()` reports the bad value and + * blocks connecting, so no adapter is ever chosen from a guess. + */ +export const resolveNetwork = (key: NetworkOption['key']): NetworkOption => { + const base = NETWORKS.find((network) => network.key === key)!; + if (key !== 'undeployed') return base; + const family = undeployedProtocol().family ?? DEFAULT_UNDEPLOYED_PROTOCOL; + return { ...base, protocolFamily: family, label: UNDEPLOYED_LABEL[family] }; +}; + +/** The `UNDEPLOYED_PROTOCOL` error for this network, if it has one (only `undeployed` can). */ +export const protocolConfigurationError = (key: NetworkOption['key']): string | undefined => + key === 'undeployed' ? undeployedProtocol().error : undefined; + +/** + * A hint for the failure the protocol switch causes: the wallet's ledger + * generation is not observable before connecting, so a 1.x wallet on a + * `midnight-2.x` local network (or the reverse) only fails when the adapter + * runs. The caller logs this next to that failure — it is a message, never a + * gate. + */ +export const protocolMismatchHint = (key: NetworkOption['key']): string | undefined => { + if (key !== 'undeployed') return undefined; + const network = resolveNetwork(key); + return network.protocolFamily === 'midnight-2.x' + ? `${network.label} is configured for midnight-2.x (UNDEPLOYED_PROTOCOL); a Midnight 1.x wallet or a ledger-v8 devnet cannot serve it — connect a 2.x wallet, or drop UNDEPLOYED_PROTOCOL to go back to midnight-1.x.` + : `${network.label} is running midnight-1.x (the default); if this devnet is a Midnight 2.x chain, set UNDEPLOYED_PROTOCOL=midnight-2.x (build env or window.SHIELDED_NIGHT) and reload.`; +}; + /** The env var (and runtime-config key) holding each network's contract address. */ const ADDRESS_VAR: Record = { preview: 'PREVIEW_ADDRESS', @@ -54,9 +124,16 @@ export const contractAddressFor = (key: NetworkOption['key']): string | undefine return address && /^[0-9a-f]{64}$/.test(address) ? address : undefined; }; +/** + * Why the selected network cannot be used, if it cannot. The protocol switch is + * checked FIRST: an unusable `UNDEPLOYED_PROTOCOL` is a configuration error in + * its own right, and reporting a missing address instead would hide it. + */ export const contractConfigurationError = (key: NetworkOption['key']): string | undefined => { + const protocolError = protocolConfigurationError(key); + if (protocolError) return protocolError; const raw = rawContractAddressFor(key); - if (!raw) return `Shielded NIGHT is not deployed on ${NETWORKS.find((item) => item.key === key)?.label ?? key}.`; + if (!raw) return `Shielded NIGHT is not deployed on ${resolveNetwork(key).label}.`; if (!contractAddressFor(key)) return `${ADDRESS_VAR[key]} must be exactly 32 bytes of hexadecimal.`; return undefined; }; @@ -82,7 +159,7 @@ export const explorerContractUrl = (key: NetworkOption['key'], address: string): */ export const configuredNetworks = (): NetworkOption[] => { const publicNetworks = NETWORKS.filter((network) => network.key !== 'undeployed'); - const local = NETWORKS.find((network) => network.key === 'undeployed')!; + const local = resolveNetwork('undeployed'); return contractAddressFor('undeployed') || import.meta.env.DEV ? [...publicNetworks, local] : publicNetworks; diff --git a/frontend/src/lib/runtime-config.ts b/frontend/src/lib/runtime-config.ts index f72769a..b4a3e48 100644 --- a/frontend/src/lib/runtime-config.ts +++ b/frontend/src/lib/runtime-config.ts @@ -21,9 +21,16 @@ * present nothing changes: the build-time values are used exactly as before, so * this is backward compatible for every existing build and deployment. * - * Only contract addresses are injectable. The wallet still supplies the - * indexer / node / proof-server URLs (`getConfiguration()`), so a stack on - * non-default ports needs no URL override lane in the page. + * The same lane carries `UNDEPLOYED_PROTOCOL` — the ledger generation the local + * `undeployed` network runs (`midnight-1.x`, the default, or `midnight-2.x`). + * A stack that brings up a Midnight 2.x devnet writes it next to the address: + * + * window.SHIELDED_NIGHT = { UNDEPLOYED_PROTOCOL: "midnight-2.x", UNDEPLOYED_ADDRESS: "0123…" }; + * + * Contract addresses and that protocol switch are the only injectable settings. + * The wallet still supplies the indexer / node / proof-server URLs + * (`getConfiguration()`), so a stack on non-default ports needs no URL override + * lane in the page. * * GREP MARKER: the literal `SHIELDED_NIGHT` is a property name on `window`, so * it survives minification and appears verbatim in the built bundle. A @@ -42,8 +49,24 @@ export type ContractAddressVar = | 'STAGENET_ADDRESS' | 'UNDEPLOYED_ADDRESS'; +/** + * The protocol switch for the local `undeployed` network (build-time env AND + * runtime config share the name). The public networks are pinned to a ledger + * generation by the chain they are, so only `undeployed` has a switch. + */ +export type ProtocolVar = 'UNDEPLOYED_PROTOCOL'; + +/** The two ledger generations the app carries an adapter for. */ +export type ProtocolFamilyName = 'midnight-1.x' | 'midnight-2.x'; + +/** Accepted `UNDEPLOYED_PROTOCOL` values, in the order the error message lists them. */ +export const PROTOCOL_FAMILIES: readonly ProtocolFamilyName[] = ['midnight-1.x', 'midnight-2.x']; + +/** What `undeployed` is without any setting — today's behaviour, unchanged. */ +export const DEFAULT_UNDEPLOYED_PROTOCOL: ProtocolFamilyName = 'midnight-1.x'; + /** Shape of `window.SHIELDED_NIGHT`. Every key optional: inject only what the deployment knows. */ -export type ShieldedNightRuntimeConfig = Partial>; +export type ShieldedNightRuntimeConfig = Partial>; declare global { interface Window { @@ -81,3 +104,32 @@ export const resolveContractAddress = ( buildTimeValue: string | undefined, host: RuntimeConfigHost | undefined = typeof window === 'undefined' ? undefined : window, ): string | undefined => nonEmpty(runtimeConfig(host)?.[key]) ?? nonEmpty(buildTimeValue); + +const isProtocolFamily = (value: string): value is ProtocolFamilyName => + (PROTOCOL_FAMILIES as readonly string[]).includes(value); + +/** A resolved protocol family, or the message explaining why the value was rejected. */ +export type UndeployedProtocolResolution = + | { readonly family: ProtocolFamilyName; readonly error?: undefined } + | { readonly family?: undefined; readonly error: string }; + +/** + * Ledger generation for the local `undeployed` network: the runtime-injected + * `UNDEPLOYED_PROTOCOL` if present and non-blank, else the build-time one, else + * `midnight-1.x` (what `undeployed` has always been). Values are trimmed and + * case-sensitive; anything else is an ERROR naming the variable and both + * accepted values — never a silent fallback, because a wrong guess here loads + * the wrong ledger adapter against a real chain. + */ +export const resolveUndeployedProtocol = ( + buildTimeValue: string | undefined, + host: RuntimeConfigHost | undefined = typeof window === 'undefined' ? undefined : window, +): UndeployedProtocolResolution => { + const value = nonEmpty(runtimeConfig(host)?.UNDEPLOYED_PROTOCOL) ?? nonEmpty(buildTimeValue); + if (value === undefined) return { family: DEFAULT_UNDEPLOYED_PROTOCOL }; + if (isProtocolFamily(value)) return { family: value }; + const shown = value.length > 40 ? `${value.slice(0, 40)}…` : value; + return { + error: `UNDEPLOYED_PROTOCOL must be ${PROTOCOL_FAMILIES.map((f) => `"${f}"`).join(' or ')}; got "${shown}".`, + }; +}; diff --git a/package.json b/package.json index f65aedd..ccc258d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "compact:v2:fast": "compact compile +0.34.0 --skip-zk contracts/v2/shielded-night.compact contracts/v2/managed/", "typecheck:v2": "npm --prefix contracts/v2 run typecheck", "test:v2": "npm --prefix contracts/v2 run test:unit", + "test:v2:external": "npm --prefix contracts/v2 run test:external", "deploy:v2": "npm --prefix contracts/v2 run deploy", "verify:deployment:v2": "npm --prefix contracts/v2 run verify:deployment", "typecheck": "tsc -p tsconfig.json --noEmit", diff --git a/test/unit/networks-protocol.unit.test.ts b/test/unit/networks-protocol.unit.test.ts new file mode 100644 index 0000000..bc44d07 --- /dev/null +++ b/test/unit/networks-protocol.unit.test.ts @@ -0,0 +1,185 @@ +/** + * The `undeployed` row's protocol family (frontend/src/lib/networks.ts). + * + * Preview, Preprod and Stagenet are pinned to the ledger generation their chain + * runs; `undeployed` is whichever devnet is on the other end, so it is selected + * by `UNDEPLOYED_PROTOCOL` — build-time env, overridable by the runtime config + * a stack injects. The claims pinned here are the ones a packaged deployment + * depends on: the default is unchanged (1.x), the runtime value wins, an + * invalid value is reported through the same `configurationError` that disables + * Connect, and the exported `NETWORKS` const the other consumers import is + * never mutated. + * + * Lives in the ROOT unit tier: `import.meta.env` is supplied by vitest under + * node, and `vi.stubEnv` writes to it, which is exactly the build-time lane. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + configuredNetworks, + contractConfigurationError, + NETWORKS, + protocolConfigurationError, + protocolMismatchHint, + resolveNetwork, +} from '../../frontend/src/lib/networks.js'; + +const ADDRESS = 'a'.repeat(64); + +const globalScope = globalThis as { window?: unknown }; + +/** + * Inject a runtime config the way `/config.js` does. Addresses go through this + * lane in these tests rather than through `vi.stubEnv`, because networks.ts + * reads the build-time addresses ONCE at module load while the protocol switch + * is read per call — `vi.stubEnv` therefore only reaches the latter. + */ +const inject = (config: Record) => { + globalScope.window = { SHIELDED_NIGHT: config }; +}; + +afterEach(() => { + delete globalScope.window; + vi.unstubAllEnvs(); +}); + +describe('undeployed protocol family', () => { + it('is midnight-1.x with no setting — today’s behaviour', () => { + const local = resolveNetwork('undeployed'); + expect(local.protocolFamily).toBe('midnight-1.x'); + expect(local.label).toBe('Local (undeployed)'); + expect(local.networkId).toBe('undeployed'); + expect(protocolConfigurationError('undeployed')).toBeUndefined(); + }); + + it('follows the build-time UNDEPLOYED_PROTOCOL and says so in the label', () => { + vi.stubEnv('UNDEPLOYED_PROTOCOL', 'midnight-2.x'); + expect(resolveNetwork('undeployed')).toEqual({ + key: 'undeployed', + label: 'Local (undeployed · 2.x)', + networkId: 'undeployed', + protocolFamily: 'midnight-2.x', + }); + expect(protocolConfigurationError('undeployed')).toBeUndefined(); + }); + + it('lets the injected runtime value win over the build-time one (both directions)', () => { + vi.stubEnv('UNDEPLOYED_PROTOCOL', 'midnight-1.x'); + inject({ UNDEPLOYED_PROTOCOL: 'midnight-2.x' }); + expect(resolveNetwork('undeployed').protocolFamily).toBe('midnight-2.x'); + + vi.stubEnv('UNDEPLOYED_PROTOCOL', 'midnight-2.x'); + inject({ UNDEPLOYED_PROTOCOL: 'midnight-1.x' }); + expect(resolveNetwork('undeployed').protocolFamily).toBe('midnight-1.x'); + }); + + it('falls through to the build-time value when the injected one is blank', () => { + vi.stubEnv('UNDEPLOYED_PROTOCOL', 'midnight-2.x'); + inject({ UNDEPLOYED_PROTOCOL: ' ' }); + expect(resolveNetwork('undeployed').protocolFamily).toBe('midnight-2.x'); + }); + + it('resolves per call, so a config injected after module load is honoured', () => { + expect(resolveNetwork('undeployed').protocolFamily).toBe('midnight-1.x'); + inject({ UNDEPLOYED_PROTOCOL: 'midnight-2.x' }); + expect(resolveNetwork('undeployed').protocolFamily).toBe('midnight-2.x'); + }); + + it('never mutates the exported NETWORKS const (other consumers import it)', () => { + vi.stubEnv('UNDEPLOYED_PROTOCOL', 'midnight-2.x'); + inject({ UNDEPLOYED_PROTOCOL: 'midnight-2.x' }); + resolveNetwork('undeployed'); + configuredNetworks(); + const local = NETWORKS.find((network) => network.key === 'undeployed')!; + expect(local.protocolFamily).toBe('midnight-1.x'); + expect(local.label).toBe('Local (undeployed)'); + }); + + it('leaves the three public rows exactly as declared', () => { + vi.stubEnv('UNDEPLOYED_PROTOCOL', 'midnight-2.x'); + for (const key of ['preview', 'preprod', 'stagenet'] as const) { + expect(resolveNetwork(key)).toBe(NETWORKS.find((network) => network.key === key)); + expect(protocolConfigurationError(key)).toBeUndefined(); + } + expect(resolveNetwork('preview').protocolFamily).toBe('midnight-1.x'); + expect(resolveNetwork('stagenet').protocolFamily).toBe('midnight-2.x'); + }); + + it('shows the resolved label in the selector', () => { + vi.stubEnv('UNDEPLOYED_PROTOCOL', 'midnight-2.x'); + inject({ UNDEPLOYED_ADDRESS: ADDRESS }); + const labels = configuredNetworks().map((network) => network.label); + expect(labels).toContain('Local (undeployed · 2.x)'); + expect(labels).not.toContain('Local (undeployed)'); + }); +}); + +describe('an invalid UNDEPLOYED_PROTOCOL', () => { + it('is reported, naming the variable and both accepted values', () => { + vi.stubEnv('UNDEPLOYED_PROTOCOL', 'ledger9'); + const error = protocolConfigurationError('undeployed'); + expect(error).toContain('UNDEPLOYED_PROTOCOL'); + expect(error).toContain('midnight-1.x'); + expect(error).toContain('midnight-2.x'); + expect(error).toContain('ledger9'); + }); + + it('reaches the page through configurationError, ahead of the address error', () => { + vi.stubEnv('UNDEPLOYED_PROTOCOL', 'ledger9'); + // No address either: the protocol error is the one that must be shown. + expect(contractConfigurationError('undeployed')).toBe(protocolConfigurationError('undeployed')); + inject({ UNDEPLOYED_ADDRESS: ADDRESS }); + expect(contractConfigurationError('undeployed')).toBe(protocolConfigurationError('undeployed')); + }); + + it('is not silently substituted by a family (the row falls back for display only)', () => { + vi.stubEnv('UNDEPLOYED_PROTOCOL', 'ledger9'); + expect(resolveNetwork('undeployed').protocolFamily).toBe('midnight-1.x'); + expect(resolveNetwork('undeployed').label).toBe('Local (undeployed)'); + expect(contractConfigurationError('undeployed')).toBeDefined(); + }); + + it('does not affect the public networks', () => { + vi.stubEnv('UNDEPLOYED_PROTOCOL', 'ledger9'); + inject({ STAGENET_ADDRESS: ADDRESS }); + expect(contractConfigurationError('stagenet')).toBeUndefined(); + }); +}); + +describe('address errors', () => { + it('still report the missing deployment, with the resolved label', () => { + expect(contractConfigurationError('undeployed')).toBe( + 'Shielded NIGHT is not deployed on Local (undeployed).', + ); + vi.stubEnv('UNDEPLOYED_PROTOCOL', 'midnight-2.x'); + expect(contractConfigurationError('undeployed')).toBe( + 'Shielded NIGHT is not deployed on Local (undeployed · 2.x).', + ); + }); + + it('still reject a malformed address', () => { + inject({ UNDEPLOYED_ADDRESS: 'not-hex' }); + expect(contractConfigurationError('undeployed')).toBe( + 'UNDEPLOYED_ADDRESS must be exactly 32 bytes of hexadecimal.', + ); + }); +}); + +describe('protocol mismatch hint', () => { + it('points at the wallet generation when the local network is 2.x', () => { + vi.stubEnv('UNDEPLOYED_PROTOCOL', 'midnight-2.x'); + const hint = protocolMismatchHint('undeployed') ?? ''; + expect(hint).toContain('midnight-2.x'); + expect(hint).toContain('UNDEPLOYED_PROTOCOL'); + }); + + it('points at the switch when the local network is the 1.x default', () => { + const hint = protocolMismatchHint('undeployed') ?? ''; + expect(hint).toContain('midnight-1.x'); + expect(hint).toContain('UNDEPLOYED_PROTOCOL=midnight-2.x'); + }); + + it('has nothing to say about the pinned public networks', () => { + expect(protocolMismatchHint('preview')).toBeUndefined(); + expect(protocolMismatchHint('stagenet')).toBeUndefined(); + }); +}); diff --git a/test/unit/runtime-config.unit.test.ts b/test/unit/runtime-config.unit.test.ts index 708c238..2726385 100644 --- a/test/unit/runtime-config.unit.test.ts +++ b/test/unit/runtime-config.unit.test.ts @@ -6,7 +6,9 @@ * bundle, and that value must win over the address baked in at build time — * while a build with no such global keeps behaving exactly as before. These * are the two claims the packaging of this dApp into a compose stack depends - * on, so they are pinned here rather than left to the browser. + * on, so they are pinned here rather than left to the browser. The same lane + * carries `UNDEPLOYED_PROTOCOL` (which ledger generation the local network + * runs), pinned in the second block below. * * Lives in the ROOT unit tier (not the frontend package) because * runtime-config.ts is deliberately dependency-free and free of @@ -14,8 +16,11 @@ */ import { afterEach, describe, expect, it } from 'vitest'; import { + DEFAULT_UNDEPLOYED_PROTOCOL, + PROTOCOL_FAMILIES, RUNTIME_CONFIG_GLOBAL, resolveContractAddress, + resolveUndeployedProtocol, runtimeConfig, type RuntimeConfigHost, } from '../../frontend/src/lib/runtime-config.js'; @@ -89,3 +94,94 @@ describe('runtime contract-address override', () => { expect(resolveContractAddress('UNDEPLOYED_ADDRESS', BUILD_TIME)).toBe(BUILD_TIME); }); }); + +/** + * The same injection lane carries the ledger generation the local `undeployed` + * network runs. It decides which adapter (v1/ledger-v8 or v2/ledger-v9) is + * loaded against a real chain, so a value that is not one of the two accepted + * names must be reported rather than guessed at. + */ +describe('undeployed protocol switch', () => { + it('defaults to midnight-1.x — what `undeployed` has always been', () => { + expect(DEFAULT_UNDEPLOYED_PROTOCOL).toBe('midnight-1.x'); + expect(resolveUndeployedProtocol(undefined, undefined)).toEqual({ family: 'midnight-1.x' }); + expect(resolveUndeployedProtocol(undefined, host({}))).toEqual({ family: 'midnight-1.x' }); + }); + + it('accepts exactly the two protocol families', () => { + expect([...PROTOCOL_FAMILIES]).toEqual(['midnight-1.x', 'midnight-2.x']); + }); + + it('uses the build-time value when nothing is injected', () => { + expect(resolveUndeployedProtocol('midnight-2.x', undefined)).toEqual({ family: 'midnight-2.x' }); + expect(resolveUndeployedProtocol('midnight-1.x', host({}))).toEqual({ family: 'midnight-1.x' }); + }); + + it('prefers the injected value over the build-time one (both directions)', () => { + expect( + resolveUndeployedProtocol('midnight-1.x', host({ UNDEPLOYED_PROTOCOL: 'midnight-2.x' })), + ).toEqual({ family: 'midnight-2.x' }); + expect( + resolveUndeployedProtocol('midnight-2.x', host({ UNDEPLOYED_PROTOCOL: 'midnight-1.x' })), + ).toEqual({ family: 'midnight-1.x' }); + }); + + it('treats a blank injected value as absent and falls through to the build-time one', () => { + expect(resolveUndeployedProtocol('midnight-2.x', host({ UNDEPLOYED_PROTOCOL: '' }))).toEqual({ + family: 'midnight-2.x', + }); + expect(resolveUndeployedProtocol('midnight-2.x', host({ UNDEPLOYED_PROTOCOL: ' ' }))).toEqual({ + family: 'midnight-2.x', + }); + expect(resolveUndeployedProtocol('', host({ UNDEPLOYED_PROTOCOL: '' }))).toEqual({ + family: 'midnight-1.x', + }); + }); + + it('trims both sources (a config file written with a trailing newline still works)', () => { + expect(resolveUndeployedProtocol(' midnight-2.x\n', undefined)).toEqual({ family: 'midnight-2.x' }); + expect( + resolveUndeployedProtocol(undefined, host({ UNDEPLOYED_PROTOCOL: ' midnight-2.x ' })), + ).toEqual({ family: 'midnight-2.x' }); + }); + + it('rejects an unknown value with a message naming the variable and both accepted values', () => { + const { family, error } = resolveUndeployedProtocol('ledger9', undefined); + expect(family).toBeUndefined(); + expect(error).toContain('UNDEPLOYED_PROTOCOL'); + expect(error).toContain('midnight-1.x'); + expect(error).toContain('midnight-2.x'); + expect(error).toContain('ledger9'); + }); + + it('is case-sensitive, and rejects a near-miss instead of silently defaulting', () => { + for (const value of ['Midnight-2.x', 'MIDNIGHT-1.X', 'midnight-2', '2.x', 'ledger8']) { + const resolved = resolveUndeployedProtocol(value, undefined); + expect(resolved.family, value).toBeUndefined(); + expect(resolved.error, value).toContain('UNDEPLOYED_PROTOCOL'); + } + }); + + it('rejects an injected bad value even when the build-time value is valid', () => { + expect( + resolveUndeployedProtocol('midnight-2.x', host({ UNDEPLOYED_PROTOCOL: 'nonsense' })).error, + ).toContain('UNDEPLOYED_PROTOCOL'); + }); + + it('keeps the reported value short (an injected blob does not become the page message)', () => { + const error = resolveUndeployedProtocol('x'.repeat(500), undefined).error ?? ''; + expect(error.length).toBeLessThan(140); + expect(error).toContain('…'); + }); + + it('reads the global off `window` when no host is passed (the browser lane)', () => { + expect(resolveUndeployedProtocol(undefined)).toEqual({ family: 'midnight-1.x' }); + globalScope.window = { SHIELDED_NIGHT: { UNDEPLOYED_PROTOCOL: 'midnight-2.x' } }; + expect(resolveUndeployedProtocol(undefined)).toEqual({ family: 'midnight-2.x' }); + }); + + it('ignores a non-object global instead of throwing', () => { + globalScope.window = { SHIELDED_NIGHT: 'nonsense' }; + expect(resolveUndeployedProtocol('midnight-2.x')).toEqual({ family: 'midnight-2.x' }); + }); +});