example-payment-dapp

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Midnight Network Payment DApp

Midnight Network 支付DApp

A privacy-preserving payment vault: users deposit/withdraw tNIGHT through a Compact smart contract with zero gas fees via the 1AM wallet.
一个隐私保护支付金库:用户可通过1AM钱包,借助Compact智能合约存入/取出tNIGHT,且无需支付Gas费用。

Workflow

工作流程

When helping the user, follow this sequence:
  1. Detect where they are: Contract authoring → Provider setup → Deploy → Circuit calls → Indexer/state polling → UI
  2. Check for known gotchas first (see
    references/gotchas.md
    ) before writing any provider or circuit code
  3. Copy provider wiring from
    references/midnight-session.md
    (canonical
    lib/midnight.ts
    for all browser dApps)
  4. Use low-level SDK functions (
    createUnprovenDeployTx
    ,
    submitTxAsync
    ,
    submitCallTxAsync
    ) — never the high-level wrappers (
    deployContract
    ,
    createProofProvider
    ) which are broken on preprod
  5. Always wrap the public data provider with the patched version to avoid the GraphQL
    offset: null
    bug

当为用户提供帮助时,请遵循以下步骤:
  1. 确定用户所处阶段:合约编写 → 提供者设置 → 部署 → 电路调用 → 索引器/状态轮询 → UI界面
  2. 先检查已知陷阱(详见
    references/gotchas.md
    ),再编写任何提供者或电路代码
  3. references/midnight-session.md
    复制提供者配置代码
    (所有浏览器端DApp的标准
    lib/midnight.ts
  4. 使用底层SDK函数
    createUnprovenDeployTx
    submitTxAsync
    submitCallTxAsync
    )——切勿使用高层封装函数(
    deployContract
    createProofProvider
    ),这些函数在预生产环境中存在问题
  5. 始终使用补丁版本包装公共数据提供者,以避免GraphQL的
    offset: null
    错误

Architecture

架构

Browser (Next.js)
├── app/payment/PaymentClient.tsx   ← client component (all UI + logic)
├── lib/midnight.ts                 ← wallet detection, session, providers (see references/midnight-session.md)
├── lib/payment.ts                  ← deploy, deposit, withdraw
└── public/zk/payment/             ← ZK proving assets (synced from contract build)

1AM Extension
├── detectWallet() → window.midnight['1am']
├── api.connect('preprod')
├── api.getConfiguration()          → { networkId, indexerUri, ... }
├── api.getUnshieldedAddress()
├── api.getShieldedAddresses()
├── api.balanceUnsealedTransaction(hex)
└── api.submitTransaction(hex)
Transaction flow:
  1. Detect 1AM wallet → connect → create session with all providers
  2. Deploy contract (low-level) → store address + private state
  3. Deposit: call
    receiveUnshielded
    circuit → poll indexer for state update
  4. Withdraw: call
    sendUnshielded
    circuit (owner only) → poll indexer

Browser (Next.js)
├── app/payment/PaymentClient.tsx   ← 客户端组件(包含所有UI与逻辑)
├── lib/midnight.ts                 ← 钱包检测、会话、提供者(详见references/midnight-session.md)
├── lib/payment.ts                  ← 部署、存款、取款
└── public/zk/payment/             ← ZK证明资产(从合约构建同步而来)

1AM Extension
├── detectWallet() → window.midnight['1am']
├── api.connect('preprod')
├── api.getConfiguration()          → { networkId, indexerUri, ... }
├── api.getUnshieldedAddress()
├── api.getShieldedAddresses()
├── api.balanceUnsealedTransaction(hex)
└── api.submitTransaction(hex)
交易流程:
  1. 检测1AM钱包 → 连接 → 创建包含所有提供者的会话
  2. 部署合约(底层方式)→ 存储地址与私有状态
  3. 存款:调用
    receiveUnshielded
    电路 → 轮询索引器获取状态更新
  4. 取款:调用
    sendUnshielded
    电路(仅所有者可操作)→ 轮询索引器

Compact Contract

Compact合约

compact
pragma language_version >= 0.20;
import CompactStandardLibrary;

export ledger balance: Uint<128>;
export ledger totalDeposited: Uint<128>;
export ledger totalWithdrawn: Uint<128>;
export ledger owner: Bytes<32>;

witness ownerKey(): Bytes<32>;

constructor() {
  balance = 0;
  totalDeposited = 0;
  totalWithdrawn = 0;
  owner = disclose(deriveKey(ownerKey()));
}

