1am-wallet

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese
Scope This skill covers detecting, connecting, and wiring the 1AM browser extension (
window.midnight['1am']
) 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
YourContract
/
yourCircuit
/
your-contract
placeholder with your actual contract name and circuit IDs.
Suggested file layout (adapt to your project):
  • src/lib/midnight.ts
    → wallet session, provider wiring, indexer patch (canonical source:
    references/midnight-session.md
    )
  • src/lib/encryption.ts
    → optional payload encryption derived from wallet signature
  • src/hooks/useContract.ts
    → app logic and state orchestration
  • src/contexts/WalletContext.tsx
    → wallet connection state
Shared references:
references/midnight-session.md
,
references/gotchas.md
,
references/versions.json

范围 本技能涵盖检测、连接1AM浏览器扩展(
window.midnight['1am']
)并将其接入前端dApp。1AM钱包负责所有ZK证明和粉尘费用赞助——用户无需支付任何gas费用。本技能为通用模板:请将所有
YourContract
/
yourCircuit
/
your-contract
占位符替换为实际的合约名称和电路ID。
建议的文件结构(可根据项目调整):
  • src/lib/midnight.ts
    → 钱包会话、提供者配置、索引器补丁(标准来源:
    references/midnight-session.md
  • src/lib/encryption.ts
    → 可选的基于钱包签名派生的负载加密功能
  • src/hooks/useContract.ts
    → 应用逻辑与状态编排
  • src/contexts/WalletContext.tsx
    → 钱包连接状态
共享参考文档:
references/midnight-session.md
,
references/gotchas.md
,
references/versions.json

1) 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.0
Vite 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-await
For 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.0
Vite需要以下插件来支持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
WalletProvider
, then call
useWallet()
in any component.
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;
}
使用
WalletProvider
包裹应用,然后在任意组件中调用
useWallet()
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:
checking
,
not-found
, disconnected (connect CTA), connected (address + disconnect).
tsx
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>
  );
}

请始终渲染四种状态:
checking
(检测中)、
not-found
(未找到)、已断开(连接按钮)、已连接(地址+断开按钮)。
tsx
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
)

3) 会话设置(
createConnectedSession

Fetch 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

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;
}
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
offset: null
in latest-state queries. The default SDK
queryContractState()
without a config block hits this bug. Always wrap
indexerPublicDataProvider
with this patch — without it, state reads will fail on these networks.
ts
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(),
      ];
    },
  };
}

预览版和预生产版索引器在最新状态查询中存在
offset: null
的GraphQL bug。默认SDK的
queryContractState()
在没有配置块时会触发此bug。请始终使用此补丁包装
indexerPublicDataProvider
——否则在这些网络上读取状态会失败。
ts
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
localStorage
or an encrypted server store.
ts
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。私有状态不会在页面刷新后保留——这是最小参考实现的有意设计。如需生产环境持久化,请替换为
localStorage
或加密的服务器存储。
ts
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
YourContract
,
your-contract
, and
yourCircuit
with your actual names.
ts
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 use
deployContract()
for deploy on preprod/preview.
deployContract
calls
watchForTxData
internally, 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-level
createUnprovenDeployTx
+
submitTxAsync
pattern in §8 instead.
The contract address is available from
deployTxData.public.contractAddress
immediately, before any submission.
submitTxAsync
skips the blocking
watchForTxData
call entirely.

请将
YourContract
your-contract
yourCircuit
替换为实际名称。
ts
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
内部调用
watchForTxData
,会轮询索引器直到交易被索引——在预生产环境中这可能需要30–120秒且无反馈,如果索引器延迟还会无限挂起。请改用第8节中的低级
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.
balanceUnsealedTransaction
is where ProofStation's server wallet pays fees on behalf of the user. Never skip it.

dApp构建未验证交易
proofProvider.proveTx()  →  1AM / ProofStation  →  ZK证明  (~2–5秒)
walletProvider.balanceTx()  →  api.balanceUnsealedTransaction()  → 服务器添加粉尘费用
midnightProvider.submitTx()  →  api.submitTransaction()  →  Midnight链

用户总成本:0 NIGHT,0粉尘费用。
balanceUnsealedTransaction
是ProofStation服务器钱包代表用户支付费用的环节。切勿跳过此步骤

8) Low-Level Deploy + Call (with Indexer Polling)

8) 低级部署与调用(带索引器轮询)

Use these when you need finer control than
deployContract
/
submitCallTx
— e.g. saving private state, waiting for indexer confirmation, or updating UI after the transaction lands.
当你需要比
deployContract
/
submitCallTx
更精细的控制时使用这些方法——例如保存私有状态、等待索引器确认,或在交易完成后更新UI。

