mcpField
E
example-private-reserve-auction-dapp

example-private-reserve-auction-dapp

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

关于 example-private-reserve-auction-dapp

用于构建 Midnight Network 私密保留价拍卖 DApp 的开发技能,面向实现 Compact 合约与 Next.js 界面的开发者。内容涵盖隐藏底价、公开出价与结算时的隐私边界。

主要功能

  1. 01生成 Compact 拍卖合约与编译脚本
  2. 02提供卖家和竞拍者界面流程
  3. 03提交出价并支持更高出价覆盖
  4. 04验证并揭示保留价
  5. 05处理竞拍者和卖家的领取结算
  6. 06管理钱包会话、状态与秘密值

适用场景

  1. 01实现隐藏保留价的链上拍卖
  2. 02构建 Midnight 拍卖的 Next.js 前端
  3. 03梳理出价至领取阶段的隐私边界

安装

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

$npx skills add https://github.com/kali-decoder/midnight-skills --skill example-private-reserve-auction-dapp

安装后在 Claude Code 中输入/example-private-reserve-auction-dapp确认已加载。

详细介绍

Midnight Network 私密保留价拍卖 DApp

私密保留价拍卖合约允许卖家在链上隐藏保留价,同时竞拍者以私密身份公开出价。获胜者通过未屏蔽的 NIGHT 付款领取物品时,会跨越隐私边界。

**可运行模板:**安装 1AM 钱包后,运行 npm install && npm run compact && npm run sync:assets && npm run dev。

此技能会生成:

  • contract/ — private-reserve-auction.compact(无见证、Map、Counter)和编译脚本
  • app/auction/ — Next.js 客户端 UI(卖家部署/关闭/揭示/领取 + 竞拍者出价/领取物品)
  • lib/midnight.ts — 钱包会话 + 修补后的索引器提供程序
  • lib/auction.ts — 部署、bid、closeAuction、revealPrice、claimItem、claimProceeds、账本解码
  • lib/address.ts — Bech32 未屏蔽地址 → { bytes: Uint8Array },用于 UserAddress 电路参数
  • lib/secret.ts — 在 localStorage 中生成/存储 32 字节 DApp 密钥
  • public/zk/private-reserve-auction/ — 从合约构建同步而来的 ZK 证明资产

关键架构说明:

  • 无见证 — 调用方身份验证使用电路私有 _secret → getDappPublicKey(_secret),并与链上的 organizer 比较
  • 隐藏保留价 — 通过 persistentCommit(price, secret) 提交,之后由卖家通过 revealPrice 揭示
  • 公开出价,身份私密 — bid 会公开出价金额,但身份是 getDappPublicKey(secret) 哈希
  • 覆盖出价 — 竞拍者可以提高自己的出价;通过 Map<Bytes<32>, Uint<32>> 跟踪
  • 自动关闭 — 当 bidCount == maxBids 时,拍卖关闭
  • 隐私边界 — claimItem 调用 receiveUnshielded(nativeToken(), publicPrice),然后调用 winnerClaimed.insert(disclose(address))
  • 卖家身份公开 — claimProceeds 调用 sendUnshielded(...),向卖家的 UserAddress 转账
  • disclose() 是开发者断言 — 它标记可在公开域中使用的值;它本身不会执行披露
  • 使用 createUnprovenDeployTx + submitTxAsync,不要使用 deployContract()(在 preview 上会挂起)
  • 使用修补后的 queryContractState 包装 indexerPublicDataProvider(GraphQL offset: null 漏洞)
  • 账本中的保留价类型为 Uint<32>(单位为 Stars),但在未屏蔽操作中会转换为 Uint<128>;1 NIGHT = 1,000,000 Stars
  • UI 接受/显示 NIGHT;传给合约前会转换为 Stars(×1,000,000)
  • 在 localStorage 中持久化卖家/竞拍者的 _secret — 丢失密钥意味着失去相应角色的身份验证能力
  • 网络:Preview(在 AuctionClient.tsx 中硬编码)

工作流

帮助用户时,请遵循以下顺序:

  1. 合约 — 编写 private-reserve-auction.compact;使用 npm run compact 编译
  2. 理解隐私边界 — 隐藏价格 → 公开出价 → 揭示 → 领取(未屏蔽)→ 公开结算
  3. 提供程序 — createConnectedSession(来自 references/midnight-session.md)
  4. 部署 — 卖家将 (reservePriceStars, maxBidders, sellerSecret) 传给构造函数
  5. 出价 — 竞拍者调用 bid(bidAmountStars, userAddress, secret)(金额公开,身份私密)
  6. 关闭 — 卖家调用 closeAuction(secret)(或在名额满时自动关闭)
  7. 揭示 — 卖家调用 revealPrice(reservePriceStars, secret)(根据承诺验证)
  8. 领取 — 获胜者调用 claimItem(address, secret) + 支付保留价(跨越边界)
  9. 收益 — 领取后,卖家调用 claimProceeds(address, secret)
  10. UI — 角色卡片(卖家/竞拍者)、拍卖状态面板、用于获取公开状态的索引器轮询

1) 项目结构

private-reserve-auction-dapp/
├── package.json
├── next.config.mjs
├── postcss.config.mjs
├── lib/
│   ├── isomorphic-ws-fix.mjs
│   ├── midnight.ts                 # session, patched provider, hex helpers
│   ├── auction.ts                  # deploy, circuits, decode state
│   ├── address.ts                  # Bech32 → UserAddress bytes
│   └── secret.ts                   # crypto.getRandomValues + localStorage
├── app/
│   ├── globals.css
│   ├── layout.tsx
│   ├── page.tsx                    # landing page
│   └── auction/
│       ├── page.tsx                # server shell
│       └── AuctionClient.tsx       # seller + bidder UI
├── contract/
│   ├── package.json
│   └── src/
│       ├── private-reserve-auction.compact
│       ├── index.ts                # CompiledContract.withVacantWitnesses
│       └── managed/private-reserve-auction/  # compiler output (gitignored)
├── scripts/
│   └── sync-zk-assets.mjs          # → public/zk/private-reserve-auction/
└── public/zk/private-reserve-auction/        # keys + zkir (gitignored until sync)

