mcpField
1
1am-wallet

1am-wallet

github.com/kali-decoder开发18136 次安装1 天前更新
代码仓库

关于 1am-wallet

面向 Midnight DApp 前端开发的技能,介绍如何接入 1AM 和 Lace 钱包扩展。内容涵盖钱包会话、合约交易、索引器处理及可选的载荷加密。

主要功能

  1. 01检测并连接钱包扩展
  2. 02配置 Midnight Provider 会话
  3. 03部署和调用合约
  4. 04修补索引器状态查询
  5. 05轮询部署和状态更新
  6. 06可选加密载荷

适用场景

  1. 01为 Midnight DApp 添加钱包连接
  2. 02从前端提交合约交易
  3. 03处理 preview 网络的索引器延迟

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/kali-decoder/midnight-skills --skill 1am-wallet

安装后在 Claude Code 中输入/1am-wallet确认已加载。

详细介绍

范围 此技能涵盖检测、连接并将 1AM 浏览器扩展(window.midnight['1am'])接入前端 dApp。1AM 钱包负责所有 ZK 证明和 dust 手续费赞助——用户无需支付 Gas。此技能通用:将所有 YourContract / yourCircuit / your-contract 占位符替换为实际的合约名称和电路 ID。

建议的文件布局(可根据项目调整):

  • src/lib/midnight.ts → 钱包会话、Provider 接线、索引器补丁(规范来源: 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) 依赖项

已知可协同工作的准确版本:

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 两者都用到了):

npm install -D vite-plugin-wasm vite-plugin-top-level-await

对于 Next.js,请参阅 §12 — 必须使用 webpack 配置。


2) 钱包检测与连接

该扩展会异步注入——始终进行轮询,切勿假设它会立即可用。支持 1AM 和 Lace 钱包。

// 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'
React Context + useWallet Hook

使用 WalletProvider 包裹应用,然后在任意组件中调用 useWallet()。

// 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;
}
WalletConnect UI 组件

始终呈现全部四种状态:checking、not-found、已断开连接(连接 CTA)、已连接(地址 + 断开连接)。

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) 会话设置(createConnectedSession)

并行获取配置、网络 ID 和所有地址——切勿按顺序逐个等待。

// 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,
  };
}
十六进制辅助函数(必需——切勿跳过 padStart)
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 公钥辅助函数

当合约需要将钱包地址作为参数时(例如收款人字段),请使用这些函数:

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('');
}

4) 已修补的公共数据 Provider ⚠️ 重要

preview 和 preprod 索引器在最新状态查询中使用 offset: null 时存在 GraphQL 错误。默认 SDK 的 queryContractState() 未提供配置块时会触发此错误。始终使用此补丁包装 indexerPublicDataProvider——否则,在这些网络上读取状态会失败。

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) 私有状态 Provider(内存中)

对于大多数 dApp 来说已经足够。页面重新加载后,私有状态不会保留——这是精简参考实现的有意设计。若要在生产环境中持久化,请替换为 localStorage 或加密的服务器存储。

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) 部署和调用合约

将 YourContract、your-contract 和 yourCircuit 替换为实际名称。

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;
}

⚠️ 不要在 preprod/preview 上使用 deployContract() 部署。 deployContract 内部会调用 watchForTxData,该函数会轮询索引器,直到交易被编入索引——在 preprod 上这可能需要 30–120s,且没有任何反馈;如果索引器滞后,还会无限期挂起。请改用 §8 中的低级 createUnprovenDeployTx + submitTxAsync 模式。 在提交之前,合约地址就已可通过 deployTxData.public.contractAddress 获取。submitTxAsync 会完全跳过阻塞式的 watchForTxData 调用。


7) 交易流程(免 Dust)

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 会由 ProofStation 的服务器钱包代用户支付手续费。切勿跳过此步骤。


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

当你需要比 deployContract / submitCallTx 更精细的控制时,请使用这些方式——例如保存私有状态、等待索引器确认,或在交易上链后更新 UI。

部署并持久化私有状态
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;
}
调用 Circuit 并轮询状态变更
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;
}

9) 轮询辅助函数

// 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`);
}

使用示例——传入一个捕获调用前快照的谓词:

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;
});

10) 通用 React Hook 模式

这是一个最简模板。请将 YourState、yourLedger 和电路名称替换为符合合约实际结构的内容。

// 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 };
}

关键设计要点:

  • 调用前快照 — 提交前捕获预期会发生变化的字段,并在轮询判断条件中使用该字段。
  • 乐观更新 — 交易落链后,立即将预期变化量应用到本地状态,无需等待 fetchState() 返回。
  • 与服务端同步 — 乐观更新后调用 fetchState(),与实际索引状态同步。

11) 可选:负载加密

使用钱包签名派生的密钥对链上字符串进行加密。需要 api.signData。

// 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:');

设计决策:

  • 使用 base64url(替换 +/=),避免 URL 和 JSON 中的填充问题。
  • 在 KDF 盐值和信息中同时包含 networkId 和 contractAddress——密钥按合约限定范围;同一钱包在不同合约和网络上会生成不同的密钥。
  • 尝试解密前,先使用 isEncryptedPayload() 检测加密值。
  • 如果 signData 不可用,应立即抛出错误,不要悄悄退化为未加密存储。

12) Next.js 兼容性

该 SDK 使用 isomorphic-ws、异步 WebAssembly 和顶层 await——这些功能在 Next.js 中均无法开箱即用。需要执行以下步骤:

1. 创建 WebSocket shim(Next.js 会为浏览器目标打包一个有问题的 isomorphic-ws):

// lib/isomorphic-ws-fix.mjs
export default globalThis.WebSocket;
export const WebSocket = globalThis.WebSocket;

2. 配置 next.config.mjs:

// 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 模式:

// package.json
{
  "scripts": {
    "dev": "next dev --webpack"
  }
}

如果使用基于 Next.js 配置启用 Turbopack,也可以只在不使用自定义 webpack 实验功能时显式设置 turbopack: {}。两者不能同时使用。

TypeScript 类型断言 — SDK 泛型与编译后的合约输出无法很好地组合。在 createUnprovenDeployTx、submitTxAsync 和 submitCallTx 调用处使用 as any。运行时类型是正确的;静态泛型对于编译器生成的合约结构而言过于严格。正确的注解是 FetchZkConfigProvider<any>。


13) ZK 密钥托管

编译后的合约会生成必须通过启用 CORS 的 HTTP 提供的资源。FetchZkConfigProvider 会在运行时获取这些资源。

your-server.com/<zk-path>/
  keys/
    circuitName.prover      # 2–10 MB each
    circuitName.verifier    # ~2 KB each
  zkir/
    circuitName.bzkir       # 1–3 KB each

必需的标头:Access-Control-Allow-Origin: *

对于本地 Vite 开发,可使用 npm 脚本将资源同步到 public/:

{
  "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/"
  }
}

调试任何 Provider 错误前,请先在浏览器中直接打开资源 URL。 此处的 404 或 CORS 错误会表现为难以理解的 SDK 错误。在 npm run dev 前运行 sync:zk——Vite 只会提供启动时已存在于 public/ 中的文件。


14) ProofStation API

1AM 钱包会通过 balanceUnsealedTransaction 在内部调用 ProofStation。通常无需直接调用。仅供参考:

端点方法描述
/healthGET服务健康状况 + 上游状态
/provePOST生成 ZK 证明
/verifyPOST验证 ZK 证明
/prove-and-balancePOST在一次调用中完成证明 + 余额处理
/balance-onlyPOST对已生成证明的交易进行余额处理
/wallet-statusGET赞助钱包的 dust 余额

基础 URL:

  • Preview:https://api-preview.1am.xyz
  • Preprod:https://api-preprod.1am.xyz
  • Mainnet:https://api.1am.xyz

身份验证:X-API-Key: pk_live_xxx(仅直接调用时需要——建议通过 api.balanceUnsealedTransaction 路由)。


15) 网络

网络用途索引器RPC
preview活跃开发indexer.preview.midnight.networkrpc.preview.midnight.network
preprod发布前测试indexer.preprod.midnight.networkrpc.preprod.midnight.network
mainnet生产环境indexer.mainnet.midnight.networkrpc.mainnet.midnight.network

开发期间使用 preview。getConfiguration() 会返回用户在钱包中所选网络对应的正确 URL — 请始终动态使用这些值,切勿将其硬编码。


16) 钱包 API 参考

window.midnight['1am'](InitialAPI)
方法返回值说明
connect(networkId)ConnectedAPI'preview' | 'preprod' | 'mainnet'
namestring'1AM'
apiVersionstring'4.0.0'
ConnectedAPI
方法返回值说明
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 }添加 dust 费用 — 切勿跳过
submitTransaction(hex)string | void返回 txId 或 void
signData(data, options)string用于密钥派生的签名
makeTransfer(outputs){ tx }代币转账

17)常见问题

问题解决方法
在 toHex 中漏掉 padStart(2, '0')单位数的十六进制字节会导致交易损坏。务必使用它。
未先调用 setNetworkId()在调用 getConfiguration() 后立即调用它。若未调用,会导致 SDK 中到处出现不易察觉的类型不匹配。
fromHex 中未去掉 0x 前缀钱包返回的十六进制值可能带有或不带有 0x。上面的 fromHex 辅助函数可以处理这两种情况。
在 preprod/preview 上使用 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(),而不是 ChargedStatequeryContractState() 返回的是 ContractState。它的 .data 属性才是 ledger() 需要的 ChargedState。务必调用 yourLedger(contractState.data),不要调用 yourLedger(contractState)。
部署后立即读取状态索引器的更新与链上最终确认不同步。务必轮询,切勿在提交后立即读取状态。
ZK 资源返回 404在运行 npm run dev 前执行 sync:zk。Vite 只会提供启动时 public/ 中已存在的文件。
重新编译后继续使用旧合约地址任何合约变更都会重新生成验证密钥。旧地址将无法通过证明验证。务必重新部署。
Preview 上电路过小Preview ProofStation 要求电路大小至少为 k≥6。如果看到 prove: no SRS params for k=6,可添加虚拟账本字段来增大电路,作为临时解决方法。
将索引器/RPC URL 硬编码务必从 getConfiguration() 读取 URL。用户所在的网络可能与你预期的不同。
Next.js:isomorphic-ws / WASM / 顶层 await 错误必需的 webpack 配置、WebSocket 垫片和禁用 Turbopack 的说明,请参见 §12。
SDK 调用位置出现 TypeScript 错误对 createUnprovenDeployTx、submitTxAsync、submitCallTx 和 FetchZkConfigProvider<any> 使用 as any 类型断言。运行时类型没有问题;对于编译器生成的合约输出,泛型范围过窄。

评论

支持 Markdown