Deploy 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
YourState
,
yourLedger
, and circuit names with your contract's actual shape.
tsx
// 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
    fetchState()
    returns.
  • Server reconcile — call
    fetchState()
    after the optimistic update to sync with the true indexed state.

这是一个最小模板。请将
YourState
yourLedger
和电路名称替换为合约的实际结构。
tsx
// 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.signData
.
ts
// 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
    base64url
    (replacing
    +/=
    ) to avoid padding issues in URLs and JSON.
  • Include both
    networkId
    and
    contractAddress
    in KDF salt and info — keys are contract-scoped; the same wallet produces different keys for different contracts and networks.
  • Detect encrypted values with
    isEncryptedPayload()
    before attempting decryption.
  • If
    signData
    is unavailable, throw immediately — do not silently degrade to unencrypted storage.

使用从钱包签名确定性派生的密钥加密链上字符串。需要
api.signData
ts
// 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
isomorphic-ws
, async WebAssembly, and top-level await — none of which work out of the box in Next.js. Required steps:
1. Create a WebSocket shim (Next.js bundles a broken
isomorphic-ws
for browser targets):
ts
// lib/isomorphic-ws-fix.mjs
export default globalThis.WebSocket;
export const WebSocket = globalThis.WebSocket;
2. Configure
next.config.mjs
:
ts
// 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
turbopack: {}
only when not using custom webpack experiments. You cannot use both simultaneously.
TypeScript casts — SDK generics don't compose cleanly with compiled contract output. Use
as any
on
createUnprovenDeployTx
,
submitTxAsync
, and
submitCallTx
call sites. The types are correct at runtime; the static generics are too narrow for the compiler-generated contract shape.
FetchZkConfigProvider<any>
is the correct annotation.

SDK使用
isomorphic-ws
、异步WebAssembly和顶级await——这些在Next.js中无法开箱即用。需要执行以下步骤:
1. 创建WebSocket垫片(Next.js为浏览器目标打包了有问题的
isomorphic-ws
):
ts
// lib/isomorphic-ws-fix.mjs
export default globalThis.WebSocket;
export const WebSocket = globalThis.WebSocket;
2. 配置
next.config.mjs
ts
// 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泛型与编译后的合约输出无法完美组合。在
createUnprovenDeployTx
submitTxAsync
submitCallTx
调用点使用
as any
。运行时类型是正确的;静态泛型对于编译器生成的合约结构来说过于狭窄。
FetchZkConfigProvider<any>
是正确的注解。

13) ZK Key Hosting

13) ZK密钥托管

Compiled contracts produce assets that must be served over HTTP with CORS enabled. The
FetchZkConfigProvider
fetches them at runtime.
your-server.com/<zk-path>/
  keys/
    circuitName.prover      # 2–10 MB each
    circuitName.verifier    # ~2 KB each
  zkir/
    circuitName.bzkir       # 1–3 KB each
Required header:
Access-Control-Allow-Origin: *
For local Vite development, sync assets into
public/
with an npm script:
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
sync:zk
before
npm run dev
— Vite only serves files present in
public/
at startup.

编译后的合约会生成必须通过启用CORS的HTTP服务的资产。
FetchZkConfigProvider
会在运行时获取这些资产。
your-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错误。在
npm run dev
前运行
sync:zk
——Vite仅在启动时提供
public/
中存在的文件。

14) ProofStation API

14) ProofStation API

The 1AM wallet calls ProofStation internally via
balanceUnsealedTransaction
. You typically don't need to call it directly. Reference only:
EndpointMethodDescription
/health
GETServer health + upstream status
/prove
POSTGenerate ZK proof
/verify
POSTVerify a ZK proof
/prove-and-balance
POSTProve + balance in one call
/balance-only
POSTBalance a pre-proven tx
/wallet-status
GETSponsorship wallet dust balance
Base URLs:
  • Preview:
    https://api-preview.1am.xyz
  • Preprod:
    https://api-preprod.1am.xyz
  • Mainnet:
    https://api.1am.xyz
Auth:
X-API-Key: pk_live_xxx
(only needed for direct calls — prefer routing through
api.balanceUnsealedTransaction
).