export circuit deposit(amount: Uint<128>): [] {
  receiveUnshielded(default<Bytes<32>>, disclose(amount));
  totalDeposited = disclose((totalDeposited + amount) as Uint<128>);
  balance = disclose((balance + amount) as Uint<128>);
}

export circuit withdraw(amount: Uint<128>, recipient: UserAddress): [] {
  assert(deriveKey(ownerKey()) == owner, "Only owner can withdraw");
  assert(balance >= amount, "Insufficient balance");
  sendUnshielded(
    default<Bytes<32>>,
    disclose(amount),
    right<ContractAddress, UserAddress>(disclose(recipient))
  );
  totalWithdrawn = disclose((totalWithdrawn + amount) as Uint<128>);
  balance = disclose((balance - amount) as Uint<128>);
}

pure circuit deriveKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "payment:owner:v1"), sk]);
}
Compile:
cd contract && npm run compact
→ output lands in
contract/src/managed/payment/

compact
pragma language_version >= 0.20;
import CompactStandardLibrary;

export ledger balance: Uint<128>;
export ledger totalDeposited: Uint<128>;
export ledger totalWithdrawn: Uint<128>;
export ledger owner: Bytes<32>;

witness ownerKey(): Bytes<32>;

constructor() {
  balance = 0;
  totalDeposited = 0;
  totalWithdrawn = 0;
  owner = disclose(deriveKey(ownerKey()));
}

export circuit deposit(amount: Uint<128>): [] {
  receiveUnshielded(default<Bytes<32>>, disclose(amount));
  totalDeposited = disclose((totalDeposited + amount) as Uint<128>);
  balance = disclose((balance + amount) as Uint<128>);
}

export circuit withdraw(amount: Uint<128>, recipient: UserAddress): [] {
  assert(deriveKey(ownerKey()) == owner, "Only owner can withdraw");
  assert(balance >= amount, "Insufficient balance");
  sendUnshielded(
    default<Bytes<32>>,
    disclose(amount),
    right<ContractAddress, UserAddress>(disclose(recipient))
  );
  totalWithdrawn = disclose((totalWithdrawn + amount) as Uint<128>);
  balance = disclose((balance - amount) as Uint<128>);
}

pure circuit deriveKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "payment:owner:v1"), sk]);
}
编译命令:
cd contract && npm run compact
→ 输出文件将生成在
contract/src/managed/payment/
目录下

Provider Setup (Critical — Read Before Writing Any Code)

提供者设置(至关重要 — 编写任何代码前请阅读)

Session Type

会话类型

ts
export type ConnectedSession = {
  api: any;
  config: any;
  providers: {
    privateStateProvider: ReturnType<typeof createPrivateStateProvider>;
    publicDataProvider: ReturnType<typeof createPatchedPublicDataProvider>;
    zkConfigProvider: FetchZkConfigProvider<any>;
    proofProvider: { proveTx: (unprovenTx: any) => Promise<any> };
    walletProvider: WalletProvider;
    midnightProvider: MidnightProvider;
  };
  unshieldedAddress: string;
  coinPublicKeyBytes: Uint8Array;   // ← pre-converted, never pass raw pk to circuits
};
ts
export type ConnectedSession = {
  api: any;
  config: any;
  providers: {
    privateStateProvider: ReturnType<typeof createPrivateStateProvider>;
    publicDataProvider: ReturnType<typeof createPatchedPublicDataProvider>;
    zkConfigProvider: FetchZkConfigProvider<any>;
    proofProvider: { proveTx: (unprovenTx: any) => Promise<any> };
    walletProvider: WalletProvider;
    midnightProvider: MidnightProvider;
  };
  unshieldedAddress: string;
  coinPublicKeyBytes: Uint8Array;   // ← 预先转换,切勿将原始公钥传入电路
};

createConnectedSession

createConnectedSession