2) 先决条件

node --version   # 20+
docker --version # optional local devnet tests

curl --proto '=https' --tlsv1.2 -sSf \
  https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh
source $HOME/.local/bin/env

浏览器:在 preview 网络上的 1AM 钱包,其中有用于出价和支付保留价的 tNIGHT。


3) 根目录 package.json

{
  "name": "private-reserve-auction-dapp",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "next dev",
    "build": "npm run sync:assets && next build",
    "compact": "npm run compact --prefix contract",
    "sync:assets": "node scripts/sync-zk-assets.mjs",
    "postinstall": "echo 'All deps at root level'"
  },
  "dependencies": {
    "@midnight-ntwrk/compact-js": "^2.5.0",
    "@midnight-ntwrk/compact-runtime": "0.16.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",
    "@tailwindcss/postcss": "^4.3.2",
    "next": "^15.0.0",
    "postcss": "^8.5.16",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "tailwindcss": "^4.3.2"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "typescript": "^5.7.0"
  }
}

4) contract/package.json

{
  "name": "@private-reserve-auction/contract",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "compact": "compact compile src/private-reserve-auction.compact src/managed/private-reserve-auction"
  },
  "devDependencies": {
    "@midnight-ntwrk/compact-runtime": "0.16.0"
  }
}

编译:

npm run compact
# → contract/src/managed/private-reserve-auction/{contract,keys,zkir}/
npm run sync:assets
# → public/zk/private-reserve-auction/

预期电路:bid、closeAuction、revealPrice、claimItem、claimProceeds。


5) contract/src/private-reserve-auction.compact

pragma language_version 0.23;
import CompactStandardLibrary;

export enum AuctionState {
    OPEN,
    CLOSED,
    SETTLED
}

export sealed ledger organizer: Bytes<32>;
export sealed ledger hiddenPrice: Bytes<32>;
export sealed ledger maxBids: Uint<16>;
export ledger publicPrice: Uint<32>;
export ledger auctionState: AuctionState;
export ledger bidders: Map<Bytes<32>, Uint<32>>;
export ledger bidCount: Counter;
export ledger highestBid: Uint<32>;
export ledger winnerClaimed: Set<UserAddress>;

constructor(minPrice: Uint<32>, maxBidCount: Uint<16>, _secret: Bytes<32>) {
    assert(minPrice > 0, "Reserve price must be greater than zero");
    assert(maxBidCount > 0, "Max bids must be greater than zero");

    const pubKey = getDappPublicKey(_secret);
    organizer = disclose(pubKey);

    hiddenPrice = commitPrice(minPrice as Bytes<32>, _secret);
    maxBids = disclose(maxBidCount);
    publicPrice = 0;
    highestBid = 0;
    auctionState = AuctionState.OPEN;
}

export circuit bid(bidAmount: Uint<32>, _address: UserAddress, _secret: Bytes<32>): [] {
    assert(auctionState == AuctionState.OPEN, "Auction is not open");
    assert(bidCount < maxBids, "Bids are full");
    assert(bidAmount > 0, "Bid must be greater than zero");

    const pubKey = getDappPublicKey(_secret);
    assert(pubKey != organizer, "Organizer cannot bid");

    const bidderId = disclose(pubKey);
    const publicBid = disclose(bidAmount);

    if (bidders.member(bidderId)) {
        assert(bidders.lookup(bidderId) < publicBid, "New bid must be higher");
    }

    bidders.insert(bidderId, publicBid);
    bidCount.increment(1);

    if (publicBid > highestBid) {
        highestBid = publicBid;
    }

    if (bidCount == maxBids) {
        auctionState = AuctionState.CLOSED;
    }
}

export circuit closeAuction(_secret: Bytes<32>): [] {
    const pubKey = getDappPublicKey(_secret);
    assert(organizer == pubKey, "Only organizer can close");
    assert(auctionState == AuctionState.OPEN, "Auction already closed");

    auctionState = AuctionState.CLOSED;
}

export circuit revealPrice(minPrice: Uint<32>, _secret: Bytes<32>): [] {
    const pubKey = getDappPublicKey(_secret);
    assert(organizer == pubKey, "Only organizer can reveal");
    assert(auctionState == AuctionState.CLOSED, "Auction not closed");

    const hashedPrice = commitPrice(minPrice as Bytes<32>, _secret);
    assert(hashedPrice == hiddenPrice, "Price mismatch — cannot change reserve");

    publicPrice = disclose(minPrice);
    auctionState = AuctionState.SETTLED;
}

export circuit claimItem(_address: UserAddress, _secret: Bytes<32>): [] {
    assert(auctionState == AuctionState.SETTLED, "Auction not settled");
    assert(highestBid >= publicPrice, "No valid winning bid");
    assert(!winnerClaimed.member(disclose(_address)), "Already claimed");

    const pubKey = getDappPublicKey(_secret);
    const bidderId = disclose(pubKey);
    assert(bidders.lookup(bidderId) == highestBid, "Not the highest bidder");

    // Privacy boundary: winner pays reserve, identity revealed
    receiveUnshielded(nativeToken(), publicPrice as Uint<128>);
    winnerClaimed.insert(disclose(_address));
}

export circuit claimProceeds(_address: UserAddress, _secret: Bytes<32>): [] {
    const pubKey = getDappPublicKey(_secret);
    assert(organizer == pubKey, "Not organizer");
    assert(auctionState == AuctionState.SETTLED, "Auction not settled");
    assert(winnerClaimed.size() > 0, "No winner claimed");

    sendUnshielded(
        nativeToken(),
        publicPrice as Uint<128>,
        right<ContractAddress, UserAddress>(disclose(_address))
    );
}

circuit commitPrice(_price: Bytes<32>, _secret: Bytes<32>): Bytes<32> {
    return persistentCommit<Bytes<32>>(_price, _secret);
}

