Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,15 @@ 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=<path> 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
│ └── src/
│ ├── 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
Expand Down Expand Up @@ -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=<path>` | `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=<dedicated-deployer-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.
Expand Down
52 changes: 51 additions & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 0 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
</head>
<body>
<div id="root"></div>
<!-- Runtime config (contract address per network), classic script so it
runs BEFORE the deferred module bundle. public/config.js is a no-op
placeholder; a deployment that deploys its own contract replaces it at
container start. See src/lib/runtime-config.ts. -->
<script src="/config.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
18 changes: 18 additions & 0 deletions frontend/public/config.js
Original file line number Diff line number Diff line change
@@ -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 || {};
34 changes: 27 additions & 7 deletions frontend/src/lib/networks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<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.<NETWORK>_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;
Expand All @@ -18,17 +23,29 @@ export const NETWORKS: NetworkOption[] = [
{ key: 'undeployed', label: 'Local (undeployed)', networkId: 'undeployed' },
];

const CONTRACT_ADDRESSES: Record<NetworkOption['key'], string | undefined> = {
/** The env var (and runtime-config key) holding each network's contract address. */
const ADDRESS_VAR: Record<NetworkOption['key'], ContractAddressVar> = {
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<NetworkOption['key'], string | undefined> = {
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.<NETWORK>_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<NetworkOption['key'], string | undefined> = {
Expand All @@ -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 <NETWORK>_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);
Expand Down
83 changes: 83 additions & 0 deletions frontend/src/lib/runtime-config.ts
Original file line number Diff line number Diff line change
@@ -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 (`<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:
*
* <!-- index.html -->
* <script src="/config.js"></script>
*
* // /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<Record<ContractAddressVar, string>>;

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);
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Loading
Loading