ts
export async function createConnectedSession(api: any, zkAssetBasePath: string): Promise<ConnectedSession> {
  const [config, unshieldedAddress, shieldedAddress] = await Promise.all([
    api.getConfiguration(),
    api.getUnshieldedAddress(),
    api.getShieldedAddresses(),
  ]);

  setNetworkId(config.networkId);

  const zkConfigProvider = new FetchZkConfigProvider(
    new URL(zkAssetBasePath, window.location.origin).toString(),
    window.fetch.bind(window),
  );
  const provingProvider = await api.getProvingProvider(zkConfigProvider);

  const proofProvider = {
    async proveTx(unprovenTx: 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);
      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
    },
  };

  return {
    api, config,
    providers: {
      privateStateProvider: createPrivateStateProvider(),
      publicDataProvider: createPatchedPublicDataProvider(config.indexerUri, config.indexerWsUri),
      zkConfigProvider,
      proofProvider,
      walletProvider,
      midnightProvider,
    },
    unshieldedAddress: unshieldedAddress.unshieldedAddress,
    coinPublicKeyBytes: coinPublicKeyToBytes(shieldedAddress.shieldedCoinPublicKey),
  };
}
ts
export async function createConnectedSession(api: any, zkAssetBasePath: string): Promise<ConnectedSession> {
  const [config, unshieldedAddress, shieldedAddress] = await Promise.all([
    api.getConfiguration(),
    api.getUnshieldedAddress(),
    api.getShieldedAddresses(),
  ]);

  setNetworkId(config.networkId);

  const zkConfigProvider = new FetchZkConfigProvider(
    new URL(zkAssetBasePath, window.location.origin).toString(),
    window.fetch.bind(window),
  );
  const provingProvider = await api.getProvingProvider(zkConfigProvider);

  const proofProvider = {
    async proveTx(unprovenTx: 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);
      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
    },
  };

  return {
    api, config,
    providers: {
      privateStateProvider: createPrivateStateProvider(),
      publicDataProvider: createPatchedPublicDataProvider(config.indexerUri, config.indexerWsUri),
      zkConfigProvider,
      proofProvider,
      walletProvider,
      midnightProvider,
    },
    unshieldedAddress: unshieldedAddress.unshieldedAddress,
    coinPublicKeyBytes: coinPublicKeyToBytes(shieldedAddress.shieldedCoinPublicKey),
  };
}

coinPublicKeyToBytes (required — format varies by wallet version)

coinPublicKeyToBytes(必填 — 格式因钱包版本而异)

ts
function coinPublicKeyToBytes(pk: unknown): Uint8Array {
  if (pk instanceof Uint8Array) return pk.length === 32 ? pk : pk.slice(0, 32);
  if (typeof pk === 'string') {
    const hex = pk.startsWith('0x') ? pk.slice(2) : pk;
    if (hex.length === 64 && /^[0-9a-fA-F]+$/.test(hex)) return fromHex(hex);
    console.warn('coinPublicKey not hex, using fallback');
    return new Uint8Array(32);
  }
  if (Array.isArray(pk)) return new Uint8Array(pk.length >= 32 ? pk.slice(0, 32) : [...pk, ...new Uint8Array(32 - pk.length)]);
  if (pk && typeof pk === 'object' && 'bytes' in (pk as any)) return coinPublicKeyToBytes((pk as any).bytes);
  return new Uint8Array(32);
}
ts
function coinPublicKeyToBytes(pk: unknown): Uint8Array {
  if (pk instanceof Uint8Array) return pk.length === 32 ? pk : pk.slice(0, 32);
  if (typeof pk === 'string') {
    const hex = pk.startsWith('0x') ? pk.slice(2) : pk;
    if (hex.length === 64 && /^[0-9a-fA-F]+$/.test(hex)) return fromHex(hex);
    console.warn('coinPublicKey not hex, using fallback');
    return new Uint8Array(32);
  }
  if (Array.isArray(pk)) return new Uint8Array(pk.length >= 32 ? pk.slice(0, 32) : [...pk, ...new Uint8Array(32 - pk.length)]);
  if (pk && typeof pk === 'object' && 'bytes' in (pk as any)) return coinPublicKeyToBytes((pk as any).bytes);
  return new Uint8Array(32);
}

Patched Public Data Provider

补丁版公共数据提供者

ts
function createPatchedPublicDataProvider(queryUrl: string, subscriptionUrl: string) {
  const base = indexerPublicDataProvider(queryUrl, subscriptionUrl);
  return {
    ...base,
    async queryContractState(contractAddress: string, config?: any) {
      if (config) return base.queryContractState(contractAddress, config);
      const res = await fetch(queryUrl, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          query: `query LATEST_CONTRACT_STATE($address: HexEncoded!) {
            contractAction(address: $address) { state }
          }`,
          variables: { address: contractAddress },
        }),
      });
      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('; '));
      const action = payload.data?.contractAction ?? null;
      return action ? ContractState.deserialize(fromHex(action.state)) : null;
    },
  };
}
ts
function createPatchedPublicDataProvider(queryUrl: string, subscriptionUrl: string) {
  const base = indexerPublicDataProvider(queryUrl, subscriptionUrl);
  return {
    ...base,
    async queryContractState(contractAddress: string, config?: any) {
      if (config) return base.queryContractState(contractAddress, config);
      const res = await fetch(queryUrl, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          query: `query LATEST_CONTRACT_STATE($address: HexEncoded!) {
            contractAction(address: $address) { state }
          }`,
          variables: { address: contractAddress },
        }),
      });
      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('; '));
      const action = payload.data?.contractAction ?? null;
      return action ? ContractState.deserialize(fromHex(action.state)) : null;
    },
  };
}

