| name | lovable-midnight |
| description | Ship a Midnight ZK dApp (Compact contract + Lace wallet + local proof server + Indexer reads) in a single Lovable build. Use when the user asks for anything on the Midnight Network — private-by-default smart contracts, zero-knowledge circuits, Lace wallet, tDUST, or "how do I add privacy to my app". |
lovable-midnight
Build a Midnight Network dApp in one shot. Midnight is a privacy-first L1 where every smart contract has a public ledger, a ZK circuit, and a local off-chain component. Circuit parameters are private by default — you must call disclose() to move any value to public state.
Non-negotiables
- Compact language
0.23, MidnightJS SDK midnight-js-contracts@4.1.1 + wallet@4.0.0 + wallet-sdk-hd@3.1.0-beta.1, proof server midnightntwrk/proof-server:latest.
- Every
.compact file starts with pragma language_version 0.23; and imports CompactStandardLibrary.
- Every ledger write from a circuit parameter needs an explicit
disclose(...) — otherwise the compiler rejects it. This is the whole privacy model; don't work around it.
witness callbacks (private inputs from TypeScript) return values that never touch the chain. Never send the witness value in a transaction — pass it into the circuit only.
- Circuits are bounded: no recursion, no dynamic-length loops, no I/O, no oracles. All loops fold over compile-time constants.
- Proofs for medium circuits (
k=14) take 30–120s on the local proof server (first proof after container boot is the slowest; warm proofs are seconds). Every write UI must show a Proving… state and stay usable.
- No SSR. MidnightJS uses
window, Node Buffer, and WASM with top-level await. Load all @midnight-ntwrk/* imports behind a client-only boundary; put import { Buffer } from 'buffer'; (globalThis as any).Buffer = Buffer; as the very first line of src/main.tsx (Vite SPA) OR of a client-only entry that a <ClientOnly> gate loads (TanStack Start).
- Do NOT attempt bridging to Ethereum, oracle calls inside circuits, or sub-second UX.
Two live networks
| Network | VITE_NETWORK_ID | Address prefix | Faucet | Explorer |
|---|
| Preview (unstable, resets) | preview | mn_shield-addr_test1… | midnight-tmnight-preview.nethermind.dev | preview.midnightexplorer.com |
| Preprod (stable, closer to mainnet) | preprod | mn_shield-addr_preprod1… / unshielded mn_addr_preprod1… | midnight-tmnight-preprod.nethermind.dev | preprod.midnightexplorer.com |
Both explorers accept a contract address (/contract/<hex>) or tx hash (/tx/<hex>). Prefer preprod for anything demoed to real users.
Environment secrets
VITE_NETWORK_ID=preprod # or preview
VITE_INDEXER_URL=https://indexer.preprod.midnight.network/api/v4/graphql
VITE_INDEXER_WS_URL=wss://indexer.preprod.midnight.network/api/v4/graphql/ws
VITE_PROOF_SERVER_URL=http://localhost:6300
VITE_DEFAULT_CONTRACT=<hex address printed by first deploy>
Optional: VITE_PINATA_JWT when the app pins artefacts to IPFS.
One-time terminal setup (the user does this on their machine)
# 1. Compact compiler
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh
source ~/.bashrc && compact update
compact compile contracts/YourContract.compact contracts/managed/your-contract
cp -r contracts/managed/your-contract/keys ./public/keys
cp -r contracts/managed/your-contract/zkir ./public/zkir
# 2. Proof server — Docker Desktop must be RUNNING first (menubar whale icon).
# "Cannot connect to the Docker daemon at unix:///.../docker.sock" = Docker Desktop not started.
docker run -d --name midnight-proof-server \
-p 6300:6300 \
midnightntwrk/proof-server:latest \
midnight-proof-server -v
# 3. Verify — expect {"status":"ok","timestamp":"..."}.
curl http://localhost:6300/health
# 4. Lifecycle
docker ps # confirm running + port 6300:6300
docker logs -f midnight-proof-server # tail
docker stop midnight-proof-server # pause
docker start midnight-proof-server # resume (image already pulled)
Lace wallet ships from https://www.lace.io/ (Midnight-enabled build). Install → switch network to Preview or Preprod → create/restore wallet → copy the shielded/unshielded address.
Funding: tNIGHT ≠ tDUST (the #1 support question)
Deploys and shielded txs spend tDUST, but the faucet only dispenses tNIGHT. Every user hits this once.
Preferred derivation path — midnight-wallet-cli (community CLI, recommended by Midnight dev-rel in #dev-chat, 27 July 2026). Do NOT hand-roll seed → bech32 with WalletSeeds + createKeystore + address encoders when a one-liner does it:
npm i -g midnight-wallet-cli
# --seed = 64-char HEX master seed (32 bytes), NOT a BIP-39 mnemonic
mn address --seed <64-hex-master-seed> --network preprod # → mn_addr_preprod1…
mn balance <mn_addr_preprod1…> --network preprod
--network accepts preprod, preview, undeployed, mainnet. Prints the UNSHIELDED bech32 (mn_addr_…) the faucet wants; shielded (mn_shield-addr_…) is a different identity the faucet rejects. Never accept a user's seed in chat — run locally only. Keep scripts/derive-unshielded-address.mjs as an offline fallback.
- Copy your unshielded address (
mn_addr_preprod1…) from Lace or mn address.
- Paste it into the preprod faucet, click Request → you now hold tNIGHT.
- In Lace, click Generate tDUST to delegate tNIGHT and mint tDUST.
- Refresh — the wallet page should show a tDUST balance. Only now can you deploy.
If a deploy fails with "insufficient tDUST", step 3 was skipped.
References: https://www.npmjs.com/package/midnight-wallet-cli · https://github.com/nel349/midnight-wallet-cli
The four canonical primitives
| Primitive | When to reach for it | Compact shape |
|---|
| Compact contract deploy | Public timestamped log of actions | ledger last_message + disclose() |
| Private witness proof | Prove ownership without revealing identity | witness sk() → hash into a Set on chain |
| Lace wallet + tDUST | Wallet-first UX, no email flow | initialAPI.connect(net) → shielded address |
| IPFS content commit | Big artefacts + on-chain provenance | Pinata → CID → ledger.last_cid |
Canonical Compact contract
pragma language_version 0.23;
import CompactStandardLibrary;
export ledger entry_count: Counter;
export ledger last_message: Opaque<"string">;
export ledger last_author_commitment: Bytes<32>;
witness localSecretKey(): Bytes<32>;
constructor() { entry_count.increment(1); }
export circuit appendEntry(newMessage: Opaque<"string">): [] {
const sk = localSecretKey();
const seq = entry_count as Field as Bytes<32>;
last_author_commitment = disclose(
persistentHash<Vector<3, Bytes<32>>>([pad(32, "log:author:"), seq, sk])
);
last_message = disclose(newMessage); // disclose is REQUIRED
entry_count.increment(1);
}
Type-casting rules learned the hard way:
Counter → Field → Bytes<32> is two steps: x as Field as Bytes<32>. Direct as Bytes<32> fails.
- String literals in a
constructor() are Bytes<N>, not Opaque<"string">. Do not assign "(empty)" to a ledger field of type Opaque<"string"> — the compiler rejects it. Only disclose(<circuit-param-of-that-type>) works.
Vite config essentials (classic Vite SPA)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import wasm from 'vite-plugin-wasm';
import topLevelAwait from 'vite-plugin-top-level-await';
export default defineConfig({
build: { target: 'esnext', commonjsOptions: { transformMixedEsModules: true } },
plugins: [react(), wasm(), topLevelAwait()],
optimizeDeps: {
esbuildOptions: { target: 'esnext', supported: { 'top-level-await': true } },
include: ['@midnight-ntwrk/compact-runtime'],
exclude: ['@midnight-ntwrk/onchain-runtime-v3',
'@midnight-ntwrk/onchain-runtime-v3/midnight_onchain_runtime_wasm_bg.wasm'],
},
});
TanStack Start compatibility (Lovable's current default)
TanStack Start SSR-renders every route by default; MidnightJS crashes under SSR (window is not defined, top-level-await, WASM). Rules:
- Never import
@midnight-ntwrk/* at module scope of a route file. Do all MidnightJS work inside useEffect or a lazy component behind a useHydrated() / <ClientOnly> gate.
- Keep the Compact deploy in a
bun scripts/deploy-midnight.mjs Node script that talks to the local proof server on localhost:6300 — do NOT deploy from a server function (Cloudflare Worker runtime has no Docker, no proof server, no long-lived TCP to localhost).
- Write the deploy result to
src/data/midnight-contract.json (address, deployTx, network, versions) so the browser can import it as static JSON and hydrate an explorer link.
- Read-only Indexer GraphQL queries are the ONLY Midnight surface safe in a loader / server function — and only against the public Indexer HTTPS endpoint, never through the proof server.
MidnightJS bootstrap
// src/main.tsx — very first line, before ANY other import:
import { Buffer } from 'buffer'; (globalThis as any).Buffer = Buffer;
// src/lib/providers.ts (client-only)
import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import { FetchZkConfigProvider } from '@midnight-ntwrk/midnight-js-fetch-zk-config-provider';
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import semver from 'semver';
export async function initProviders() {
const net = import.meta.env.VITE_NETWORK_ID ?? 'preprod';
setNetworkId(net);
const lace = await new Promise<any>((res, rej) => {
const t0 = Date.now();
const iv = setInterval(() => {
const w = Object.values((window as any).midnight ?? {}).find((x: any) =>
x && 'apiVersion' in x && semver.satisfies(x.apiVersion, '4.x'));
if (w) { clearInterval(iv); res(w); }
else if (Date.now() - t0 > 5000) { clearInterval(iv); rej(new Error('Install Lace: https://www.lace.io/')); }
}, 100);
});
const api = await lace.connect(net);
const cfg = await api.getConfiguration();
const zk = new FetchZkConfigProvider(window.location.origin, fetch.bind(window));
return {
connectedAPI: api,
proofProvider: httpClientProofProvider(cfg.proverServerUri ?? import.meta.env.VITE_PROOF_SERVER_URL, zk),
publicDataProvider: indexerPublicDataProvider(cfg.indexerUri, cfg.indexerWsUri),
zkConfigProvider: zk,
};
}
Reading public ledger state (no wallet needed)
const r = await fetch(import.meta.env.VITE_INDEXER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `query($a:HexEncoded!){ contractAction(address:$a){ state } }`,
variables: { a: contractAddress },
}),
});
const stateHex = (await r.json()).data?.contractAction?.state;
Decode with the compiled contract's ledger(state) helper from contracts/managed/<name>/contract/index.cjs — but only in a client-only module.
Deploy status UI pattern
Ship a small panel that imports src/data/midnight-contract.json and branches on address. Treat the all-zero address (0000…0000) as "not yet deployed":
import contract from '@/data/midnight-contract.json';
const PLACEHOLDER = '0'.repeat(64);
const deployed = contract.address && contract.address !== PLACEHOLDER;
const explorer = contract.explorer?.replace(/\/$/, '') ?? 'https://preprod.midnightexplorer.com';
// deployed → link to `${explorer}/contract/${contract.address}` and `${explorer}/tx/${contract.deployTx}`
// not deployed → show the "run bun scripts/deploy-midnight.mjs" hint
This lets the marketing page hydrate the moment the user's local deploy script writes the JSON, with no rebuild coordination.
Failure modes ranked by frequency
| Symptom | Cause | Fix |
|---|
Cannot connect to the Docker daemon at unix:///…/docker.sock | Docker Desktop app itself isn't running | Open Docker Desktop from Spotlight, wait for the whale menubar icon, re-run docker run … |
compile error: disclose() required | Assigning a circuit param straight to a ledger field | Wrap in disclose(...) |
cannot cast Counter to Bytes<32> | Direct cast | x as Field as Bytes<32> |
Assigning a string literal to Opaque<"string"> ledger field in constructor() | Literals are Bytes<N>, not Opaque | Skip the initial assignment or set from a circuit param via disclose() |
| Deploy fails "insufficient tDUST" but faucet said success | Faucet gave tNIGHT; you never converted | In Lace click Generate tDUST — see funding section |
ReferenceError: Buffer is not defined | Missing Node polyfill | Add the buffer polyfill line as the FIRST line of src/main.tsx |
| Contract state undefined after deploy | ZK keys not served to browser | cp keys public/keys && cp zkir public/zkir before vite dev |
| Proof hangs / times out | Proof server not running, wrong port, or first-boot warm-up | docker ps → curl http://localhost:6300/health; first proof after boot takes ~30–120s |
window is not defined at build / SSR | MidnightJS at module scope in a TanStack route/loader | Move behind useEffect / useHydrated(); deploys go through the bun scripts/deploy-midnight.mjs Node script, not a server function |
Lace not found | Extension not installed / page loaded before injection | Poll window.midnight for 5s before rejecting |
Cannot find package 'bip39' (or ) running the deploy script |
Network → NetworkId mapping (source of the Preview mismatch)
The MidnightJS NetworkId enum does NOT have a Preview member. Wrong mapping = wrong bech32 suffix = Lace rejects the address.
VITE_NETWORK_ID | NetworkId (from @midnight-ntwrk/zswap) | Unshielded prefix | Shielded prefix |
|---|
preview | NetworkId.Undeployed | mn_addr_undeployed1… (Lace shows as "Preview") | mn_shield-addr_undeployed1… |
preprod / testnet | NetworkId.TestNet | mn_addr_test1… (Lace labels "Preprod") | mn_shield-addr_test1… |
mainnet | NetworkId.MainNet | mn_addr1… | mn_shield-addr1… |
Use ONE NetworkId variable across both encoders in a script — do not branch per address type. Validate the emitted prefix before writing src/data/midnight-contract.json; abort on mismatch rather than deploying to the wrong network.
Recovery-phrase safety (hard rule)
If the user offers their seed phrase to "just check it in the sandbox", refuse and give them a local script instead:
// scripts/check-midnight-wallet.mjs — runs on the user's machine only
import * as bip39 from 'bip39';
import { NetworkId } from '@midnight-ntwrk/zswap';
// ...derive the same way the deploy script does, from process.env.MIDNIGHT_WALLET_SEED
// PRINT: network, unshielded address, shielded address. NEVER print the seed.
Invocation: MIDNIGHT_WALLET_SEED="word1 word2 ..." bun scripts/check-midnight-wallet.mjs --network=preview. Never log, echo, or console.log the seed; read it once, derive, discard.
Anti-patterns
- Don't call
initialAPI.connect(...) without setNetworkId(...) first.
- Don't store the 32-byte witness secret on the server or in a cookie — localStorage only.
- Don't pretend a public ledger commitment is private. It's public. Only the witness stays hidden.
- Don't run the write path under SSR /
build:dev prerender. Read-only Indexer views are SSR-safe; wallet + proof-server writes are not.
- Don't try to deploy from a Cloudflare Worker / TanStack server function — no Docker, no proof-server access, no localhost. Deploys are a local
bun script.
- Don't hardcode
preview when the user demoed on preprod (or vice versa). Explorer + faucet + indexer URLs all differ.
- Don't accept a user's recovery phrase in chat or run it through the Lovable sandbox — give them a local script that reads the seed from their own shell env.
- Don't derive unshielded and shielded addresses through different
NetworkId values in the same script — that's the bug that makes Lace say the addresses don't match.
- Don't assume Node scripts under
scripts/ inherit Vite's dep resolution — every import must be bun add-ed into package.json.
Agentic-commerce overlays (A2A + AP2, UCP, x402 · mUSDC)
Optional overlay on top of the base Midnight stack: every agentic prompt closes with a real Midnight transaction. Three protocols, three Compact contracts, one banner.
Non-negotiables
- Every overlay ends with a Midnight tx. A2A/UCP/x402 negotiations that don't anchor on-chain are out of scope — the whole point of the overlay is auditability against a Midnight indexer, not off-chain messaging.
- mUSDC is a MIMIC token. No peg, no value. Ship
MidnightUSDC.compact with a 10-mUSDC faucet cap and NEVER deploy to Mainnet. Every UI that shows an mUSDC balance must render <ExperimentalAgenticBanner> and link to /agentic-experimental.
- Banner is non-dismissible on Mainnet. On Preview/Preprod/Undeployed it may be collapsible (localStorage key
agentic-banner-ack), but it must render on every page that touches the overlay.
- Compact-witness signing, NOT EIP-712. Cross-verifying AP2 mandates from this stack against EVM verifiers won't work — buyer/seller/merchant public keys are
persistentHash([pad(32,"<domain>:v1"), sk]). Domain separators are per-protocol and MUST NOT be reused (ap2:buyer:v1, ucp:merchant:v1, musdc:signer:v1).
- Facilitator falls back to
{ simulated: true } when no contract is deployed. Never crash at boot for missing config — same demo-fallback contract as the base skill. Return a stub midnightTxHash: "0xSIMULATED" and let the UI surface a "simulated" chip.
- x402 header casing is literal.
PAYMENT-SIGNATURE (request), PAYMENT-RESPONSE (response). Send exactly that. Read case-insensitively.
- x402 v2 envelope, Midnight scheme.
scheme: "midnight-mUSDC", network: "midnight:<preview|preprod|undeployed>", amount in atomic 6-decimal units as a string. The signed payload wraps under accepted (echo the full requirement), NOT at the top level.
- Nonce is bytes32 random, never reused.
spent_nonces: Set<Bytes<32>> on MidnightUSDC enforces this on-chain — the client must also generate a fresh nonce per attempt.
- RFC 9421 signing keys are anchored on
OrderLedger.recordSigningKey on first boot. UCP callers verify signatures against the on-chain fingerprint, not just the discovery doc.
Contracts (drop into contracts/)
| Contract | Purpose | Domain separator |
|---|
MandateVault.compact | Anchor signed AP2 CartMandate hashes | ap2:buyer:v1 |
OrderLedger.compact | Record UCP order hashes + merchant signing-key fingerprint | ucp:merchant:v1 |
MidnightUSDC.compact | mUSDC mimic token: faucet + EIP-3009-style transfer + spent-nonce set | musdc:signer:v1 |
All three follow the base skill's rules: pragma language_version 0.23;, import CompactStandardLibrary;, every ledger write is disclose(...), witness bodies live in TypeScript.
Server routes (drop into src/routes/api/public/)
All routes bypass Lovable's published-site auth gate — verify inputs yourself. Under-load callable, so the facilitator MUST be idempotent per nonce.
ap2-anchor.ts — POST { mandateHash, buyer, seller, amount, proof }; on Undeployed uses the genesis wallet (…0002), on Preview/Preprod uses Lace publishKit.
ucp-discovery.ts / ucp-checkout.ts / ucp-self-test.ts — RFC 9421-signed discovery + order recorder + conformance self-test.
x402-proxy.ts / x402-challenge.ts / x402-verify.ts / x402-settle.ts — CORS-safe same-origin proxy, 402 challenge, proof verify against the contract's verifier, and settlement via MidnightUSDC.transfer.
Failure modes specific to the overlay
| Symptom | Cause | Fix |
|---|
| AP2 verifier rejects a mandate with matching hash | Buyer signed with a different domain separator (e.g. reused ucp:merchant:v1) | Use ap2:buyer:v1 in both the Compact circuit AND the TypeScript witness derivation |
x402 client gets invalid_payload from the facilitator | Sent v1 envelope (scheme/network at top level) | Wrap under accepted — echo the full requirement chosen from accepts[] |
nonce already spent on retry | Client reused the nonce after a network hiccup | Generate a fresh crypto.getRandomValues(new Uint8Array(32)) per attempt; never store-and-replay |
| UCP receipt verifies signature but the on-chain fingerprint is empty | recordSigningKey never called on first boot | Call it once from a bootstrap route or the deploy script |
Facilitator returns a real midnightTxHash in preview but the UI shows "simulated" | Response passed through a proxy that dropped PAYMENT-RESPONSE | Restore the header in the proxy (res.headers.set("PAYMENT-RESPONSE", …)) — it's non-standard, most proxies strip unknown headers |
Buyer signs an AP2 mandate but anchorMandate reverts with buyer signature invalid | Buyer public key derived on the client with a different byte layout than the circuit expects | Match exactly: persistentHash<Vector<2, Bytes<32>>>([pad(32, "ap2:buyer:v1"), sk]) — no extra fields, no different order |
| Mainnet publish attempts an x402/mUSDC path | mUSDC has no peg — this is a real security risk | Gate the overlay off Mainnet entirely; keep the disclaimer banner non-dismissible on Mainnet routes |
Prompt-catalogue split
For a hackathon prompt bundle, the overlay adds three theme slugs on top of the base 10 disciplines:
agentic-a2a-ap2 — 500 ideas (buyer↔seller negotiation flows)
agentic-ucp — 250 ideas (RFC 9421 signed checkout flows)
agentic-x402 — 250 ideas (pay-per-call with mUSDC)
Multiply by the 4-network × 3-OS matrix if you're generating full prompt variants.
2026-08 update — hard-won lessons from working agentic repos
These rules come from three end-to-end agentic-commerce dApps that reached real on-chain Undeployed anchors (agenticmidnight — AP2 anchorMandate, ucpmidnight — UCP appendEntry, x402midnight — x402 + Sepolia + EffectStream anchorChunk). Where they contradict older sections, these rules win.
Wallet / SDK stack MUST match the indexer
- Local Undeployed =
indexer-standalone:4.0.2. Pin to MidnightWalletProvider + @midnight-ntwrk/wallet-sdk@1.2.0 + @midnight-ntwrk/testkit-js@4.1.1 + midnight-js-*@4.1.1. Do NOT use @midnight-ntwrk/wallet@5 WalletBuilder — its GraphQL subscriptions (wallet, ProgressUpdate, ViewingUpdate) don't exist on indexer 4.0.2; every deploy fails Unknown field "wallet" on type "Subscription".
- Polyfill Node WebSocket:
import WebSocket from 'ws'; (globalThis as any).WebSocket = WebSocket;.
NetworkId from @midnight-ntwrk/midnight-js-network-id is type-only at 4.1.1. Use setNetworkId("undeployed") (string literal). Wallet-side runtime enum is nested: NetworkId.NetworkId.Undeployed from wallet-sdk.
- Compact 0.31 emits ESM
contracts/managed/<name>/contract/index.js; deploy scripts resolve .js first, .cjs only as fallback.
levelPrivateStateProvider at 4.1.1 requires a function password provider AND an accountId — the old { get: async () => … } on the outer providers bag is outdated.
Required bun add for a working Node deploy: @midnight-ntwrk/midnight-js-contracts@4.1.1, midnight-js-node-zk-config-provider@4.1.1, midnight-js-level-private-state-provider@4.1.1, midnight-js-http-client-proof-provider@4.1.1, midnight-js-indexer-public-data-provider@4.1.1, midnight-js-utils@4.1.1, wallet-sdk@1.2.0, testkit-js@4.1.1, zswap@4.0.0, ws.
Undeployed writes go through a server route — design for it from day one
Undeployed: UI → POST /api/public/<verb> → genesis wallet (server) → chain
Other nets: UI → Lace publishKit → chain
Reads: indexer GraphQL, no wallet needed
Lace CANNOT sign on Undeployed. To make server-append reload the deploy-time witness (avoid RpcError 117), everything the deploy touched must be reconstructable at append time. Put into src/lib/midnight-shared.ts (imported by both scripts/deploy-midnight.mjs and every *.server.ts file):
GENESIS_SEED = "…0002" (NOT …0001 — genesis-funds live on 0002).
- Stable
PRIVATE_STATE_ID (never Date.now()).
- Shared
PRIVATE_STATE_STORE name and PRIVATE_STORAGE_PASSWORD (≥ 3 char classes).
- Deterministic
DEPLOYER_SECRET_HEX (not crypto.getRandomValues).
- Deterministic buyer/merchant PK:
persistentHash<Vector<2, Bytes<32>>>([pad(32, "<domain>:v1"), sk]). Domain separators: ap2:buyer:v1, ucp:merchant:v1, musdc:signer:v1, abodc:author:v1 — never reused across protocols. Domain string must be ≤ 32 UTF-8 bytes or Compact refuses to compile.
Persist { contractAddress, deployTxId, privateStateId, buyerPk } to src/data/midnight-contract.undeployed.json. Every server route must call providers.privateStateProvider.setContractAddress(contractAddress) BEFORE any get/set.
Indexer compose env — full midnight-local-dev/standalone.yml
Older skill snippets set only APP__INFRA__NODE__URL. Indexer 4.0.2 crashes at boot with missing field 'secret' for key "INFRA". Use:
indexer:
image: midnightntwrk/indexer-standalone:4.0.2
environment:
APP__INFRA__NODE__URL: ws://node:9944
APP__APPLICATION__NETWORK_ID: undeployed
APP__INFRA__STORAGE__PASSWORD: indexer
APP__INFRA__PUB_SUB__PASSWORD: indexer
APP__INFRA__LEDGER_STATE_STORAGE__PASSWORD: indexer
APP__INFRA__SECRET: "303132333435363738393031323334353637383930313233343536373839303132"
APP__INFRA__SPO_NODE__BLOCKFROST_ID: "placeholder-not-used-standalone"
ports: ["8088:8088"]
GraphQL readiness must use POST (GET → 405). Available subscriptions on 4.0.2: blocks, contractActions, dustLedgerEvents, shieldedTransactions, unshieldedTransactions, zswapLedgerEvents (NO wallet).
SSR stub MUST be gated with apply: "build"
Top-level rule now: any Vite plugin that swaps @midnight-ntwrk/* or *.server.ts for stubs must set apply: "build". Without it, vite dev local API handlers hit the stub and /api/public/<verb> silently returns { simulated: true, midnightTxHash: "0xSIMULATED" }. UIs that show "ANCHORED" from 0xSIMULATED are bugs, not demo modes — fail loudly in the server route when the contract JSON is missing or VITE_NETWORK_ID !== "undeployed".
optimizeDeps.exclude (ship from day one)
testkit-js and wallet-sdk pull in Node-only transitives (pino, ws, ssh2, cpu-features) that hang the dev server on "Loading…":
"@midnight-ntwrk/testkit-js", "@midnight-ntwrk/wallet-sdk",
"@midnight-ntwrk/midnight-js-contracts", "@midnight-ntwrk/midnight-js-http-client-proof-provider",
"@midnight-ntwrk/midnight-js-indexer-public-data-provider", "@midnight-ntwrk/midnight-js-node-zk-config-provider",
"@midnight-ntwrk/midnight-js-level-private-state-provider", "@midnight-ntwrk/midnight-js-network-id",
"@midnight-ntwrk/midnight-js-utils", "@midnight-ntwrk/wallet",
"@midnight-ntwrk/compact-runtime", "@midnight-ntwrk/onchain-runtime-v3",
"pino", "ws", "ssh2", "cpu-features",
Compact-side gotchas
pad(32, "<domain>:<role>") — string must be ≤ 32 UTF-8 bytes. Long product names fail with cannot pad "…" to length 32.
Opaque<"string"> fields cannot be initialised in constructor() with a literal — literals are Bytes<N>. Drop the init.
Definition of done — verify with the indexer, not the SDK
POST to indexer /api/v4/graphql: contractAction(address: $addr) { … on ContractCall { entryPoint transaction { hash block { height } } } } — confirm entry point matches your circuit and block height is non-null. midnight-js txId and indexer ledger hash are DIFFERENT strings; never string-match one against the other. After every midnight:down/up: redeploy AND restart Vite (LevelDB wiped, ctxPromise cached).
React wallet-connect: never setState during render
Symptom: Chrome "Page Unresponsive" on Connect Lace. Cause: parent setState inside the wallet-bubble render body. Fix: bubble wallet state via useEffect only.
EffectStream / dual-rail overlay (from x402midnight)
EffectStream is a sync/overlay, NOT a bridge. Sepolia Circle assets (USDC/EURC/cirBTC) stay on Sepolia; Midnight only stores the chunk anchor via anchorChunk on StreamingChoreographyIP.
- x402 challenge advertises
midnight-mUSDC + Sepolia exact options on eip155:11155111 simultaneously.
- Decimals per asset: USDC/EURC = 6, cirBTC = 8. Use
priceMicroUsdToTokenAtomic(asset, priceMicroUsd).
- Foundry
forge create silently dry-runs without --broadcast; always confirm on the explorer.
- Etherscan V1 hosts are deprecated. Verify with V2:
--verifier-url https://api.etherscan.io/v2/api?chainid=11155111 --skip-is-verified-check.
- Infura rejects gas > 2²⁴ (16 777 216). MetaMask falls back to 21M when
eth_estimateGas fails (usually 0 balance / no allowance). Surface balance ≥ required and allowance BEFORE writeContract; map RPC gas-cap errors to "estimation failed; check balance".
/api/public/sepolia-fulfill must return HTTP error when SCIP JSON missing or VITE_NETWORK_ID !== "undeployed" — NOT midnightTxHash: "0xSIMULATED" with success: true.
- Two networks, two wallets: Sepolia → MetaMask + Circle. Undeployed writes → server genesis wallet. Never conflate.
Canonical file layout
src/lib/midnight-shared.ts # seed, private-state id/store/password, deployer secret, domain sep
src/lib/midnight-providers.server.ts # shared Undeployed providers bag
src/lib/<verb>.server.ts # findDeployedContract + callTx.<verb>()
src/lib/<verb>.ssr-stub.ts # inert stub, Cloudflare production build only
src/routes/api/public/<verb>.ts # POST — Undeployed calls .server.ts; other nets defer to Lace
src/data/midnight-contract.undeployed.json
scripts/midnight-standalone.mjs # writes standalone compose with full APP__INFRA__ env
scripts/deploy-midnight.mjs # MidnightWalletProvider + CompiledContract.make
New failure modes
| Symptom | Cause | Fix |
|---|
Unknown field "wallet" on type "Subscription" | wallet@5 against indexer 4.0.2 | Use MidnightWalletProvider + wallet-sdk@1.2.0 + testkit-js@4.1.1 |
Indexer exits with missing field 'secret' for key "INFRA" | Compose only sets APP__INFRA__NODE__URL | Adopt full midnight-local-dev/standalone.yml env |
Compact: cannot pad "<long>:author:" to length 32 | Domain separator > 32 UTF-8 bytes | Shorten (abodc:author:v1, ap2:buyer:v1) |
disclose("(empty)") compile error on Opaque<"string"> | Literals are Bytes<N> | Drop constructor init |
Deploy MODULE_NOT_FOUND on contract/index.cjs | Compact 0.31 emits ESM index.js | Resolve .js first |
NetworkId is not defined at runtime | Type-only export at 4.1.1 | setNetworkId("undeployed"); use wallet-sdk's NetworkId.NetworkId.Undeployed when a runtime enum is needed |
/api/public/<verb> always returns simulated: true in dev | midnightSsrStub() runs on all SSR | Gate with apply: "build" |
| RpcError 117 / "cannot find private state" | Random deployerSecret/privateStateId/store between deploy and server | Move all to src/lib/midnight-shared.ts; both sides import the same constants |
| Indexer POST returns 405 | Sent GET | GraphQL requires POST |
| Chrome "Page Unresponsive" on wallet-connect | Parent setState during render | Bubble via useEffect only |
| UI "ANCHORED" with |
2026-07 update — flymidnight hard-won lessons (Fly.io hosted Undeployed)
From github.com/arunnadarasa/flymidnight — the canonical working example of a public
Fly-hosted Undeployed stack. Skip any one and hours evaporate.
-
Readiness = state.dust.state.progress.isStrictlyComplete(). WalletFacade 4.1.1
shape. Do NOT check state.progress?.isSynced, state.progress === true, or an older
walletReady boolean — those never flip on 4.1.1. Symptom: "warming up" toast stuck
forever after DUST is fully synced. Debug once by logging the raw state.dust.state.progress
object; it exposes applyGap, sourceGap, and isStrictlyComplete().
-
Browser → proof server MUST use the public HTTPS URL. https://choreo-proof.fly.dev.
The proof-server binary listens on IPv4 only, Fly 6PN is IPv6-only, and the browser is
HTTPS — choreo-proof.internal:6300 fails on all three counts. Do NOT wrap it with
socat; the distroless image has no shell (exec: 127) and public IPv4 through Fly's
edge is the supported path. Only server-to-server 6PN calls need .internal names
(indexer → node, faucet → node); proof server is always public.
-
VITE_DEFAULT_CONTRACT must OVERRIDE cached localStorage on load. After a Fly
redeploy the volume can rotate, and yesterday's contract address is dead — but the SPA
cached it in localStorage["midnight-contract-address"]. On boot: prefer
import.meta.env.VITE_DEFAULT_CONTRACT when set, otherwise fall back to localStorage.
Symptom if you invert the priority: Couldn't find template … on every write after
redeploy.
-
Health probe order matters: node → indexer HTTP → indexer WS → proof HTTP. If the
node is stuck at #0 (see the Fly failure-mode table above), every other probe returns
misleading errors and users chase phantom bugs. Fail fast on node before painting the
rest of the preflight grid.
-
Fund each Lace visitor from an in-app Get tDUST button. The genesis seed …0002
funds ONLY the deploy wallet; every Lace visitor on Undeployed starts with 0 tDUST and
writes fail with a cryptic Unexpected error submitting scoped transaction after
signing. Wire a Faucet button that POSTs { address: laceUnshieldedAddress } to
${VITE_FAUCET_URL}/grant. Poll getDustBalance() afterwards; disable the mint button
until balance > 0.
-
Retry the faucet with backoff for 90 s after redeploy. cold boot:
needs 10–90 s to sync a non-zero balance. During that window
returns . Show a "faucet warming up (~90 s)" toast, retry every 10 s,
and don't set on the faucet unless you accept the delay.
2026-07 update — mobilemidnight hard-won lessons (Kuira Android on Undeployed)
Source: arunnadarasa/mobilemidnight (Tokenized Choreo Kits). First verified end-to-end Kuira dApp on Undeployed — passkey Sigil forge → mn airdrop funding → on-device ZK proving → 2 kits published (~25s warm prove).
When to reach for Kuira
Mobile-first hackathon lanes (NFC tap-to-anchor, offline receipts, POS, wearables) where a browser + Lace extension is a non-starter. Passkey biometric identity replaces seed phrases. Not a drop-in replacement for the web/Lace path — different toolchain (Gradle/Kotlin/AVD), different local devnet (mn localnet, not Docker midnight-node:0.22.5), different funding path (mn airdrop CLI, no in-app browser faucet).
Verified stack
| Piece | Version |
|---|
| Kuira SDK | 0.1.0-alpha05 |
| Compact | 0.31.1 |
| Local devnet | mn localnet (CLI — NOT midnightntwrk/midnight-node:0.22.5 Docker image) |
| Proving | On-device |
| Reference AVD | API 35 google_apis arm64, 8GB host |
Non-negotiables
Form-enablement pattern (real app bug we hit)
Symptom: Publish/Deploy button stayed disabled with all TextFields visibly filled, showing "Enter a kit title" despite a title being typed. Cause: enablement gate read a different piece of state than the TextField value. Fire-and-forget viewModel.foo() that only reads .value doesn't observe recompositions.
Fix pattern (used in KitsCard.kt / KitsViewModel.kt):
// Local rememberSaveable form state = source of truth
var title by rememberSaveable { mutableStateOf("") }
// Write-through to VM so business logic sees it
LaunchedEffect(title) { vm.setTitle(title) }
// Derive enabled from the SAME state the TextField writes
val blocked = publishBlockedReason(title, steps, priceDust)
Button(enabled = blocked == null, onClick = { vm.publish() }) { … }
Verified happy path
# Host
mn localnet up
export JAVA_HOME="$(/usr/libexec/java_home 2>/dev/null || echo /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home)"
export PATH="$ANDROID_HOME/platform-tools:$PATH"
./gradlew :app:installDebug
# Emulator (one-time)
# - Sign into a Google account (type password on the soft keyboard)
# - Set a screen lock
# - adb shell settings put secure show_ime_with_hard_keyboard 1
# In-app
# 1. Forge Sigil (passkey create)
# 2. Receive → copy mn_addr_undeployed1…
mn airdrop 10000 --wallet <addr> --network undeployed
# 3. Register dust in-app
# 4. Deploy catalog (on-device prove, ~30–120s cold)
# 5. Title / steps / priceDust → Publish kit
Failure modes specific to Kuira / Android
| Symptom | Cause | Fix |
|---|
CreateCredentialNoCreateOptionException on forge | No signed-in Google account, or no screen lock, or Password Manager unavailable on this image | Sign into Google on the AVD, set a screen lock, prefer a Play Store system image over bare google_apis |
DAL check reports packageMatchesRpAssetlinks: false | assetlinks.json not hosted, SHA-256 mismatch, or rpId still REPLACE_ME/.example | Publish DAL at https://<rpId>/.well-known/assetlinks.json with package + current debug signing SHA; set PASSKEY_RP_ID to that domain |
| Passkey create silently canceled after "correct" DAL fix | Credential Manager cached old rpId | Full uninstall then reinstall — adb install -r is not enough |
mn airdrop returns Invalid checksum… expected "…" | Address retyped from screenshot; l vs 1 collision | Copy address from device UI or scrape with uiautomator dump |
| Publish button stays disabled with fields visibly filled | Enablement reads different state than TextField writes | Local rememberSaveable + write-through LaunchedEffect; derive enabled from the same state |
screencap frames are black | Kuira wallet UI has FLAG_SECURE | Use uiautomator dump /dev/tty for automation |
adb input text fills email/password but Google says "empty" | JS/WebView validation doesn't fire on injected input | Use on-screen Gboard; do not script Google auth via adb |
adb: command not found on macOS | Unity's Android SDK platform-tools not on PATH | export PATH="$ANDROID_HOME/platform-tools:$PATH" (or Unity SDK path) |
| Gradle fails with cryptic JAVA_HOME error | Stale Corretto/JDK path | export JAVA_HOME="$(/usr/libexec/java_home)" — this build was verified on Homebrew OpenJDK 17 |
Anti-patterns (Kuira-specific, add to the main list)
- Don't substitute the Docker
midnight-node:0.22.5 stack for mn localnet under Kuira — the SDK expects the mn CLI toolchain and its funding semantics.
- Don't automate Google account recovery / security codes via chat. Codes expire in ~60s and "Code 1 / Code 2" on the phone is a number-match tap, not the 10-digit Security code field.
- Don't ship
emulator-*.png, .cursor/debug-*.log, or ad-hoc scripts/emu-*.sh helpers in the repo — clean before commit.
- Don't rely on host-keyboard input for Compose or WebView fields. Force the soft keyboard on from day one.
Cross-references
mn CLI install and Undeployed prerequisites: see midnight-environment-setup.
- The four-app Fly.io topology from the earlier flymidnight section is still valid as the indexer/proof backend if you'd rather host than run
mn localnet on a laptop — point the Kuira SDK config at https://choreo-indexer.fly.dev/api/v4/graphql and https://choreo-proof.fly.dev the same way the web demos do.
- Reference build:
https://github.com/arunnadarasa/mobilemidnight (Tokenized Choreo Kits).
2026-08 update — zealymidnight / MoveNft NFT rail on Undeployed
Source: github.com/arunnadarasa/zealymidnight (StreetRail Move Rights NFT). First verified
Compact NFT rail — mint → list → buy settled in mUSDC — on Local Undeployed, with
activity visible in the local ledger + indexer (bun scripts/z-check.mjs prints E2E_OK).
Read this BEFORE writing any NFT / marketplace Compact contract.
NFT design rules (the headline lesson)
- Public ledger maps must be insert-only / append-only for v1. The dust wallet's fee
balancer panics when a circuit updates an existing map key — symptoms are
Wallet.Other: wasm.transaction_feesWithMargin or transaction_merge Unreachable on the
SECOND callTx against the same genesis wallet. It looks like cache invalidation or
process isolation; it is not. Fix the ledger shape, not the plumbing.
mint and listSale insert NEW keys.
buy / transfer append to a sales map under a fresh random id.
- Current owner lives in the server-side JSON mirror, not in an overwritten map cell.
- Do NOT start from an ERC-721-shaped Compact contract.
_owners[tokenId] = newOwner
is exactly the overwrite that breaks. Defer lookup-heavy ownership checks to the server
mirror (or a later shielded design).
list is a reserved Compact keyword. Name the circuit listSale and regenerate
prover/verifier artefacts. Calling callTx.list against listSale keys is a silent footgun.
- Owner PK derivation must be identical everywhere. Server used
sha256("movenft:owner:v1:" + label) while an ad-hoc diagnostic script used raw
sha256(label) → on-chain not owner while the local ledger looked correct. One helper,
imported by server routes AND scripts.
- No cross-contract Compact call in v1. Sequence
musdcFaucet / musdcTransfer, then
MoveNft.buy, inside ONE server handler; document it as demo-only atomicity (same genesis
wallet). Do not promise atomic cross-contract settlement.
- Fresh wallet per call.
withMoveNft(...) mirroring withMusdc(...), with stop()
between contract families. Order that worked for the full rail:
mint → list → faucet (if needed) → pay → buy. Opening mUSDC then MoveNft on the shared
genesis LevelDB in one session leaves the next MoveNft submit broken.
- Undeployed writes stay server-append. Lace cannot sign on Undeployed — reuse the
MoveRegistry / mUSDC pattern (genesis wallet +
findDeployedContract + ). Never
half-wire Lace signing locally.
Operational rules
- Address resolution: deploy JSON first, env second. Vite caches
VITE_* across
redeploys, so a stale VITE_DEFAULT_CONTRACT mints to a dead contract. Read
src/data/midnight-contract.undeployed.json, reset per-contract local state on deploy, and
keep the resolution in ONE unit-tested helper.
- Redeploy checklist after ANY Compact change:
midnight:compile → midnight:artefacts → rm -rf midnight-level-db .midnight →
midnight:deploy → restart bun run dev → run e2e. Never mix new verifier keys with an old
LevelDB or old on-chain state. SSR keeps old addresses until the dev process restarts.
- One exclusive "stack owner" process for e2e. A single uniquely named script
(
z-check.mjs) does compile → artefacts → wipe → deploy → mint/list/buy, exits non-zero on
any failure, and prints one E2E_OK line. Take a mkdir-based marker lock and refuse to
start if another owner holds it. Never broadly pkill shared patterns (bun -e, deploy-*)
while another job holds the stack — parallel agents wiping midnight-level-db mid-prove
produce "exit 0" with truncated logs and deploy JSON timestamps that don't match the run.
- Never pipe long proves through
awk/head. SIGPIPE kills the prove process and the e2e
looks flaky. tee to /tmp/<run>.log, then rg the file.
- Pinata is server-only.
PINATA_JWT / PINATA_GATEWAY — never VITE_PINATA_* (the
Worker/Cloudflare secret-leak trap). Document in .env.example only, gate clip UI on server
config, and restart dev after env changes.
- macOS has no
flock. Use mkdir-based locks or unique process names.
- Keep submission artefacts pushed as you go. README, Compact sources, managed artefacts
and the notes file should match every green e2e — not a final dump after the demo works.
- Park the nice-to-haves (OZ NonFungibleToken vendor, true atomic cross-contract buy,
external-chain mint) until Undeployed mint/list/buy is boringly reliable.
New failure modes
| Symptom | Cause | Fix |
|---|
wasm.transaction_feesWithMargin / transaction_merge Unreachable on the 2nd callTx | Circuit UPDATES an existing public map key; dust fee balancing panics | Redesign insert-only/append-only; fresh wallet per call (withX), stop() between contract families |
callTx.list does nothing / key not found | list is a reserved Compact keyword; circuit is actually listSale | Rename circuit to listSale, regenerate artefacts, call callTx.listSale |
On-chain not owner but local ledger looks right | Owner PK derived differently in server vs script | One shared helper: sha256("movenft:owner:v1:" + label) |
RpcError 117 | Stale private state — LevelDB vs chain (concurrent clients, killed mid-prove, wiped LevelDB without redeploy) | Wipe midnight-level-db .midnight AND redeploy, then restart Vite |
RpcError 104 | Dirty LevelDB after a failed faucet → mint handoff | Wipe + redeploy; sequence faucet and mint with stop() between |
RpcError 196 | Verifier key mismatch — recompiled artefacts without redeploying, or deployed against wrong keys | Recompile → refresh artefacts → redeploy in one pass |
| Mint targets a dead contract after redeploy | Stale VITE_* address cached by Vite | Resolve from midnight-contract.undeployed.json first; restart dev after deploy |
| e2e "flaky", logs truncated, exit 0 | Prove piped through awk/head (SIGPIPE), or a parallel agent pkilled the run | tee to a log file; single stack-owner process with a mkdir lock |
SubmissionError on the next musdcTransfer / callTx | A MidnightWalletProvider was cached across HTTP requests and still holds LevelDB open |
Anti-patterns (add to the main list)
- Don't half-wire Lace signing on Undeployed — the demo write path is the server genesis wallet.
- Don't model NFTs as ERC-721 in Compact with overwriting owner maps.
- Don't run parallel agents/shells against one Docker stack or LevelDB.
- Don't let the README claim features whose code isn't pushed yet.
- Don't prefix Pinata secrets with
VITE_.
- Don't cache a wallet provider across HTTP requests — always
stop() in finally.
- Don't hardcode a contract count in user-facing copy; derive it from the contract registry length.
- Don't keep re-clicking a failing UI action through an
RpcError 117 — run the recovery checklist.
- Don't leave the previous chain's branding in user-visible strings after migrating a demo to Midnight.
Multi-contract topology (five contracts, one genesis wallet)
| Key | Compact source | Circuits | Witness domain |
|---|
| moveRegistry | MoveRegistry.compact | appendEntry | abodc:author:v1 |
| moveNft | MoveNft.compact | mint, listSale, buy, cancel, transfer | movenft:minter:v1 |
| mandateVault | MandateVault.compact | anchorMandate | ap2:buyer:v1 |
| orderLedger | OrderLedger.compact | recordOrder | ucp:merchant:v1 |
| midnightUsdc | MidnightUSDC.compact | faucet, transfer | musdc:signer:v1 |
Keep one UI registry (src/lib/contracts.ts → CONTRACTS) and derive every user-facing count
from CONTRACTS.length. A hardcoded "four deployed contracts" heading goes stale the moment a
contract is added.
Ledger shapes that survive Undeployed:
MoveNft: owners // insert on mint only
listed_price // insert on listSale / cancel under a NEW key
sales // append buy/transfer with a fresh random id
MidnightUSDC: credits / credit_to // insert by nonce (transfer) or once by pk (faucet)
faucet_claimed // Set
spent_nonces // Set
Insert-only applies to the token contract too. Overwriting balances[from] /
balances[to] reproduces the exact feesWithMargin panic that the NFT redesign fixed — it
just surfaces later, on settle/claim instead of mint.
Additional non-negotiables
- Never cache a
MidnightWalletProvider across HTTP requests. Open → callTx →
stop() in finally, every request. A cached wallet holds LevelDB open and breaks the next
contract family's call. No long-lived ctxPromise.
- One Midnight write at a time. A UI busy flag on the write action, and exactly one
process owning the Docker stack / LevelDB in ops. No parallel agents wiping state mid-prove.
- Soft-fail secondary appends. When an action does a primary token write plus a secondary
registry
appendEntry, the append must never fail the whole action — the transfer is the
receipt. Instrument transfer and append separately when debugging.
- Deploy JSON first,
VITE_* second — and after a redeploy restart Vite and
hard-refresh the browser. HMR is not enough; SSR keeps the old address.
- Third-party keys stay server-only.
PINATA_JWT / PINATA_GATEWAY, never
VITE_PINATA_*. Skip optional external verifiers (e.g. an ERC-1271 check) when the network
is Undeployed or the secret is absent, instead of surfacing missing_secret: <KEY> in the UI.
- Humanize the recoverable RPC errors. Map 117/104/196 to plain-language copy with the
recovery action, rather than leaking
FiberFailure stacks to the user.
RpcError 117 recovery checklist (in order)
SubmissionError / FiberFailure usually wrap RpcError 1010: Invalid Transaction: Custom error: 117. Don't retry the same click — run this:
# 1. stop the dev server
# 2. wipe local private state
rm -rf midnight-level-db .midnight
# 3. recreate node/indexer/proof if the chain may be dirty
docker compose -p <project> -f docker-compose.yml up -d
# 4. full deploy (not a single-contract redeploy)
bun run midnight:deploy
# 5. verify twice — expect TWO OK lines
bun scripts/debug-musdc-transfer.mjs
# 6. restart dev, hard-refresh the browser, then ONE action only
bun run dev
| Code | Action |
|---|
| 117 | Run the checklist above; do not keep retrying the same UI click |
| 104 | Wipe LevelDB + full deploy |
| 196 | Recompile artefacts + wipe + full deploy |
| UI "Pending" rows | Indexer miss or pre-wipe hash in localStorage — Refresh, then Clear if the chain was wiped |
Log long proves with tee + rg (see the prove-log rule above) while working through this —
piping through awk/head SIGPIPEs the prove and makes recovery look like flakiness.
2026-08 update — m402 / private agentic payments (Hack Buenos Aires open-track winner)
Source: julianariel/m402 — x402's 402-and-retry flow on
Midnight, where the payment carries no payer. Its docs/constraints.md is the most rigorously
measured set of Midnight platform limits published so far. Treat every rule below as verified on
Preview (Aug 2026), not as guidance.
The payment rail (copy this shape for any pay-per-call design)
- NIGHT is unshielded; DUST is non-transferable fee gas. Neither can be the private payment
asset. Pool NIGHT in a vault and mint a shielded credit 1:1 against it. Deposits and
redemptions are public; the payments between them are unlinkable — the trade every shielded pool
makes. This is the correct justification for a mimic-token rail (mUSDC etc.), not "USDC isn't on
Midnight yet".
pay takes no payer argument and reads no caller identity. It proves possession of a valid
credit and nothing more:
assert(coin.color == tokenType(creditDomain(), kernel.self()), "not an m402 credit");
assert(coin.value == price as Uint<128>, "wrong amount");
Contract token colours are collision-resistant so only the vault can mint its credit — but pay
must still assert the colour, or any minted token buys API calls.
- Privacy is "who paid", not "how much". Prices stay public because a marketplace needs a price
list. Never sell amount-hiding you don't have.
- The gateway is a pure reader. The agent submits its own transaction; the gateway watches the
indexer for the receipt and then dispatches. It never signs, never holds funds, and cannot fake a
payment. If your design has the server signing on the user's behalf, say so explicitly — it is a
different trust model.
sendShielded can only pay the caller (it creates no coin ciphertexts). Pay merchants in
unshielded NIGHT via sendUnshielded to the address recorded at registration — then anyone may
call withdraw and the payout still goes to the right party.
- A contract cannot hold a coin in ledger state. Ledger state is public in full, including a
QualifiedShieldedCoinInfo written to a cell — consecutive pot totals differ by exactly the amount
just paid. Keep only merchantBalance public; pass the spendable pot in as a witness (Zswap
independently validates it is real and contract-owned) and bound the payout by the recorded
balance. Cost: the change coin must be persisted off-chain by the caller.
pay cannot mint its own change. The compiler rejects it:
the call to standard-library circuit mintShieldedToken might disclose the value of a token mint given by the result of a subtraction involving the witness value. Change comes from the wallet
balancer one layer down — it splits a larger coin, sends price to the vault and returns the
remainder to the payer in the same transaction, with the change output's value hidden. So one
deposit funds many calls and payability is , a denomination match.
This is not a spending limit; it only says which layer does the splitting.
Quote cost honestly (measured, Preview)
| Phase | Cost |
|---|
| Verify a proof | ~3.4 ms |
pay end-to-end | 23–25 s → proof 1.4 s · submit 22.5 s · chain 1.5 s |
deploy / registerService / deposit / redeem / withdraw | 18–32 s (±20% run variance) |
| Trivial-contract floor | ~19 s |
Submission dominates. Attributing 25 s to "generating a zero-knowledge proof" is wrong by an order
of magnitude. The circuit body is a modest share of the cost, so a heavier circuit is not
proportionally slower — and pre-proving optimisations save under 2 s of 25. Any latency win must come
from taking the on-chain round trip off the per-call path.
Wallet sync is the real cost — cache it
A cold Preview deposit measured 687.5 s wall clock (643.9 s CPU); the same command warm measured
53.8 s (12.4 s CPU). 12.8x. Cause: FluentWalletBuilder can only build from a seed, so
appliedIndex === 0; both shielded/src/v1/Sync.ts and dust-wallet/src/v1/Sync.ts compute
resumeFrom = appliedIndex - 1n and open the subscription with no cursor when negative — every
invocation replays every indexer event from genesis.
Fix, and the non-obvious parts of it:
- Persist all three sub-wallet states with
serializeState() and restore() on the next build.
- All three sub-wallets and the facade must share one
txHistoryStorage, or shielded and
unshielded writes land in a storage the facade never reads.
- Never cache before sync completes — a mid-sync position restores cleanly and resumes from
somewhere the wallet never applied.
- Restore is best-effort: a missing or unreadable file falls back to the from-seed build.
- The cache holds the wallet's coins. Key it by hash(master seed + network id), write mode
0600,
treat it as a wallet secret.
@midnight-ntwrk/testkit-js costs ~5.2 s just to import (it is essentially all of a
contracts/client import; contracts/pure costs 0.20 s). Load it lazily so --help,
--version, a mistyped command and --dry-run never pay for a wallet builder they don't call, and
assert the startup budget in a test so a top-level import can't creep back.
- Set a 10-minute sync deadline (
MIDNIGHT_SYNC_TIMEOUT_MS), not 60 minutes: a transient indexer
WS error kills one sub-wallet's sync fibre (Wallet.Sync: [object ErrorEvent]) while the facade
keeps emitting never-complete state, so the deadline is the only thing that ends the command.
Implement it as an explicit Rx.race against a timer — Rx.timeout({ each }) placed after a
filter silently behaves as a total deadline instead of an inter-emission one.
Error table (add to the existing 117/104/196 table)
| Symptom | Cause | Action |
|---|
1010 Custom error: 192 (InputsSignaturesLengthMismatch) | A circuit touching unshielded value pulled a NIGHT UTXO; balanceUnboundTransaction does not sign it | await wallet.signRecipe(recipe, (p) => keystore.signData(p)) between balance and finalize. Shielded-only calls pass without it, so the bug only appears once NIGHT is touched |
1010 Custom error: 170 (InvalidDustSpendProof) | Two txs from one wallet at once — both build a DUST spend proof against the same wallet state | Serialize calls per wallet. A rejected submit never settles its promise, so wrap submits in a timeout or the test hangs instead of failing |
LEVEL_LOCKED / lock midnight-level-db/LOCK: already held | midnightDbName is the directory; privateStateStoreName is a store inside it. LevelDB is single-writer | Give concurrent callers different midnightDbName. Different privateStateStoreName changes nothing |
| "No private state found at private state ID …" | Fresh store is empty | provider.setContractAddress(addr) then provider.set(id, emptyPrivateState()) |
| "Contract address not set" | Store scopes keys by contract address | Same: setContractAddress before seeding |
first argument 'location' must be a non-empty string | midnightDbName: undefined overwrites the default via { ...DEFAULT_CONFIG, ...config } | Spread the key in only when set |
ERR_PACKAGE_PATH_NOT_EXPORTED inside a tsx stack | Node 23 or 26 — the SDK's ESM exports don't resolve | Node 22 or 24 only. Check node -v first; it reads like a dependency problem |
ENOENT: open '.mnemonic' | Relative paths in an env file resolve against CWD, not the env file | Resolve env-file-sourced paths against dirname(envFile); keep CLI flags resolving against CWD, and report the resolved absolute path on ENOENT |
All four LevelDB failures look exactly like on-chain contention. Rule out the local causes before
reading any concurrency result as a property of the chain.
Agent-facing CLI shape worth copying
m402 init # sync the wallet, report holdings; submits nothing
m402 deposit 5000 # once: NIGHT -> shielded credit
m402 call https://gw.example/s/<id> # per request, resource on stdout
m402 redeem 4500 --yes # unspent credit -> NIGHT
- Consuming a paid service needs no Midnight code: the gateway speaks ordinary HTTP 402, so any
client completes it in two requests (
GET /s/<id> → 402 {serviceId, price, vaultAddress}, then
the same GET with X-Payment: <hex> → 200 + resource).
- Paying is the part worth a client: build, prove and submit a Midnight tx, then hold the payment
secret durably until the resource actually arrives.
- stdout carries only the resource body so it pipes; progress goes to stderr; exit codes distinguish
"retry" from "fix your config".
- Payment and delivery are separate steps — health-check the origin before returning a 402 to narrow
(not close) the window where an agent pays and gets nothing.
2026-08 update — ipsmidnight lessons (Undeployed on Fly + UI-driven deploy)
From github.com/arunnadarasa/ipsmidnight — a clinical IPS console that provisions the whole
Undeployed stack on Fly and then deploys/anchors/verifies a Compact contract from the UI
instead of from a chat session. Everything below cost hours.
Node RPC: one working shape only
- Publish the node's 9944 as a pure
tls service through the Fly edge and point every
consumer at wss://<app>.fly.dev:9944.
- Why nothing else works:
<app>.internal is IPv6-only 6PN while the node binds IPv4 (and
binding both is fragile); <app>.flycast needs a private IP allocation and still answered
000 even from inside the app; Fly's http handler terminates the WebSocket partway through
a long proof submission, so writes fail after minutes of proving.
- Report reachability as measured facts, not assumptions: probe
127.0.0.1:9944/health from the
node machine and the edge URL from the indexer machine, and read public-IP / flycast presence
back from Fly (flycast: present|absent · public IP: …).
The indexer fails silently, so probe it explicitly
If the indexer cannot reach the node RPC it does not error — it serves an empty chain and the
UI reports block 0 forever, which looks like "nothing anchored yet". Always assert
node reachable from indexer as its own timeline step before believing any indexer answer.
Runner-machine pattern (deploy from the UI, not from chat)
Contract compile → deploy → anchor → verify needs a persistent disk, the Compact toolchain, and
connections that stay open for minutes. The app runtime (edge/Worker) has none of that.
- Add a 4th runner machine to the same Fly app as node / indexer / proof server, with a volume
holding the contract bundle, keys, and LevelDB private state.
- Drive it with the Machines
exec API from a server function: prepare (pull the toolchain
bundle), then fire-and-poll jobs. Never block a request on proving.
- Scripts must print machine-readable result lines (
RESULT {json}) plus human log output, so
one poll returns both a parsed result and a log tail the UI can render as a terminal.
- Ship the toolchain as a versioned tarball in storage and require an explicit "Prepare runner"
step after you bump it — otherwise users silently run last week's contract code.
- Existence-probe before trusting stored state: Fly apps/machines get destroyed outside the app, so
a "ready" row must be re-verified against Fly and downgraded rather than shown as green.
SDK v4 provider gotchas
privateStoragePasswordProvider has a minimum length; a short password throws during wallet
construction with an unrelated-looking error.
httpClientProofProvider(url, zkConfigProvider) must receive the same
NodeZkConfigProvider(CONTRACT_DIR) instance you pass as zkConfigProvider. Omit it and the
proof server answers 400 Bad Request on /check.
- Genesis wallet must be fully synced before deploy; resync after any chain-data volume rebuild.
Verification honesty (from a public code review of this repo)
- Ledger membership is the only proof.
commitments.member(commitment) read from the deployed
contract's public state is verification; a stored tx hash, a "confirmed" status column, or a
green badge derived from your own database is not.
- Persist the salt alongside the anchor. A salted commitment whose salt was never stored can
never be re-derived, which makes the anchor permanently unverifiable — and no reviewer will
accept "trust the row".
- Minimise credential claims. Do not put patient name, title, or raw DOB in a credential that
exists to prove a property; derive
over18 and ship that instead.
- Label what you did not check. Simulated credentials, unresolvable
did:prism values, and
unverified JWS signatures must render as unverifiable / not checked, never as pass. Reject
simulated artefacts outright in signature-check paths.
Key and state hygiene
.gitignore must cover .env, the LevelDB private-state directory (midnight-level-db/), and
*.tgz toolchain bundles. Untrack them if they already landed in a commit.
- Dev seeds, storage passwords, and contract addresses come from
process.env with a documented
fallback — never hardcoded literals in scripts/*.mjs.
Graceful degradation
Any Fly/stack status server function must return an unconfigured result when FLY_API_TOKEN is
missing instead of throwing. A throw inside a loader or status query blanks the page and hides the
one instruction the user needs ("add the secret").
New failure modes
| Symptom | Cause | Fix |
|---|
| Indexer stuck at block 0, no errors | Indexer cannot reach node RPC | Publish 9944 as tls on the Fly edge; point the indexer at wss://<app>.fly.dev:9944; add an explicit reachability step. |
| Proof submission dies after minutes of proving | Fly http handler closed the WebSocket | Use a pure tls handler on 9944 — no http/http_options. |
000 from <app>.flycast:9944 even inside the app | No private IP allocated / IPv6-only path to an IPv4 listener | Allocate the private IP for diagnostics, but route traffic over the public edge. |
Proof server returns 400 Bad Request on /check | httpClientProofProvider called without the zkConfigProvider | Pass the same NodeZkConfigProvider instance to both. |
| Wallet construction throws on start | privateStoragePasswordProvider value too short | Use a long password from env. |
| Anchor shows "confirmed" but cannot be proven to anyone | Salt not persisted; verification read from your own DB | Store the salt; verify with commitments.member(commitment) on-chain. |
| Contract "compiled but never deployed" | Runner never prepared, or running an old toolchain bundle | Bump the bundle version and require an explicit "Prepare runner" run. |
| Stack shows ready but the Fly app is gone | Stored state trusted without probing | Existence-probe app + machines and downgrade the row. |
Page blanks with FLY_API_TOKEN is not configured | Status function throws | Return an unconfigured state. |
Anti-patterns (add to the main list)
- Deriving a "verified" badge from your own database instead of the contract's public state.
- Putting raw PII in a credential whose only job is to prove a predicate.
- Committing
.env, LevelDB private state, or toolchain tarballs.
- Running proving work inside the app runtime instead of on a persistent runner machine.
- Assuming
.internal / .flycast reachability instead of probing it from the consuming machine.
2026-08 update — ipsmidnight hard-won lessons (runner toolchain, job observability, anchor UX)
Source: github.com/arunnadarasa/ipsmidnight (IPS Compass — International Patient Summary anchoring
on Undeployed via a Fly-hosted stack plus a persistent runner machine). These rules come from
getting deploy → anchor → verify green end-to-end from inside the app UI. Where they contradict
older sections, these win.
1. Pin the whole Compact/Midnight toolchain — including transitive alphas
@midnight-ntwrk/compact-js@2.5.3 depends on @midnight-ntwrk/ledger-v9@^0.1.0-alpha.1, which is
not published. Every runner install dies with
npm error code ETARGET / notarget No matching version found for @midnight-ntwrk/ledger-v9@^0.1.0-alpha.1.
Pin compact-js@2.5.1 — it resolves cleanly and has no ledger-v9 edge.
- Runner bootstrap installs exact pinned versions only. Never
@latest, never a caret on a
Midnight package. A toolchain bump is a deliberate, tested change with a bundle-version bump.
- Before retrying an install, delete stale
package-lock.json and node_modules from the
half-finished attempt — otherwise the bad resolution is cached on the volume forever and every
retry reproduces the same failure.
- Ship a Clear toolchain action so the user can reset a broken install without destroying the
volume (and thereby the chain/private state).
2. A stalled install on a runner machine is an OOM kill, not a hang
Symptom: the install step stops mid-way with no error line, the UI retries forever, and the job
eventually exits status=1.
- Give the runner real resources: 4 vCPU / 4 GB RAM, 10 GB volume. The Midnight SDK install is
memory-hungry; 1 GB machines die silently.
- Install in small sequential groups with a persistent npm cache on the volume, not one giant
npm i.
- Emit a heartbeat every ~30 s so the UI can distinguish "slow" from "dead".
- Surface Fly machine events (OOM flag, exit code) in the UI, not just the log tail — the log
tail of an OOM-killed process is empty, which is exactly why it looks like a hang.
3. Detached jobs need step markers, not raw logs
- Runner scripts print explicit machine-readable markers:
STEP_<name> on entry and
JOB_FAILED status=<n> during: <phase> on exit. The UI maps them onto a monotonic step
timeline (identical model to the stack deploy page) with per-step timers and a progress bar.
Monotonic matters: never let a later log line move the timeline backwards.
- On failure, name the exact command that failed and include the npm debug-log tail.
- Persist the failed job's state and log (auto-expanded) after the run ends. A toast alone loses
the only diagnostic the user could have pasted.
- Always ship a copy-log button. And never pipe long proving logs through
head/awk —
SIGPIPE kills the prove and the failure looks flaky.
4. Submitted ≠ verified — one status mapping, honest labels
- A transaction that landed on-chain is
anchored, not verified. Ledger membership is only
confirmed by an explicit read-only check against the contract's public state
(commitments.member(commitment) via the indexer). Until that runs, label the row
"anchored · not re-checked".
- Derive the status dot colour and the badge from one shared tone helper. Two independent
mappings drift and a healthy
anchored row renders amber/red, which reads as a failure.
- Once anchored, demote the write action to secondary (
Re-anchor, outline) and promote the
verification action to primary. The next useful click is verification, not another submit.
5. Mobile-first rows for long-running blockchain operations
- Single column on mobile: metadata first, then full-width equal-width action buttons in a
2-up grid. Side-by-side buttons squeeze tx hashes and block numbers into hard-wrapped noise.
- Step timelines and log tails span the full card width — do not nest them inside the metadata
column.
truncate record/bundle titles in queue lists so a long clinical title cannot stretch the layout.
New failure modes
| Symptom | Cause | Fix |
|---|
npm error code ETARGET / notarget No matching version found for @midnight-ntwrk/ledger-v9@^0.1.0-alpha.1 | compact-js@2.5.3 depends on an unpublished transitive alpha | Pin compact-js@2.5.1; pin every Midnight package exactly |
| Toolchain install fails identically on every retry | Stale package-lock.json / node_modules from the half-finished attempt cached on the volume | Clear them in bootstrap; expose a "Clear toolchain" action that keeps the volume |
Install stalls mid-step with no error, then status=1 | Runner machine OOM-killed (empty log tail) | 4 vCPU / 4 GB, sequential install groups, npm cache on volume, 30 s heartbeats, surface Fly machine events + exit code |
| Detached job "failed" with nothing to debug | Error only shown as a toast | Persist failed job state + log tail, auto-expand, add copy-log |
| Step timeline jumps backwards during a job | UI recomputes step from the newest matching log line | Make step derivation monotonic — only advance |
| Anchored row shows an amber/red dot and looks broken | Dot and badge computed from separate status maps | One shared tone helper feeding both |
| UI claims "verified" right after submitting | Verification inferred from your own DB write | Only verified after a read-only ledger membership check; otherwise "not re-checked" |
Anti-patterns (add to the main list)
- Installing Midnight packages on the runner with
@latest or caret ranges — one unpublished
transitive alpha breaks every deploy.
- Retrying a failed toolchain install without clearing the stale lockfile /
node_modules.
- Running the SDK install as one giant
npm i on a small machine and reading the resulting silence
as a hang.
- Showing a detached job's only error in a toast.
- Deriving status colour in two places.
- Calling a submitted transaction "verified".