diff --git a/README.md b/README.md index 08c7581..69b536d 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Locked NIGHT backs the wrapper 1:1 across both models - the invariant `locked NI │ ├── deploy.ts # deploy from src/managed (mnemonic or seed) │ ├── deploy-and-lock.ts # deploy, then lock (one-way, non-upgradeable) │ ├── lock.ts # lock an already-deployed contract (has DRY_RUN) +│ ├── deploy-record.ts # optional DEPLOY_OUT= JSON record of a deploy │ └── verify-deployment.ts # read-only: on-chain keys == this repo, lock status ├── envs/docker-compose-dynamic.yml # local node + indexer + proof server ├── frontend/ # Vite + React dApp @@ -52,7 +53,7 @@ Locked NIGHT backs the wrapper 1:1 across both models - the invariant `locked NI │ ├── App.tsx │ ├── components/ # WalletBar, SwapCard, BalancePanel, PendingSwaps, ActivityLog │ ├── hooks/useShieldedNight.ts # connect, providers, balances, state -│ └── lib/ # connector, providers, walletAdapter, contract, swap, tokens, networks +│ └── lib/ # connector, providers, walletAdapter, contract, swap, tokens, networks, runtime-config ├── .github/workflows/ │ ├── ci.yml # unit, frontend, byte-exact rebuild, integration │ └── deploy.yml # manual-only frontend deploy to Cloudflare Pages @@ -90,6 +91,37 @@ 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). +### Deploying into a stack you already have + +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 +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)) | + +```bash +# deploy into a compose stack, from a container on its network +MN_ENV=undeployed MN_SEED= \ + MN_INDEXER_URL=http://indexer:8088/api/v4/graphql \ + MN_INDEXER_WS_URL=ws://indexer:8088/api/v4/graphql/ws \ + MN_NODE_URL=http://node:9944 \ + MN_PROOF_SERVER_URL=http://proof-server:6300 \ + DEPLOY_OUT=/srv/shielded-night/contract.json \ + bun run scripts/deploy.ts +``` + +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. + ## Locking the 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. diff --git a/TESTING.md b/TESTING.md index 3510f0e..2485efc 100644 --- a/TESTING.md +++ b/TESTING.md @@ -57,8 +57,58 @@ bun run smoke | Var | Default | Meaning | | --- | --- | --- | | `MN_ENV` | `undeployed` | `undeployed` boots the local stack; `preprod`/`preview`/`qanet` run against hosted networks (requires `MN_SEED`, boots only a local proof server) | -| `MN_SEED` | genesis seed on `undeployed` | wallet seed for hosted envs | +| `MN_SEED` | genesis seed on `undeployed` | wallet seed for hosted envs; stays optional on `undeployed`, including in external-stack mode | | `MN_TEST_RETRY` | `2` | vitest retry count | +| `MN_EXTERNAL_STACK` | unset | `1` = run against an already-running stack instead of booting one (see below) | +| `MN_INDEXER_URL` | `http://127.0.0.1:8088/api/v4/graphql` | indexer endpoint (`undeployed` only) | +| `MN_INDEXER_WS_URL` | `ws://127.0.0.1:8088/api/v4/graphql/ws` | indexer subscription endpoint (`undeployed` only) | +| `MN_NODE_URL` | `http://127.0.0.1:9944` | node RPC endpoint (`undeployed` only) | +| `MN_PROOF_SERVER_URL` | `http://127.0.0.1:6300` | proof server endpoint — the one override that also applies to the hosted envs, whose proof server is your own | + +The four URL vars are resolved by `networkFor()` in +[test/support/network.ts](test/support/network.ts), so they steer the deploy / +lock / verify scripts too: + +```bash +MN_ENV=undeployed MN_NODE_URL=http://127.0.0.1:31944 \ + MN_INDEXER_URL=http://127.0.0.1:31088/api/v4/graphql \ + MN_INDEXER_WS_URL=ws://127.0.0.1:31088/api/v4/graphql/ws \ + MN_PROOF_SERVER_URL=http://127.0.0.1:31300 \ + bun run scripts/deploy.ts +``` + +On the hosted envs only `MN_PROOF_SERVER_URL` is honoured: the indexer and node +URLs identify the network itself, and silently repointing `preview` at a local +indexer because a variable was left exported would be an expensive, invisible +bug. + +### Running against a stack you already have (`MN_EXTERNAL_STACK=1`) + +The default is unchanged and is what CI runs: the suite owns its stack, so a +green run proves the contract against a known-clean devnet. External mode is for +the other direction — running the SAME suite against a stack somebody else +brought up (a compose deployment of this dApp, a devnet on non-default ports, a +container with no docker socket of its own). testcontainers is skipped, the URLs +above are used as-is, and **the stack is never torn down** (we do not stop what +we did not start): + +```bash +MN_EXTERNAL_STACK=1 MN_ENV=undeployed \ + MN_INDEXER_URL=http://indexer:8088/api/v4/graphql \ + MN_INDEXER_WS_URL=ws://indexer:8088/api/v4/graphql/ws \ + MN_NODE_URL=http://node:9944 \ + MN_PROOF_SERVER_URL=http://proof-server:6300 \ + bun run test:integration # or: bun run smoke +``` + +Global setup preflights the three HTTP endpoints and fails immediately, naming +the URL that is wrong, rather than letting a misconfiguration surface ten +minutes later as a wallet-sync timeout. `MN_SEED` stays optional on +`undeployed` (the genesis seed is the default) — set it when that seed belongs +to another facade on the target stack. + +The suite deploys contracts and spends from the genesis-funded seeds, so point +it only at a throwaway devnet. ### Provider wiring note diff --git a/bun.lock b/bun.lock index adf3040..db83fad 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,6 @@ }, }, "overrides": { - "//": "Force a single ledger-v8 copy tree-wide: two copies give two LedgerParameters class identities and break `instanceof` checks during proving.", "@midnight-ntwrk/ledger-v8": "8.1.0", }, "packages": { diff --git a/frontend/README.md b/frontend/README.md index eb31331..fb3bef7 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -35,6 +35,53 @@ Secrets never go in it - deploy scripts read `MN_MNEMONIC` / `MN_SEED` from the shell environment. For personal overrides use `.env.local` (gitignored; Vite loads it over `.env`). +### Runtime address override (`window.SHIELDED_NIGHT`) + +`.env` bakes the addresses in at BUILD time, which is right for the hosted +networks and wrong for a deployment that brings up its own chain: an image built +once and run against many throwaway local devnets only learns the contract +address when the container starts. So `index.html` loads `/config.js` as a +classic script — it therefore runs BEFORE the deferred module bundle — and +[public/config.js](public/config.js) ships a **no-op placeholder**, so every +deployment serves a real file (never a 404, never an HTML fallback the browser +refuses to execute): + +```js +// public/config.js → dist/config.js, as built +window.SHIELDED_NIGHT = window.SHIELDED_NIGHT || {}; +``` + +A stack-hosted deployment overwrites that one file at container start, and needs +to touch nothing else in the build: + +```js +// dist/config.js, written from the deploy record before nginx starts +window.SHIELDED_NIGHT = { UNDEPLOYED_ADDRESS: "0123…" }; +``` + +Per network, the injected value wins over the build-time one; a blank or absent +value falls through to `.env`, so **a build with no global behaves exactly as +before**. The keys are the same names as the env vars: `PREVIEW_ADDRESS`, +`PREPROD_ADDRESS`, `MAINNET_ADDRESS`, `UNDEPLOYED_ADDRESS`. The dropdown follows +suit — inject `UNDEPLOYED_ADDRESS` and "Local (undeployed)" appears in a bundle +built without one. + +Only ADDRESSES are injectable. The wallet still supplies the indexer / node / +proof-server URLs (`getConfiguration()`), so a stack on non-default ports needs +no URL lane in the page — one reason there is nothing else to get wrong. + +Packaging note: the literal `SHIELDED_NIGHT` is a property name on `window`, so +it survives minification and appears verbatim in the built bundle. An image that +injects `/config.js` can prove the lane is present in the build it ships instead +of trusting it: + +```bash +grep -q SHIELDED_NIGHT dist/assets/*.js # fail the build if the override is gone +``` + +See [src/lib/runtime-config.ts](src/lib/runtime-config.ts); the behaviour is +pinned by `test/unit/runtime-config.unit.test.ts` in the repo root's unit tier. + ## How it works Each conversion is **two transactions** with a pool credit keyed by a diff --git a/frontend/index.html b/frontend/index.html index a20137a..f0b2543 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,6 +7,11 @@
+ + diff --git a/frontend/public/config.js b/frontend/public/config.js new file mode 100644 index 0000000..eb9da41 --- /dev/null +++ b/frontend/public/config.js @@ -0,0 +1,18 @@ +// Runtime configuration, loaded by index.html BEFORE the module bundle. +// +// This copy is a no-op placeholder: it only guarantees the file exists, so +// every deployment (dev server, Cloudflare Pages, any static host) serves a +// real script instead of a 404 or an HTML SPA fallback the browser refuses to +// execute. +// +// A deployment that deploys its OWN contract — a compose stack whose image was +// built long before it knew the address — REPLACES this file at container start +// with the address it just deployed: +// +// 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, MAINNET_ADDRESS, +// UNDEPLOYED_ADDRESS. See src/lib/runtime-config.ts. +window.SHIELDED_NIGHT = window.SHIELDED_NIGHT || {}; diff --git a/frontend/src/lib/networks.ts b/frontend/src/lib/networks.ts index b5fc769..62ad4e7 100644 --- a/frontend/src/lib/networks.ts +++ b/frontend/src/lib/networks.ts @@ -2,9 +2,14 @@ * Supported networks. `networkId` is the string hinted to the wallet's * `connect(networkId)` and also fed to midnight-js `setNetworkId`. The contract * address is read from one env var per network (`_ADDRESS`, exposed - * via vite.config's `envPrefix`), so the same build works across networks. - * The wrapper (sNight) token type is always derived from the address. + * via vite.config's `envPrefix`), so the same build works across networks, and + * may be overridden at RUNTIME by `window.SHIELDED_NIGHT._ADDRESS` + * (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. */ +import { resolveContractAddress, type ContractAddressVar } from './runtime-config'; + export interface NetworkOption { key: 'preview' | 'preprod' | 'mainnet' | 'undeployed'; label: string; @@ -18,17 +23,29 @@ export const NETWORKS: NetworkOption[] = [ { key: 'undeployed', label: 'Local (undeployed)', networkId: 'undeployed' }, ]; -const CONTRACT_ADDRESSES: Record = { +/** The env var (and runtime-config key) holding each network's contract address. */ +const ADDRESS_VAR: Record = { + preview: 'PREVIEW_ADDRESS', + preprod: 'PREPROD_ADDRESS', + mainnet: 'MAINNET_ADDRESS', + undeployed: 'UNDEPLOYED_ADDRESS', +}; + +/** Build-time values, baked from frontend/.env at `vite build` (envPrefix). */ +const BUILD_TIME_ADDRESSES: Record = { preview: import.meta.env.PREVIEW_ADDRESS, preprod: import.meta.env.PREPROD_ADDRESS, mainnet: import.meta.env.MAINNET_ADDRESS, undeployed: import.meta.env.UNDEPLOYED_ADDRESS, }; -export const contractAddressFor = (key: NetworkOption['key']): string | undefined => { - const v = CONTRACT_ADDRESSES[key]; - return v && v.trim().length > 0 ? v.trim() : undefined; -}; +/** + * Contract address for a network: `window.SHIELDED_NIGHT._ADDRESS` if a + * deployment injected one, else the build-time env var. Resolved per CALL (not + * once at module load) so an injected config is picked up whenever it lands. + */ +export const contractAddressFor = (key: NetworkOption['key']): string | undefined => + resolveContractAddress(ADDRESS_VAR[key], BUILD_TIME_ADDRESSES[key]); /** Midnight explorer base per network (only where known; undeployed has none). */ const EXPLORER_BASE: Record = { @@ -48,6 +65,9 @@ export const explorerContractUrl = (key: NetworkOption['key'], address: string): * Networks that actually have a deployed contract configured. The dropdown * shows only these, so unconfigured networks (e.g. preprod, mainnet) appear * the moment their _ADDRESS env var is set - no code change needed. + * The same holds for a runtime-injected address: a stack that deploys its own + * contract and injects `window.SHIELDED_NIGHT.UNDEPLOYED_ADDRESS` makes "Local + * (undeployed)" appear in a bundle built with an empty UNDEPLOYED_ADDRESS. */ export const configuredNetworks = (): NetworkOption[] => { const live = NETWORKS.filter((n) => contractAddressFor(n.key) !== undefined); diff --git a/frontend/src/lib/runtime-config.ts b/frontend/src/lib/runtime-config.ts new file mode 100644 index 0000000..986f27c --- /dev/null +++ b/frontend/src/lib/runtime-config.ts @@ -0,0 +1,83 @@ +/** + * Runtime (post-build) configuration for the SPA. + * + * Contract addresses are normally BAKED IN at build time, one env var per + * network (`_ADDRESS`, exposed through vite.config's `envPrefix` — see + * networks.ts). That is right for the hosted deployments: their addresses are + * known when the bundle is built and live in `frontend/.env` in git history. + * + * It is not enough for a deployment that brings up its OWN chain — a docker + * image built once and run against many throwaway local devnets only learns the + * contract address when the container starts. Such a deployment writes a tiny + * script served BEFORE the module bundle: + * + * + * + * + * // /config.js, written at container start + * window.SHIELDED_NIGHT = { UNDEPLOYED_ADDRESS: "0123…" }; + * + * and that value wins over the build-time one for that network. With no global + * 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. + * + * GREP MARKER: the literal `SHIELDED_NIGHT` is a property name on `window`, so + * it survives minification and appears verbatim in the built bundle. A + * packaging step that injects `/config.js` can therefore `grep -q + * SHIELDED_NIGHT dist/assets/*.js` to prove the override lane is still present + * in the build it is about to ship, instead of trusting it. + */ + +/** The `window` property the runtime config is read from. */ +export const RUNTIME_CONFIG_GLOBAL = 'SHIELDED_NIGHT'; + +/** The per-network contract-address variable names (build-time env AND runtime config share them). */ +export type ContractAddressVar = + | 'PREVIEW_ADDRESS' + | 'PREPROD_ADDRESS' + | 'MAINNET_ADDRESS' + | 'UNDEPLOYED_ADDRESS'; + +/** Shape of `window.SHIELDED_NIGHT`. Every key optional: inject only what the deployment knows. */ +export type ShieldedNightRuntimeConfig = Partial>; + +declare global { + interface Window { + /** Injected before the module bundle (see the module docstring); absent in a plain build. */ + SHIELDED_NIGHT?: ShieldedNightRuntimeConfig; + } +} + +/** Anything carrying the global — `window` in the browser, a stub in tests. */ +export interface RuntimeConfigHost { + SHIELDED_NIGHT?: ShieldedNightRuntimeConfig; +} + +/** Trim and treat blank as absent, so an injected `""` falls through to the build-time value. */ +const nonEmpty = (v: unknown): string | undefined => { + const s = typeof v === 'string' ? v.trim() : ''; + return s.length > 0 ? s : undefined; +}; + +/** The injected config, or undefined when there is no browser global (SSR, tests, plain build). */ +export const runtimeConfig = ( + host: RuntimeConfigHost | undefined = typeof window === 'undefined' ? undefined : window, +): ShieldedNightRuntimeConfig | undefined => { + const cfg = host?.SHIELDED_NIGHT; + return cfg != null && typeof cfg === 'object' ? cfg : undefined; +}; + +/** + * Contract address for one network: the runtime-injected value if present and + * non-blank, else the build-time one. `host` exists for tests; production code + * passes nothing and reads `window`. + */ +export const resolveContractAddress = ( + key: ContractAddressVar, + buildTimeValue: string | undefined, + host: RuntimeConfigHost | undefined = typeof window === 'undefined' ? undefined : window, +): string | undefined => nonEmpty(runtimeConfig(host)?.[key]) ?? nonEmpty(buildTimeValue); diff --git a/package.json b/package.json index 3ffd583..75bd71d 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,8 @@ "typescript": "^5.9.3", "vitest": "^4.1.0" }, + "//overrides": "Force a single ledger-v8 copy tree-wide: two copies give two LedgerParameters class identities and break `instanceof` checks during proving. The note lives OUT here because bun >= 1.4.0 no longer counts a `//` key inside `overrides` as an override, so a lockfile saved with one is rejected by `--frozen-lockfile`.", "overrides": { - "//": "Force a single ledger-v8 copy tree-wide: two copies give two LedgerParameters class identities and break `instanceof` checks during proving.", "@midnight-ntwrk/ledger-v8": "8.1.0" } } diff --git a/scripts/deploy-and-lock.ts b/scripts/deploy-and-lock.ts index 1ba404c..6ac572d 100644 --- a/scripts/deploy-and-lock.ts +++ b/scripts/deploy-and-lock.ts @@ -15,6 +15,10 @@ * MN_ENV preview | preprod | undeployed | qanet (default: preview) * MN_MNEMONIC BIP-39 phrase; derived to a seed exactly as Lace does * MN_SEED raw hex seed (alternative to MN_MNEMONIC) + * MN_INDEXER_URL / MN_INDEXER_WS_URL / MN_NODE_URL / MN_PROOF_SERVER_URL + * endpoint overrides (see test/support/network.ts) + * DEPLOY_OUT path to write the deploy record to, atomically, with + * "locked": true (see scripts/deploy-record.ts) * * MN_MNEMONIC / MN_SEED can live in the repo-root .env (gitignored; see * .env.example) instead of the shell - the shell still takes precedence. @@ -27,6 +31,7 @@ import { awaitWalletReady, buildWallet, DEFAULT_RESTORED_SYNC_TIMEOUT_MS } from import { setupContract } from '../test/support/setup-contract.js'; import { DEPLOY_ARGS, factory } from '../test/support/shielded-night.js'; import { lockContract, readAuthority } from '../test/support/governance.js'; +import { writeDeployRecord } from './deploy-record.js'; /** * Resolve the wallet seed from MN_MNEMONIC (BIP-39, derived as Lace does) or a @@ -95,6 +100,16 @@ async function main() { console.log(` authority: committee=${after.committeeSize} threshold=${after.threshold} counter=${after.counter}`); console.log(`\nPaste into frontend/.env:`); console.log(` ${env.toUpperCase()}_ADDRESS=${address}`); + + const recordPath = writeDeployRecord({ + address, + networkId: network.networkId, + name, + symbol, + decimals, + locked: true, + }); + if (recordPath) console.log(`\n[deploy+lock] deploy record written: ${recordPath}`); } finally { await walletCtx.wallet.stop().catch(() => undefined); } diff --git a/scripts/deploy-record.ts b/scripts/deploy-record.ts new file mode 100644 index 0000000..a67cda8 --- /dev/null +++ b/scripts/deploy-record.ts @@ -0,0 +1,111 @@ +/** + * Optional machine-readable deploy record: `DEPLOY_OUT=`. + * + * The deploy scripts print the new contract address for a human to paste into + * `frontend/.env`. An automated deployment (a compose one-shot that deploys the + * contract once per stack and hands the address to the web container) needs it + * as DATA, and scraping stdout is fragile — the address line moves, the wallet + * SDK logs to stdout, a retry duplicates it. With `DEPLOY_OUT` set, the same + * run also writes: + * + * { + * "address": "0123…", // hex contract address + * "networkId": "undeployed", + * "name": "Shielded Night", + * "symbol": "sNight", + * "decimals": 6, + * "deployedAt": "2026-09-02T12:34:56.789Z", + * "commit": "1502200…" | null, // source revision that produced it + * "locked": false // true only from deploy-and-lock.ts + * } + * + * Unset (the default), nothing is written and the scripts behave exactly as + * before. + * + * The file is published ATOMICALLY (write a sibling temp file, then rename), so + * a reader polling for it never sees a half-written record. + * + * `commit` comes from `SHIELDED_NIGHT_COMMIT` when set — an image built from a + * pinned SHA knows its revision but usually ships no `.git` — else from `git + * rev-parse HEAD`, else null. It is provenance, never trusted as an identity: + * verify the deployment with `scripts/verify-deployment.ts`. + */ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +const REPO_ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..'); + +export interface DeployRecord { + readonly address: string; + readonly networkId: string; + readonly name: string; + readonly symbol: string; + readonly decimals: number; + readonly deployedAt: string; + readonly commit: string | null; + readonly locked: boolean; +} + +/** The source revision this deploy came from: env first (images have no .git), then git, else null. */ +export const resolveCommit = (): string | null => { + const fromEnv = process.env.SHIELDED_NIGHT_COMMIT?.trim(); + if (fromEnv) return fromEnv; + try { + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: REPO_ROOT, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return sha.length > 0 ? sha : null; + } catch { + return null; + } +}; + +export interface DeployRecordInput { + readonly address: string; + readonly networkId: string; + readonly name: string; + readonly symbol: string; + readonly decimals: bigint | number; + readonly locked?: boolean; +} + +/** + * Write the deploy record if `DEPLOY_OUT` is set; return the path written, or + * undefined when the knob is unset. Never throws away the deploy: a write + * failure is reported by throwing AFTER the contract exists on chain, so the + * caller's address line is already on stdout. + */ +export const writeDeployRecord = (input: DeployRecordInput): string | undefined => { + const target = process.env.DEPLOY_OUT?.trim(); + if (!target || target.length === 0) return undefined; + + const record: DeployRecord = { + address: input.address, + networkId: input.networkId, + name: input.name, + symbol: input.symbol, + decimals: Number(input.decimals), + deployedAt: new Date().toISOString(), + commit: resolveCommit(), + locked: input.locked ?? false, + }; + + const outPath = path.resolve(target); + mkdirSync(path.dirname(outPath), { recursive: true }); + const tmpPath = `${outPath}.tmp.${process.pid}`; + try { + writeFileSync(tmpPath, `${JSON.stringify(record, null, 2)}\n`, 'utf-8'); + renameSync(tmpPath, outPath); + } catch (e) { + try { + unlinkSync(tmpPath); + } catch { + // best effort: the temp file may never have been created + } + throw e; + } + return outPath; +}; diff --git a/scripts/deploy.ts b/scripts/deploy.ts index 023caa0..57b6339 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -13,10 +13,19 @@ * # optional metadata overrides (default: "Shielded Night" / "sNight" / 6) * CV_NAME="Shielded Night" CV_SYMBOL=sNight CV_DECIMALS=6 MN_ENV=preview MN_MNEMONIC="…" bun run scripts/deploy.ts * + * # optional: also write the deploy record as JSON, for an automated deployment + * DEPLOY_OUT=/srv/shielded-night/contract.json MN_ENV=undeployed bun run scripts/deploy.ts + * * Env: * MN_ENV preview | preprod | undeployed | qanet (default: preview) * MN_MNEMONIC BIP-39 phrase; derived to a seed exactly as Lace does * MN_SEED raw hex seed (alternative to MN_MNEMONIC) + * MN_INDEXER_URL / MN_INDEXER_WS_URL / MN_NODE_URL / MN_PROOF_SERVER_URL + * endpoint overrides (see test/support/network.ts) — required + * when deploying from INSIDE a docker network, where the + * `undeployed` 127.0.0.1 defaults are unreachable + * DEPLOY_OUT path to write the deploy record to, atomically (see + * scripts/deploy-record.ts); unset = nothing is written * * MN_MNEMONIC / MN_SEED can live in the repo-root .env (gitignored; see * .env.example) instead of the shell - the shell still takes precedence. @@ -28,6 +37,7 @@ import { isEnvName, networkFor, type EnvName, GENESIS_MINT_SEED } from '../test/ import { awaitWalletReady, buildWallet, DEFAULT_RESTORED_SYNC_TIMEOUT_MS } from '../test/support/wallet-builder.js'; import { setupContract } from '../test/support/setup-contract.js'; import { DEPLOY_ARGS, factory } from '../test/support/shielded-night.js'; +import { writeDeployRecord } from './deploy-record.js'; /** * Resolve the wallet seed from MN_MNEMONIC (BIP-39, derived as Lace does) or a @@ -83,6 +93,17 @@ async function main() { console.log(` address: ${address}`); console.log(`\nPaste into frontend/.env:`); console.log(` ${env.toUpperCase()}_ADDRESS=${address}`); + + // The contract exists on chain from here on: the address above is the + // record of record even if writing the JSON below fails. + const recordPath = writeDeployRecord({ + address, + networkId: network.networkId, + name, + symbol, + decimals, + }); + if (recordPath) console.log(`\n[deploy] deploy record written: ${recordPath}`); } finally { await walletCtx.wallet.stop().catch(() => undefined); } diff --git a/test/integration/global-setup.ts b/test/integration/global-setup.ts index ab44931..5fe5066 100644 --- a/test/integration/global-setup.ts +++ b/test/integration/global-setup.ts @@ -1,7 +1,79 @@ import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { startProofServerOnly, startUndeployedStack } from '../support/local-stack.js'; -import { ENV_NAMES, isEnvName, networkFor, type EnvName, type NetworkConfig } from '../support/network.js'; +import { + ENV_NAMES, + isEnvName, + NETWORK_URL_ENV_VARS, + networkFor, + type EnvName, + type NetworkConfig, +} from '../support/network.js'; + +/** + * `MN_EXTERNAL_STACK=1` — run against an ALREADY-RUNNING stack instead of + * booting one with testcontainers. + * + * The default (unset) behaviour is unchanged and stays what CI runs: the suite + * owns its stack, so a green run proves the contract against a known-clean + * devnet. External mode exists for the other direction — proving the SAME suite + * against a stack somebody else brought up (a compose deployment of this dApp, + * a devnet on non-default ports, a stack this process cannot start because it + * has no docker socket). Point it at that stack with the endpoint overrides: + * + * MN_EXTERNAL_STACK=1 MN_ENV=undeployed \ + * MN_INDEXER_URL=http://indexer:8088/api/v4/graphql \ + * MN_INDEXER_WS_URL=ws://indexer:8088/api/v4/graphql/ws \ + * MN_NODE_URL=http://node:9944 \ + * MN_PROOF_SERVER_URL=http://proof-server:6300 \ + * bun run test:integration + * + * `MN_SEED` stays optional on `undeployed` (the genesis seed is the default), so + * a stack whose genesis funds the usual seeds needs nothing else; set `MN_SEED` + * when that seed belongs to another facade on the target stack. + * + * NOTE the suite is written for a devnet it may treat as its own: it deploys + * contracts and spends from the genesis-funded seeds. Point it only at a + * throwaway stack. + */ +const EXTERNAL_STACK_TRUTHY = ['1', 'true', 'yes', 'on']; +const useExternalStack = (): boolean => + EXTERNAL_STACK_TRUTHY.includes((process.env.MN_EXTERNAL_STACK ?? '').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 (e) { + return `${url} (${e instanceof Error ? e.message : String(e)})`; + } +}; + +/** Preflight the external stack's endpoints; the WS URL is covered by its HTTP sibling. */ +const assertExternalStackReachable = async (network: NetworkConfig): Promise => { + const probes: ReadonlyArray = [ + ['indexer', network.indexer], + ['node', network.node], + ['proof server', network.proofServer], + ]; + const misses = ( + await Promise.all(probes.map(async ([label, url]) => ({ label, miss: await unreachable(url) }))) + ).filter((r) => r.miss !== undefined); + if (misses.length > 0) { + throw new Error( + `MN_EXTERNAL_STACK=1 but ${misses.length} endpoint(s) are unreachable:\n` + + misses.map((m) => ` - ${m.label}: ${m.miss}`).join('\n') + + `\nStart the stack, or point the suite at it with ` + + `${Object.values(NETWORK_URL_ENV_VARS).join(' / ')}.`, + ); + } +}; export default async function setup(): Promise<() => Promise> { const raw = process.env.MN_ENV ?? 'undeployed'; @@ -18,11 +90,21 @@ export default async function setup(): Promise<() => Promise> { throw new Error(`MN_SEED is required for MN_ENV=${env}.`); } + // Endpoint overrides (MN_INDEXER_URL etc.) are applied here, so the external + // stack's URLs — and a hosted run's own proof server — come from one place. const base = networkFor(env); + const external = useExternalStack(); let stop: () => Promise; let network: NetworkConfig; - if (env === 'undeployed') { + if (external) { + console.log(`[vitest] MN_EXTERNAL_STACK=1: using the running ${env} stack (no testcontainers)`); + network = base; + await assertExternalStackReachable(network); + // Nothing was started here, so nothing is torn down: never stop a stack we + // do not own. + stop = async () => undefined; + } else if (env === 'undeployed') { console.log('[vitest] starting undeployed stack (proof + indexer + node)…'); const stack = await startUndeployedStack(); network = { @@ -46,6 +128,10 @@ export default async function setup(): Promise<() => Promise> { process.env.__MN_CFG__ = JSON.stringify(network); return async () => { + if (external) { + console.log('[vitest] external stack: leaving it running'); + return; + } console.log('[vitest] tearing down stack…'); await stop().catch((e) => console.warn('[vitest] stop failed:', e)); }; diff --git a/test/support/network.ts b/test/support/network.ts index 8883ee9..0121890 100644 --- a/test/support/network.ts +++ b/test/support/network.ts @@ -6,6 +6,41 @@ export interface NetworkConfig { readonly networkId: string; } +/** + * Environment variables that override the endpoint URLs, applied by {@link networkFor}. + * + * The `undeployed` defaults below assume the devnet is reachable on this host's + * loopback (what `bun run test:integration` gets from testcontainers, and what a + * hand-started `envs/docker-compose-dynamic.yml` gives on the default ports). + * That is wrong for two real cases: a stack on non-default ports, and a caller + * running INSIDE the same docker network, which must dial service hostnames + * (`http://indexer:8088/api/v4/graphql`) and cannot reach 127.0.0.1 at all. + * Setting these makes `deploy.ts` / `deploy-and-lock.ts` / `lock.ts` / + * `verify-deployment.ts` — and the integration suite in external-stack mode + * (`MN_EXTERNAL_STACK=1`, see test/integration/global-setup.ts) — talk to that + * stack instead. Unset means "use the default", so nothing changes for existing + * callers. + * + * On the HOSTED envs only `MN_PROOF_SERVER_URL` applies: the indexer/node URLs + * identify the network itself, and silently repointing `preview` at some other + * indexer because a local-stack variable was left exported would be a footgun. + * The proof server is the operator's own (the hosted configs hard-coded a local + * one), so it is the one endpoint worth overriding there. + */ +export const NETWORK_URL_ENV_VARS = { + indexer: 'MN_INDEXER_URL', + indexerWS: 'MN_INDEXER_WS_URL', + node: 'MN_NODE_URL', + proofServer: 'MN_PROOF_SERVER_URL', +} as const; + +/** Read an override; blank/whitespace counts as unset so `MN_NODE_URL=` in an .env file is harmless. */ +const fromEnv = (name: string, fallback: string): string => { + const value = process.env[name]?.trim(); + return value !== undefined && value.length > 0 ? value : fallback; +}; + +/** Local devnet defaults (loopback, default ports) — overridable, see {@link NETWORK_URL_ENV_VARS}. */ export const UndeployedNetwork: NetworkConfig = { indexer: 'http://127.0.0.1:8088/api/v4/graphql', indexerWS: 'ws://127.0.0.1:8088/api/v4/graphql/ws', @@ -14,6 +49,7 @@ export const UndeployedNetwork: NetworkConfig = { networkId: 'undeployed', }; +/** Hosted preprod defaults; only `proofServer` is overridable (see {@link NETWORK_URL_ENV_VARS}). */ export const PreprodNetwork: NetworkConfig = { indexer: 'https://indexer.preprod.midnight.network/api/v4/graphql', indexerWS: 'wss://indexer.preprod.midnight.network/api/v4/graphql/ws', @@ -22,6 +58,7 @@ export const PreprodNetwork: NetworkConfig = { networkId: 'preprod', }; +/** Hosted preview defaults; only `proofServer` is overridable (see {@link NETWORK_URL_ENV_VARS}). */ export const PreviewNetwork: NetworkConfig = { indexer: 'https://indexer.preview.midnight.network/api/v4/graphql', indexerWS: 'wss://indexer.preview.midnight.network/api/v4/graphql/ws', @@ -30,6 +67,7 @@ export const PreviewNetwork: NetworkConfig = { networkId: 'preview', }; +/** Hosted qanet defaults; only `proofServer` is overridable (see {@link NETWORK_URL_ENV_VARS}). */ export const QanetNetwork: NetworkConfig = { indexer: 'https://indexer.qanet.midnight.network/api/v4/graphql', indexerWS: 'wss://indexer.qanet.midnight.network/api/v4/graphql/ws', @@ -49,7 +87,8 @@ export type EnvName = (typeof ENV_NAMES)[number]; /** Type guard for {@link EnvName}. */ export const isEnvName = (s: string): s is EnvName => (ENV_NAMES as readonly string[]).includes(s); -export const networkFor = (env: EnvName): NetworkConfig => { +/** The unmodified defaults for an env, before {@link NETWORK_URL_ENV_VARS} are applied. */ +const defaultsFor = (env: EnvName): NetworkConfig => { switch (env) { case 'undeployed': return UndeployedNetwork; @@ -62,6 +101,25 @@ export const networkFor = (env: EnvName): NetworkConfig => { } }; +/** + * The network config for an env, with {@link NETWORK_URL_ENV_VARS} applied: + * all four endpoints on `undeployed`, the proof server only on hosted envs. + * Resolved per CALL, so a caller that loads a `.env.` file before calling + * (global-setup does) still gets its overrides. + */ +export const networkFor = (env: EnvName): NetworkConfig => { + const base = defaultsFor(env); + const proofServer = fromEnv(NETWORK_URL_ENV_VARS.proofServer, base.proofServer); + if (env !== 'undeployed') return { ...base, proofServer }; + return { + ...base, + proofServer, + indexer: fromEnv(NETWORK_URL_ENV_VARS.indexer, base.indexer), + indexerWS: fromEnv(NETWORK_URL_ENV_VARS.indexerWS, base.indexerWS), + node: fromEnv(NETWORK_URL_ENV_VARS.node, base.node), + }; +}; + // Genesis-block-funded seed; only valid on undeployed (dev) networks. export const GENESIS_MINT_SEED = '0000000000000000000000000000000000000000000000000000000000000001'; diff --git a/test/unit/deploy-record.unit.test.ts b/test/unit/deploy-record.unit.test.ts new file mode 100644 index 0000000..e67e4fc --- /dev/null +++ b/test/unit/deploy-record.unit.test.ts @@ -0,0 +1,95 @@ +/** + * The optional deploy record (`DEPLOY_OUT`, scripts/deploy-record.ts). + * + * An automated deployment reads the contract address out of this file instead + * of scraping stdout, and may poll for it while the deploy runs — so the two + * things pinned here are that the knob is OFF by default (nothing written, no + * behaviour change for existing callers) and that the file appears atomically + * with the fields the reader expects. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { writeDeployRecord } from '../../scripts/deploy-record.js'; + +const ADDRESS = '80b89b9a4213c61da84f54b2ea02e2809f9c4dedbdafacd04b38d4667bee1396'; +const INPUT = { + address: ADDRESS, + networkId: 'undeployed', + name: 'Shielded Night', + symbol: 'sNight', + decimals: 6n, +} as const; + +let dir: string; +const savedOut = process.env.DEPLOY_OUT; +const savedCommit = process.env.SHIELDED_NIGHT_COMMIT; + +beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), 'sn-deploy-record-')); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + if (savedOut === undefined) delete process.env.DEPLOY_OUT; + else process.env.DEPLOY_OUT = savedOut; + if (savedCommit === undefined) delete process.env.SHIELDED_NIGHT_COMMIT; + else process.env.SHIELDED_NIGHT_COMMIT = savedCommit; +}); + +describe('writeDeployRecord', () => { + it('writes nothing when DEPLOY_OUT is unset', () => { + delete process.env.DEPLOY_OUT; + expect(writeDeployRecord(INPUT)).toBeUndefined(); + expect(readdirSync(dir)).toEqual([]); + }); + + it('writes nothing when DEPLOY_OUT is blank', () => { + process.env.DEPLOY_OUT = ' '; + expect(writeDeployRecord(INPUT)).toBeUndefined(); + expect(readdirSync(dir)).toEqual([]); + }); + + it('writes the record, creating missing parent directories', () => { + const target = path.join(dir, 'srv', 'shielded-night', 'contract.json'); + process.env.DEPLOY_OUT = target; + process.env.SHIELDED_NIGHT_COMMIT = '1502200513'.padEnd(40, '0'); + + expect(writeDeployRecord(INPUT)).toBe(target); + + const record = JSON.parse(readFileSync(target, 'utf-8')) as Record; + expect(record.address).toBe(ADDRESS); + expect(record.networkId).toBe('undeployed'); + expect(record.name).toBe('Shielded Night'); + expect(record.symbol).toBe('sNight'); + expect(record.decimals).toBe(6); // bigint in, JSON number out + expect(record.commit).toBe(process.env.SHIELDED_NIGHT_COMMIT); + expect(record.locked).toBe(false); + expect(typeof record.deployedAt).toBe('string'); + expect(new Date(record.deployedAt as string).toISOString()).toBe(record.deployedAt); + }); + + it('records locked:true when the caller locked the contract', () => { + const target = path.join(dir, 'contract.json'); + process.env.DEPLOY_OUT = target; + writeDeployRecord({ ...INPUT, locked: true }); + expect(JSON.parse(readFileSync(target, 'utf-8')).locked).toBe(true); + }); + + it('leaves no temp file behind (a reader must never see a partial record)', () => { + const target = path.join(dir, 'contract.json'); + process.env.DEPLOY_OUT = target; + writeDeployRecord(INPUT); + expect(readdirSync(dir)).toEqual(['contract.json']); + }); + + it('overwrites a previous record in place', () => { + const target = path.join(dir, 'contract.json'); + process.env.DEPLOY_OUT = target; + writeDeployRecord(INPUT); + writeDeployRecord({ ...INPUT, address: 'f'.repeat(64) }); + expect(JSON.parse(readFileSync(target, 'utf-8')).address).toBe('f'.repeat(64)); + expect(readdirSync(dir)).toEqual(['contract.json']); + }); +}); diff --git a/test/unit/network-env.unit.test.ts b/test/unit/network-env.unit.test.ts new file mode 100644 index 0000000..ff8cfac --- /dev/null +++ b/test/unit/network-env.unit.test.ts @@ -0,0 +1,88 @@ +/** + * Endpoint overrides for the network configs (test/support/network.ts). + * + * `networkFor()` is what every script and the integration global-setup resolve + * their URLs through, so these assertions pin the contract a compose + * deployment depends on: on `undeployed` all four endpoints are overridable + * (nothing can reach 127.0.0.1 from inside another container), on the hosted + * envs only the proof server is (repointing `preview` at a stray local indexer + * because a variable was left exported would be a silent, expensive bug). + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { + NETWORK_URL_ENV_VARS, + networkFor, + PreviewNetwork, + UndeployedNetwork, +} from '../support/network.js'; + +const VARS = Object.values(NETWORK_URL_ENV_VARS); + +afterEach(() => { + for (const v of VARS) delete process.env[v]; +}); + +describe('networkFor endpoint overrides', () => { + it('defaults to the loopback devnet on undeployed', () => { + expect(networkFor('undeployed')).toEqual(UndeployedNetwork); + }); + + it('defaults to the hosted endpoints on preview', () => { + expect(networkFor('preview')).toEqual(PreviewNetwork); + }); + + it('overrides all four endpoints on undeployed (compose service hostnames)', () => { + 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_PROOF_SERVER_URL = 'http://proof-server:6300'; + + expect(networkFor('undeployed')).toEqual({ + indexer: 'http://indexer:8088/api/v4/graphql', + indexerWS: 'ws://indexer:8088/api/v4/graphql/ws', + node: 'http://node:9944', + proofServer: 'http://proof-server:6300', + networkId: 'undeployed', + }); + }); + + it('overrides one endpoint at a time, leaving the rest at their defaults', () => { + process.env.MN_NODE_URL = 'http://127.0.0.1:31944'; + expect(networkFor('undeployed')).toEqual({ ...UndeployedNetwork, node: 'http://127.0.0.1:31944' }); + }); + + it('treats a blank override as unset', () => { + process.env.MN_INDEXER_URL = ' '; + process.env.MN_NODE_URL = ''; + expect(networkFor('undeployed')).toEqual(UndeployedNetwork); + }); + + it('trims an override (a value read from a file with a trailing newline)', () => { + process.env.MN_NODE_URL = ' http://node:9944\n'; + expect(networkFor('undeployed').node).toBe('http://node:9944'); + }); + + it('honours only MN_PROOF_SERVER_URL on the hosted envs', () => { + 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_PROOF_SERVER_URL = 'http://proof-server:6300'; + + for (const env of ['preview', 'preprod', 'qanet'] as const) { + const cfg = networkFor(env); + expect(cfg.proofServer).toBe('http://proof-server:6300'); + expect(cfg.indexer).toBe(networkFor(env).indexer); + expect(cfg.indexer).toContain(`indexer.${env}.midnight.network`); + expect(cfg.node).toContain(`rpc.${env}.midnight.network`); + expect(cfg.indexerWS).toContain(`indexer.${env}.midnight.network`); + } + }); + + it('does not mutate the exported defaults', () => { + process.env.MN_NODE_URL = 'http://node:9944'; + networkFor('undeployed'); + networkFor('preview'); + expect(UndeployedNetwork.node).toBe('http://127.0.0.1:9944'); + expect(PreviewNetwork.proofServer).toBe('http://127.0.0.1:6300'); + }); +}); diff --git a/test/unit/runtime-config.unit.test.ts b/test/unit/runtime-config.unit.test.ts new file mode 100644 index 0000000..7ec9b34 --- /dev/null +++ b/test/unit/runtime-config.unit.test.ts @@ -0,0 +1,91 @@ +/** + * The SPA's runtime contract-address override (frontend/src/lib/runtime-config.ts). + * + * A deployment that brings up its own chain injects + * `window.SHIELDED_NIGHT = { UNDEPLOYED_ADDRESS: "…" }` before the module + * 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. + * + * Lives in the ROOT unit tier (not the frontend package) because + * runtime-config.ts is deliberately dependency-free and free of + * `import.meta.env`, so it runs under the existing `bun run test:unit`. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { + RUNTIME_CONFIG_GLOBAL, + resolveContractAddress, + runtimeConfig, + type RuntimeConfigHost, +} from '../../frontend/src/lib/runtime-config.js'; + +const BUILD_TIME = 'b'.repeat(64); +const INJECTED = 'a'.repeat(64); + +/** Stand-in for `window` (these tests run under the node environment). */ +const host = (cfg: RuntimeConfigHost['SHIELDED_NIGHT']): RuntimeConfigHost => ({ SHIELDED_NIGHT: cfg }); + +const globalScope = globalThis as { window?: unknown }; + +afterEach(() => { + delete globalScope.window; +}); + +describe('runtime contract-address override', () => { + it('names the injected global SHIELDED_NIGHT (the marker downstream images grep for)', () => { + expect(RUNTIME_CONFIG_GLOBAL).toBe('SHIELDED_NIGHT'); + }); + + it('falls back to the build-time address when nothing is injected', () => { + expect(resolveContractAddress('UNDEPLOYED_ADDRESS', BUILD_TIME, undefined)).toBe(BUILD_TIME); + expect(resolveContractAddress('UNDEPLOYED_ADDRESS', BUILD_TIME, {})).toBe(BUILD_TIME); + expect(resolveContractAddress('PREVIEW_ADDRESS', BUILD_TIME, host({}))).toBe(BUILD_TIME); + }); + + it('prefers the injected address over the build-time one', () => { + expect( + resolveContractAddress('UNDEPLOYED_ADDRESS', BUILD_TIME, host({ UNDEPLOYED_ADDRESS: INJECTED })), + ).toBe(INJECTED); + }); + + it('injects per network — other networks keep their build-time address', () => { + const h = host({ UNDEPLOYED_ADDRESS: INJECTED }); + expect(resolveContractAddress('PREVIEW_ADDRESS', BUILD_TIME, h)).toBe(BUILD_TIME); + expect(resolveContractAddress('PREVIEW_ADDRESS', undefined, h)).toBeUndefined(); + }); + + it('treats a blank or whitespace-only injected value as absent', () => { + expect(resolveContractAddress('UNDEPLOYED_ADDRESS', BUILD_TIME, host({ UNDEPLOYED_ADDRESS: '' }))).toBe( + BUILD_TIME, + ); + expect(resolveContractAddress('UNDEPLOYED_ADDRESS', BUILD_TIME, host({ UNDEPLOYED_ADDRESS: ' ' }))).toBe( + BUILD_TIME, + ); + }); + + it('trims both sources (a config file written with a trailing newline still works)', () => { + expect(resolveContractAddress('UNDEPLOYED_ADDRESS', ` ${BUILD_TIME}\n`, host({}))).toBe(BUILD_TIME); + expect( + resolveContractAddress('UNDEPLOYED_ADDRESS', BUILD_TIME, host({ UNDEPLOYED_ADDRESS: `${INJECTED}\n` })), + ).toBe(INJECTED); + }); + + it('returns undefined when neither source has a value (network stays out of the dropdown)', () => { + expect(resolveContractAddress('MAINNET_ADDRESS', undefined, undefined)).toBeUndefined(); + expect(resolveContractAddress('MAINNET_ADDRESS', '', host({}))).toBeUndefined(); + }); + + it('reads the global off `window` when no host is passed (the browser lane)', () => { + expect(resolveContractAddress('UNDEPLOYED_ADDRESS', BUILD_TIME)).toBe(BUILD_TIME); + globalScope.window = { SHIELDED_NIGHT: { UNDEPLOYED_ADDRESS: INJECTED } }; + expect(resolveContractAddress('UNDEPLOYED_ADDRESS', BUILD_TIME)).toBe(INJECTED); + expect(runtimeConfig()).toEqual({ UNDEPLOYED_ADDRESS: INJECTED }); + }); + + it('ignores a non-object global instead of throwing', () => { + globalScope.window = { SHIELDED_NIGHT: 'nonsense' }; + expect(runtimeConfig()).toBeUndefined(); + expect(resolveContractAddress('UNDEPLOYED_ADDRESS', BUILD_TIME)).toBe(BUILD_TIME); + }); +});