Private State Provider (In-Memory)

私有状态提供者(内存型)

ts
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.'); },
  };
}

ts
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.'); },
  };
}

Deploy & Circuit Calls

部署与电路调用

Deploy (use low-level API — high-level hangs on preprod)

部署(使用底层API — 高层API在预生产环境中会卡住)

ts
export async function deployPayment(session: ConnectedSession, ownerKey: Uint8Array): Promise<string> {
  const compiledContract = makeCompiledContract();
  const initialPrivateState = { ownerSecretKey: ownerKey };

  const deployTxData = await (createUnprovenDeployTx as any)(
    { zkConfigProvider: session.providers.zkConfigProvider, walletProvider: session.providers.walletProvider },
    { compiledContract, args: [], privateStateId: PRIVATE_STATE_ID, initialPrivateState, signingKey: sampleSigningKey() },
  );

  const contractAddress = deployTxData.public.contractAddress;
  await (submitTxAsync as any)(session.providers, { unprovenTx: deployTxData.private.unprovenTx });

  await session.providers.privateStateProvider.setContractAddress(contractAddress);
  await session.providers.privateStateProvider.set(PRIVATE_STATE_ID, initialPrivateState);
  await session.providers.privateStateProvider.setSigningKey(contractAddress, deployTxData.private.signingKey);

  return contractAddress;
}
ts
export async function deployPayment(session: ConnectedSession, ownerKey: Uint8Array): Promise<string> {
  const compiledContract = makeCompiledContract();
  const initialPrivateState = { ownerSecretKey: ownerKey };

  const deployTxData = await (createUnprovenDeployTx as any)(
    { zkConfigProvider: session.providers.zkConfigProvider, walletProvider: session.providers.walletProvider },
    { compiledContract, args: [], privateStateId: PRIVATE_STATE_ID, initialPrivateState, signingKey: sampleSigningKey() },
  );

  const contractAddress = deployTxData.public.contractAddress;
  await (submitTxAsync as any)(session.providers, { unprovenTx: deployTxData.private.unprovenTx });

  await session.providers.privateStateProvider.setContractAddress(contractAddress);
  await session.providers.privateStateProvider.set(PRIVATE_STATE_ID, initialPrivateState);
  await session.providers.privateStateProvider.setSigningKey(contractAddress, deployTxData.private.signingKey);

  return contractAddress;
}

Deposit / Withdraw

存款 / 取款

ts
export async function depositPayment(session: ConnectedSession, contractAddress: string, amount: bigint) {
  await (submitCallTxAsync as any)(session.providers, {
    compiledContract: makeCompiledContract(),
    contractAddress,
    circuitId: 'deposit',
    args: [amount],
    privateStateId: PRIVATE_STATE_ID,
  });
}

export async function withdrawPayment(session: ConnectedSession, contractAddress: string, amount: bigint, recipientBytes: Uint8Array) {
  await (submitCallTxAsync as any)(session.providers, {
    compiledContract: makeCompiledContract(),
    contractAddress,
    circuitId: 'withdraw',
    args: [amount, { bytes: recipientBytes }],
    privateStateId: PRIVATE_STATE_ID,
  });
}
ts
export async function depositPayment(session: ConnectedSession, contractAddress: string, amount: bigint) {
  await (submitCallTxAsync as any)(session.providers, {
    compiledContract: makeCompiledContract(),
    contractAddress,
    circuitId: 'deposit',
    args: [amount],
    privateStateId: PRIVATE_STATE_ID,
  });
}

export async function withdrawPayment(session: ConnectedSession, contractAddress: string, amount: bigint, recipientBytes: Uint8Array) {
  await (submitCallTxAsync as any)(session.providers, {
    compiledContract: makeCompiledContract(),
    contractAddress,
    circuitId: 'withdraw',
    args: [amount, { bytes: recipientBytes }],
    privateStateId: PRIVATE_STATE_ID,
  });
}