circuit getDappPublicKey(_secret: Bytes<32>): Bytes<32> {
    return persistentHash<Vector<2, Bytes<32>>>([pad(32, "private-auction:pk:"), _secret]);
}
隐私模型摘要
阶段投标人身份链上数据
部署卖家隐藏hiddenPrice = persistentCommit(price, secret) 哈希
投标隐藏(仅哈希)Map 中的投标金额公开;投标人身份 = 哈希
结束隐藏auctionState = CLOSED
揭示隐藏披露 publicPrice;并根据承诺值进行验证
领取公开receiveUnshielded + winnerClaimed 中的地址——隐私边界
收益卖家公开通过 sendUnshielded 转账至卖家地址
始终公开的 Compact 域
  • 账本字段(在 disclose() 或安全提交后)
  • 导出电路的电路返回值
  • 合约间调用
  • 非屏蔽代币转账(receiveUnshielded、sendUnshielded)
  • 投标金额(在 bid 电路中披露)

6) contract/src/index.ts

无见证值——使用 withVacantWitnesses。使用惰性 await import() 模式,以避免 SSR 问题,并确保从正确的模块解析 CompiledContract。

import { CompiledContract } from '@midnight-ntwrk/compact-js';
import { sampleSigningKey, ContractState } from '@midnight-ntwrk/compact-runtime';

let _contractModule: any = null;
let _compiledContract: any = null;
let _ledgerFn: any = null;

export async function getCompiledContract(zkPath?: string): Promise<any> {
  if (!_compiledContract) {
    if (!_contractModule) {
      _contractModule = await import('./managed/private-reserve-auction/contract/index.js');
    }
    _compiledContract = CompiledContract.make(
      'private-reserve-auction',
      _contractModule.Contract,
    );
    _compiledContract = CompiledContract.withVacantWitnesses(_compiledContract);
  }
  return _compiledContract;
}

export async function getLedger(): Promise<any> {
  if (!_ledgerFn) {
    if (!_contractModule) {
      _contractModule = await import('./managed/private-reserve-auction/contract/index.js');
    }
    _ledgerFn = _contractModule.ledger;
  }
  return _ledgerFn;
}

export { sampleSigningKey, ContractState };

7) lib/address.ts

解码 Bech32 非屏蔽地址,用作 UserAddress 电路参数。

import { MidnightBech32m, UnshieldedAddress } from '@midnight-ntwrk/wallet-sdk-address-format';

export function bech32ToUserAddress(bech32: string, networkId: string): { bytes: Uint8Array } {
  const parsed = MidnightBech32m.parse(bech32).decode(UnshieldedAddress, networkId);
  return { bytes: new Uint8Array(parsed.data) };
}

预期为 UserAddress 时,切勿传入原始 Bech32 字符串或屏蔽币公钥。UserAddress 需要 { bytes } 包装,但 Bytes<32> 不需要——它们是原始 Uint8Array。


8) lib/secret.ts

import { fromHex, toHex } from './midnight';

export function generateSecret(): Uint8Array {
  const bytes = new Uint8Array(32);
  crypto.getRandomValues(bytes);
  return bytes;
}

export function saveSecret(
  role: 'seller' | 'bidder',
  contractAddress: string,
  secret: Uint8Array,
) {
  localStorage.setItem(`private-auction:${role}:${contractAddress}`, toHex(secret));
}

export function loadSecret(
  role: 'seller' | 'bidder',
  contractAddress: string,
): Uint8Array | null {
  const hex = localStorage.getItem(`private-auction:${role}:${contractAddress}`);
  return hex ? fromHex(hex) : null;
}

9) Provider 设置——lib/midnight.ts

import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import { FetchZkConfigProvider } from '@midnight-ntwrk/midnight-js-fetch-zk-config-provider';
import { ContractState } from '@midnight-ntwrk/compact-runtime';
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<any>;
    proofProvider: { proveTx: (unprovenTx: any) => Promise<any> };
    walletProvider: WalletProvider;
    midnightProvider: MidnightProvider;
  };
  unshieldedAddress: string;
  coinPublicKeyBytes: Uint8Array;
};

export function fromHex(hex: string): Uint8Array {
  const h = hex.startsWith('0x') ? hex.slice(2) : hex;
  return Uint8Array.from(h.match(/.{1,2}/g)!.map((b) => parseInt(b, 16)));
}

export function toHex(bytes: Uint8Array): string {
  return Array.from(bytes)
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
}

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);
    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 object)) {
    return coinPublicKeyToBytes((pk as { bytes: unknown }).bytes);
  }
  return new Uint8Array(32);
}

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

function createPatchedPublicDataProvider(queryUrl: string, subscriptionUrl: string) {
  const base = indexerPublicDataProvider(queryUrl, subscriptionUrl);
  return {
    ...base,
    async queryContractState(contractAddress: string, config?: unknown) {
      if (config) return base.queryContractState(contractAddress, config as never);
      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: { message: string }) => e.message).join('; '));
      }
      const action = payload.data?.contractAction ?? null;
      return action ? ContractState.deserialize(fromHex(action.state)) : null;
    },
  };
}

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

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

export async function pollForState(
  queryUrl: string,
  contractAddress: string,
  maxAttempts = 120,
  intervalMs = 2000,
): Promise<string> {
  for (let i = 0; i < maxAttempts; i++) {
    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) {
      const payload = await res.json();
      const state = payload.data?.contractAction?.state;
      if (state) return state;
    }
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  throw new Error(`Contract state not indexed after ${(maxAttempts * intervalMs) / 1000}s`);
}

export async function detectWallet(): Promise<any> {
  const w =
    (window as any).midnight?.['1am'] ??
    Object.values((window as any).midnight ?? {})[0];
  if (!w) throw new Error('No Midnight wallet extension found');
  return w;
}