1AM钱包通过
balanceUnsealedTransaction
内部调用ProofStation。通常你不需要直接调用它。仅作参考:
端点方法描述
/health
GET服务器健康状态 + 上游服务状态
/prove
POST生成ZK证明
/verify
POST验证ZK证明
/prove-and-balance
POST一次调用完成证明 + 费用平衡
/balance-only
POST平衡已验证的交易
/wallet-status
GET赞助钱包的粉尘余额
基础URL:
  • 预览版:
    https://api-preview.1am.xyz
  • 预生产版:
    https://api-preprod.1am.xyz
  • 主网:
    https://api.1am.xyz
认证:
X-API-Key: pk_live_xxx
(仅直接调用时需要——优先通过
api.balanceUnsealedTransaction
路由调用)。

15) Networks

15) 网络

NetworkUse forIndexerRPC
preview
Active development
indexer.preview.midnight.network
rpc.preview.midnight.network
preprod
Pre-release testing
indexer.preprod.midnight.network
rpc.preprod.midnight.network
mainnet
Production
indexer.mainnet.midnight.network
rpc.mainnet.midnight.network
Use
preview
during development.
getConfiguration()
returns the correct URLs for whichever network the user has selected in the wallet — always use those values dynamically; never hardcode them.

网络用途索引器RPC
preview
活跃开发
indexer.preview.midnight.network
rpc.preview.midnight.network
preprod
预发布测试
indexer.preprod.midnight.network
rpc.preprod.midnight.network
mainnet
生产环境
indexer.mainnet.midnight.network
rpc.mainnet.midnight.network
开发期间使用
preview
getConfiguration()
会返回用户在钱包中选择的网络的正确URL——请始终动态使用这些值;切勿硬编码。

16) Wallet API Reference

16) 钱包API参考

window.midnight['1am']
(InitialAPI)

window.midnight['1am']
(InitialAPI)

MethodReturnsNotes
connect(networkId)
ConnectedAPI
'preview'
|
'preprod'
|
'mainnet'
name
string
'1AM'
apiVersion
string
'4.0.0'
方法返回值说明
connect(networkId)
ConnectedAPI
'preview'
|
'preprod'
|
'mainnet'
name
string
'1AM'
apiVersion
string
'4.0.0'

ConnectedAPI

ConnectedAPI

MethodReturnsNotes
getConfiguration()
{ networkId, indexerUri, indexerWsUri, proverServerUri, substrateNodeUri }
Source of truth for all URLs — always use dynamically
getShieldedAddresses()
{ shieldedAddress, shieldedCoinPublicKey, shieldedEncryptionPublicKey }
getUnshieldedAddress()
{ unshieldedAddress }
getDustAddress()
{ dustAddress }
getShieldedBalances()
Record<string, bigint>
getUnshieldedBalances()
Record<string, bigint>
getDustBalance()
{ balance, cap }
getProvingProvider(zkConfigProvider)
ProvingProvider
Pass your
FetchZkConfigProvider
balanceUnsealedTransaction(hex)
{ tx: string }
Adds dust fees — never skip
submitTransaction(hex)
string | void
Returns txId or void
signData(data, options)
string
Signature for key derivation
makeTransfer(outputs)
{ tx }
Token transfer

方法返回值说明
getConfiguration()
{ networkId, indexerUri, indexerWsUri, proverServerUri, substrateNodeUri }
所有URL的权威来源——请始终动态使用
getShieldedAddresses()
{ shieldedAddress, shieldedCoinPublicKey, shieldedEncryptionPublicKey }
getUnshieldedAddress()
{ unshieldedAddress }
getDustAddress()
{ dustAddress }
getShieldedBalances()
Record<string, bigint>
getUnshieldedBalances()
Record<string, bigint>
getDustBalance()
{ balance, cap }
getProvingProvider(zkConfigProvider)
ProvingProvider
传入你的
FetchZkConfigProvider
balanceUnsealedTransaction(hex)
{ tx: string }
添加粉尘费用——切勿跳过
submitTransaction(hex)
string | void
返回txId或无返回值
signData(data, options)
string
用于密钥派生的签名
makeTransfer(outputs)
{ tx }
代币转账

17) Common Pitfalls

17) 常见陷阱