Compiled Contract Builder

编译合约构建器

ts
function makeCompiledContract() {
  const witnesses = {
    ownerKey: (context: any) => [context.privateState, context.privateState.ownerSecretKey],
  };
  return CompiledContract.make('payment', Payment.Contract).pipe(
    CompiledContract.withWitnesses(witnesses),
    CompiledContract.withCompiledFileAssets(ZK_ASSET_PATH),
  ) as any;
}

ts
function makeCompiledContract() {
  const witnesses = {
    ownerKey: (context: any) => [context.privateState, context.privateState.ownerSecretKey],
  };
  return CompiledContract.make('payment', Payment.Contract).pipe(
    CompiledContract.withWitnesses(witnesses),
    CompiledContract.withCompiledFileAssets(ZK_ASSET_PATH),
  ) as any;
}

Indexer & State Polling

索引器与状态轮询

ts
export async function pollForState(
  queryUrl: string,
  contractAddress: string,
  onProgress?: (attempt: number) => void,
  maxAttempts = 120,
  intervalMs = 2000,
): Promise<string> {
  for (let i = 0; i < maxAttempts; i++) {
    onProgress?.(i + 1);
    const state = await fetchContractState(queryUrl, contractAddress);
    if (state) return state;
    await new Promise(r => setTimeout(r, intervalMs));
  }
  throw new Error(`State not found after ${maxAttempts * intervalMs / 1000}s`);
}

export function decodePaymentState(stateHex: string) {
  const contractState = ContractState.deserialize(fromHex(stateHex));
  const ledger = Payment.ledger(contractState.data); // ← pass .data, not contractState itself
  return {
    balance: ledger.balance as unknown as bigint,
    totalDeposited: ledger.totalDeposited as unknown as bigint,
    totalWithdrawn: ledger.totalWithdrawn as unknown as bigint,
  };
}

ts
export async function pollForState(
  queryUrl: string,
  contractAddress: string,
  onProgress?: (attempt: number) => void,
  maxAttempts = 120,
  intervalMs = 2000,
): Promise<string> {
  for (let i = 0; i < maxAttempts; i++) {
    onProgress?.(i + 1);
    const state = await fetchContractState(queryUrl, contractAddress);
    if (state) return state;
    await new Promise(r => setTimeout(r, intervalMs));
  }
  throw new Error(`State not found after ${maxAttempts * intervalMs / 1000}s`);
}

export function decodePaymentState(stateHex: string) {
  const contractState = ContractState.deserialize(fromHex(stateHex));
  const ledger = Payment.ledger(contractState.data); // ← 传入.data,而非contractState本身
  return {
    balance: ledger.balance as unknown as bigint,
    totalDeposited: ledger.totalDeposited as unknown as bigint,
    totalWithdrawn: ledger.totalWithdrawn as unknown as bigint,
  };
}

Next.js Configuration

Next.js配置

WebSocket shim (
lib/isomorphic-ws-fix.mjs
)

WebSocket垫片(
lib/isomorphic-ws-fix.mjs

js
export default globalThis.WebSocket;
export const WebSocket = globalThis.WebSocket;
js
export default globalThis.WebSocket;
export const WebSocket = globalThis.WebSocket;

next.config.mjs

next.config.mjs

js
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);

export default {
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.resolve.fallback = { ...config.resolve.fallback, fs: false, net: false, tls: false, child_process: false };
      config.resolve.alias = { ...config.resolve.alias, "isomorphic-ws": require.resolve("./lib/isomorphic-ws-fix.mjs") };
    }
    config.experiments = { ...config.experiments, asyncWebAssembly: true, topLevelAwait: true };
    return config;
  },
};
js
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);

export default {
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.resolve.fallback = { ...config.resolve.fallback, fs: false, net: false, tls: false, child_process: false };
      config.resolve.alias = { ...config.resolve.alias, "isomorphic-ws": require.resolve("./lib/isomorphic-ws-fix.mjs") };
    }
    config.experiments = { ...config.experiments, asyncWebAssembly: true, topLevelAwait: true };
    return config;
  },
};

package.json scripts — must use
--webpack

package.json脚本 — 必须使用
--webpack
参数

json
{ "dev": "next dev --webpack", "build": "next build --webpack" }

json
{ "dev": "next dev --webpack", "build": "next build --webpack" }

Token Units

代币单位

UnitConversion
1 NIGHT1,000,000 Stars
1 Star1 base unit (used in all transactions)
Always use
BigInt
— Stars overflow JS
number
at realistic balances.