关键实现细节:

  • createPatchedPublicDataProvider 对 SDK 的 indexerPublicDataProvider 进行包装,并拦截 queryContractState。未传入配置时(轮询路径),它会回退到原始 fetch + 手写的 GraphQL 查询,并完全省略 offset 参数——这样无需单独修复 SDK 版本,就能避开 GraphQL 的 offset: null 错误。

  • coinPublicKeyToBytes 会防御性地规范化四种输入形式:Uint8Array、十六进制字符串、普通 number[] 和 { bytes: ... } 包装。这是必要的,因为不同版本的钱包 SDK 会以不同格式返回币公钥。

  • createPrivateStateProvider 是一个简洁的、基于内存 Map 的 Provider,不依赖 @midnight-ntwrk——这样可以避免引入 SDK 自带的私有状态 Provider,从而避免增加另一个 WASM 包。

  • 惰性 WASM 导入——proofProvider.proveTx 会动态导入 @midnight-ntwrk/ledger-v8 以获取 CostModel,而 balanceTx 也会以同样方式导入 Transaction。这样可避免在模块求值时加载基于 WASM 的模块,这一点很重要,因为同样的做法可以避免“双 WASM 实例”错误(参见故障排查)。如果在此文件顶部静态导入 ledger-v8,它就会与 compact-runtime 自己的 WASM 实例冲突。

  • createConnectedSession 会通过一次 Promise.all,预先调用 getConfiguration、getUnshieldedAddress 和 getShieldedAddresses。如果其中任一调用抛出异常(例如钱包尚未完全初始化,或用户拒绝了连接提示),整个会话创建就会失败。调用方应捕获此异常并显示有意义的错误——参见 AuctionClient.tsx 中的 connectWallet。

  • 如果找不到钱包,detectWallet 会抛出异常,而不是返回 null。调用方必须使用 .catch(),否则会产生未处理的拒绝。UI 组件使用 .then().catch() 将其转换为 false 状态——参见故障排查中的错误说明。


10) lib/auction.ts

使用 getCompiledContract()(延迟单例),并采用 Compact 0.23+ 的正确参数格式。

import { createUnprovenDeployTx, submitCallTxAsync, submitTxAsync } from '@midnight-ntwrk/midnight-js-contracts';
import { getCompiledContract, getLedger, sampleSigningKey, ContractState } from '../contract/src/index';
import type { ConnectedSession } from './midnight';
import { fromHex, pollForState } from './midnight';
import { bech32ToUserAddress } from './address';

const PRIVATE_STATE_ID = 'PrivateAuctionState';
export const ZK_PATH = '/zk/private-reserve-auction';

const AUCTION_STATE_NAMES = ['OPEN', 'CLOSED', 'SETTLED'] as const;

export type AuctionStateName = (typeof AUCTION_STATE_NAMES)[number];

let _compiledContract: any = null;
async function makeCompiledContract() {
  if (!_compiledContract) {
    _compiledContract = await getCompiledContract(ZK_PATH);
  }
  return _compiledContract;
}

function setSize(value: unknown): number {
  if (typeof value === 'number') return value;
  if (value && typeof value === 'object' && 'size' in value) {
    const size = (value as { size: unknown }).size;
    if (typeof size === 'function') return Number((size as () => number)());
    if (typeof size === 'number') return size;
  }
  return 0;
}

export async function deployAuction(
  session: ConnectedSession,
  reservePriceNight: number,
  maxBidders: number,
  sellerSecret: Uint8Array,
): Promise<string> {
  const reservePriceStars = reservePriceNight * 1_000_000;
  const cc = await makeCompiledContract();
  const deployTxData = await (createUnprovenDeployTx as any)(
    {
      zkConfigProvider: session.providers.zkConfigProvider,
      walletProvider: session.providers.walletProvider,
    },
    {
      compiledContract: cc,
      args: [BigInt(reservePriceStars), BigInt(maxBidders), sellerSecret],
      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, {});
  await session.providers.privateStateProvider.setSigningKey(
    contractAddress,
    deployTxData.private.signingKey,
  );
  return contractAddress;
}

async function call(
  session: ConnectedSession,
  contractAddress: string,
  circuitId: string,
  args: unknown[],
) {
  const cc = await makeCompiledContract();
  await (submitCallTxAsync as any)(session.providers, {
    compiledContract: cc,
    contractAddress,
    circuitId,
    args,
    privateStateId: PRIVATE_STATE_ID,
  });
}

export const placeBid = (session: ConnectedSession, contractAddress: string, bidAmountNight: number, userAddress: { bytes: Uint8Array }, bidderSecret: Uint8Array) =>
  call(session, contractAddress, 'bid', [BigInt(bidAmountNight * 1_000_000), userAddress, bidderSecret]);

export const closeAuction = (session: ConnectedSession, contractAddress: string, sellerSecret: Uint8Array) =>
  call(session, contractAddress, 'closeAuction', [sellerSecret]);

export const revealPrice = (session: ConnectedSession, contractAddress: string, reservePriceNight: number, sellerSecret: Uint8Array) =>
  call(session, contractAddress, 'revealPrice', [BigInt(reservePriceNight * 1_000_000), sellerSecret]);

export const claimItem = (session: ConnectedSession, contractAddress: string, userAddress: { bytes: Uint8Array }, bidderSecret: Uint8Array) =>
  call(session, contractAddress, 'claimItem', [userAddress, bidderSecret]);

export const claimProceeds = (session: ConnectedSession, contractAddress: string, sellerAddress: { bytes: Uint8Array }, sellerSecret: Uint8Array) =>
  call(session, contractAddress, 'claimProceeds', [sellerAddress, sellerSecret]);

export async function decodeAuctionState(stateHex: string) {
  const contractState = ContractState.deserialize(fromHex(stateHex));
  const ledger = await getLedger();
  const l = ledger(contractState.data) as any;
  const stateIdx = Number(l.auctionState);
  return {
    auctionState: (AUCTION_STATE_NAMES[stateIdx] ?? `UNKNOWN(${stateIdx})`) as AuctionStateName | string,
    auctionStateIndex: stateIdx,
    maxBidders: Number(l.maxBids),
    publicPriceNight: Number(l.publicPrice) / 1_000_000,
    highestBidNight: Number(l.highestBid) / 1_000_000,
    bidCount: setSize(l.bidCount),
    bidderCount: setSize(l.bidders),
  };
}

export async function fetchAuctionState(queryUrl: string, contractAddress: string) {
  const hex = await pollForState(queryUrl, contractAddress);
  return decodeAuctionState(hex);
}

export function userAddressFromSession(session: ConnectedSession) {
  return bech32ToUserAddress(session.unshieldedAddress, session.config.networkId);
}

关键细节:

  • args 中的 Uint<32> 字段使用 BigInt(...),而不是普通数字
  • Bytes<32> 参数以原始 Uint8Array 传入,不使用 { bytes: ... } 包装。UserAddress 参数则需要 { bytes } 包装
  • ledger() 通过合约自身编译模块中的 getLedger() 解析
  • setSize() 同时处理 .size(属性)和 .size()(方法),因为编译后的 Set/Counter 账本字段会因 SDK 版本而异
  • 传入合约前,先将 NIGHT 计价的保留价格转换为 Stars(×1,000,000)

11) 前端 — app/auction/AuctionClient.tsx

