example-locker-dapp

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Midnight Network Locker DApp

Midnight Network 锁仓dApp

A locker dApp holds unshielded NIGHT in a Compact contract until
unlockTime
(Unix seconds,
Uint<64>
). No one — including the deployer — can withdraw early. After the deadline, only the beneficiary (proven via witness) can call
release
and send tokens to a recipient address.
Runnable template: Copy
templates/locker-dapp/
for a complete Next.js project (contract + UI). Run
npm install && npm run compact && npm run dev
after installing the 1AM wallet.
What this skill produces:
  • contract/
    — Compact locker vault + TypeScript witnesses + compile scripts
  • app/locker/
    — Next.js client UI (connect, deploy, lock, release, status)
  • lib/midnight.ts
    — wallet session + patched indexer provider (copy from
    references/midnight-session.md
    or
    templates/locker-dapp/lib/midnight.ts
    )
  • lib/locker.ts
    — deploy,
    lockTokens
    ,
    release
    , ledger decode
  • public/zk/locker/
    — ZK proving assets synced from contract build
Shared references (canonical provider + troubleshooting — do not duplicate in prompts):
  • references/midnight-session.md
    createConnectedSession
    , indexer patch, deploy/call helpers
  • references/gotchas.md
    — preprod deploy hangs, GraphQL
    offset: null
    , ZK asset paths
  • references/versions.json
    — pinned
    @midnight-ntwrk/*
    versions
Primary references:
  • example-payment-dapp/
    — provider wiring, low-level deploy/call, indexer patch
  • 1am-wallet/
    — 1AM browser extension, dust-free flow
  • compact/
    blockTimeGte
    /
    blockTimeLt
    ,
    disclose()
    , ledger ADTs
  • token-transfers/
    — unshielded NIGHT units (Stars),
    UserAddress
    recipient pattern
  • indexer/
    — poll contract state after transactions
Key architecture notes:
  • Use
    blockTimeGte(unlockTime)
    — never compare raw block timestamps on ledger (privacy + API design)
  • Always use
    Uint<64>
    for Unix timestamps —
    Uint<16>
    cannot hold current epoch values
  • Use
    createUnprovenDeployTx
    +
    submitTxAsync
    — not
    deployContract()
    (hangs on preprod)
  • Wrap
    indexerPublicDataProvider
    with the patched
    queryContractState
    (GraphQL
    offset: null
    bug)
  • Token amounts are in Stars (1 NIGHT = 1_000_000 Stars); use
    BigInt
    everywhere
  • Beneficiary public key bytes are stored on-chain at lock time;
    beneficiaryKey
    witness proves release authority

一款锁仓dApp将未屏蔽的NIGHT代币存放在Compact合约中,直至
unlockTime
(Unix时间戳,类型为
Uint<64>
)。包括部署者在内的任何人都无法提前提取。截止时间过后,只有受益人(通过见证证明身份)可调用
release
方法,将代币发送至指定接收地址。
可运行模板: 复制
templates/locker-dapp/
目录获取完整的Next.js项目(合约+UI)。安装1AM钱包后,运行
npm install && npm run compact && npm run dev
即可启动。
本技能生成的内容:
  • contract/
    — Compact锁仓金库合约 + TypeScript见证脚本 + 编译脚本
  • app/locker/
    — Next.js客户端UI(连接钱包、部署合约、锁仓、提取、状态展示)
  • lib/midnight.ts
    — 钱包会话 + 修补后的索引器提供者(
    references/midnight-session.md
    templates/locker-dapp/lib/midnight.ts
    复制)
  • lib/locker.ts
    — 部署、
    lockTokens
    release
    、账本解码逻辑
  • public/zk/locker/
    — 从合约构建同步而来的ZK证明资产
共享参考资料(标准提供者+故障排查——请勿在提示中重复):
  • references/midnight-session.md
    createConnectedSession
    、索引器补丁、部署/调用工具
  • references/gotchas.md
    — 预生产环境部署卡顿、GraphQL
    offset: null
    问题、ZK资产路径
  • references/versions.json
    — 固定版本的
    @midnight-ntwrk/*
    依赖
主要参考资料:
  • example-payment-dapp/
    — 提供者配置、底层部署/调用、索引器补丁
  • 1am-wallet/
    — 1AM浏览器扩展、无小额资产流程
  • compact/
    blockTimeGte
    /
    blockTimeLt
    disclose()
    、账本ADTs
  • token-transfers/
    — 未屏蔽NIGHT单位(Stars)、
    UserAddress
    接收者模式
  • indexer/
    — 交易完成后轮询合约状态
关键架构说明:
  • 使用
    blockTimeGte(unlockTime)
    ——切勿在账本上直接比较原始区块时间戳(隐私+API设计考量)
  • 始终使用
    Uint<64>
    存储Unix时间戳——
    Uint<16>
    无法容纳当前时间戳数值
  • 使用
    createUnprovenDeployTx
    +
    submitTxAsync
    ——而非
    deployContract()
    (预生产环境下会卡顿)
  • 使用修补后的
    queryContractState
    包装
    indexerPublicDataProvider
    (解决GraphQL
    offset: null
    bug)
  • 代币数量以Stars为单位(1 NIGHT = 1_000_000 Stars);所有场景均使用
    BigInt
    处理
  • 受益人公钥字节在锁仓时存储在链上;
    beneficiaryKey
    见证用于验证提取权限

Workflow

工作流程

When helping the user, follow this sequence:
  1. Contract
    locker.compact
    compile + witnesses
  2. Providers
    createConnectedSession
    (from
    references/midnight-session.md
    )
  3. Deploy — low-level deploy, persist private state + contract address
  4. Lock
    lockTokens(amount, releaseTime, beneficiaryPkBytes)
  5. Poll indexer — read
    balance
    ,
    unlockTime
    ,
    lockActive
    from ledger
  6. Release — after
    blockTimeGte(unlockTime)
    , beneficiary calls
    release(recipient)
  7. UI — show countdown, disable release until unlock time (UX only — contract enforces)

协助用户时,请遵循以下步骤:
  1. 合约 — 编译
    locker.compact
    合约 + 生成见证脚本
  2. 提供者 — 配置
    createConnectedSession
    (来自
    references/midnight-session.md
  3. 部署 — 底层部署合约,持久化私有状态 + 合约地址
  4. 锁仓 — 调用
    lockTokens(amount, releaseTime, beneficiaryPkBytes)
  5. 轮询索引器 — 从账本读取
    balance
    unlockTime
    lockActive
    状态
  6. 提取 — 当
    blockTimeGte(unlockTime)
    后,受益人调用
    release(recipient)
  7. UI — 显示倒计时,在解锁时间前禁用提取按钮(仅UX层面限制——合约会强制验证)

1) Project Structure

1) 项目结构

locker-dapp/
├── package.json
├── next.config.mjs
├── lib/
│   ├── isomorphic-ws-fix.mjs
│   ├── midnight.ts                 # session, patched provider, hex helpers
│   └── locker.ts                   # deploy, lock, release, decode state
├── app/
│   ├── layout.tsx
│   └── locker/
│       └── LockerClient.tsx        # all wallet + contract UI (client component)
├── contract/
│   ├── package.json
│   ├── src/
│   │   ├── locker.compact
│   │   ├── witnesses.ts
│   │   ├── index.ts                # barrel → managed/locker
│   │   └── managed/locker/         # compiler output (gitignored)
│   └── tsconfig.json
└── public/zk/locker/               # synced keys + zkir (gitignored in dev, committed for deploy)

locker-dapp/
├── package.json
├── next.config.mjs
├── lib/
│   ├── isomorphic-ws-fix.mjs
│   ├── midnight.ts                 # 会话、修补后的提供者、十六进制工具
│   └── locker.ts                   # 部署、锁仓、提取、状态解码
├── app/
│   ├── layout.tsx
│   └── locker/
│       └── LockerClient.tsx        # 所有钱包+合约UI(客户端组件)
├── contract/
│   ├── package.json
│   ├── src/
│   │   ├── locker.compact
│   │   ├── witnesses.ts
│   │   ├── index.ts                # 导出入口 → managed/locker
│   │   └── managed/locker/         # 编译器输出(Git忽略)
│   └── tsconfig.json
└── public/zk/locker/               # 同步的密钥+zkir(开发环境Git忽略,部署时提交)

2) Prerequisites

2) 前置条件

bash
node --version   # 20+ for Next.js; 22+ if adding vitest contract tests
docker --version # optional local stack
bash
node --version   # Next.js需要20+版本;若添加vitest合约测试则需要22+版本
docker --version # 可选本地栈

Compact compiler

Compact编译器

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

Browser: **1AM wallet** extension on `preprod` (or Lace fallback).

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

浏览器:安装`preprod`环境的**1AM wallet**扩展(或使用Lace作为备选)。

---

3) Root
package.json

3) 根目录
package.json

json
{
  "name": "locker-dapp",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "next dev --webpack",
    "build": "npm run sync:assets && next build --webpack",
    "compact": "npm run compact --prefix contract",
    "sync:assets": "node scripts/sync-zk-assets.mjs",
    "postinstall": "npm install --prefix contract"
  },
  "dependencies": {
    "@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",
    "next": "^15.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "typescript": "^5.7.0"
  }
}

json
{
  "name": "locker-dapp",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "next dev --webpack",
    "build": "npm run sync:assets && next build --webpack",
    "compact": "npm run compact --prefix contract",
    "sync:assets": "node scripts/sync-zk-assets.mjs",
    "postinstall": "npm install --prefix contract"
  },
  "dependencies": {
    "@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",
    "next": "^15.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "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

4)
contract/package.json

json
{
  "name": "@locker/contract",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "compact": "compact compile src/locker.compact src/managed/locker"
  },
  "dependencies": {
    "@midnight-ntwrk/compact-runtime": "0.16.0"
  }
}
Compile:
bash
npm run compact
json
{
  "name": "@locker/contract",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "compact": "compact compile src/locker.compact src/managed/locker"
  },
  "dependencies": {
    "@midnight-ntwrk/compact-runtime": "0.16.0"
  }
}
编译:
bash
npm run compact

→ contract/src/managed/locker/{contract,keys,zkir}/

→ contract/src/managed/locker/{contract,keys,zkir}/

npm run sync:assets
npm run sync:assets

→ public/zk/locker/

→ public/zk/locker/


---

---

5)
contract/src/locker.compact

5)
contract/src/locker.compact

Single active lock per contract instance (simple vault). Deploy multiple contract addresses for parallel locks, or extend with
Map<Bytes<32>, LockRecord>
(see §18).
compact
pragma language_version >= 0.23;
import CompactStandardLibrary;

export ledger balance: Uint<128>;
export ledger unlockTime: Uint<64>;
export ledger beneficiary: Bytes<32>;
export ledger lockActive: Boolean;
export ledger totalLocked: Uint<128>;
export ledger totalReleased: Uint<128>;

witness beneficiaryKey(): Bytes<32>;

constructor() {
  balance = 0;
  unlockTime = 0;
  beneficiary = default<Bytes<32>>;
  lockActive = false;
  totalLocked = 0;
  totalReleased = 0;
}

// Deposit unshielded NIGHT and schedule release for beneficiaryPk at releaseTime (Unix seconds).
export circuit lockTokens(
  amount: Uint<128>,
  releaseTime: Uint<64>,
  beneficiaryPk: Bytes<32>
): [] {
  assert(!disclose(lockActive), "Vault already has an active lock");
  assert(disclose(amount) > 0, "Amount must be positive");
  receiveUnshielded(default<Bytes<32>>, disclose(amount));
  beneficiary = disclose(beneficiaryPk);
  unlockTime = disclose(releaseTime);
  balance = disclose(amount);
  lockActive = disclose(true);
  totalLocked = disclose((totalLocked + amount) as Uint<128>);
}

// Beneficiary releases locked tokens after unlockTime to recipient (unshielded UserAddress).
export circuit release(recipient: UserAddress): [] {
  assert(disclose(lockActive), "No active lock");
  assert(blockTimeGte(disclose(unlockTime)), "Lock period not finished");
  assert(deriveKey(beneficiaryKey()) == beneficiary, "Only beneficiary can release");
  const amount = disclose(balance);
  assert(amount > 0, "Nothing to release");
  sendUnshielded(
    default<Bytes<32>>,
    amount,
    right<ContractAddress, UserAddress>(disclose(recipient))
  );
  totalReleased = disclose((totalReleased + amount) as Uint<128>);
  balance = 0;
  lockActive = false;
}

pure circuit deriveKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "locker:beneficiary:v1"), sk]);
}
Time API:
blockTimeGte(t)
returns true when
block_time >= t
. Use
Uint<64>
Unix seconds. Do not store raw
block_time
on ledger.

每个合约实例仅支持一个活跃锁仓(简易金库)。若需并行锁仓,可部署多个合约地址,或扩展为使用
Map<Bytes<32>, LockRecord>
(见第18节)。
compact
pragma language_version >= 0.23;
import CompactStandardLibrary;

export ledger balance: Uint<128>;
export ledger unlockTime: Uint<64>;
export ledger beneficiary: Bytes<32>;
export ledger lockActive: Boolean;
export ledger totalLocked: Uint<128>;
export ledger totalReleased: Uint<128>;

witness beneficiaryKey(): Bytes<32>;

constructor() {
  balance = 0;
  unlockTime = 0;
  beneficiary = default<Bytes<32>>;
  lockActive = false;
  totalLocked = 0;
  totalReleased = 0;
}

// 存入未屏蔽的NIGHT代币,并安排在releaseTime(Unix时间戳)由beneficiaryPk对应的受益人提取。
export circuit lockTokens(
  amount: Uint<128>,
  releaseTime: Uint<64>,
  beneficiaryPk: Bytes<32>
): [] {
  assert(!disclose(lockActive), "Vault already has an active lock");
  assert(disclose(amount) > 0, "Amount must be positive");
  receiveUnshielded(default<Bytes<32>>, disclose(amount));
  beneficiary = disclose(beneficiaryPk);
  unlockTime = disclose(releaseTime);
  balance = disclose(amount);
  lockActive = disclose(true);
  totalLocked = disclose((totalLocked + amount) as Uint<128>);
}

// 受益人在unlockTime之后将锁定的代币提取至接收者(未屏蔽的UserAddress)。
export circuit release(recipient: UserAddress): [] {
  assert(disclose(lockActive), "No active lock");
  assert(blockTimeGte(disclose(unlockTime)), "Lock period not finished");
  assert(deriveKey(beneficiaryKey()) == beneficiary, "Only beneficiary can release");
  const amount = disclose(balance);
  assert(amount > 0, "Nothing to release");
  sendUnshielded(
    default<Bytes<32>>,
    amount,
    right<ContractAddress, UserAddress>(disclose(recipient))
  );
  totalReleased = disclose((totalReleased + amount) as Uint<128>);
  balance = 0;
  lockActive = false;
}

pure circuit deriveKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "locker:beneficiary:v1"), sk]);
}
时间API:
blockTimeGte(t)
block_time >= t
时返回true。使用
Uint<64>
类型的Unix时间戳。请勿在账本上存储原始
block_time

6)
contract/src/witnesses.ts

6)
contract/src/witnesses.ts

typescript
import type { WitnessContext } from '@midnight-ntwrk/compact-runtime';

export type LockerPrivateState = {
  beneficiarySecretKey: Uint8Array;
};

export const witnesses = {
  beneficiaryKey: (context: WitnessContext<LockerPrivateState>) =>
    [context.privateState, context.privateState.beneficiarySecretKey] as const,
};
The beneficiary's Zswap secret seed (32 bytes) must live in private state. When locking for yourself, initialize private state with your shielded coin secret. When locking for someone else, they must deploy their own locker or use a multi-party flow — the witness must match the on-chain
beneficiary
field at release.

typescript
import type { WitnessContext } from '@midnight-ntwrk/compact-runtime';

export type LockerPrivateState = {
  beneficiarySecretKey: Uint8Array;
};

export const witnesses = {
  beneficiaryKey: (context: WitnessContext<LockerPrivateState>) =>
    [context.privateState, context.privateState.beneficiarySecretKey] as const,
};
受益人的Zswap密钥种子(32字节)必须存储在私有状态中。若为自己锁仓,需使用你的屏蔽币密钥初始化私有状态。若为他人锁仓,对方必须部署自己的锁仓合约或使用多方流程——提取时见证必须与链上存储的
beneficiary
字段匹配。

7)
contract/src/index.ts

7)
contract/src/index.ts

typescript
import { CompiledContract } from '@midnight-ntwrk/compact-runtime';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { witnesses } from './witnesses.js';

export {
  Contract,
  ledger,
  pureCircuits,
  type Ledger,
} from './managed/locker/contract/index.js';
import { Contract } from './managed/locker/contract/index.js';

const currentDir = path.dirname(fileURLToPath(import.meta.url));
export const zkConfigPath = path.resolve(currentDir, 'managed', 'locker');

export const CompiledLockerContract = CompiledContract.make('locker', Contract).pipe(
  CompiledContract.withWitnesses(witnesses),
  CompiledContract.withCompiledFileAssets(zkConfigPath),
);

typescript
import { CompiledContract } from '@midnight-ntwrk/compact-runtime';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { witnesses } from './witnesses.js';

export {
  Contract,
  ledger,
  pureCircuits,
  type Ledger,
} from './managed/locker/contract/index.js';
import { Contract } from './managed/locker/contract/index.js';

const currentDir = path.dirname(fileURLToPath(import.meta.url));
export const zkConfigPath = path.resolve(currentDir, 'managed', 'locker');

export const CompiledLockerContract = CompiledContract.make('locker', Contract).pipe(
  CompiledContract.withWitnesses(witnesses),
  CompiledContract.withCompiledFileAssets(zkConfigPath),
);

8) Provider Setup

8) 提供者配置

Copy the full provider block from
example-payment-dapp/SKILL.md
§ "Provider Setup":
  • ConnectedSession
    type
  • createConnectedSession
    (ZK path →
    /zk/locker/
    )
  • coinPublicKeyToBytes
  • createPatchedPublicDataProvider
  • createPrivateStateProvider
Change only:
  • ZK_ASSET_PATH
    /
    FetchZkConfigProvider
    base →
    /zk/locker
  • setNetworkId(config.networkId)
    after
    api.getConfiguration()
Wallet detection (1AM first, Lace fallback):
typescript
const wallet =
  (window as any).midnight?.['1am'] ??
  Object.values((window as any).midnight ?? {})[0];
Prefer enumerating
Object.values(window.midnight)
over hardcoded Lace keys — see
react-wallet-connector/
.

example-payment-dapp/SKILL.md
的“Provider Setup”部分复制完整的提供者代码块
  • ConnectedSession
    类型
  • createConnectedSession
    (ZK路径 →
    /zk/locker/
  • coinPublicKeyToBytes
  • createPatchedPublicDataProvider
  • createPrivateStateProvider
仅修改以下内容:
  • ZK_ASSET_PATH
    /
    FetchZkConfigProvider
    基础路径 →
    /zk/locker
  • api.getConfiguration()
    之后调用
    setNetworkId(config.networkId)
钱包检测(优先1AM,备选Lace):
typescript
const wallet =
  (window as any).midnight?.['1am'] ??
  Object.values((window as any).midnight ?? {})[0];
优先使用
Object.values(window.midnight)
枚举,而非硬编码Lace密钥——详见
react-wallet-connector/

9)
lib/locker.ts

9)
lib/locker.ts

typescript
import { createUnprovenDeployTx, submitCallTxAsync } from '@midnight-ntwrk/midnight-js-contracts';
import { ContractState, sampleSigningKey } from '@midnight-ntwrk/compact-runtime';
import { CompiledLockerContract, Contract, ledger } from '../contract/src/index.js';
import type { ConnectedSession } from './midnight.js';
import { fromHex, pollForState } from './midnight.js';

const PRIVATE_STATE_ID = 'LockerPrivateState';
const ZK_ASSET_PATH = '/zk/locker';

function makeCompiledContract() {
  return CompiledLockerContract as any;
}

export async function deployLocker(
  session: ConnectedSession,
  beneficiarySecretKey: Uint8Array,
): Promise<string> {
  const initialPrivateState = { beneficiarySecretKey };

  const deployTxData = await (createUnprovenDeployTx as any)(
    {
      zkConfigProvider: session.providers.zkConfigProvider,
      walletProvider: session.providers.walletProvider,
    },
    {
      compiledContract: makeCompiledContract(),
      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;
}

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

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

export function decodeLockerState(stateHex: string) {
  const contractState = ContractState.deserialize(fromHex(stateHex));
  const l = ledger(contractState.data);
  return {
    balance: l.balance as unknown as bigint,
    unlockTime: l.unlockTime as unknown as bigint,
    beneficiary: l.beneficiary as unknown as Uint8Array,
    lockActive: l.lockActive as unknown as boolean,
    totalLocked: l.totalLocked as unknown as bigint,
    totalReleased: l.totalReleased as unknown as bigint,
  };
}

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

typescript
import { createUnprovenDeployTx, submitCallTxAsync } from '@midnight-ntwrk/midnight-js-contracts';
import { ContractState, sampleSigningKey } from '@midnight-ntwrk/compact-runtime';
import { CompiledLockerContract, Contract, ledger } from '../contract/src/index.js';
import type { ConnectedSession } from './midnight.js';
import { fromHex, pollForState } from './midnight.js';

const PRIVATE_STATE_ID = 'LockerPrivateState';
const ZK_ASSET_PATH = '/zk/locker';

function makeCompiledContract() {
  return CompiledLockerContract as any;
}

export async function deployLocker(
  session: ConnectedSession,
  beneficiarySecretKey: Uint8Array,
): Promise<string> {
  const initialPrivateState = { beneficiarySecretKey };

  const deployTxData = await (createUnprovenDeployTx as any)(
    {
      zkConfigProvider: session.providers.zkConfigProvider,
      walletProvider: session.providers.walletProvider,
    },
    {
      compiledContract: makeCompiledContract(),
      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;
}

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

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

export function decodeLockerState(stateHex: string) {
  const contractState = ContractState.deserialize(fromHex(stateHex));
  const l = ledger(contractState.data);
  return {
    balance: l.balance as unknown as bigint,
    unlockTime: l.unlockTime as unknown as bigint,
    beneficiary: l.beneficiary as unknown as Uint8Array,
    lockActive: l.lockActive as unknown as boolean,
    totalLocked: l.totalLocked as unknown as bigint,
    totalReleased: l.totalReleased as unknown as bigint,
  };
}

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

10) Frontend —
app/locker/LockerClient.tsx

10) 前端 —
app/locker/LockerClient.tsx

Client component pattern (same as payment dapp). Core UI states:
StateUI
Wallet disconnectedConnect button
Connected, no contractDeploy locker
Deployed, no lockLock form (amount, unlock datetime, beneficiary = self)
Active lock, before unlockShow balance, unlock date, countdown, disabled Release
Active lock, after unlockEnabled Release → recipient = own unshielded address bytes
ReleasedShow totals, offer new lock (deploy new contract or extend contract)
tsx
'use client';

import { useCallback, useEffect, useMemo, useState } from 'react';
import {
  deployLocker,
  fetchLockerState,
  lockTokens,
  releaseTokens,
} from '@/lib/locker';

const STARS_PER_NIGHT = 1_000_000n;

export default function LockerClient() {
  const [session, setSession] = useState<ConnectedSession | null>(null);
  const [contractAddress, setContractAddress] = useState<string | null>(null);
  const [ledger, setLedger] = useState<LockerLedgerView | null>(null);
  const [amountNight, setAmountNight] = useState('1');
  const [unlockAt, setUnlockAt] = useState(''); // datetime-local input
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const nowUnix = BigInt(Math.floor(Date.now() / 1000));
  const canRelease = ledger?.lockActive && ledger.unlockTime <= nowUnix;

  const refresh = useCallback(async () => {
    if (!session || !contractAddress) return;
    const state = await fetchLockerState(session.config.indexerUri, contractAddress);
    setLedger(state);
  }, [session, contractAddress]);

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

  async function onConnect() {
    setError(null);
    const wallet =
      (window as any).midnight?.['1am'] ??
      Object.values((window as any).midnight ?? {})[0];
    if (!wallet) throw new Error('Install a Midnight wallet extension');
    const api = await wallet.connect('preprod');
    const { createConnectedSession } = await import('@/lib/midnight');
    setSession(await createConnectedSession(api, '/zk/locker'));
  }

  async function onDeploy() {
    if (!session) return;
    setBusy(true);
    setError(null);
    try {
      const beneficiarySecretKey = session.coinPublicKeyBytes; // lock to self
      const addr = await deployLocker(session, beneficiarySecretKey);
      setContractAddress(addr);
      await refresh();
    } catch (e) {
      setError(String(e));
    } finally {
      setBusy(false);
    }
  }

  async function onLock() {
    if (!session || !contractAddress) return;
    setBusy(true);
    setError(null);
    try {
      const amount = BigInt(amountNight) * STARS_PER_NIGHT;
      const releaseTime = BigInt(Math.floor(new Date(unlockAt).getTime() / 1000));
      await lockTokens(
        session,
        contractAddress,
        amount,
        releaseTime,
        session.coinPublicKeyBytes,
      );
      await refresh();
    } catch (e) {
      setError(String(e));
    } finally {
      setBusy(false);
    }
  }

  async function onRelease() {
    if (!session || !contractAddress) return;
    setBusy(true);
    setError(null);
    try {
      const recipientBytes = unshieldedAddressToBytes(session.unshieldedAddress);
      await releaseTokens(session, contractAddress, recipientBytes);
      await refresh();
    } catch (e) {
      setError(String(e));
    } finally {
      setBusy(false);
    }
  }

  return (
    <div>
      <h1>Token Locker</h1>
      {!session ? (
        <button type="button" onClick={onConnect} disabled={busy}>Connect Wallet</button>
      ) : (
        <>
          <p>Connected: {session.unshieldedAddress}</p>
          {!contractAddress ? (
            <button type="button" onClick={onDeploy} disabled={busy}>Deploy Locker</button>
          ) : (
            <>
              <p>Contract: {contractAddress}</p>
              {ledger?.lockActive ? (
                <>
                  <p>Locked: {formatStars(ledger.balance)} Stars</p>
                  <p>Unlocks: {new Date(Number(ledger.unlockTime) * 1000).toLocaleString()}</p>
                  <button type="button" onClick={onRelease} disabled={busy || !canRelease}>
                    Release Tokens
                  </button>
                </>
              ) : (
                <>
                  <label>
                    Amount (NIGHT)
                    <input value={amountNight} onChange={(e) => setAmountNight(e.target.value)} />
                  </label>
                  <label>
                    Unlock at
                    <input type="datetime-local" value={unlockAt} onChange={(e) => setUnlockAt(e.target.value)} />
                  </label>
                  <button type="button" onClick={onLock} disabled={busy}>Lock Tokens</button>
                </>
              )}
            </>
          )}
        </>
      )}
      {error ? <p role="alert">{error}</p> : null}
    </div>
  );
}

function formatStars(n: bigint) {
  return n.toString();
}

// Implement: unshieldedAddressToBytes — decode mn_addr_* to 32-byte recipient for circuits
// Never use encodeUserAddress with raw coin pk; use bytes helper from 1am-wallet skill
Add styling (Tailwind/CSS) as needed — logic above is intentionally unstyled like
react-wallet-connector/
.

客户端组件模式(与支付dApp相同)。核心UI状态:
状态UI展示
钱包未连接显示“连接钱包”按钮
已连接,无合约显示“部署锁仓合约”按钮
已部署,无活跃锁仓显示锁仓表单(金额、解锁时间、受益人为自己)
活跃锁仓,未到解锁时间显示锁定余额、解锁日期、倒计时、禁用的“提取”按钮
活跃锁仓,已到解锁时间启用“提取”按钮 → 接收地址为自己的未屏蔽地址字节
已提取显示总计金额,提供新锁仓选项(部署新合约或扩展现有合约)
tsx
'use client';

import { useCallback, useEffect, useMemo, useState } from 'react';
import {
  deployLocker,
  fetchLockerState,
  lockTokens,
  releaseTokens,
} from '@/lib/locker';

const STARS_PER_NIGHT = 1_000_000n;

export default function LockerClient() {
  const [session, setSession] = useState<ConnectedSession | null>(null);
  const [contractAddress, setContractAddress] = useState<string | null>(null);
  const [ledger, setLedger] = useState<LockerLedgerView | null>(null);
  const [amountNight, setAmountNight] = useState('1');
  const [unlockAt, setUnlockAt] = useState(''); // datetime-local输入框
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const nowUnix = BigInt(Math.floor(Date.now() / 1000));
  const canRelease = ledger?.lockActive && ledger.unlockTime <= nowUnix;

  const refresh = useCallback(async () => {
    if (!session || !contractAddress) return;
    const state = await fetchLockerState(session.config.indexerUri, contractAddress);
    setLedger(state);
  }, [session, contractAddress]);

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

  async function onConnect() {
    setError(null);
    const wallet =
      (window as any).midnight?.['1am'] ??
      Object.values((window as any).midnight ?? {})[0];
    if (!wallet) throw new Error('请安装Midnight钱包扩展');
    const api = await wallet.connect('preprod');
    const { createConnectedSession } = await import('@/lib/midnight');
    setSession(await createConnectedSession(api, '/zk/locker'));
  }

  async function onDeploy() {
    if (!session) return;
    setBusy(true);
    setError(null);
    try {
      const beneficiarySecretKey = session.coinPublicKeyBytes; // 锁仓给自己
      const addr = await deployLocker(session, beneficiarySecretKey);
      setContractAddress(addr);
      await refresh();
    } catch (e) {
      setError(String(e));
    } finally {
      setBusy(false);
    }
  }

  async function onLock() {
    if (!session || !contractAddress) return;
    setBusy(true);
    setError(null);
    try {
      const amount = BigInt(amountNight) * STARS_PER_NIGHT;
      const releaseTime = BigInt(Math.floor(new Date(unlockAt).getTime() / 1000));
      await lockTokens(
        session,
        contractAddress,
        amount,
        releaseTime,
        session.coinPublicKeyBytes,
      );
      await refresh();
    } catch (e) {
      setError(String(e));
    } finally {
      setBusy(false);
    }
  }

  async function onRelease() {
    if (!session || !contractAddress) return;
    setBusy(true);
    setError(null);
    try {
      const recipientBytes = unshieldedAddressToBytes(session.unshieldedAddress);
      await releaseTokens(session, contractAddress, recipientBytes);
      await refresh();
    } catch (e) {
      setError(String(e));
    } finally {
      setBusy(false);
    }
  }

  return (
    <div>
      <h1>代币锁仓</h1>
      {!session ? (
        <button type="button" onClick={onConnect} disabled={busy}>连接钱包</button>
      ) : (
        <>
          <p>已连接:{session.unshieldedAddress}</p>
          {!contractAddress ? (
            <button type="button" onClick={onDeploy} disabled={busy}>部署锁仓合约</button>
          ) : (
            <>
              <p>合约地址:{contractAddress}</p>
              {ledger?.lockActive ? (
                <>
                  <p>锁定金额:{formatStars(ledger.balance)} Stars</p>
                  <p>解锁时间:{new Date(Number(ledger.unlockTime) * 1000).toLocaleString()}</p>
                  <button type="button" onClick={onRelease} disabled={busy || !canRelease}>
                    提取代币
                  </button>
                </>
              ) : (
                <>
                  <label>
                    金额(NIGHT)
                    <input value={amountNight} onChange={(e) => setAmountNight(e.target.value)} />
                  </label>
                  <label>
                    解锁时间
                    <input type="datetime-local" value={unlockAt} onChange={(e) => setUnlockAt(e.target.value)} />
                  </label>
                  <button type="button" onClick={onLock} disabled={busy}>锁定代币</button>
                </>
              )}
            </>
          )}
        </>
      )}
      {error ? <p role="alert">{error}</p> : null}
    </div>
  );
}

function formatStars(n: bigint) {
  return n.toString();
}

// 实现:unshieldedAddressToBytes — 将mn_addr_*解码为32字节的接收者地址,用于合约调用
// 切勿使用encodeUserAddress处理原始币公钥;请使用1am-wallet技能中的字节工具
可根据需要添加样式(Tailwind/CSS)——上述逻辑故意保持无样式,与
react-wallet-connector/
一致。

11) ZK Asset Sync —
scripts/sync-zk-assets.mjs

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

javascript
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/locker');
const dest = join(root, 'public/zk/locker');

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 });
}
Verify before debugging SDK errors:
http://localhost:3000/zk/locker/keys/lockTokens.prover
must return 200.

javascript
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/locker');
const dest = join(root, 'public/zk/locker');

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 });
}
调试SDK错误前请验证:
http://localhost:3000/zk/locker/keys/lockTokens.prover
必须返回200状态码。

12) Next.js Config

12) Next.js配置

Same as
example-payment-dapp/SKILL.md
§ Next.js:
  • lib/isomorphic-ws-fix.mjs
    WebSocket shim
  • next.config.mjs
    with
    asyncWebAssembly
    ,
    topLevelAwait
    ,
    isomorphic-ws
    alias
  • Scripts must use
    next dev --webpack
    and
    next build --webpack

example-payment-dapp/SKILL.md
中的Next.js配置相同:
  • lib/isomorphic-ws-fix.mjs
    WebSocket垫片
  • next.config.mjs
    启用
    asyncWebAssembly
    topLevelAwait
    ,并添加
    isomorphic-ws
    别名
  • 脚本必须使用
    next dev --webpack
    next build --webpack

13) End-to-End Flow

13) 端到端流程

1. npm install && npm run compact && npm run sync:assets
2. npm run dev
3. Connect 1AM wallet (preprod, funded with tNIGHT)
4. Deploy locker contract
5. Lock 1 NIGHT until chosen datetime
6. UI shows locked balance + unlock time (poll indexer every 2s after tx)
7. Wait until unlock (or advance clock on local undeployed only)
8. Beneficiary clicks Release → tokens sent to unshielded address

1. npm install && npm run compact && npm run sync:assets
2. npm run dev
3. 连接1AM钱包(preprod环境,已充值tNIGHT)
4. 部署锁仓合约
5. 锁定1 NIGHT至指定时间
6. UI显示锁定余额+解锁时间(交易完成后每2秒轮询索引器)
7. 等待至解锁时间(或仅在本地未部署环境中调整时钟)
8. 受益人点击“提取”→ 代币发送至未屏蔽地址

14) Troubleshooting

14) 故障排查

SymptomCauseFix
Lock period not finished
blockTimeGte
false
Wait until on-chain time ≥
unlockTime
; check wallet network
Only beneficiary can release
Witness key ≠ ledger
beneficiary
Private state
beneficiarySecretKey
must derive same pk stored at lock
Vault already has an active lock
Single-lock contractRelease first, or deploy another contract instance
Uint<16>
compile error on timestamp
Wrong integer widthUse
Uint<64>
for all Unix times
Deploy hangs 30–120sUsed
deployContract()
Use
createUnprovenDeployTx
+
submitTxAsync
GraphQL
offset: null
Default indexer providerUse patched
queryContractState
ZK 404Assets not synced
npm run sync:assets
Invalid character 'm' at position 0
Wrong address encoding for recipientPass
{ bytes: Uint8Array }
to circuits, not
encodeUserAddress(coinPk)
Release button works before time in UIUI-only checkContract still rejects — fix UX but trust on-chain
blockTimeGte

症状原因修复方案
Lock period not finished
blockTimeGte
返回false
等待链上时间≥
unlockTime
;检查钱包网络
Only beneficiary can release
见证密钥≠账本
beneficiary
私有状态中的
beneficiarySecretKey
必须与锁仓时存储的公钥匹配
Vault already has an active lock
合约仅支持单锁仓先提取代币,或部署新的合约实例
时间戳出现
Uint<16>
编译错误
整数宽度错误所有Unix时间戳均使用
Uint<64>
部署卡顿30–120秒使用了
deployContract()
使用
createUnprovenDeployTx
+
submitTxAsync
GraphQL
offset: null
错误
默认索引器提供者问题使用修补后的
queryContractState
ZK资源404资产未同步运行
npm run sync:assets
Invalid character 'm' at position 0
接收者地址编码错误向合约传递
{ bytes: Uint8Array }
,而非
encodeUserAddress(coinPk)
UI中提取按钮在解锁时间前可用仅UX层面限制合约仍会拒绝调用——修复UX但信任链上的
blockTimeGte
验证

15) Agent Checklist

15) 代理检查清单

When generating this dApp for a user:
  • Create
    locker.compact
    with
    lockTokens
    +
    release
    +
    blockTimeGte
  • Compile contract; sync ZK assets to
    public/zk/locker/
  • Wire
    createConnectedSession
    with patched indexer (from payment dapp)
  • Use low-level deploy +
    submitCallTxAsync
    for lock/release
  • Store
    beneficiarySecretKey
    in private state; pass
    coinPublicKeyBytes
    at lock
  • Decode ledger with
    ledger(contractState.data)
    — not raw
    ContractState
  • Use
    BigInt
    for amounts and Unix timestamps
  • Next.js
    --webpack
    flag for WASM
  • Document: 1 NIGHT = 1_000_000 Stars

为用户生成该dApp时,请确保:
  • 创建包含
    lockTokens
    +
    release
    +
    blockTimeGte
    locker.compact
    合约
  • 编译合约;将ZK资产同步至
    public/zk/locker/
  • 配置带有修补后索引器的
    createConnectedSession
    (来自支付dApp)
  • 使用底层部署+
    submitCallTxAsync
    处理锁仓/提取
  • beneficiarySecretKey
    存储在私有状态;锁仓时传递
    coinPublicKeyBytes
  • 使用
    ledger(contractState.data)
    解码账本——而非原始
    ContractState
  • 使用
    BigInt
    处理金额和Unix时间戳
  • Next.js使用
    --webpack
    标志以支持WASM
  • 文档说明:1 NIGHT = 1_000_000 Stars

16) Use Case Mapping

16) 用例映射

Use caseHow this template maps
Fixed depositUser locks until
unlockTime
, releases to self
Token vestingDeploy one locker per tranche, or extend with
Map
of schedules (§18)
Liquidity lockLock LP amount until
releaseTime
; publish contract address for verification
EscrowSet beneficiary to counterparty pk; release after deadline when conditions met off-chain
NFT lockNot covered here — use
nft/
skill + custom
receive
/
send
pattern for shielded assets

用例本模板的适配方式
定期存款用户锁定代币至
unlockTime
,之后提取至自己地址
代币归属为每个 tranche 部署一个锁仓合约,或扩展为使用
Map
存储多组计划(第18节)
流动性锁定锁定LP代币至
releaseTime
;发布合约地址供验证
托管将受益人设置为交易对手公钥;截止时间后在链下条件满足时提取
NFT锁仓本文未覆盖——使用
nft/
技能+自定义
receive
/
send
模式处理屏蔽资产

17) Reading Lock Status (Indexer)

17) 读取锁仓状态(索引器)

typescript
// After lockTokens tx — poll until balance > 0
const state = await fetchLockerState(indexerUrl, contractAddress);
console.log({
  locked: state.lockActive,
  amount: state.balance,
  unlockAt: new Date(Number(state.unlockTime) * 1000).toISOString(),
});

typescript
// lockTokens交易完成后——轮询直至balance>0
const state = await fetchLockerState(indexerUrl, contractAddress);
console.log({
  locked: state.lockActive,
  amount: state.balance,
  unlockAt: new Date(Number(state.unlockTime) * 1000).toISOString(),
});

18) Extensions

18) 扩展功能

Multi-lock (
Map
)

多锁仓(
Map

Replace single
lockActive
cell with:
compact
struct LockRecord {
  amount: Uint<128>,
  unlockTime: Uint<64>,
  beneficiary: Bytes<32>,
  active: Boolean,
}

export ledger locks: Map<Bytes<32>, LockRecord>;
export ledger nextLockId: Counter;
Add
lockId
parameter to
release
; increment counter on each
lockTokens
.
将单个
lockActive
字段替换为:
compact
struct LockRecord {
  amount: Uint<128>,
  unlockTime: Uint<64>,
  beneficiary: Bytes<32>,
  active: Boolean,
}

export ledger locks: Map<Bytes<32>, LockRecord>;
export ledger nextLockId: Counter;
release
方法添加
lockId
参数;每次调用
lockTokens
时递增计数器。

Linear vesting

线性归属

Deploy multiple locker contracts with staggered
unlockTime
values, or add a
vesting.compact
with
List<VestTranche>
and separate
claimTranche(index)
circuit using
blockTimeGte(tranche.unlockTime)
.
部署多个带有交错
unlockTime
的锁仓合约,或添加
vesting.compact
合约,使用
List<VestTranche>
结构,并添加单独的
claimTranche(index)
合约方法,通过
blockTimeGte(tranche.unlockTime)
验证。

React-only wallet shell

纯React钱包外壳

If the user only needs connect UI first, scaffold with
react-wallet-connector/
then add provider wiring from
references/midnight-session.md
.

若用户仅需先实现连接UI,可使用
react-wallet-connector/
搭建基础,再从
references/midnight-session.md
添加提供者配置。

20) Troubleshooting

20) 故障排查

See
references/gotchas.md
for the full table. Common locker-specific issues:
SymptomFix
unlockTime
overflow
Use
Uint<64>
for Unix seconds, not
Uint<16>
Release before deadline
blockTimeGte(unlockTime)
must pass — check indexer block time
Wrong beneficiary on releaseWitness
beneficiaryKey
must match bytes stored at lock time
Deploy hangs on preprodUse
createUnprovenDeployTx
+
submitTxAsync
, not
deployContract()

完整表格请见
references/gotchas.md
。锁仓相关常见问题:
症状修复方案
unlockTime
溢出
使用
Uint<64>
存储Unix时间戳,而非
Uint<16>
截止时间前可提取必须等待
blockTimeGte(unlockTime)
——检查索引器区块时间
提取时受益人错误见证
beneficiaryKey
必须与锁仓时存储的字节匹配
预生产环境部署卡顿使用
createUnprovenDeployTx
+
submitTxAsync
,而非
deployContract()

21) Related Skills

21) 相关技能

Next stepSkill
Wallet connect only
react-wallet-connector/
Payment vault (no timelock)
example-payment-dapp/
Full CLI + tests reference
example-counter/
NFT locking
nft/
Privacy audit
security/
下一步技能
仅钱包连接
react-wallet-connector/
支付金库(无时间锁)
example-payment-dapp/
完整CLI+测试参考
example-counter/
NFT锁仓
nft/
隐私审计
security/