单位换算关系
1 NIGHT1,000,000 Stars
1 Star1个基础单位(所有交易中均使用此单位)
始终使用
BigInt
类型 — 当余额达到实际数值时,Stars会超出JS
number
类型的范围。

Known Issues & Fixes

已知问题与修复方案

SymptomCauseFix
Invalid character 'm' at position 0
encodeUserAddress()
got raw coin pk
Never use
encodeUserAddress
. Use
coinPublicKeyToBytes()
→ pass
{ bytes: ... }
to circuits
Deploy hangs 30–120s silently
deployContract()
calls
watchForTxData
Use
createUnprovenDeployTx
+
submitTxAsync
Proof fails
createProofProvider()
missing CostModel
Call
unprovenTx.prove(provingProvider, CostModel.initialCostModel())
directly
GraphQL
offset: null
error
Default
queryContractState
sends null offset
Use the patched public data provider (custom GraphQL query, omit offset)
Cannot read properties of null
on balance
balanceUnsealedTransaction()
returned null
Guard:
if (!balanced?.tx) throw new Error(...)
Tx ID not found after submit
submitTransaction()
returns object not string
Normalize: string →
.transactionId
.id
→ hex prefix fallback
ledger()
fails with wrong type
Passing
contractState
instead of
.data
Always pass
contractState.data
(ChargedState) to
Payment.ledger()
ZK asset 404 → cryptic SDK errorAssets not synced to public/Run
npm run sync:assets
before dev; verify URLs directly in browser
WASM / top-level-await errorsMissing webpack config or TurbopackAdd
asyncWebAssembly: true
,
topLevelAwait: true
; use
--webpack
flag
Wrong wallet detectedBoth Lace and 1AM presentCheck
window.midnight['1am']
first; fall back to
window.midnight?.mnLace

症状原因修复方案
Invalid character 'm' at position 0
encodeUserAddress()
传入了原始代币公钥
切勿使用
encodeUserAddress
。使用
coinPublicKeyToBytes()
→ 将
{ bytes: ... }
传入电路
部署时无响应卡住30–120秒
deployContract()
调用了
watchForTxData
使用
createUnprovenDeployTx
+
submitTxAsync
证明生成失败
createProofProvider()
缺少CostModel
直接调用
unprovenTx.prove(provingProvider, CostModel.initialCostModel())
GraphQL
offset: null
错误
默认的
queryContractState
发送了null偏移量
使用补丁版公共数据提供者(自定义GraphQL查询,省略偏移量)
余额查询时出现
Cannot read properties of null
balanceUnsealedTransaction()
返回了null
添加守卫:
if (!balanced?.tx) throw new Error(...)
提交后找不到交易ID
submitTransaction()
返回的是对象而非字符串
标准化处理:字符串 →
.transactionId
.id
→ 十六进制前缀降级方案
ledger()
因类型错误执行失败
传入了
contractState
而非
.data
始终将
contractState.data
(ChargedState)传入
Payment.ledger()
ZK资产404 → SDK返回模糊错误资产未同步到public/目录启动开发服务前运行
npm run sync:assets
;直接在浏览器中验证URL
WASM / 顶层await错误缺少webpack配置或使用了Turbopack添加
asyncWebAssembly: true
topLevelAwait: true
;使用
--webpack
参数
检测到错误的钱包同时存在Lace和1AM钱包优先检查
window.midnight['1am']
;降级到
window.midnight?.mnLace

ZK Asset Hosting

ZK资产托管

Run
npm run sync:assets
to copy
contract/src/managed/payment/{keys,zkir}/
public/zk/payment/
.
Before debugging any provider/SDK error, open the asset URLs directly:
http://localhost:3000/zk/payment/keys/deposit.prover
— a 404 here surfaces as a cryptic SDK error.
The
FetchZkConfigProvider
fetches from
/zk/payment/
at runtime. Next.js serves
public/
by default — no extra config needed.
运行
npm run sync:assets
contract/src/managed/payment/{keys,zkir}/
目录下的文件复制到
public/zk/payment/
目录。
在调试任何提供者/SDK错误之前,直接打开资产URL验证:
http://localhost:3000/zk/payment/keys/deposit.prover
— 此处的404错误会导致SDK返回模糊的错误信息。
FetchZkConfigProvider
在运行时会从
/zk/payment/
获取资产。Next.js默认会托管
public/
目录下的文件 — 无需额外配置。