'use client';

import { useState, useCallback, useEffect, useRef } from 'react';
import { detectWallet, createConnectedSession, pollForState } from '@/lib/midnight';
import {
  deployAuction,
  placeBid,
  closeAuction,
  revealPrice,
  claimItem,
  claimProceeds,
  fetchAuctionState,
  userAddressFromSession,
  ZK_PATH,
} from '@/lib/auction';
import { generateSecret, loadSecret, saveSecret } from '@/lib/secret';
import type { ConnectedSession } from '@/lib/midnight';

type Role = 'seller' | 'bidder';

const STATE_LABELS: Record<string, string> = {
  OPEN: 'Open for Bids',
  CLOSED: 'Bidding Closed',
  SETTLED: 'Settled',
};

export default function AuctionClient() {
  const [session, setSession] = useState<ConnectedSession | null>(null);
  const [role, setRole] = useState<Role>('bidder');
  const [contractAddress, setContractAddress] = useState('');
  const [auctionState, setAuctionState] = useState<Awaited<ReturnType<typeof fetchAuctionState>> | null>(null);
  const [error, setError] = useState('');
  const [connecting, setConnecting] = useState(false);
  const [busy, setBusy] = useState(false);
  const [statusMessage, setStatusMessage] = useState('');
  const [walletInstalled, setWalletInstalled] = useState<boolean | null>(null);
  const mountedRef = useRef(true);

  const [reservePriceNight, setReservePriceNight] = useState('0.01');
  const [maxBidders, setMaxBidders] = useState('5');
  const [bidAmountNight, setBidAmountNight] = useState('0.02');

  useEffect(() => {
    detectWallet()
      .then((w) => setWalletInstalled(w !== null))
      .catch(() => setWalletInstalled(false));
    return () => { mountedRef.current = false; };
  }, []);

  const withLoading = useCallback(async <T,>(
    message: string,
    fn: (setStatus: (msg: string) => void) => Promise<T>,
  ): Promise<T> => {
    setBusy(true);
    setError('');
    setStatusMessage(message);
    try {
      const result = await fn((msg: string) => {
        if (mountedRef.current) setStatusMessage(msg);
      });
      return result;
    } catch (e) {
      if (mountedRef.current) {
        setError(e instanceof Error ? e.message : String(e));
      }
      throw e;
    } finally {
      if (mountedRef.current) {
        setBusy(false);
        setStatusMessage('');
      }
    }
  }, []);

  const refresh = useCallback(async () => {
    if (!session || !contractAddress) return;
    try {
      const state = await fetchAuctionState(session.config.indexerUri, contractAddress);
      if (mountedRef.current) setAuctionState(state);
    } catch (e) {
      if (mountedRef.current) setError(e instanceof Error ? e.message : 'Refresh failed');
    }
  }, [session, contractAddress]);

  useEffect(() => { void refresh(); }, [refresh]);

  const connectWallet = useCallback(async () => {
    setConnecting(true);
    setError('');
    try {
      const wallet = await detectWallet();
      if (!wallet) {
        setError('1AM wallet not detected. Please install the 1AM browser extension.');
        return;
      }
      const api = await wallet.connect('preview');
      const s = await createConnectedSession(api, ZK_PATH);
      setSession(s);
    } catch (e) {
      setError(e instanceof Error ? e.message : 'Failed to connect wallet');
    } finally {
      setConnecting(false);
    }
  }, []);

  const handleDeploy = useCallback(async () => {
    if (!session) return;
    await withLoading('Deploying auction contract…', async (setStatus) => {
      const secret = generateSecret();
      const addr = await deployAuction(
        session,
        Number(reservePriceNight),
        Number(maxBidders),
        secret,
      );
      setContractAddress(addr);
      saveSecret('seller', addr, secret);

      setStatus('Waiting for indexer…');
      const state = await fetchAuctionState(session.config.indexerUri, addr);
      setAuctionState(state);
    });
  }, [session, withLoading, reservePriceNight, maxBidders]);

  const handlePlaceBid = useCallback(async () => {
    if (!session || !contractAddress) return;
    await withLoading('Placing bid (proving + submitting)…', async (setStatus) => {
      let secret = loadSecret('bidder', contractAddress);
      if (!secret) {
        secret = generateSecret();
        saveSecret('bidder', contractAddress, secret);
      }
      await placeBid(session, contractAddress, Number(bidAmountNight), userAddressFromSession(session), secret);

      setStatus('Waiting for indexer…');
      const state = await fetchAuctionState(session.config.indexerUri, contractAddress);
      setAuctionState(state);
    });
  }, [session, contractAddress, withLoading, bidAmountNight]);

  const handleCloseAuction = useCallback(async () => {
    if (!session || !contractAddress) return;
    const secret = loadSecret('seller', contractAddress);
    if (!secret) { setError('Seller secret not found.'); return; }
    await withLoading('Closing auction…', async (setStatus) => {
      await closeAuction(session, contractAddress, secret);
      setStatus('Waiting for indexer…');
      const state = await fetchAuctionState(session.config.indexerUri, contractAddress);
      setAuctionState(state);
    });
  }, [session, contractAddress, withLoading]);

  const handleRevealPrice = useCallback(async () => {
    if (!session || !contractAddress) return;
    const secret = loadSecret('seller', contractAddress);
    if (!secret) { setError('Seller secret not found.'); return; }
    await withLoading('Revealing reserve price…', async (setStatus) => {
      await revealPrice(session, contractAddress, Number(reservePriceNight), secret);
      setStatus('Waiting for indexer…');
      const state = await fetchAuctionState(session.config.indexerUri, contractAddress);
      setAuctionState(state);
    });
  }, [session, contractAddress, withLoading, reservePriceNight]);

  const handleClaimItem = useCallback(async () => {
    if (!session || !contractAddress) return;
    const secret = loadSecret('bidder', contractAddress);
    if (!secret) { setError('Bidder secret not found. Place a bid first.'); return; }
    await withLoading('Claiming item (proving + submitting)…', async (setStatus) => {
      await claimItem(session, contractAddress, userAddressFromSession(session), secret);
      setStatus('Waiting for indexer…');
      const state = await fetchAuctionState(session.config.indexerUri, contractAddress);
      setAuctionState(state);
    });
  }, [session, contractAddress, withLoading]);

  const handleClaimProceeds = useCallback(async () => {
    if (!session || !contractAddress) return;
    const secret = loadSecret('seller', contractAddress);
    if (!secret) { setError('Seller secret not found.'); return; }
    await withLoading('Claiming proceeds…', async (setStatus) => {
      await claimProceeds(session, contractAddress, userAddressFromSession(session), secret);
      setStatus('Waiting for indexer…');
      const state = await fetchAuctionState(session.config.indexerUri, contractAddress);
      setAuctionState(state);
    });
  }, [session, contractAddress, withLoading]);

  const reset = useCallback(() => {
    setContractAddress('');
    setAuctionState(null);
    setError('');
  }, []);

  if (walletInstalled === false) {
    return (
      <div className="text-center py-20">
        <h2 className="text-xl font-semibold mb-4">1AM Wallet Required</h2>
        <p className="text-zinc-600 dark:text-zinc-400 mb-6">
          Please install the <strong>1AM</strong> browser extension for Midnight Network.
        </p>
        <a
          href="https://1am.xyz"
          target="_blank"
          rel="noopener noreferrer"
          className="inline-flex h-11 items-center justify-center rounded-full bg-zinc-900 px-6 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200"
        >
          Install 1AM Wallet
        </a>
      </div>
    );
  }

  const state = auctionState;

  return (
    <div className="mx-auto max-w-lg w-full">
      <div className="text-center mb-8">
        <h1 className="text-2xl font-bold tracking-tight">Private Reserve Auction</h1>
        <p className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
          Hidden reserve price, public bids, private bidder identities
        </p>
      </div>

      {!session && (
        <div className="text-center">
          <button
            onClick={connectWallet}
            disabled={connecting}
            className="inline-flex h-11 items-center justify-center rounded-full bg-zinc-900 px-8 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200"
          >
            {connecting ? 'Connecting…' : 'Connect 1AM Wallet'}
          </button>
        </div>
      )}

      {session && (
        <div className="mb-6 rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-xs dark:border-zinc-800 dark:bg-zinc-900/50">
          <p className="font-medium text-zinc-500 uppercase tracking-wider mb-2">Wallet</p>
          <p className="text-zinc-700 dark:text-zinc-300 truncate">
            <span className="text-zinc-400">Unshielded: </span>
            {session.unshieldedAddress}
          </p>
          <p className="text-zinc-500 mt-1">
            Network: <span className="font-medium text-zinc-700 dark:text-zinc-300">{session.config.networkId}</span>
          </p>
        </div>
      )}

      {session && !contractAddress && (
        <div className="grid grid-cols-2 gap-4 mb-6">
          <button
            onClick={() => setRole('seller')}
            className={`rounded-lg border p-5 text-left transition ${
              role === 'seller'
                ? 'border-zinc-900 dark:border-white bg-zinc-100 dark:bg-zinc-800'
                : 'border-zinc-200 dark:border-zinc-800 hover:border-zinc-400 dark:hover:border-zinc-600'
            }`}
          >
            <p className="text-sm font-semibold">I&apos;m a Seller</p>
            <p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
              Deploy an auction with a hidden reserve price
            </p>
          </button>
          <button
            onClick={() => setRole('bidder')}
            className={`rounded-lg border p-5 text-left transition ${
              role === 'bidder'
                ? 'border-zinc-900 dark:border-white bg-zinc-100 dark:bg-zinc-800'
                : 'border-zinc-200 dark:border-zinc-800 hover:border-zinc-400 dark:hover:border-zinc-600'
            }`}
          >
            <p className="text-sm font-semibold">I&apos;m a Bidder</p>
            <p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
              Place private bids on an existing auction
            </p>
          </button>
        </div>
      )}

      {/* Deploy form — seller only, no contract yet */}
      {session && !contractAddress && role === 'seller' && !busy && (
        <div className="space-y-4">
          <div className="grid grid-cols-2 gap-3">
            <label className="flex flex-col gap-1 text-xs text-zinc-500 dark:text-zinc-400">
              Reserve price (NIGHT)
              <input
                type="number"
                min={0.000001}
                step={0.01}
                value={reservePriceNight}
                onChange={(e) => setReservePriceNight(e.target.value)}
                className="h-10 rounded-lg border border-zinc-200 bg-white px-3 text-sm dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100"
              />
            </label>
            <label className="flex flex-col gap-1 text-xs text-zinc-500 dark:text-zinc-400">
              Max bidders
              <input
                type="number"
                min={1}
                value={maxBidders}
                onChange={(e) => setMaxBidders(e.target.value)}
                className="h-10 rounded-lg border border-zinc-200 bg-white px-3 text-sm dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100"
              />
            </label>
          </div>
          <button
            onClick={handleDeploy}
            className="w-full h-11 rounded-full bg-zinc-900 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200"
          >
            Deploy Auction
          </button>
        </div>
      )}

      {/* Bidder hint — no contract yet */}
      {session && !contractAddress && role === 'bidder' && !busy && (
        <div className="text-center text-sm text-zinc-500 dark:text-zinc-400 py-8">
          <p>Paste a deployed auction contract address to participate.</p>
          <p className="mt-2">Ask the seller for the contract address.</p>
        </div>
      )}

      {/* Auction state + actions */}
      {session && contractAddress && (
        <div className="space-y-6">
          <div className="flex items-center justify-center gap-2 text-xs">
            <span className="rounded-full border border-zinc-200 dark:border-zinc-800 px-3 py-1 text-zinc-500 dark:text-zinc-400">
              Viewing as <strong className="text-zinc-700 dark:text-zinc-300 capitalize">{role}</strong>
            </span>
            <button
              onClick={reset}
              className="text-zinc-400 underline underline-offset-2 hover:text-zinc-600 dark:hover:text-zinc-300"
            >
              switch role
            </button>
          </div>

          {state && (
            <div className="rounded-lg border border-zinc-200 p-6 text-center dark:border-zinc-800">
              <p className="text-xs text-zinc-400 uppercase tracking-wider mb-1">Auction State</p>
              <p className="text-2xl font-bold tracking-tight">
                {STATE_LABELS[state.auctionState] ?? state.auctionState}
              </p>
              <div className="mt-4 flex justify-center gap-6 text-sm text-zinc-500 dark:text-zinc-400">
                <span>Bids: <strong className="text-zinc-700 dark:text-zinc-300">{state.bidCount}</strong> / {state.maxBidders}</span>
                {state.highestBidNight > 0 && (
                  <span>Highest: <strong className="text-zinc-700 dark:text-zinc-300">{state.highestBidNight}</strong> NIGHT</span>
                )}
              </div>
              {state.publicPriceNight > 0 && (
                <p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
                  Reserve: <strong className="text-zinc-700 dark:text-zinc-300">{state.publicPriceNight}</strong> NIGHT
                </p>
              )}
            </div>
          )}

          {/* Seller actions */}
          {role === 'seller' && (
            <div className="space-y-3">
              {state?.auctionState === 'OPEN' && (
                <button
                  onClick={handleCloseAuction}
                  disabled={busy}
                  className="w-full h-12 rounded-full bg-zinc-900 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200"
                >
                  {busy ? statusMessage || 'Processing…' : 'Close Auction'}
                </button>
              )}
              {state?.auctionState === 'CLOSED' && (
                <>
                  <label className="flex flex-col gap-1 text-xs text-zinc-500 dark:text-zinc-400">
                    Reveal reserve price (NIGHT) — must match deploy value
                    <input
                      type="number"
                      min={0.000001}
                      step={0.01}
                      value={reservePriceNight}
                      onChange={(e) => setReservePriceNight(e.target.value)}
                      className="h-10 rounded-lg border border-zinc-200 bg-white px-3 text-sm dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100"
                    />
                  </label>
                  <button
                    onClick={handleRevealPrice}
                    disabled={busy}
                    className="w-full h-12 rounded-full bg-zinc-900 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200"
                  >
                    {busy ? statusMessage || 'Processing…' : 'Reveal Reserve Price'}
                  </button>
                </>
              )}
              {state?.auctionState === 'SETTLED' && (
                <button
                  onClick={handleClaimProceeds}
                  disabled={busy}
                  className="w-full h-12 rounded-full bg-zinc-900 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200"
                >
                  {busy ? statusMessage || 'Processing…' : 'Claim Proceeds'}
                </button>
              )}
            </div>
          )}

          {/* Bidder actions */}
          {role === 'bidder' && (
            <div className="space-y-3">
              {state?.auctionState === 'OPEN' && (
                <>
                  <label className="flex flex-col gap-1 text-xs text-zinc-500 dark:text-zinc-400">
                    Your bid (NIGHT)
                    <input
                      type="number"
                      min={0.000001}
                      step={0.01}
                      value={bidAmountNight}
                      onChange={(e) => setBidAmountNight(e.target.value)}
                      className="h-10 rounded-lg border border-zinc-200 bg-white px-3 text-sm dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100"
                    />
                  </label>
                  <button
                    onClick={handlePlaceBid}
                    disabled={busy}
                    className="w-full h-12 rounded-full bg-zinc-900 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200"
                  >
                    {busy ? statusMessage || 'Processing…' : 'Place Bid'}
                  </button>
                </>
              )}
              {state?.auctionState === 'SETTLED' && state.highestBidNight >= state.publicPriceNight && (
                <button
                  onClick={handleClaimItem}
                  disabled={busy}
                  className="w-full h-12 rounded-full bg-zinc-900 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200"
                >
                  {busy ? statusMessage || 'Processing…' : 'Claim Item (pays reserve → public)'}
                </button>
              )}
            </div>
          )}

          <div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-xs dark:border-zinc-800 dark:bg-zinc-900/50">
            <p className="text-zinc-500">
              <span className="text-zinc-400">Contract: </span>
              <span className="font-mono text-zinc-700 dark:text-zinc-300 break-all">{contractAddress}</span>
            </p>
          </div>

          <div className="flex justify-center gap-3">
            <button
              onClick={() => void refresh()}
              disabled={busy}
              className="text-xs text-zinc-400 underline underline-offset-2 hover:text-zinc-600 disabled:opacity-40 dark:hover:text-zinc-300"
            >
              refresh
            </button>
            <button
              onClick={reset}
              disabled={busy}
              className="text-xs text-zinc-400 underline underline-offset-2 hover:text-zinc-600 disabled:opacity-40 dark:hover:text-zinc-300"
            >
              new contract
            </button>
          </div>
        </div>
      )}

      {busy && !contractAddress && (
        <div className="mt-6 text-center">
          <div className="inline-flex items-center gap-2 rounded-full bg-zinc-100 px-4 py-2 text-xs text-zinc-600 dark:bg-zinc-900 dark:text-zinc-400">
            <span className="inline-block h-1.5 w-1.5 rounded-full bg-zinc-400 animate-pulse" />
            {statusMessage}
          </div>
        </div>
      )}

      {error && (
        <div className="mt-6 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-400">
          {error}
        </div>
      )}
    </div>
  );
}
withLoading 模式

