1am-wallet
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseScope
This skill covers detecting, connecting, and wiring the 1AM browser extension () into a frontend dApp. The 1AM wallet handles all ZK proving and dust fee sponsorship — users pay zero gas. This skill is generic: replace every / / placeholder with your actual contract name and circuit IDs.
window.midnight['1am']YourContractyourCircuityour-contractSuggested file layout (adapt to your project):
- → wallet session, provider wiring, indexer patch (canonical source:
src/lib/midnight.ts)references/midnight-session.md - → optional payload encryption derived from wallet signature
src/lib/encryption.ts - → app logic and state orchestration
src/hooks/useContract.ts - → wallet connection state
src/contexts/WalletContext.tsx
Shared references: , ,
references/midnight-session.mdreferences/gotchas.mdreferences/versions.json范围
本技能涵盖检测、连接1AM浏览器扩展()并将其接入前端dApp。1AM钱包负责所有ZK证明和粉尘费用赞助——用户无需支付任何gas费用。本技能为通用模板:请将所有 / / 占位符替换为实际的合约名称和电路ID。
window.midnight['1am']YourContractyourCircuityour-contract建议的文件结构(可根据项目调整):
- → 钱包会话、提供者配置、索引器补丁(标准来源:
src/lib/midnight.ts)references/midnight-session.md - → 可选的基于钱包签名派生的负载加密功能
src/lib/encryption.ts - → 应用逻辑与状态编排
src/hooks/useContract.ts - → 钱包连接状态
src/contexts/WalletContext.tsx
共享参考文档: , ,
references/midnight-session.mdreferences/gotchas.mdreferences/versions.json1) Dependencies
1) 依赖项
Exact versions known to work together:
bash
npm install \
@midnight-ntwrk/compact-runtime@^0.15.0 \
@midnight-ntwrk/ledger@^4.0.0 \
@midnight-ntwrk/ledger-v8@^8.0.3 \
@midnight-ntwrk/midnight-js-contracts@^4.0.4 \
@midnight-ntwrk/midnight-js-fetch-zk-config-provider@^4.0.4 \
@midnight-ntwrk/midnight-js-indexer-public-data-provider@^4.0.4 \
@midnight-ntwrk/midnight-js-network-id@^4.0.4 \
@midnight-ntwrk/midnight-js-types@^4.0.4 \
@midnight-ntwrk/wallet-sdk-address-format@^3.1.0Vite requires these plugins for WASM and top-level await (the Compact SDK uses both):
bash
npm install -D vite-plugin-wasm vite-plugin-top-level-awaitFor Next.js, see §12 — webpack config is required instead.
已知可兼容的精确版本:
bash
npm install \
@midnight-ntwrk/compact-runtime@^0.15.0 \
@midnight-ntwrk/ledger@^4.0.0 \
@midnight-ntwrk/ledger-v8@^8.0.3 \
@midnight-ntwrk/midnight-js-contracts@^4.0.4 \
@midnight-ntwrk/midnight-js-fetch-zk-config-provider@^4.0.4 \
@midnight-ntwrk/midnight-js-indexer-public-data-provider@^4.0.4 \
@midnight-ntwrk/midnight-js-network-id@^4.0.4 \
@midnight-ntwrk/midnight-js-types@^4.0.4 \
@midnight-ntwrk/wallet-sdk-address-format@^3.1.0Vite需要以下插件来支持WASM和顶级await(Compact SDK同时使用这两项特性):
bash
npm install -D vite-plugin-wasm vite-plugin-top-level-await对于Next.js,请参考第12节——需要配置webpack替代上述插件。
2) Wallet Detection & Connection
2) 钱包检测与连接
The extension injects asynchronously — always poll, never assume it's immediately available. Both 1AM and Lace wallets are supported.
ts
// Inline detection (non-React)
function detectWallet(): Promise<any | null> {
return new Promise((resolve) => {
let attempts = 0;
const check = () => {
const wallet = (window as any).midnight?.['1am'];
if (wallet) { resolve(wallet); return; }
if (++attempts > 50) { resolve(null); return; }
setTimeout(check, 100);
};
check();
});
}
// Connect
const wallet = await detectWallet();
if (!wallet) throw new Error('1AM wallet not installed');
const api = await wallet.connect('preprod'); // 'preview' | 'preprod' | 'mainnet'扩展会异步注入——请始终轮询检测,切勿假设它会立即可用。本技能同时支持1AM和Lace钱包。
ts
// 内联检测(非React环境)
function detectWallet(): Promise<any | null> {
return new Promise((resolve) => {
let attempts = 0;
const check = () => {
const wallet = (window as any).midnight?.['1am'];
if (wallet) { resolve(wallet); return; }
if (++attempts > 50) { resolve(null); return; }
setTimeout(check, 100);
};
check();
});
}
// 连接钱包
const wallet = await detectWallet();
if (!wallet) throw new Error('1AM wallet not installed');
const api = await wallet.connect('preprod'); // 'preview' | 'preprod' | 'mainnet'React Context + useWallet Hook
React上下文 + useWallet钩子
Wrap your app with , then call in any component.
WalletProvideruseWallet()tsx
// contexts/WalletContext.tsx
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
type WalletContextType = {
address: string | null;
isConnected: boolean;
walletType: '1am' | 'lace' | null;
isConnecting: boolean;
walletStatus: 'checking' | 'detected' | 'not-found';
session: ConnectedSession | null;
connect: (network?: string) => Promise<ConnectedSession | undefined>;
disconnect: () => void;
};
const WalletContext = createContext<WalletContextType | null>(null);
export function WalletProvider({ children }: { children: React.ReactNode }) {
const [address, setAddress] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [walletType, setWalletType] = useState<'1am' | 'lace' | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [walletStatus, setWalletStatus] = useState<'checking' | 'detected' | 'not-found'>('checking');
const [session, setSession] = useState<ConnectedSession | null>(null);
const connectingRef = useRef(false);
// Poll for wallet injection — runs once on mount
useEffect(() => {
const startedAt = Date.now();
const id = setInterval(() => {
const w1am = (window as any).midnight?.['1am'];
const wLace = (window as any).midnight?.mnLace;
if (w1am) { setWalletType('1am'); setWalletStatus('detected'); clearInterval(id); return; }
if (wLace) { setWalletType('lace'); setWalletStatus('detected'); clearInterval(id); return; }
if (Date.now() - startedAt >= 6000) { setWalletStatus('not-found'); clearInterval(id); }
}, 300);
return () => clearInterval(id);
}, []);
const connect = useCallback(async (network = 'preprod') => {
if (connectingRef.current) return;
connectingRef.current = true;
setIsConnecting(true);
try {
const wallet = (window as any).midnight?.['1am'] ?? (window as any).midnight?.mnLace;
if (!wallet) throw new Error('No wallet found');
const api = await wallet.connect(network);
const { createConnectedSession } = await import('../lib/midnight');
const sess = await createConnectedSession(api);
setSession(sess);
setAddress((await api.getUnshieldedAddress()).unshieldedAddress);
setIsConnected(true);
return sess;
} finally {
connectingRef.current = false;
setIsConnecting(false);
}
}, []);
const disconnect = useCallback(() => {
setAddress(null); setIsConnected(false); setSession(null);
setWalletStatus('checking'); setWalletType(null);
}, []);
return (
<WalletContext.Provider value={{ address, isConnected, walletType, isConnecting, walletStatus, session, connect, disconnect }}>
{children}
</WalletContext.Provider>
);
}
export function useWallet(): WalletContextType {
const ctx = useContext(WalletContext);
if (!ctx) throw new Error('useWallet must be used within a WalletProvider');
return ctx;
}使用包裹应用,然后在任意组件中调用。
WalletProvideruseWallet()tsx
// contexts/WalletContext.tsx
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
type WalletContextType = {
address: string | null;
isConnected: boolean;
walletType: '1am' | 'lace' | null;
isConnecting: boolean;
walletStatus: 'checking' | 'detected' | 'not-found';
session: ConnectedSession | null;
connect: (network?: string) => Promise<ConnectedSession | undefined>;
disconnect: () => void;
};
const WalletContext = createContext<WalletContextType | null>(null);
export function WalletProvider({ children }: { children: React.ReactNode }) {
const [address, setAddress] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [walletType, setWalletType] = useState<'1am' | 'lace' | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [walletStatus, setWalletStatus] = useState<'checking' | 'detected' | 'not-found'>('checking');
const [session, setSession] = useState<ConnectedSession | null>(null);
const connectingRef = useRef(false);
// 轮询检测钱包注入——挂载时仅执行一次
useEffect(() => {
const startedAt = Date.now();
const id = setInterval(() => {
const w1am = (window as any).midnight?.['1am'];
const wLace = (window as any).midnight?.mnLace;
if (w1am) { setWalletType('1am'); setWalletStatus('detected'); clearInterval(id); return; }
if (wLace) { setWalletType('lace'); setWalletStatus('detected'); clearInterval(id); return; }
if (Date.now() - startedAt >= 6000) { setWalletStatus('not-found'); clearInterval(id); }
}, 300);
return () => clearInterval(id);
}, []);
const connect = useCallback(async (network = 'preprod') => {
if (connectingRef.current) return;
connectingRef.current = true;
setIsConnecting(true);
try {
const wallet = (window as any).midnight?.['1am'] ?? (window as any).midnight?.mnLace;
if (!wallet) throw new Error('No wallet found');
const api = await wallet.connect(network);
const { createConnectedSession } = await import('../lib/midnight');
const sess = await createConnectedSession(api);
setSession(sess);
setAddress((await api.getUnshieldedAddress()).unshieldedAddress);
setIsConnected(true);
return sess;
} finally {
connectingRef.current = false;
setIsConnecting(false);
}
}, []);
const disconnect = useCallback(() => {
setAddress(null); setIsConnected(false); setSession(null);
setWalletStatus('checking'); setWalletType(null);
}, []);
return (
<WalletContext.Provider value={{ address, isConnected, walletType, isConnecting, walletStatus, session, connect, disconnect }}>
{children}
</WalletContext.Provider>
);
}
export function useWallet(): WalletContextType {
const ctx = useContext(WalletContext);
if (!ctx) throw new Error('useWallet must be used within a WalletProvider');
return ctx;
}WalletConnect UI Component
WalletConnect UI组件
Always render all four states: , , disconnected (connect CTA), connected (address + disconnect).
checkingnot-foundtsx
import { Loader2, LogOut, Shield, Smartphone } from 'lucide-react';
import { useWallet } from '../contexts/WalletContext';
export default function WalletConnect() {
const { isConnected, address, walletType, walletStatus, isConnecting, connect, disconnect } = useWallet();
if (walletStatus === 'checking')
return <span className="text-zinc-600 text-[11px] font-mono animate-pulse">Checking wallet...</span>;
if (isConnected)
return (
<div className="flex items-center gap-3 border border-white/[0.06] px-4 py-2">
{walletType === 'lace'
? <Smartphone className="w-3.5 h-3.5 text-violet-400" />
: <Shield className="w-3.5 h-3.5 text-violet-400" />}
<div>
<span className="text-[9px] tracking-[0.2em] font-mono text-zinc-600 uppercase block">
{walletType === '1am' ? '1AM' : 'Lace'}
</span>
<span className="text-[11px] font-mono text-zinc-300 truncate max-w-[130px] block">{address}</span>
</div>
<button onClick={disconnect} title="Disconnect" className="text-zinc-600 hover:text-red-400">
<LogOut className="w-3.5 h-3.5" />
</button>
</div>
);
return (
<div className="flex flex-col gap-2">
<button
onClick={() => connect('preprod')}
disabled={isConnecting}
className="flex items-center gap-2 bg-violet-600 hover:bg-violet-500 text-white text-[11px] font-mono tracking-widest uppercase py-2.5 px-5 transition-all disabled:opacity-40"
>
{isConnecting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Smartphone className="w-3.5 h-3.5" />}
Connect Wallet
</button>
{walletStatus === 'not-found' &&
<p className="text-[10px] font-mono text-zinc-600">Install 1AM or Lace wallet extension</p>}
</div>
);
}请始终渲染四种状态:(检测中)、(未找到)、已断开(连接按钮)、已连接(地址+断开按钮)。
checkingnot-foundtsx
import { Loader2, LogOut, Shield, Smartphone } from 'lucide-react';
import { useWallet } from '../contexts/WalletContext';
export default function WalletConnect() {
const { isConnected, address, walletType, walletStatus, isConnecting, connect, disconnect } = useWallet();
if (walletStatus === 'checking')
return <span className="text-zinc-600 text-[11px] font-mono animate-pulse">Checking wallet...</span>;
if (isConnected)
return (
<div className="flex items-center gap-3 border border-white/[0.06] px-4 py-2">
{walletType === 'lace'
? <Smartphone className="w-3.5 h-3.5 text-violet-400" />
: <Shield className="w-3.5 h-3.5 text-violet-400" />}
<div>
<span className="text-[9px] tracking-[0.2em] font-mono text-zinc-600 uppercase block">
{walletType === '1am' ? '1AM' : 'Lace'}
</span>
<span className="text-[11px] font-mono text-zinc-300 truncate max-w-[130px] block">{address}</span>
</div>
<button onClick={disconnect} title="Disconnect" className="text-zinc-600 hover:text-red-400">
<LogOut className="w-3.5 h-3.5" />
</button>
</div>
);
return (
<div className="flex flex-col gap-2">
<button
onClick={() => connect('preprod')}
disabled={isConnecting}
className="flex items-center gap-2 bg-violet-600 hover:bg-violet-500 text-white text-[11px] font-mono tracking-widest uppercase py-2.5 px-5 transition-all disabled:opacity-40"
>
{isConnecting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Smartphone className="w-3.5 h-3.5" />}
Connect Wallet
</button>
{walletStatus === 'not-found' &&
<p className="text-[10px] font-mono text-zinc-600">Install 1AM or Lace wallet extension</p>}
</div>
);
}3) Session Setup (createConnectedSession
)
createConnectedSession3) 会话设置(createConnectedSession
)
createConnectedSessionFetch config, network ID, and all addresses in parallel — never await them in sequence.
ts
// src/lib/midnight.ts
import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import { FetchZkConfigProvider } from '@midnight-ntwrk/midnight-js-fetch-zk-config-provider';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import type { MidnightProvider, WalletProvider } from '@midnight-ntwrk/midnight-js-types';
export type ConnectedSession = {
api: any;
config: any;
providers: {
privateStateProvider: ReturnType<typeof createPrivateStateProvider>;
publicDataProvider: ReturnType<typeof createPatchedPublicDataProvider>;
zkConfigProvider: FetchZkConfigProvider;
proofProvider: { proveTx: (unprovenTx: any, _config: any) => Promise<any> };
walletProvider: WalletProvider;
midnightProvider: MidnightProvider;
};
unshieldedAddress: string;
};
export async function createConnectedSession(api: any): Promise<ConnectedSession> {
// Fetch in parallel — do not await sequentially
const [config, unshieldedAddress, shieldedAddress] = await Promise.all([
api.getConfiguration(),
api.getUnshieldedAddress(),
api.getShieldedAddresses(),
]);
// Must be called before any SDK operations
setNetworkId(config.networkId);
// ZK assets are served from /contract/collection relative to your origin.
// Adjust the path to match where your compiled contract assets are hosted.
const zkConfigProvider = new FetchZkConfigProvider(
new URL('/contract/your-contract', window.location.origin).toString(),
window.fetch.bind(window),
);
// Optional: smoke-test ZK asset reachability at startup
zkConfigProvider.getZKIR('yourCircuit').then(
(zkir) => console.log('[zkConfigProvider] getZKIR ok, length:', zkir?.length),
(err) => console.error('[zkConfigProvider] getZKIR failed — check ZK asset hosting:', err),
);
const provingProvider = await api.getProvingProvider(zkConfigProvider);
// ✅ Use this custom wrapper — do NOT use createProofProvider() from @midnight-ntwrk/midnight-js-types.
// createProofProvider wraps the provider differently and does not pass CostModel correctly.
// unprovenTx.prove() called directly is the only pattern confirmed to work with 1AM's provingProvider.
const proofProvider = {
async proveTx(unprovenTx: any, _config: any) {
const { CostModel } = await import('@midnight-ntwrk/ledger-v8');
return unprovenTx.prove(provingProvider, CostModel.initialCostModel());
},
};
const walletProvider: WalletProvider = {
getCoinPublicKey: () => shieldedAddress.shieldedCoinPublicKey,
getEncryptionPublicKey: () => shieldedAddress.shieldedEncryptionPublicKey,
balanceTx: async (tx: any) => {
const txHex = toHex(tx.serialize());
const balanced = await api.balanceUnsealedTransaction(txHex);
if (!balanced?.tx) throw new Error('balanceUnsealedTransaction returned invalid result');
const { Transaction } = await import('@midnight-ntwrk/ledger-v8');
return Transaction.deserialize('signature', 'proof', 'binding', fromHex(balanced.tx));
},
};
const midnightProvider: MidnightProvider = {
submitTx: async (tx: any) => {
const txHex = toHex(tx.serialize());
const result = await api.submitTransaction(txHex);
// Accept string txId, or object with transactionId/id, or fall back to hex prefix
if (typeof result === 'string' && result) return result;
if (result?.transactionId) return result.transactionId;
if (result?.id) return result.id;
return txHex.slice(0, 64); // fallback pseudo-txId
},
};
const publicDataProvider = createPatchedPublicDataProvider(config.indexerUri, config.indexerWsUri);
return {
api,
config,
providers: {
privateStateProvider: createPrivateStateProvider(),
publicDataProvider,
zkConfigProvider,
proofProvider,
walletProvider,
midnightProvider,
},
unshieldedAddress: unshieldedAddress.unshieldedAddress,
};
}请并行获取配置、网络ID和所有地址——切勿按顺序等待。
ts
// src/lib/midnight.ts
import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import { FetchZkConfigProvider } from '@midnight-ntwrk/midnight-js-fetch-zk-config-provider';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import type { MidnightProvider, WalletProvider } from '@midnight-ntwrk/midnight-js-types';
export type ConnectedSession = {
api: any;
config: any;
providers: {
privateStateProvider: ReturnType<typeof createPrivateStateProvider>;
publicDataProvider: ReturnType<typeof createPatchedPublicDataProvider>;
zkConfigProvider: FetchZkConfigProvider;
proofProvider: { proveTx: (unprovenTx: any, _config: any) => Promise<any> };
walletProvider: WalletProvider;
midnightProvider: MidnightProvider;
};
unshieldedAddress: string;
};
export async function createConnectedSession(api: any): Promise<ConnectedSession> {
// 并行获取——请勿按顺序等待
const [config, unshieldedAddress, shieldedAddress] = await Promise.all([
api.getConfiguration(),
api.getUnshieldedAddress(),
api.getShieldedAddresses(),
]);
// 必须在所有SDK操作前调用
setNetworkId(config.networkId);
// ZK资产从相对于当前域名的/contract/collection路径加载。
// 请调整路径以匹配编译后的合约资产托管位置。
const zkConfigProvider = new FetchZkConfigProvider(
new URL('/contract/your-contract', window.location.origin).toString(),
window.fetch.bind(window),
);
// 可选:在启动时测试ZK资产的可达性
zkConfigProvider.getZKIR('yourCircuit').then(
(zkir) => console.log('[zkConfigProvider] getZKIR ok, length:', zkir?.length),
(err) => console.error('[zkConfigProvider] getZKIR failed — check ZK asset hosting:', err),
);
const provingProvider = await api.getProvingProvider(zkConfigProvider);
// ✅ 使用这个自定义包装器——请勿使用@midnight-ntwrk/midnight-js-types中的createProofProvider()。
// createProofProvider对提供者的包装方式不同,无法正确传递CostModel。
// 直接调用unprovenTx.prove()是唯一经确认可与1AM的provingProvider兼容的模式。
const proofProvider = {
async proveTx(unprovenTx: any, _config: any) {
const { CostModel } = await import('@midnight-ntwrk/ledger-v8');
return unprovenTx.prove(provingProvider, CostModel.initialCostModel());
},
};
const walletProvider: WalletProvider = {
getCoinPublicKey: () => shieldedAddress.shieldedCoinPublicKey,
getEncryptionPublicKey: () => shieldedAddress.shieldedEncryptionPublicKey,
balanceTx: async (tx: any) => {
const txHex = toHex(tx.serialize());
const balanced = await api.balanceUnsealedTransaction(txHex);
if (!balanced?.tx) throw new Error('balanceUnsealedTransaction returned invalid result');
const { Transaction } = await import('@midnight-ntwrk/ledger-v8');
return Transaction.deserialize('signature', 'proof', 'binding', fromHex(balanced.tx));
},
};
const midnightProvider: MidnightProvider = {
submitTx: async (tx: any) => {
const txHex = toHex(tx.serialize());
const result = await api.submitTransaction(txHex);
// 接受字符串格式的txId,或包含transactionId/id的对象,或回退到十六进制前缀
if (typeof result === 'string' && result) return result;
if (result?.transactionId) return result.transactionId;
if (result?.id) return result.id;
return txHex.slice(0, 64); // 回退的伪txId
},
};
const publicDataProvider = createPatchedPublicDataProvider(config.indexerUri, config.indexerWsUri);
return {
api,
config,
providers: {
privateStateProvider: createPrivateStateProvider(),
publicDataProvider,
zkConfigProvider,
proofProvider,
walletProvider,
midnightProvider,
},
unshieldedAddress: unshieldedAddress.unshieldedAddress,
};
}Hex Helpers (required — never skip padStart
)
padStart十六进制工具类(必填——切勿省略padStart
)
padStartts
export function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
export function fromHex(hex: string): Uint8Array {
const normalized = hex.startsWith('0x') ? hex.slice(2) : hex;
if (normalized.length % 2 !== 0) throw new Error('Invalid hex string from wallet.');
const bytes = new Uint8Array(normalized.length / 2);
for (let i = 0; i < normalized.length; i += 2) {
bytes[i / 2] = parseInt(normalized.slice(i, i + 2), 16);
}
return bytes;
}ts
export function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
export function fromHex(hex: string): Uint8Array {
const normalized = hex.startsWith('0x') ? hex.slice(2) : hex;
if (normalized.length % 2 !== 0) throw new Error('Invalid hex string from wallet.');
const bytes = new Uint8Array(normalized.length / 2);
for (let i = 0; i < normalized.length; i += 2) {
bytes[i / 2] = parseInt(normalized.slice(i, i + 2), 16);
}
return bytes;
}Coin Public Key Helpers
硬币公钥工具类
Use these when your contract takes a wallet address as an argument (e.g. recipient fields):
ts
export function coinPublicKeyToBytes(walletProvider: WalletProvider): Uint8Array {
const pk = walletProvider?.getCoinPublicKey?.() ?? '';
const hex = typeof pk === 'string' ? pk : Array.from(pk as number[]).map((b) => b.toString(16).padStart(2, '0')).join('');
const bytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return bytes;
}
// Compact's Either type — Left = shielded coin key, Right = unshielded/raw key
export function makeEitherLeft(bytes: Uint8Array) {
return { is_left: true, left: { bytes }, right: { bytes: new Uint8Array(32) } };
}
// Format an Either<Bytes, Bytes> address for display
export function formatAddress(either: any): string {
if (!either) return '—';
const bytes = either.is_left ? either.left?.bytes : either.right?.bytes;
if (!bytes) return '—';
return '0x' + Array.from(bytes as number[]).map((b) => b.toString(16).padStart(2, '0')).join('');
}当合约需要钱包地址作为参数时(例如接收者字段),请使用以下工具:
ts
export function coinPublicKeyToBytes(walletProvider: WalletProvider): Uint8Array {
const pk = walletProvider?.getCoinPublicKey?.() ?? '';
const hex = typeof pk === 'string' ? pk : Array.from(pk as number[]).map((b) => b.toString(16).padStart(2, '0')).join('');
const bytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return bytes;
}
// Compact的Either类型——Left = 屏蔽硬币密钥,Right = 未屏蔽/原始密钥
export function makeEitherLeft(bytes: Uint8Array) {
return { is_left: true, left: { bytes }, right: { bytes: new Uint8Array(32) } };
}
// 格式化Either<Bytes, Bytes>地址用于显示
export function formatAddress(either: any): string {
if (!either) return '—';
const bytes = either.is_left ? either.left?.bytes : either.right?.bytes;
if (!bytes) return '—';
return '0x' + Array.from(bytes as number[]).map((b) => b.toString(16).padStart(2, '0')).join('');
}4) Patched Public Data Provider ⚠️ Critical
4) 修补后的公共数据提供者 ⚠️ 关键
The preview and preprod indexers have a GraphQL bug with in latest-state queries. The default SDK without a config block hits this bug. Always wrap with this patch — without it, state reads will fail on these networks.
offset: nullqueryContractState()indexerPublicDataProviderts
import { ContractState } from '@midnight-ntwrk/compact-runtime';
import { LedgerParameters, ZswapChainState } from '@midnight-ntwrk/ledger-v8';
export function createPatchedPublicDataProvider(queryUrl: string, subscriptionUrl: string) {
const base = indexerPublicDataProvider(queryUrl, subscriptionUrl);
async function queryLatest(query: string, address: string) {
const res = await fetch(queryUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query, variables: { address } }),
});
if (!res.ok) throw new Error(`Indexer HTTP error: ${res.status}`);
const payload = await res.json();
if (payload.errors?.length) throw new Error(payload.errors.map((e: any) => e.message).join('; '));
return payload.data?.contractAction ?? null;
}
return {
...base,
async queryContractState(contractAddress: string, config?: any) {
if (config) return base.queryContractState(contractAddress, config);
const action = await queryLatest(`
query LATEST_CONTRACT_STATE($address: HexEncoded!) {
contractAction(address: $address) { state }
}`, contractAddress);
return action ? ContractState.deserialize(fromHex(action.state)) : null;
},
async queryZSwapAndContractState(contractAddress: string, config?: any) {
if (config) return base.queryZSwapAndContractState(contractAddress, config);
const action = await queryLatest(`
query LATEST_BOTH_STATE($address: HexEncoded!) {
contractAction(address: $address) {
state
zswapState
transaction { block { ledgerParameters } }
}
}`, contractAddress);
if (!action?.zswapState) return null;
return [
ZswapChainState.deserialize(fromHex(action.zswapState)),
ContractState.deserialize(fromHex(action.state)),
action.transaction?.block?.ledgerParameters
? LedgerParameters.deserialize(fromHex(action.transaction.block.ledgerParameters))
: LedgerParameters.initialParameters(),
];
},
};
}预览版和预生产版索引器在最新状态查询中存在的GraphQL bug。默认SDK的在没有配置块时会触发此bug。请始终使用此补丁包装——否则在这些网络上读取状态会失败。
offset: nullqueryContractState()indexerPublicDataProviderts
import { ContractState } from '@midnight-ntwrk/compact-runtime';
import { LedgerParameters, ZswapChainState } from '@midnight-ntwrk/ledger-v8';
export function createPatchedPublicDataProvider(queryUrl: string, subscriptionUrl: string) {
const base = indexerPublicDataProvider(queryUrl, subscriptionUrl);
async function queryLatest(query: string, address: string) {
const res = await fetch(queryUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query, variables: { address } }),
});
if (!res.ok) throw new Error(`Indexer HTTP error: ${res.status}`);
const payload = await res.json();
if (payload.errors?.length) throw new Error(payload.errors.map((e: any) => e.message).join('; '));
return payload.data?.contractAction ?? null;
}
return {
...base,
async queryContractState(contractAddress: string, config?: any) {
if (config) return base.queryContractState(contractAddress, config);
const action = await queryLatest(`
query LATEST_CONTRACT_STATE($address: HexEncoded!) {
contractAction(address: $address) { state }
}`, contractAddress);
return action ? ContractState.deserialize(fromHex(action.state)) : null;
},
async queryZSwapAndContractState(contractAddress: string, config?: any) {
if (config) return base.queryZSwapAndContractState(contractAddress, config);
const action = await queryLatest(`
query LATEST_BOTH_STATE($address: HexEncoded!) {
contractAction(address: $address) {
state
zswapState
transaction { block { ledgerParameters } }
}
}`, contractAddress);
if (!action?.zswapState) return null;
return [
ZswapChainState.deserialize(fromHex(action.zswapState)),
ContractState.deserialize(fromHex(action.state)),
action.transaction?.block?.ledgerParameters
? LedgerParameters.deserialize(fromHex(action.transaction.block.ledgerParameters))
: LedgerParameters.initialParameters(),
];
},
};
}5) Private State Provider (In-Memory)
5) 私有状态提供者(内存版)
Sufficient for most dApps. Private state does not persist across page reloads — this is intentional for a minimal reference implementation. For production persistence, replace with or an encrypted server store.
localStoragets
export function createPrivateStateProvider() {
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.'); },
};
}适用于大多数dApp。私有状态不会在页面刷新后保留——这是最小参考实现的有意设计。如需生产环境持久化,请替换为或加密的服务器存储。
localStoragets
export function createPrivateStateProvider() {
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.'); },
};
}6) Deploy & Call Contracts
6) 部署与调用合约
Replace , , and with your actual names.
YourContractyour-contractyourCircuitts
import { CompiledContract } from '@midnight-ntwrk/compact-js';
import { deployContract, submitCallTx } from '@midnight-ntwrk/midnight-js-contracts';
import { Contract } from './your-compiled-contract'; // generated by `compact` compiler
// Build the compiled contract handle (do this once, cache it)
function getCompiledContract() {
return CompiledContract.make('YourContract', Contract).pipe(
CompiledContract.withVacantWitnesses,
CompiledContract.withCompiledFileAssets('./contract/your-contract'),
) as any; // TypeScript: compiled contract generics are too narrow; cast is safe at runtime
}
// Call any circuit by name
async function callCircuit(
session: ConnectedSession,
contractAddress: string,
circuitId: string,
args: any[],
) {
const compiledContract = getCompiledContract();
const result = await submitCallTx(session.providers as any, {
compiledContract,
contractAddress,
circuitId,
args,
});
console.log('Tx hash:', result.public.txHash);
return result;
}⚠️ Do not usefor deploy on preprod/preview.deployContract()callsdeployContractinternally, which polls the indexer until the transaction is indexed — on preprod this can take 30–120s with no feedback, and will hang indefinitely if the indexer lags. Use the low-levelwatchForTxData+createUnprovenDeployTxpattern in §8 instead. The contract address is available fromsubmitTxAsyncimmediately, before any submission.deployTxData.public.contractAddressskips the blockingsubmitTxAsynccall entirely.watchForTxData
请将、和替换为实际名称。
YourContractyour-contractyourCircuitts
import { CompiledContract } from '@midnight-ntwrk/compact-js';
import { deployContract, submitCallTx } from '@midnight-ntwrk/midnight-js-contracts';
import { Contract } from './your-compiled-contract'; // 由`compact`编译器生成
// 构建编译后的合约句柄(仅执行一次,缓存结果)
function getCompiledContract() {
return CompiledContract.make('YourContract', Contract).pipe(
CompiledContract.withVacantWitnesses,
CompiledContract.withCompiledFileAssets('./contract/your-contract'),
) as any; // TypeScript:编译后的合约泛型过于狭窄;运行时类型转换是安全的
}
// 按名称调用任意电路
async function callCircuit(
session: ConnectedSession,
contractAddress: string,
circuitId: string,
args: any[],
) {
const compiledContract = getCompiledContract();
const result = await submitCallTx(session.providers as any, {
compiledContract,
contractAddress,
circuitId,
args,
});
console.log('Tx hash:', result.public.txHash);
return result;
}⚠️ 请勿在预生产/预览环境中使用。deployContract()内部调用deployContract,会轮询索引器直到交易被索引——在预生产环境中这可能需要30–120秒且无反馈,如果索引器延迟还会无限挂起。请改用第8节中的低级watchForTxData+createUnprovenDeployTx模式。合约地址可从submitTxAsync立即获取,无需等待提交。deployTxData.public.contractAddress完全跳过阻塞的submitTxAsync调用。watchForTxData
7) Transaction Flow (Dust-Free)
7) 交易流程(无粉尘费用)
dApp builds unproven tx
↓
proofProvider.proveTx() → 1AM / ProofStation → ZK proof (~2–5s)
↓
walletProvider.balanceTx() → api.balanceUnsealedTransaction() → server adds dust fees
↓
midnightProvider.submitTx() → api.submitTransaction() → Midnight chain
Total user cost: 0 NIGHT, 0 dust.balanceUnsealedTransactiondApp构建未验证交易
↓
proofProvider.proveTx() → 1AM / ProofStation → ZK证明 (~2–5秒)
↓
walletProvider.balanceTx() → api.balanceUnsealedTransaction() → 服务器添加粉尘费用
↓
midnightProvider.submitTx() → api.submitTransaction() → Midnight链
用户总成本:0 NIGHT,0粉尘费用。balanceUnsealedTransaction8) Low-Level Deploy + Call (with Indexer Polling)
8) 低级部署与调用(带索引器轮询)
Use these when you need finer control than / — e.g. saving private state, waiting for indexer confirmation, or updating UI after the transaction lands.
deployContractsubmitCallTx当你需要比 / 更精细的控制时使用这些方法——例如保存私有状态、等待索引器确认,或在交易完成后更新UI。
deployContractsubmitCallTxDeploy with Private State Persistence
带私有状态持久化的部署
ts
import { createUnprovenDeployTx, submitTxAsync } from '@midnight-ntwrk/midnight-js-contracts';
import { sampleSigningKey } from '@midnight-ntwrk/compact-runtime';
async function deployAndPersist(
session: ConnectedSession,
constructorArgs: any[],
onDeployed?: (address: string) => Promise<void>,
): Promise<string> {
const compiledContract = getCompiledContract();
const deployTxData = await createUnprovenDeployTx(
{ zkConfigProvider: session.providers.zkConfigProvider, walletProvider: session.providers.walletProvider },
{ compiledContract, args: constructorArgs, signingKey: sampleSigningKey() },
);
const contractAddress = deployTxData.public.contractAddress;
await submitTxAsync(session.providers, { unprovenTx: deployTxData.private.unprovenTx });
// Persist private state so subsequent circuit calls can find it
await session.providers.privateStateProvider.setContractAddress(contractAddress);
await session.providers.privateStateProvider.setSigningKey(contractAddress, deployTxData.private.signingKey);
// Optional: persist to your backend
await onDeployed?.(contractAddress);
await waitForContractDeployment(session.providers.publicDataProvider, contractAddress);
return contractAddress;
}ts
import { createUnprovenDeployTx, submitTxAsync } from '@midnight-ntwrk/midnight-js-contracts';
import { sampleSigningKey } from '@midnight-ntwrk/compact-runtime';
async function deployAndPersist(
session: ConnectedSession,
constructorArgs: any[],
onDeployed?: (address: string) => Promise<void>,
): Promise<string> {
const compiledContract = getCompiledContract();
const deployTxData = await createUnprovenDeployTx(
{ zkConfigProvider: session.providers.zkConfigProvider, walletProvider: session.providers.walletProvider },
{ compiledContract, args: constructorArgs, signingKey: sampleSigningKey() },
);
const contractAddress = deployTxData.public.contractAddress;
await submitTxAsync(session.providers, { unprovenTx: deployTxData.private.unprovenTx });
// 持久化私有状态,以便后续电路调用可以找到它
await session.providers.privateStateProvider.setContractAddress(contractAddress);
await session.providers.privateStateProvider.setSigningKey(contractAddress, deployTxData.private.signingKey);
// 可选:持久化到后端
await onDeployed?.(contractAddress);
await waitForContractDeployment(session.providers.publicDataProvider, contractAddress);
return contractAddress;
}Call a Circuit with State Change Polling
带状态变更轮询的电路调用
ts
import { createUnprovenCallTx } from '@midnight-ntwrk/midnight-js-contracts';
async function callAndWait(
session: ConnectedSession,
contractAddress: string,
circuitId: string,
args: any[],
// Provide a predicate that returns true when the indexed state has advanced past the pre-call snapshot
hasStateAdvanced: (publicDataProvider: any) => Promise<boolean>,
): Promise<string> {
const compiledContract = getCompiledContract();
const callTxData = await createUnprovenCallTx(session.providers, {
compiledContract,
contractAddress,
circuitId,
args,
});
const txId = await submitTxAsync(session.providers, {
unprovenTx: callTxData.private.unprovenTx,
circuitId,
});
await waitForStateAdvance(session.providers.publicDataProvider, hasStateAdvanced);
return txId;
}ts
import { createUnprovenCallTx } from '@midnight-ntwrk/midnight-js-contracts';
async function callAndWait(
session: ConnectedSession,
contractAddress: string,
circuitId: string,
args: any[],
// 提供一个谓词,当索引状态超过调用前快照时返回true
hasStateAdvanced: (publicDataProvider: any) => Promise<boolean>,
): Promise<string> {
const compiledContract = getCompiledContract();
const callTxData = await createUnprovenCallTx(session.providers, {
compiledContract,
contractAddress,
circuitId,
args,
});
const txId = await submitTxAsync(session.providers, {
unprovenTx: callTxData.private.unprovenTx,
circuitId,
});
await waitForStateAdvance(session.providers.publicDataProvider, hasStateAdvanced);
return txId;
}9) Polling Helpers
9) 轮询工具类
ts
// Wait until a newly deployed contract appears in the indexer
export async function waitForContractDeployment(
publicDataProvider: ReturnType<typeof createPatchedPublicDataProvider>,
contractAddress: string,
pollIntervalMs = 2000,
maxAttempts = 30,
): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
const state = await publicDataProvider.queryContractState(contractAddress);
if (state?.data) return;
await new Promise(r => setTimeout(r, pollIntervalMs));
}
throw new Error(`Contract not indexed after ${maxAttempts * pollIntervalMs}ms — check address or indexer lag`);
}
// Wait until a caller-supplied predicate signals that state has advanced
// The predicate receives publicDataProvider so it can query whatever ledger field is relevant
export async function waitForStateAdvance(
publicDataProvider: ReturnType<typeof createPatchedPublicDataProvider>,
hasAdvanced: (provider: typeof publicDataProvider) => Promise<boolean>,
pollIntervalMs = 2000,
maxAttempts = 30,
): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
if (await hasAdvanced(publicDataProvider)) return;
await new Promise(r => setTimeout(r, pollIntervalMs));
}
throw new Error(`State did not advance after ${maxAttempts * pollIntervalMs}ms`);
}Usage example — pass a predicate that captures a pre-call snapshot:
ts
const stateBefore = await getRelevantLedgerValue(session, contractAddress);
await callAndWait(session, contractAddress, 'increment', [arg1], async (provider) => {
const contractState = await provider.queryContractState(contractAddress);
if (!contractState?.data) return false;
// Always pass contractState.data (ChargedState) to your ledger() function, not contractState itself
const current = yourLedgerReader(contractState.data).someField;
return current !== stateBefore;
});ts
// 等待新部署的合约出现在索引器中
export async function waitForContractDeployment(
publicDataProvider: ReturnType<typeof createPatchedPublicDataProvider>,
contractAddress: string,
pollIntervalMs = 2000,
maxAttempts = 30,
): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
const state = await publicDataProvider.queryContractState(contractAddress);
if (state?.data) return;
await new Promise(r => setTimeout(r, pollIntervalMs));
}
throw new Error(`Contract not indexed after ${maxAttempts * pollIntervalMs}ms — check address or indexer lag`);
}
// 等待调用者提供的谓词信号表示状态已更新
// 谓词接收publicDataProvider,因此可以查询任何相关的账本字段
export async function waitForStateAdvance(
publicDataProvider: ReturnType<typeof createPatchedPublicDataProvider>,
hasAdvanced: (provider: typeof publicDataProvider) => Promise<boolean>,
pollIntervalMs = 2000,
maxAttempts = 30,
): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
if (await hasAdvanced(publicDataProvider)) return;
await new Promise(r => setTimeout(r, pollIntervalMs));
}
throw new Error(`State did not advance after ${maxAttempts * pollIntervalMs}ms`);
}使用示例 —— 传递一个捕获调用前快照的谓词:
ts
const stateBefore = await getRelevantLedgerValue(session, contractAddress);
await callAndWait(session, contractAddress, 'increment', [arg1], async (provider) => {
const contractState = await provider.queryContractState(contractAddress);
if (!contractState?.data) return false;
// 始终将contractState.data(ChargedState)传递给ledger()函数,而不是contractState本身
const current = yourLedgerReader(contractState.data).someField;
return current !== stateBefore;
});10) Generic React Hook Pattern
10) 通用React钩子模式
This is a minimal template. Replace , , and circuit names with your contract's actual shape.
YourStateyourLedgertsx
// hooks/useContract.ts
import { useCallback, useEffect, useState } from 'react';
import { useWallet } from '../contexts/WalletContext';
import { waitForContractDeployment, waitForStateAdvance } from '../lib/midnight';
export function useContract(contractAddress: string | null) {
const { session } = useWallet();
const [contractState, setContractState] = useState<YourState | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchState = useCallback(async () => {
if (!session || !contractAddress) return;
const contractState = await session.providers.publicDataProvider.queryContractState(contractAddress);
// ⚠️ Pass contractState.data (a ChargedState), NOT the ContractState itself.
// The compiled contract's ledger() function expects a ChargedState and accesses .state on it.
// Passing a raw ContractState (from ContractState.deserialize) will fail with
// "expected instance of ChargedState" because ContractState has no .state property.
if (contractState?.data) setContractState(yourLedger(contractState.data));
}, [session, contractAddress]);
useEffect(() => { fetchState(); }, [fetchState]);
const callSomeCircuit = useCallback(async (...args: any[]) => {
if (!session || !contractAddress) return;
setIsLoading(true);
setError(null);
try {
const snapshotBefore = contractState?.someField;
const callTxData = await createUnprovenCallTx(session.providers, {
compiledContract: getCompiledContract(),
contractAddress,
circuitId: 'someCircuit',
args,
});
await submitTxAsync(session.providers, { unprovenTx: callTxData.private.unprovenTx, circuitId: 'someCircuit' });
await waitForStateAdvance(session.providers.publicDataProvider, async (provider) => {
const s = await provider.queryContractState(contractAddress);
return s?.data ? yourLedger(s.data).someField !== snapshotBefore : false;
});
// Optimistic local update (optional — feels instant)
setContractState(prev => prev ? { ...prev /* apply expected delta */ } : prev);
await fetchState(); // reconcile with chain
} catch (e: any) {
setError(e.message);
} finally {
setIsLoading(false);
}
}, [session, contractAddress, contractState, fetchState]);
return { contractState, isLoading, error, callSomeCircuit, refreshState: fetchState };
}Key design points:
- Snapshot before calling — capture the field you expect to change before submitting, use it in the polling predicate.
- Optimistic update — apply the expected delta to local state immediately after the tx lands, before returns.
fetchState() - Server reconcile — call after the optimistic update to sync with the true indexed state.
fetchState()
这是一个最小模板。请将、和电路名称替换为合约的实际结构。
YourStateyourLedgertsx
// hooks/useContract.ts
import { useCallback, useEffect, useState } from 'react';
import { useWallet } from '../contexts/WalletContext';
import { waitForContractDeployment, waitForStateAdvance } from '../lib/midnight';
export function useContract(contractAddress: string | null) {
const { session } = useWallet();
const [contractState, setContractState] = useState<YourState | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchState = useCallback(async () => {
if (!session || !contractAddress) return;
const contractState = await session.providers.publicDataProvider.queryContractState(contractAddress);
// ⚠️ 传递contractState.data(ChargedState),而不是ContractState本身。
// 编译后的合约的ledger()函数期望接收ChargedState,并访问其.state属性。
// 传递原始ContractState(来自ContractState.deserialize)会失败,报错
// "expected instance of ChargedState",因为ContractState没有.state属性。
if (contractState?.data) setContractState(yourLedger(contractState.data));
}, [session, contractAddress]);
useEffect(() => { fetchState(); }, [fetchState]);
const callSomeCircuit = useCallback(async (...args: any[]) => {
if (!session || !contractAddress) return;
setIsLoading(true);
setError(null);
try {
const snapshotBefore = contractState?.someField;
const callTxData = await createUnprovenCallTx(session.providers, {
compiledContract: getCompiledContract(),
contractAddress,
circuitId: 'someCircuit',
args,
});
await submitTxAsync(session.providers, { unprovenTx: callTxData.private.unprovenTx, circuitId: 'someCircuit' });
await waitForStateAdvance(session.providers.publicDataProvider, async (provider) => {
const s = await provider.queryContractState(contractAddress);
return s?.data ? yourLedger(s.data).someField !== snapshotBefore : false;
});
// 乐观本地更新(可选——体验更流畅)
setContractState(prev => prev ? { ...prev /* 应用预期的变更 */ } : prev);
await fetchState(); // 与链上状态同步
} catch (e: any) {
setError(e.message);
} finally {
setIsLoading(false);
}
}, [session, contractAddress, contractState, fetchState]);
return { contractState, isLoading, error, callSomeCircuit, refreshState: fetchState };
}关键设计要点:
- 调用前快照 —— 在提交前捕获预期会变更的字段,在轮询谓词中使用它。
- 乐观更新 —— 交易提交后立即将预期的变更应用到本地状态,在返回前完成。
fetchState() - 服务器同步 —— 乐观更新后调用,与真实的索引状态同步。
fetchState()
11) Optional: Payload Encryption
11) 可选:负载加密
Encrypt on-chain strings using a key derived deterministically from a wallet signature. Requires .
api.signDatats
// src/lib/encryption.ts
// Derive a scoped AES-GCM key from the user's wallet signature.
// Key is deterministic: same wallet + same contract = same key across sessions.
export async function deriveContractKey(api: any, networkId: string, contractAddress: string): Promise<CryptoKey> {
const message = `midnight-app-key|${networkId}|${contractAddress}`;
const signature = await api.signData(message, { encoding: 'text' });
if (!signature) throw new Error('signData returned empty — cannot derive encryption key');
const keyMaterial = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(signature), 'HKDF', false, ['deriveKey'],
);
return crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: new TextEncoder().encode(`midnight-salt|${networkId}`),
info: new TextEncoder().encode(`midnight-contract|${contractAddress}`),
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
}
// Encrypt a string → versioned envelope: "enc:v1:<base64url(iv+ciphertext)>"
export async function encryptPayload(key: CryptoKey, plaintext: string): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(plaintext),
);
const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ciphertext), iv.byteLength);
return 'enc:v1:' + btoa(String.fromCharCode(...combined)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
// Decrypt a versioned envelope → original string
export async function decryptPayload(key: CryptoKey, envelope: string): Promise<string> {
if (!envelope.startsWith('enc:v1:')) throw new Error('Not an encrypted payload');
const b64 = envelope.slice(7).replace(/-/g, '+').replace(/_/g, '/');
const combined = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
return new TextDecoder().decode(plain);
}
export const isEncryptedPayload = (s: string) => s.startsWith('enc:v1:');Design decisions:
- Use (replacing
base64url) to avoid padding issues in URLs and JSON.+/= - Include both and
networkIdin KDF salt and info — keys are contract-scoped; the same wallet produces different keys for different contracts and networks.contractAddress - Detect encrypted values with before attempting decryption.
isEncryptedPayload() - If is unavailable, throw immediately — do not silently degrade to unencrypted storage.
signData
使用从钱包签名确定性派生的密钥加密链上字符串。需要。
api.signDatats
// src/lib/encryption.ts
// 从用户钱包签名派生范围限定的AES-GCM密钥。
// 密钥是确定性的:相同钱包 + 相同合约 = 跨会话的相同密钥。
export async function deriveContractKey(api: any, networkId: string, contractAddress: string): Promise<CryptoKey> {
const message = `midnight-app-key|${networkId}|${contractAddress}`;
const signature = await api.signData(message, { encoding: 'text' });
if (!signature) throw new Error('signData returned empty — cannot derive encryption key');
const keyMaterial = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(signature), 'HKDF', false, ['deriveKey'],
);
return crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: new TextEncoder().encode(`midnight-salt|${networkId}`),
info: new TextEncoder().encode(`midnight-contract|${contractAddress}`),
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
}
// 加密字符串 → 版本化信封:"enc:v1:<base64url(iv+ciphertext)>"
export async function encryptPayload(key: CryptoKey, plaintext: string): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(plaintext),
);
const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ciphertext), iv.byteLength);
return 'enc:v1:' + btoa(String.fromCharCode(...combined)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
// 解密版本化信封 → 原始字符串
export async function decryptPayload(key: CryptoKey, envelope: string): Promise<string> {
if (!envelope.startsWith('enc:v1:')) throw new Error('Not an encrypted payload');
const b64 = envelope.slice(7).replace(/-/g, '+').replace(/_/g, '/');
const combined = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
return new TextDecoder().decode(plain);
}
export const isEncryptedPayload = (s: string) => s.startsWith('enc:v1:');设计决策:
- 使用(替换
base64url)避免URL和JSON中的填充问题。+/= - 在KDF盐和信息中同时包含和
networkId——密钥是合约范围限定的;同一钱包在不同合约和网络中会生成不同的密钥。contractAddress - 在尝试解密前使用检测加密值。
isEncryptedPayload() - 如果不可用,立即抛出错误——不要静默降级为未加密存储。
signData
12) Next.js Compatibility
12) Next.js兼容性
The SDK uses , async WebAssembly, and top-level await — none of which work out of the box in Next.js. Required steps:
isomorphic-ws1. Create a WebSocket shim (Next.js bundles a broken for browser targets):
isomorphic-wsts
// lib/isomorphic-ws-fix.mjs
export default globalThis.WebSocket;
export const WebSocket = globalThis.WebSocket;2. Configure :
next.config.mjsts
// next.config.mjs
const nextConfig = {
webpack(config) {
config.resolve.alias['isomorphic-ws'] = new URL('./lib/isomorphic-ws-fix.mjs', import.meta.url).pathname;
config.resolve.fallback = { fs: false, net: false, tls: false, child_process: false };
config.experiments = { asyncWebAssembly: true, topLevelAwait: true };
return config;
},
};
export default nextConfig;3. Disable Turbopack — Next.js 15+ enables Turbopack by default. Custom webpack config requires webpack mode:
json
// package.json
{
"scripts": {
"dev": "next dev --webpack"
}
}Or if using Next.js config-based Turbopack opt-in, explicitly set only when not using custom webpack experiments. You cannot use both simultaneously.
turbopack: {}TypeScript casts — SDK generics don't compose cleanly with compiled contract output. Use on , , and call sites. The types are correct at runtime; the static generics are too narrow for the compiler-generated contract shape. is the correct annotation.
as anycreateUnprovenDeployTxsubmitTxAsyncsubmitCallTxFetchZkConfigProvider<any>SDK使用、异步WebAssembly和顶级await——这些在Next.js中无法开箱即用。需要执行以下步骤:
isomorphic-ws1. 创建WebSocket垫片(Next.js为浏览器目标打包了有问题的):
isomorphic-wsts
// lib/isomorphic-ws-fix.mjs
export default globalThis.WebSocket;
export const WebSocket = globalThis.WebSocket;2. 配置:
next.config.mjsts
// next.config.mjs
const nextConfig = {
webpack(config) {
config.resolve.alias['isomorphic-ws'] = new URL('./lib/isomorphic-ws-fix.mjs', import.meta.url).pathname;
config.resolve.fallback = { fs: false, net: false, tls: false, child_process: false };
config.experiments = { asyncWebAssembly: true, topLevelAwait: true };
return config;
},
};
export default nextConfig;3. 禁用Turbopack —— Next.js 15+默认启用Turbopack。自定义webpack配置需要使用webpack模式:
json
// package.json
{
"scripts": {
"dev": "next dev --webpack"
}
}或者如果使用Next.js配置中的Turbopack可选功能,请仅在不使用自定义webpack实验特性时显式设置。无法同时使用两者。
turbopack: {}TypeScript类型转换 —— SDK泛型与编译后的合约输出无法完美组合。在、和调用点使用。运行时类型是正确的;静态泛型对于编译器生成的合约结构来说过于狭窄。是正确的注解。
createUnprovenDeployTxsubmitTxAsyncsubmitCallTxas anyFetchZkConfigProvider<any>13) ZK Key Hosting
13) ZK密钥托管
Compiled contracts produce assets that must be served over HTTP with CORS enabled. The fetches them at runtime.
FetchZkConfigProvideryour-server.com/<zk-path>/
keys/
circuitName.prover # 2–10 MB each
circuitName.verifier # ~2 KB each
zkir/
circuitName.bzkir # 1–3 KB eachRequired header:
Access-Control-Allow-Origin: *For local Vite development, sync assets into with an npm script:
public/json
{
"scripts": {
"sync:zk": "mkdir -p public/contract/your-contract && cp -r contracts/managed/your-contract/keys public/contract/your-contract/ && cp -r contracts/managed/your-contract/zkir public/contract/your-contract/"
}
}Before debugging any provider error, open the asset URLs directly in the browser. A 404 or CORS failure here surfaces as a cryptic SDK error. Run before — Vite only serves files present in at startup.
sync:zknpm run devpublic/编译后的合约会生成必须通过启用CORS的HTTP服务的资产。会在运行时获取这些资产。
FetchZkConfigProvideryour-server.com/<zk-path>/
keys/
circuitName.prover # 每个2–10 MB
circuitName.verifier # 每个约2 KB
zkir/
circuitName.bzkir # 每个1–3 KB必需的响应头:
Access-Control-Allow-Origin: *对于本地Vite开发,使用npm脚本将资产同步到:
public/json
{
"scripts": {
"sync:zk": "mkdir -p public/contract/your-contract && cp -r contracts/managed/your-contract/keys public/contract/your-contract/ && cp -r contracts/managed/your-contract/zkir public/contract/your-contract/"
}
}在调试任何提供者错误之前,请直接在浏览器中打开资产URL。此处的404或CORS失败会表现为模糊的SDK错误。在前运行——Vite仅在启动时提供中存在的文件。
npm run devsync:zkpublic/14) ProofStation API
14) ProofStation API
The 1AM wallet calls ProofStation internally via . You typically don't need to call it directly. Reference only:
balanceUnsealedTransaction| Endpoint | Method | Description |
|---|---|---|
| GET | Server health + upstream status |
| POST | Generate ZK proof |
| POST | Verify a ZK proof |
| POST | Prove + balance in one call |
| POST | Balance a pre-proven tx |
| GET | Sponsorship wallet dust balance |
Base URLs:
- Preview:
https://api-preview.1am.xyz - Preprod:
https://api-preprod.1am.xyz - Mainnet:
https://api.1am.xyz
Auth: (only needed for direct calls — prefer routing through ).
X-API-Key: pk_live_xxxapi.balanceUnsealedTransaction1AM钱包通过内部调用ProofStation。通常你不需要直接调用它。仅作参考:
balanceUnsealedTransaction| 端点 | 方法 | 描述 |
|---|---|---|
| GET | 服务器健康状态 + 上游服务状态 |
| POST | 生成ZK证明 |
| POST | 验证ZK证明 |
| POST | 一次调用完成证明 + 费用平衡 |
| POST | 平衡已验证的交易 |
| GET | 赞助钱包的粉尘余额 |
基础URL:
- 预览版:
https://api-preview.1am.xyz - 预生产版:
https://api-preprod.1am.xyz - 主网:
https://api.1am.xyz
认证:(仅直接调用时需要——优先通过路由调用)。
X-API-Key: pk_live_xxxapi.balanceUnsealedTransaction15) Networks
15) 网络
| Network | Use for | Indexer | RPC |
|---|---|---|---|
| Active development | | |
| Pre-release testing | | |
| Production | | |
Use during development. returns the correct URLs for whichever network the user has selected in the wallet — always use those values dynamically; never hardcode them.
previewgetConfiguration()| 网络 | 用途 | 索引器 | RPC |
|---|---|---|---|
| 活跃开发 | | |
| 预发布测试 | | |
| 生产环境 | | |
开发期间使用。会返回用户在钱包中选择的网络的正确URL——请始终动态使用这些值;切勿硬编码。
previewgetConfiguration()16) Wallet API Reference
16) 钱包API参考
window.midnight['1am']
(InitialAPI)
window.midnight['1am']window.midnight['1am']
(InitialAPI)
window.midnight['1am']| Method | Returns | Notes |
|---|---|---|
| | |
| | |
| | |
| 方法 | 返回值 | 说明 |
|---|---|---|
| | |
| | |
| | |
ConnectedAPI
ConnectedAPI
| Method | Returns | Notes |
|---|---|---|
| | Source of truth for all URLs — always use dynamically |
| | |
| | |
| | |
| | |
| | |
| | |
| | Pass your |
| | Adds dust fees — never skip |
| | Returns txId or void |
| | Signature for key derivation |
| | Token transfer |
| 方法 | 返回值 | 说明 |
|---|---|---|
| | 所有URL的权威来源——请始终动态使用 |
| | |
| | |
| | |
| | |
| | |
| | |
| | 传入你的 |
| | 添加粉尘费用——切勿跳过 |
| | 返回txId或无返回值 |
| | 用于密钥派生的签名 |
| | 代币转账 |
17) Common Pitfalls
17) 常见陷阱
| Pitfall | Fix |
|---|---|
Missing | Single-digit hex bytes corrupt the transaction. Always use it. |
| Call it immediately after |
| The wallet may return hex with or without |
Using | It calls |
Using | Does not pass |
| Normalize the return: check for string, then |
| Guard with |
Passing | |
| Reading state immediately after deploy | The indexer is not synchronous with chain finality. Always poll — never read state right after submit. |
| ZK assets 404 | Run |
| Reusing old contract address after recompile | Any contract change regenerates the verifier key. Old addresses will fail proof verification. Always redeploy. |
| Circuit too small on preview | Preview ProofStation requires minimum circuit size |
| Hardcoding indexer/RPC URLs | Always read URLs from |
Next.js: | See §12 for the required webpack config, WebSocket shim, and Turbopack disable instructions. |
| TypeScript errors on SDK call sites | Use |
| 陷阱 | 解决方法 |
|---|---|
| 单字节十六进制会破坏交易。请始终使用它。 |
未首先调用 | 在 |
| 钱包返回的十六进制可能带或不带 |
在预生产/预览环境中使用 | 它会调用 |
使用 | 无法正确传递 |
| 标准化返回值:先检查是否为字符串,然后检查 |
| 在调用 |
将 | |
| 部署后立即读取状态 | 索引器与链上最终性不同步。请始终轮询——提交后切勿立即读取状态。 |
| ZK资产404 | 在 |
| 重新编译后重用旧合约地址 | 任何合约变更都会重新生成验证器密钥。旧地址会导致证明验证失败。请始终重新部署。 |
| 预览版中电路过小 | 预览版ProofStation要求最小电路大小 |
| 硬编码索引器/RPC URL | 始终从 |
Next.js: | 请参考第12节获取所需的webpack配置、WebSocket垫片和禁用Turbopack的说明。 |
| SDK调用点的TypeScript错误 | 在 |