PitfallFix
Missing
padStart(2, '0')
in
toHex
Single-digit hex bytes corrupt the transaction. Always use it.
setNetworkId()
not called first
Call it immediately after
getConfiguration()
. Missing it causes silent type mismatches throughout the SDK.
0x
prefix not stripped in
fromHex
The wallet may return hex with or without
0x
. The
fromHex
helper above handles both.
Using
deployContract()
on preprod/preview
It calls
watchForTxData
and blocks for 30–120s with no feedback. Use
createUnprovenDeployTx
+
submitTxAsync
instead (§8).
Using
createProofProvider()
from
@midnight-ntwrk/midnight-js-types
Does not pass
CostModel
correctly. Use the custom
proveTx
wrapper in §3 — call
unprovenTx.prove(provingProvider, CostModel.initialCostModel())
directly.
api.submitTransaction()
returning non-string
Normalize the return: check for string, then
.transactionId
, then
.id
, then fall back to
txHex.slice(0, 64)
. Already done in the
midnightProvider
in §3.
balanceUnsealedTransaction
returning null
Guard with
if (!balanced?.tx) throw new Error(...)
before calling
fromHex
. Already done in
walletProvider.balanceTx
in §3.
Passing
ContractState
to
ledger()
instead of
ChargedState
queryContractState()
returns a
ContractState
. Its
.data
property is the
ChargedState
that
ledger()
expects. Always call
yourLedger(contractState.data)
, never
yourLedger(contractState)
.
Reading state immediately after deployThe indexer is not synchronous with chain finality. Always poll — never read state right after submit.
ZK assets 404Run
sync:zk
before
npm run dev
. Vite only serves files that exist in
public/
at startup.
Reusing old contract address after recompileAny contract change regenerates the verifier key. Old addresses will fail proof verification. Always redeploy.
Circuit too small on previewPreview ProofStation requires minimum circuit size
k≥6
. If you see
prove: no SRS params for k=6
, add dummy ledger fields to increase circuit size as a workaround.
Hardcoding indexer/RPC URLsAlways read URLs from
getConfiguration()
. Users may be on a different network than you expect.
Next.js:
isomorphic-ws
/ WASM / top-level await errors
See §12 for the required webpack config, WebSocket shim, and Turbopack disable instructions.
TypeScript errors on SDK call sitesUse
as any
casts on
createUnprovenDeployTx
,
submitTxAsync
,
submitCallTx
, and
FetchZkConfigProvider<any>
. Types are correct at runtime; the generics are too narrow for compiler-generated contract output.
陷阱解决方法
toHex
中缺少
padStart(2, '0')
单字节十六进制会破坏交易。请始终使用它。
未首先调用
setNetworkId()
getConfiguration()
后立即调用。缺少此步骤会导致SDK中出现静默类型不匹配。
fromHex
中未去除
0x
前缀
钱包返回的十六进制可能带或不带
0x
。上述
fromHex
工具类已处理两种情况。
在预生产/预览环境中使用
deployContract()
它会调用
watchForTxData
并阻塞30–120秒且无反馈。请改用
createUnprovenDeployTx
+
submitTxAsync
(第8节)。
使用
@midnight-ntwrk/midnight-js-types
中的
createProofProvider()
无法正确传递
CostModel
。请使用第3节中的自定义
proveTx
包装器——直接调用
unprovenTx.prove(provingProvider, CostModel.initialCostModel())
api.submitTransaction()
返回非字符串
标准化返回值:先检查是否为字符串,然后检查
.transactionId
,再检查
.id
,最后回退到
txHex.slice(0, 64)
。第3节中的
midnightProvider
已实现此逻辑。
balanceUnsealedTransaction
返回null
在调用
fromHex
前使用
if (!balanced?.tx) throw new Error(...)
进行防护。第3节中的
walletProvider.balanceTx
已实现此逻辑。
ContractState
传递给
ledger()
而不是
ChargedState
queryContractState()
返回
ContractState
。其
.data
属性是
ledger()
期望的
ChargedState
。请始终调用
yourLedger(contractState.data)
,切勿调用
yourLedger(contractState)
部署后立即读取状态索引器与链上最终性不同步。请始终轮询——提交后切勿立即读取状态。
ZK资产404
npm run dev
前运行
sync:zk
。Vite仅在启动时提供
public/
中存在的文件。
重新编译后重用旧合约地址任何合约变更都会重新生成验证器密钥。旧地址会导致证明验证失败。请始终重新部署。
预览版中电路过小预览版ProofStation要求最小电路大小
k≥6
。如果看到
prove: no SRS params for k=6
,请添加虚拟账本字段作为临时解决方法以增加电路大小。
硬编码索引器/RPC URL始终从
getConfiguration()
读取URL。用户可能使用与你预期不同的网络。
Next.js:
isomorphic-ws
/ WASM / 顶级await错误
请参考第12节获取所需的webpack配置、WebSocket垫片和禁用Turbopack的说明。
SDK调用点的TypeScript错误
createUnprovenDeployTx
submitTxAsync
submitCallTx
FetchZkConfigProvider<any>
上使用
as any
类型转换。运行时类型是正确的;泛型对于编译器生成的合约结构来说过于狭窄。