每次电路调用都使用 withLoading(message, fn) 包装——这是一个可复用的辅助函数,用于集中处理忙碌状态、错误和状态信息:

  • 执行前设置 busy = true 和 statusMessage = message
  • 向 fn 传入 setStatus 回调,以便异步操作在执行过程中更新状态(例如“正在等待索引器…”)
  • 捕获错误,并将 e.message 显示在 error 状态中
  • 在 finally 中重置 busy 和 statusMessage
  • 使用 mountedRef 避免在组件卸载后设置状态

值得在任何 Midnight dApp UI 中复用此模式——它可以避免在每个处理函数中重复编写 try/catch/setError 代码块。

自动生成竞标者密钥

在 handlePlaceBid 中,会从 localStorage 加载竞标者密钥。如果不存在(首次竞标),则生成一个新密钥并立即保存。这意味着竞标者无需手动管理密钥——UI 会透明地处理这一过程。卖家流程不会自动生成密钥;密钥会在部署时创建一次,必须从 localStorage 中恢复,才能执行关闭、揭示或提取收益操作。


12) ZK 资产同步 — scripts/sync-zk-assets.mjs

import { cpSync, mkdirSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const src = join(root, 'contract/src/managed/private-reserve-auction');
const dest = join(root, 'public/zk/private-reserve-auction');

rmSync(dest, { recursive: true, force: true });
mkdirSync(dest, { recursive: true });
for (const dir of ['keys', 'zkir']) {
  cpSync(join(src, dir), join(dest, dir), { recursive: true });
}

验证:http://localhost:3000/zk/private-reserve-auction/keys/bid.prover 返回 200。


13) 端到端浏览器流程

