关于 multinetwork
面向 Midnight DApp 开发的技能,介绍如何用同一代码库支持 undeployed、preview、preprod 和 mainnet。内容涵盖网络配置、Provider、钱包、证明服务及不同的 DUST 流程。
主要功能
- 01配置四种 Midnight 网络
- 02搭建本地 Docker 服务栈
- 03设置各网络的证明 Provider
- 04组装钱包与 Provider
- 05手动及赞助式 DUST 流程
适用场景
- 01搭建本地 Midnight DApp 环境
- 02为测试网和主网配置 DApp
- 03排查特定网络的部署问题
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ kali-decoder/ midnight-skills --skill multinetwork安装后在 Claude Code 中输入/multinetwork确认已加载。
详细介绍
多网络 Midnight 技能
本技能介绍如何通过单一代码库构建适用于全部四个网络目标的 Midnight DApp。关键在于:网络有三处不同——端点、证明服务(本地 Docker 或 ProofStation)以及 DUST 流程(手动注册或赞助)。其他所有内容都相同。
主要参考资料:
github.com/midnightntwrk/midnight-local-dev— 官方本地 Docker 技术栈 + 资金发放 CLIgithub.com/midnightntwrk/example-counter— 官方参考 DApp(独立模式 + preprod 模式)
1) 四种网络一览
| 网络 | networkId | 用途 | 证明服务 | DUST 流程 |
|---|---|---|---|---|
undeployed | 'undeployed' | 本地开发、CI | 本地 Docker :6300 | 手动注册(创世钱包预先注资) |
preview | 'preview' | 在测试网上积极开发 | 1AM ProofStation(api-preview.1am.xyz) | 赞助 — 用户无需支付任何费用 |
preprod | 'preprod' | 发布前 / 集成测试 | 本地 Docker :6300 或 ProofStation | 手动注册(faucet → NIGHT → DUST) |
mainnet | 'mainnet' | 生产环境 | 1AM ProofStation(api.1am.xyz) | 赞助 — 用户无需支付任何费用 |
关键区别: 在 preview 和 mainnet 上,1AM 钱包通过 ProofStation 赞助所有费用——用户无需 NIGHT 或 DUST。在 undeployed 和 preprod 上,必须运行自己的证明服务,并在交易生效前手动注册 NIGHT UTXO 以生成 DUST。
2) 本地 Docker 技术栈(undeployed)
在本地运行 undeployed 网络需要三个 Docker 容器:Midnight 节点、索引器和证明服务。不能只运行证明服务——三个都需要运行。
选项 A:使用 midnight-local-dev(推荐)
github.com/midnightntwrk/midnight-local-dev 中的官方工具负责管理完整技术栈并处理创世钱包注资:
git clone https://github.com/midnightntwrk/midnight-local-dev
cd midnight-local-dev
npm install
npm start
npm start 将会:
- 检测容器是否已在运行(提示复用或重启)
- 通过 Docker Compose 拉取并启动全部三个容器
- 初始化创世主钱包(种子
0x00...01)并注册其 DUST - 显示资金发放菜单,可为测试账户转入 NIGHT 并注册 DUST
运行后,将 DApp 连接到:
networkId: undeployed
indexer: http://localhost:8088/api/v3/graphql
indexerWS: ws://localhost:8088/api/v3/graphql/ws
node: http://localhost:9944
proofServer: http://localhost:6300
注意: 本地索引器使用 /api/v3/graphql(测试网使用的是 v4)。请使用正确的路径。
选项 B:直接使用 Docker Compose(不使用资金发放 CLI)
# From midnight-local-dev repo root:
docker compose -f standalone.yml up -d
# Check all three services are healthy
docker compose -f standalone.yml ps
# View logs
docker compose -f standalone.yml logs -f
# Tear down
docker compose -f standalone.yml down
在此模式下,需要自行管理创世钱包初始化和 DUST 注册。
Docker 镜像版本(截至 2026 年)
| 服务 | 镜像 | 版本 |
|---|---|---|
| 节点 | midnightntwrk/midnight-node | 0.21.0 |
| 索引器 | midnightntwrk/indexer-standalone | 3.1.0 |
| 证明服务 | midnightntwrk/proof-server | 7.0.0 |
在 undeployed 上使用 Lace 钱包
在网络设置中选择“Undeployed”后,Lace 浏览器钱包会自动连接到本地技术栈——它将 localhost:9944、localhost:8088 和 localhost:6300 硬编码在其中。无需配置自定义端点。
在本地为测试账户注资
# Option 1: Fund from a JSON file (gets NIGHT + DUST registered)
# accounts.json: { "accounts": [{ "name": "Alice", "mnemonic": "..." }] }
# (max 10 accounts, 50,000 NIGHT each)
# Option 2: Fund by Bech32 address (NIGHT only — DUST not registered)
# Use when you have a Lace address or DApp-generated address
创世主钱包种子:0x0000000000000000000000000000000000000000000000000000000000000001
3) 网络配置
// src/config/networks.ts
import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
export type NetworkId = 'undeployed' | 'preview' | 'preprod' | 'mainnet';
// 'sponsored' = 1AM ProofStation pays fees (preview, mainnet)
// 'manual' = you run a proof server, user registers NIGHT→DUST (undeployed, preprod)
export type DustMode = 'sponsored' | 'manual';
export interface NetworkConfig {
networkId: NetworkId;
indexerHttp: string;
indexerWs: string;
rpc: string;
proofServerUrl: string;
dustMode: DustMode;
faucetUrl?: string;
}
export const NETWORKS: Record<NetworkId, NetworkConfig> = {
undeployed: {
networkId: 'undeployed',
indexerHttp: 'http://localhost:8088/api/v3/graphql', // NOTE: v3, not v4
indexerWs: 'ws://localhost:8088/api/v3/graphql/ws',
rpc: 'ws://localhost:9944',
proofServerUrl: 'http://127.0.0.1:6300',
dustMode: 'manual',
},
preview: {
networkId: 'preview',
indexerHttp: 'https://indexer.preview.midnight.network/api/v4/graphql',
indexerWs: 'wss://indexer.preview.midnight.network/api/v4/graphql/ws',
rpc: 'wss://rpc.preview.midnight.network',
proofServerUrl: 'https://api-preview.1am.xyz', // 1AM ProofStation
dustMode: 'sponsored',
},
preprod: {
networkId: 'preprod',
indexerHttp: 'https://indexer.preprod.midnight.network/api/v4/graphql',
indexerWs: 'wss://indexer.preprod.midnight.network/api/v4/graphql/ws',
rpc: 'https://rpc.preprod.midnight.network',
proofServerUrl: 'http://127.0.0.1:6300', // local proof server (run via Docker)
dustMode: 'manual',
faucetUrl: 'https://faucet.preprod.midnight.network',
},
mainnet: {
networkId: 'mainnet',
indexerHttp: 'https://indexer.mainnet.midnight.network/api/v4/graphql',
indexerWs: 'wss://indexer.mainnet.midnight.network/api/v4/graphql/ws',
rpc: 'https://rpc.mainnet.midnight.network',
proofServerUrl: 'https://api.1am.xyz', // 1AM ProofStation
dustMode: 'sponsored',
},
};
export function getActiveNetwork(): NetworkConfig {
const id = (
process.env.NETWORK ||
process.env.VITE_NETWORK ||
'undeployed'
) as NetworkId;
const config = NETWORKS[id];
if (!config) throw new Error(`Unknown network: "${id}". Valid: ${Object.keys(NETWORKS).join(', ')}`);
return config;
}
// Call this once at startup before any SDK operations
export function applyNetworkId(config: NetworkConfig) {
setNetworkId(config.networkId);
}
4) 统一 Provider 构建器
// src/config/providers.ts
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import type { NetworkConfig } from './networks';
// ── ZK Config Provider ──────────────────────────────────────────────────────
// Node.js: load ZK assets from filesystem
export function makeNodeZkConfigProvider(zkConfigPath: string) {
const { NodeZkConfigProvider } = require('@midnight-ntwrk/midnight-js-node-zk-config-provider');
const path = require('path');
return new NodeZkConfigProvider(path.resolve(zkConfigPath));
}
// Browser: load ZK assets via fetch from CDN or public/
export function makeFetchZkConfigProvider(zkAssetBasePath: string) {
const { FetchZkConfigProvider } = require('@midnight-ntwrk/midnight-js-fetch-zk-config-provider');
return new FetchZkConfigProvider(
new URL(zkAssetBasePath, window.location.origin).toString(),
window.fetch.bind(window),
);
}
// ── Proof Provider ───────────────────────────────────────────────────────────
// Node.js / self-hosted: points at local Docker proof server or ProofStation URL
// For ProofStation (preview/mainnet headless), pass the 1AM endpoint + API key
export function makeHttpProofProvider(proofServerUrl: string, zkConfigProvider: any) {
const { httpClientProofProvider } = require('@midnight-ntwrk/midnight-js-http-client-proof-provider');
return httpClientProofProvider(new URL(proofServerUrl), zkConfigProvider);
}
// Browser + 1AM wallet: proving handled by the wallet extension (no proof server needed)
export function make1AMProofProvider(provingProvider: any) {
const { createProofProvider } = require('@midnight-ntwrk/midnight-js-types');
return createProofProvider(provingProvider);
}
// ── Public Data Provider ─────────────────────────────────────────────────────
export function makePublicDataProvider(network: NetworkConfig) {
return indexerPublicDataProvider(network.indexerHttp, network.indexerWs);
}
// ── Private State Provider ───────────────────────────────────────────────────
// Node.js: persistent LevelDB store (survives restarts)
export function makeLevelPrivateStateProvider(storeName: string, walletProvider: any) {
const { levelPrivateStateProvider } = require('@midnight-ntwrk/midnight-js-level-private-state-provider');
return levelPrivateStateProvider({ privateStateStoreName: storeName, walletProvider });
}
// Browser: in-memory (lost on page refresh — acceptable for browser DApps)
export function makeInMemoryPrivateStateProvider() {
let scope = '';
const stateStore = new Map<string, unknown>();
const signingKeyStore = new Map<string, unknown>();
const key = (id: string) => `${scope}:${id}`;
return {
setContractAddress(address: string) { scope = address; },
async set(id: string, state: unknown) { stateStore.set(key(id), state); },
async get(id: string) { return stateStore.get(key(id)) ?? null; },
async remove(id: string) { stateStore.delete(key(id)); },
async clear() { stateStore.clear(); },
async setSigningKey(addr: string, k: unknown) { signingKeyStore.set(addr, k); },
async getSigningKey(addr: string) { return signingKeyStore.get(addr) ?? null; },
async removeSigningKey(addr: string) { signingKeyStore.delete(addr); },
async clearSigningKeys() { signingKeyStore.clear(); },
async exportPrivateStates(): Promise<never> { throw new Error('Not implemented'); },
async importPrivateStates(): Promise<never> { throw new Error('Not implemented'); },
async exportSigningKeys(): Promise<never> { throw new Error('Not implemented'); },
async importSigningKeys(): Promise<never> { throw new Error('Not implemented'); },
};
}
5) WalletProvider 和 MidnightProvider(Node.js 无头模式,适用于所有网络)
此桥接在所有网络上都相同。所有网络都需要使用 signTransactionIntents 变通方案——这是钱包 SDK 的缺陷,并非特定于某个网络。
// src/config/wallet-provider.ts
import * as Rx from 'rxjs';
import * as ledger from '@midnight-ntwrk/ledger-v8';
import type { WalletProvider, MidnightProvider } from '@midnight-ntwrk/midnight-js-types';
/**
* Workaround for wallet SDK bug: signRecipe hardcodes 'pre-proof' marker,
* but proven (UnboundTransaction) intents contain 'proof' data → "Failed to clone intent".
* Call with proofMarker='proof' for baseTransaction, 'pre-proof' for balancingTransaction.
*/
function signTransactionIntents(
tx: { intents?: Map<number, any> },
signFn: (payload: Uint8Array) => ledger.Signature,
proofMarker: 'proof' | 'pre-proof',
): void {
if (!tx.intents || tx.intents.size === 0) return;
for (const segment of tx.intents.keys()) {
const intent = tx.intents.get(segment);
if (!intent) continue;
const cloned = ledger.Intent.deserialize<
ledger.SignatureEnabled, ledger.Proofish, ledger.PreBinding
>('signature', proofMarker, 'pre-binding', intent.serialize());
const signature = signFn(cloned.signatureData(segment));
if (cloned.fallibleUnshieldedOffer) {
const sigs = cloned.fallibleUnshieldedOffer.inputs.map(
(_: ledger.UtxoSpend, i: number) =>
cloned.fallibleUnshieldedOffer!.signatures.at(i) ?? signature,
);
cloned.fallibleUnshieldedOffer = cloned.fallibleUnshieldedOffer.addSignatures(sigs);
}
if (cloned.guaranteedUnshieldedOffer) {
const sigs = cloned.guaranteedUnshieldedOffer.inputs.map(
(_: ledger.UtxoSpend, i: number) =>
cloned.guaranteedUnshieldedOffer!.signatures.at(i) ?? signature,
);
cloned.guaranteedUnshieldedOffer = cloned.guaranteedUnshieldedOffer.addSignatures(sigs);
}
tx.intents.set(segment, cloned);
}
}
export async function makeWalletAndMidnightProvider(
walletFacade: any,
shieldedSecretKeys: ledger.ZswapSecretKeys,
dustSecretKey: ledger.DustSecretKey,
unshieldedKeystore: any,
): Promise<WalletProvider & MidnightProvider> {
// state() is an Observable — must subscribe, not access as property
const state = await Rx.firstValueFrom(
walletFacade.state().pipe(Rx.filter((s: any) => s.isSynced)),
);
const signFn = (payload: Uint8Array) => unshieldedKeystore.signData(payload);
return {
getCoinPublicKey() {
return state.shielded.coinPublicKey.toHexString();
},
getEncryptionPublicKey() {
return state.shielded.encryptionPublicKey.toHexString();
},
async balanceTx(tx: any, ttl?: Date) {
const recipe = await walletFacade.balanceUnboundTransaction(
tx,
{ shieldedSecretKeys, dustSecretKey },
{ ttl: ttl ?? new Date(Date.now() + 30 * 60 * 1000) },
);
// Apply signTransactionIntents workaround on both tx parts
signTransactionIntents(recipe.baseTransaction, signFn, 'proof');
if (recipe.balancingTransaction) {
signTransactionIntents(recipe.balancingTransaction, signFn, 'pre-proof');
}
return walletFacade.finalizeRecipe(recipe);
},
async submitTx(tx: any) {
return walletFacade.submitTransaction(tx) as any;
},
};
}
6) 各网络的 DUST 流程
undeployed → manual: register NIGHT UTXOs → wait for DUST to accrue
preview → sponsored: 1AM ProofStation pays all fees → user needs nothing
preprod → manual: faucet NIGHT → register UTXOs → wait for DUST
mainnet → sponsored: 1AM ProofStation pays all fees → user needs nothing
// src/config/dust.ts
import * as Rx from 'rxjs';
import type { NetworkConfig } from './networks';
export async function ensureDust(
walletFacade: any,
unshieldedKeystore: any,
network: NetworkConfig,
): Promise<void> {
// sponsored networks: 1AM pays fees — nothing to do
if (network.dustMode === 'sponsored') return;
const state = await Rx.firstValueFrom(
walletFacade.state().pipe(Rx.filter((s: any) => s.isSynced)),
);
// already have DUST
if (state.dust.availableCoins.length > 0) return;
// find NIGHT UTXOs not yet registered for DUST generation
const unregistered = state.unshielded.availableCoins.filter(
(coin: any) => coin.meta?.registeredForDustGeneration !== true,
);
if (unregistered.length === 0) {
// all UTXOs registered — just wait for DUST to accrue
console.log('Waiting for DUST to generate (all UTXOs already registered)...');
} else {
// submit registration tx
console.log(`Registering ${unregistered.length} NIGHT UTXO(s) for DUST generation...`);
const recipe = await walletFacade.registerNightUtxosForDustGeneration(
unregistered,
unshieldedKeystore.getPublicKey(),
(payload: Uint8Array) => unshieldedKeystore.signData(payload),
);
const finalized = await walletFacade.finalizeRecipe(recipe);
await walletFacade.submitTransaction(finalized);
}
// wait for DUST balance > 0 (can take a few minutes on preprod)
await Rx.firstValueFrom(
walletFacade.state().pipe(
Rx.throttleTime(5_000),
Rx.filter((s: any) => s.isSynced),
Rx.filter((s: any) => s.dust.walletBalance(new Date()) > 0n),
),
);
console.log('DUST available.');
}
7) 钱包初始化(Node.js,适用于所有网络)
// src/config/wallet.ts
import * as Rx from 'rxjs';
import * as ledger from '@midnight-ntwrk/ledger-v8';
import { HDWallet, generateRandomSeed, Roles } from '@midnight-ntwrk/wallet-sdk-hd';
import { ShieldedWallet } from '@midnight-ntwrk/wallet-sdk-shielded';
import {
UnshieldedWallet, createKeystore, PublicKey,
InMemoryTransactionHistoryStorage,
} from '@midnight-ntwrk/wallet-sdk-unshielded-wallet';
import { DustWallet } from '@midnight-ntwrk/wallet-sdk-dust-wallet';
import { WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';
import { WebSocket } from 'ws';
import { Buffer } from 'buffer';
import { applyNetworkId, type NetworkConfig } from './networks';
import { ensureDust } from './dust';
import { makeWalletAndMidnightProvider } from './wallet-provider';
// Required: set WebSocket global before any wallet SDK imports
globalThis.WebSocket = WebSocket as unknown as typeof globalThis.WebSocket;
export async function buildHeadlessWallet(network: NetworkConfig, seedHex?: string) {
applyNetworkId(network);
const seed = seedHex
? Buffer.from(seedHex, 'hex')
: Buffer.from(generateRandomSeed());
const hdWallet = HDWallet.fromSeed(seed);
if (hdWallet.type !== 'seedOk') throw new Error('Invalid wallet seed');
const derivationResult = hdWallet.hdWallet
.selectAccount(0)
.selectRoles([Roles.Zswap, Roles.NightExternal, Roles.Dust])
.deriveKeysAt(0);
if (derivationResult.type !== 'keysDerived') throw new Error('Key derivation failed');
hdWallet.hdWallet.clear(); // wipe secret material from memory
const keys = derivationResult.keys;
const shieldedSecretKeys = ledger.ZswapSecretKeys.fromSeed(keys[Roles.Zswap]);
const dustSecretKey = ledger.DustSecretKey.fromSeed(keys[Roles.Dust]);
const unshieldedKeystore = createKeystore(keys[Roles.NightExternal], network.networkId);
// relayURL must be WebSocket — convert http→ws, https→wss
const relayURL = new URL(network.rpc.replace(/^https/, 'wss').replace(/^http(?!s)/, 'ws'));
const shieldedWallet = ShieldedWallet({
networkId: network.networkId,
indexerClientConnection: { indexerHttpUrl: network.indexerHttp, indexerWsUrl: network.indexerWs },
provingServerUrl: new URL(network.proofServerUrl),
relayURL,
}).startWithSecretKeys(shieldedSecretKeys);
const unshieldedWallet = UnshieldedWallet({
networkId: network.networkId,
indexerClientConnection: { indexerHttpUrl: network.indexerHttp, indexerWsUrl: network.indexerWs },
txHistoryStorage: new InMemoryTransactionHistoryStorage(),
}).startWithPublicKey(PublicKey.fromKeyStore(unshieldedKeystore));
const dustWallet = DustWallet({
networkId: network.networkId,
costParameters: {
additionalFeeOverhead: 300_000_000_000_000n,
feeBlocksMargin: 5,
},
indexerClientConnection: { indexerHttpUrl: network.indexerHttp, indexerWsUrl: network.indexerWs },
provingServerUrl: new URL(network.proofServerUrl),
relayURL,
}).startWithSecretKey(dustSecretKey, ledger.LedgerParameters.initialParameters().dust);
const walletFacade = new WalletFacade(shieldedWallet, unshieldedWallet, dustWallet);
await walletFacade.start(shieldedSecretKeys, dustSecretKey);
// Wait for sync
await Rx.firstValueFrom(
walletFacade.state().pipe(
Rx.throttleTime(5_000),
Rx.filter((s: any) => s.isSynced),
),
);
// Handle DUST based on network's dustMode
await ensureDust(walletFacade, unshieldedKeystore, network);
const walletProvider = await makeWalletAndMidnightProvider(
walletFacade, shieldedSecretKeys, dustSecretKey, unshieldedKeystore,
);
return {
walletFacade,
shieldedSecretKeys,
dustSecretKey,
unshieldedKeystore,
walletProvider,
seedHex: Buffer.from(seed).toString('hex'),
unshieldedAddress: unshieldedKeystore.getBech32Address(),
};
}
8) 完整 Provider 组装
// src/config/providers-full.ts
import type { NetworkConfig } from './networks';
import {
makeNodeZkConfigProvider, makeFetchZkConfigProvider,
makeHttpProofProvider, make1AMProofProvider,
makePublicDataProvider,
makeLevelPrivateStateProvider, makeInMemoryPrivateStateProvider,
} from './providers';
export async function assembleProviders(
network: NetworkConfig,
zkConfigPath: string, // filesystem path (Node.js) or URL path (browser)
walletContext: {
walletProvider: any;
midnightProvider?: any;
},
privateStateStoreName: string,
proofProviderOverride?: any, // pass 1AM provingProvider for browser
) {
const isBrowser = typeof window !== 'undefined';
const zkConfigProvider = isBrowser
? makeFetchZkConfigProvider(zkConfigPath)
: makeNodeZkConfigProvider(zkConfigPath);
const proofProvider = proofProviderOverride
? make1AMProofProvider(proofProviderOverride) // browser + 1AM
: makeHttpProofProvider(network.proofServerUrl, zkConfigProvider); // Node.js
const privateStateProvider = isBrowser
? makeInMemoryPrivateStateProvider()
: makeLevelPrivateStateProvider(privateStateStoreName, walletContext.walletProvider);
return {
zkConfigProvider,
publicDataProvider: makePublicDataProvider(network),
proofProvider,
privateStateProvider,
walletProvider: walletContext.walletProvider,
midnightProvider: walletContext.midnightProvider ?? walletContext.walletProvider,
};
}
9) 合约注册表
// src/config/registry.ts
// { contractName: { networkId: contractAddress } }
export type ContractRegistry = Record<string, Partial<Record<string, string>>>;
export const registry: ContractRegistry = {
counter: {
undeployed: '', // fill after local deploy
preview: '', // fill after preview deploy
preprod: '09dbe05f...',
mainnet: '',
},
};
export function resolveAddress(
contractName: string,
networkId: string,
): string {
const addr = registry[contractName]?.[networkId];
if (!addr) throw new Error(
`No address for contract "${contractName}" on network "${networkId}". Deploy first.`,
);
return addr;
}
export function saveAddress(contractName: string, networkId: string, address: string): void {
if (!registry[contractName]) registry[contractName] = {};
registry[contractName][networkId] = address;
// In a real app, persist this to a JSON file or env variable
}
10) npm 脚本
{
"scripts": {
"dev": "VITE_NETWORK=undeployed vite",
"dev:preview": "VITE_NETWORK=preview vite",
"dev:preprod": "VITE_NETWORK=preprod vite",
"deploy:local": "NETWORK=undeployed npx tsx scripts/deploy.ts",
"deploy:preview": "NETWORK=preview npx tsx scripts/deploy.ts",
"deploy:preprod": "NETWORK=preprod npx tsx scripts/deploy.ts",
"deploy:mainnet": "NETWORK=mainnet npx tsx scripts/deploy.ts",
"local:start": "docker compose -f standalone.yml up -d",
"local:stop": "docker compose -f standalone.yml down",
"local:logs": "docker compose -f standalone.yml logs -f",
"local:proof": "docker run -d -p 6300:6300 midnightntwrk/proof-server:latest midnight-proof-server -v",
"preprod:proof": "docker compose -f proof-server.yml up"
}
}
11) Vite 配置
// vite.config.ts
import { defineConfig } from 'vite';
import wasm from 'vite-plugin-wasm';
import topLevelAwait from 'vite-plugin-top-level-await';
export default defineConfig({
plugins: [wasm(), topLevelAwait()],
build: {
target: 'esnext',
},
server: { // proxy lives inside 'server', not at the top level
allowedHosts: true,
proxy: process.env.VITE_NETWORK === 'undeployed' ? {
'/api': {
target: 'http://localhost:8088',
changeOrigin: true,
},
} : undefined,
},
});
12)常见陷阱
undeployed 索引器使用 /api/v3/graphql,而不是 /api/v4/ — 本地 Docker 索引器随附的是 v3。使用 v4 会返回 404。所有线上网络(preview、preprod、mainnet)都使用 v4。
本地开发需要运行 3 个容器,而不仅仅是证明服务 — 节点和索引器也必须运行。使用 midnight-local-dev 或 docker compose -f standalone.yml up。
walletFacade.state 是方法,而不是属性 — walletFacade.state() 会返回一个 Observable。始终使用 Rx.firstValueFrom(walletFacade.state().pipe(...)) 来读取它。直接访问 .state 返回的是函数,而不是状态。
relayURL 必须使用 WebSocket — 将 https:// 转换为 wss://,将 http:// 转换为 ws://。钱包 SDK 会静默拒绝非 WS 中继 URL,或返回令人困惑的错误。
globalThis.WebSocket = WebSocket 必须放在入口文件顶部 — 位于任何 @midnight-ntwrk/wallet-sdk-* 导入之前。若未设置,Node.js 中的 GraphQL 订阅(钱包同步)会静默失败。
所有网络都需要使用 signTransactionIntents 变通方案 — 这是钱包 SDK 的错误,与环境无关。若不使用此方案,无头钱包中的 balanceTx 会抛出 “Failed to clone intent”。
部署失败后 DUST 会被锁定 — 重启 DApp 以释放锁定的 DUST 代币。仅影响 undeployed 和 preprod;赞助网络不受影响。
直接无头访问 preprod 需要 ProofStation API 密钥 — 如果直接调用 api-preprod.1am.xyz(而不是通过浏览器 1AM 扩展),需要在标头中加入 X-API-Key: pk_live_xxx。httpClientProofProvider 不会自动添加此项,你需要对其进行包装或派生分支。
重新编译后合约地址会过期 — verifier 密钥会随 Compact 合约的每次更改而变化。更新注册表并在受影响的网络上重新部署。其他网络的地址不受影响。
在 SDK 操作之后才调用 setNetworkId — 应在构建任何 provider 或钱包之前调用它。在设置流程最开始使用 applyNetworkId(network)。
浏览器构建中包含钱包助记词 — 切勿将 WALLET_SEED 传入 Vite 的环境变量(它会暴露在打包文件中)。无头钱包仅支持 Node.js。浏览器始终使用 1AM 扩展。