1. npm install && npm run compact && npm run sync:assets
2. npm run dev
3. Seller: Connect 1AM → Select "I'm a Seller" → Deploy (0.01 NIGHT reserve, 5 max bidders) → copy contract address
4. Bidder (other browser/wallet): Connect → Select "I'm a Bidder" → Paste address → Place bid (0.02 NIGHT)
5. Seller: Close auction (or auto-closes when 5 bids received)
6. Seller: Reveal reserve price (must match deploy value: 0.01 NIGHT)
7. Bidder: Claim item (pays 0.01 NIGHT unshielded — address now public on ledger)
8. Seller: Claim proceeds (0.01 NIGHT to unshielded address)
9. Poll indexer — verify bidCount, highestBid, publicPrice, auctionState transitions

14) 故障排除

症状原因解决方法
Organizer cannot bid出价时使用了卖家密钥使用单独的竞拍者密钥
New bid must be higher出价金额 <= 上一笔出价提高出价金额
Auction is not open拍卖结束后出价仅在 OPEN 状态下出价
Only organizer can close卖家密钥错误从 localStorage 重新加载,或重新部署
Price mismatch — cannot change reserverevealPrice 中的价格错误使用与部署时相同的保留价
Not the highest bidder使用非获胜者的密钥领取使用最高出价者的密钥
Invalid character 'm' at position 0将 Bech32 作为字节传入使用 bech32ToUserAddress()
部署卡住 30–120s使用了 deployContract()使用 createUnprovenDeployTx + submitTxAsync
GraphQL offset: null使用了默认索引器提供程序使用修补后的 queryContractState
ZK 404资产未同步npm run sync:assets
卖家密钥丢失链上无法恢复重新部署合约;将密钥存储在 localStorage 中
CompiledContract.withVacantWitnesses TypeScript 错误compact-runtime 与 compact-js 不匹配从 @midnight-ntwrk/compact-js 导入 CompiledContract
ContractMaintenanceAuthority WASM 身份错误存在两个 compact-runtime 实例删除 contract/node_modules
构造函数参数出现 bigint 类型不匹配传入了 number,但预期为 bigint对 Uint<32> 字段使用 BigInt(value) 包装
Bytes<32> 被包装在 { bytes: ... } 中导致不匹配与 UserAddress 混淆Bytes<32> 使用原始 Uint8Array;{ bytes } 仅用于 UserAddress
bidCount 显示 NaN字段名称不匹配(maxBidders 与 maxBids)访问 l.maxBids,而不是 l.maxBidders
Counter 类型出现 = undefined 错误Counter 不支持赋值不要在构造函数中初始化;使用 .increment(1)
网络不匹配使用了 wallet.connect('preprod'),但目标网络是 Preview使用 wallet.connect('preview')
walletInstalled 卡在 null(一直加载)detectWallet() 抛出异常,但没有 .catch() 处理程序添加 .catch(() => setWalletInstalled(false)) — 参见 AuctionClient.tsx 中的 useEffect
连接时 getProvingProvider 或 getConfiguration 抛出异常钱包尚未完全初始化,或用户拒绝了提示将 createConnectedSession 调用包装在 try/catch 中;将 e.message 作为连接错误显示
CostModel 或 Transaction 身份不匹配在模块顶层静态 import '@midnight-ntwrk/ledger-v8' 加载了第二个 WASM 实例在函数体内使用动态 await import('@midnight-ntwrk/ledger-v8')(参见 proofProvider.proveTx 和 walletProvider.balanceTx)

15) Agent 检查清单

为用户生成此 dApp 时:

  • 编写 private-reserve-auction.compact,包含全部五个导出电路及辅助电路
  • 编译;将 ZK 资产同步到 public/zk/private-reserve-auction/
  • 使用来自 @midnight-ntwrk/compact-js 的 CompiledContract.withVacantWitnesses(而非 compact-runtime)
  • 使用延迟加载的 getCompiledContract() / getLedger() 单例模式(避免双 WASM 实例错误)
  • 将 createConnectedSession 连接到修补后的索引器
  • 构造函数参数:[BigInt(reservePriceStars), BigInt(maxBidders), rawUint8Array]
  • 通过 wallet-sdk-address-format 解码未屏蔽的 Bech32
  • 按合约地址将卖家/竞拍者密钥存储在 localStorage 中
  • 在领取按钮前,UI 说明隐私边界
  • Next.js:在 webpack 配置中设置 asyncWebAssembly: true + topLevelAwait: true;为 fs、net、tls、child_process 配置 resolve.fallback;为 isomorphic-ws 配置别名
  • 如存在 contract/node_modules,将其删除 — 根目录级别的 node_modules 必须是唯一的 compact-runtime 副本
  • Tailwind:采用计数器 dApp 模式 — Geist 字体、rounded-full 按钮、prefers-color-scheme 深色模式
  • 文档说明:保留价以 NIGHT(UI)/ Stars(合约)计;1 NIGHT = 1,000,000 Stars

16) Related Skills

Next stepSkill
Wallet connect onlyreact-wallet-connector/
Unshielded token flowstoken-transfers/
Payment vault patternexample-payment-dapp/
Privacy auditsecurity/
Compact language referencecompact/
Local vitest harnessexample-hello-world/

评论

支持 Markdown