mcpField
T
token-transfers

token-transfers

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

关于 token-transfers

面向 Midnight 开发者的代币技能,介绍 NIGHT 账本代币、DUST 费用和基于 Compact 的合约代币,并说明转账、余额查询与代币设计。

主要功能

  1. 01比较账本代币与合约代币
  2. 02说明 shielded 与 unshielded NIGHT
  3. 03涵盖转账和 DUST 费用处理
  4. 04展示 Compact 同质化代币模式
  5. 05介绍 Zswap 和代币余额查询

适用场景

  1. 01为应用选择代币模型
  2. 02向一个或多个收款方发送 NIGHT
  3. 03设计 Compact 应用代币
  4. 04查询余额并排查 DUST 问题

安装

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

$npx skills add https://github.com/kali-decoder/midnight-skills --skill token-transfers

安装后在 Claude Code 中输入/token-transfers确认已加载。

详细介绍

代币转账技能

Midnight 有两套彼此独立运行的代币系统:账本代币(NIGHT,基于 UTXO,链的原生代币)和 合约代币(基于账户,类似 ERC-20,由 Compact 实现)。它们的工作方式、转账方式和用途各不相同。

主要参考资料:

  • docs.midnight.network/concepts/ledgers — 账本代币与合约代币模型
  • docs.midnight.network/concepts/utxo — UTXO 机制和 nullifier 集
  • docs.midnight.network/concepts/dust-architecture — DUST 生成生命周期
  • docs.midnight.network/concepts/zswap — 原子交换和屏蔽转账协议
  • github.com/OpenZeppelin/compact-contracts — FungibleToken 参考实现

1) 两套代币系统——你需要哪一种

账本代币(NIGHT)合约代币
存储位置链上账本,基于 UTXOCompact 合约内,基于账户
转账机制Zswap(零知识原子交换)电路调用(transfer、mint、burn)
隐私性在 UTXO 层屏蔽或不屏蔽私有状态(余额可以是私有的)
费用资源NIGHT 生成 DUST(交易费用)不承担费用作用——仅用于应用逻辑
钱包 SDK 方法wallet.makeTransfer(outputs)contract.callTx.transfer(to, amount)
管理方协议 + 钱包 SDK你的 Compact 合约
类比原生 ETH / BTCERC-20

**判断规则:**如果你要转移 NIGHT 代币(链的原生代币),请使用钱包 SDK 转账。如果你要构建应用代币(治理代币、游戏货币、稳定币、NFT),请使用账户/映射模型在 Compact 中实现。


2) NIGHT 代币——屏蔽与不屏蔽

每个 NIGHT UTXO 都是屏蔽的(私有,对观察者隐藏)或不屏蔽的(公开,可在链上查看)。

屏蔽不屏蔽
地址前缀mn_shield1...mn_addr_preprod1... / mn_addr1...
金额是否可见否是
发送方/接收方是否可见否是
DUST 生成是(通过 Zswap 注册)是(通过注册表)
水龙头/桥接发送至否是——始终先发送至不屏蔽地址
适用于对隐私敏感的转账互操作、水龙头、桥接、合约

重要:水龙头和桥接始终向不屏蔽地址发送代币。切勿向水龙头提供屏蔽地址。

钱包 SDK 中的地址类型
import * as Rx from 'rxjs';

const state = await Rx.firstValueFrom(
  wallet.state().pipe(Rx.filter((s: any) => s.isSynced)),
);

// Unshielded address — use for faucets, bridges, contract interactions
const unshieldedAddress = state.unshielded.address;
// → "mn_addr_preprod1qxy..."

// Shielded coin public key — used as recipient in Zswap transfers
const shieldedCoinPublicKey = state.shielded.coinPublicKey.toHexString();
// → "0x3a7f..."

// DUST address — for DUST registration queries
const dustAddress = state.dust.address;

3) 发送 NIGHT 代币(钱包 SDK)

使用 wallet.makeTransfer(outputs) 转移 NIGHT。钱包会处理 UTXO 选择、Zswap 证明和 DUST 费用支付。

import * as Rx from 'rxjs';
import { unshieldedToken } from '@midnight-ntwrk/ledger-v8';

// Get current state
const state = await Rx.firstValueFrom(
  wallet.state().pipe(Rx.filter((s: any) => s.isSynced)),
);

// Check unshielded NIGHT balance before sending
const nightTokenType = unshieldedToken().raw; // hex token type identifier
const nightBalance = state.unshielded.balances[nightTokenType] ?? 0n;
console.log('NIGHT balance (Stars):', nightBalance);
// 1 NIGHT = 1_000_000 Stars

// Send unshielded NIGHT to an unshielded address
const transferRecipe = await wallet.makeTransfer([
  {
    value: 1_000_000n,                    // 1 NIGHT in Stars
    tokenType: nightTokenType,
    receiverAddress: 'mn_addr_preprod1...', // recipient's unshielded address
  },
]);

const finalized = await wallet.finalizeRecipe(transferRecipe);
const txId = await wallet.submitTransaction(finalized);
console.log('Transfer submitted:', txId);
多输出转账(批量)
// Send to multiple recipients in one atomic transaction
const transferRecipe = await wallet.makeTransfer([
  {
    value: 500_000n,
    tokenType: nightTokenType,
    receiverAddress: 'mn_addr_preprod1_alice...',
  },
  {
    value: 250_000n,
    tokenType: nightTokenType,
    receiverAddress: 'mn_addr_preprod1_bob...',
  },
]);
查询不屏蔽的 NIGHT 余额(不使用钱包)

你也可以直接查询索引器,获取合约或地址的不屏蔽余额:

async function getUnshieldedBalance(
  indexerUrl: string,
  contractAddress: string,
): Promise<Map<string, bigint>> {
  const res = await fetch(indexerUrl, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      query: `
        query($address: HexEncoded!) {
          contractAction(address: $address) {
            ... on ContractCall   { unshieldedBalances { tokenType amount } }
            ... on ContractUpdate { unshieldedBalances { tokenType amount } }
          }
        }
      `,
      variables: { address: contractAddress },
    }),
  });
  const payload = await res.json();
  const balances: Array<{ tokenType: string; amount: string }> =
    payload.data?.contractAction?.unshieldedBalances ?? [];
  return new Map(balances.map(b => [b.tokenType, BigInt(b.amount)]));
}

4) DUST——机制和对开发者的影响

DUST 是通过持有 NIGHT 生成的不可转让费用资源。它不是可以发送的代币——提交交易时会自动消耗它,作为容量资源。

心智模型
NIGHT  →  generates  →  DUST  →  consumed by  →  transactions
(Solar Panel)          (Electricity)              (Appliances)
主要属性
  • 1 NIGHT = 最多 5 DUST 的容量(完全生成时:night_dust_ratio = 5_000_000_000)
  • 1 DUST = 10^15 Specks(内部使用的单位)
  • 达到容量所需的生成时间:约 1 周(generation_decay_rate = 8267)
  • 宽限期:3 小时(接受证明的时间戳窗口)
  • DUST 是屏蔽且不可转让的——你无法将 DUST 发送给其他用户
  • 支撑 DUST 的 NIGHT UTXO 一旦被花费,DUST 就会立即开始衰减
DUST 生命周期
NIGHT UTXO created
        ↓
Registration: DustRegistration links NIGHT public key → DUST public key
        ↓
DUST UTXO starts generating value (grows toward cap over ~1 week)
        ↓
Transaction submitted: DUST UTXO consumed → new DUST UTXO created (value - fees)
        ↓
NIGHT UTXO spent → DUST UTXO immediately begins decaying to zero
注册 NIGHT 以生成 DUST
const state = await Rx.firstValueFrom(
  wallet.state().pipe(Rx.filter((s: any) => s.isSynced)),
);

// Find unregistered NIGHT UTXOs
const unregistered = state.unshielded.availableCoins.filter(
  (coin: any) => coin.meta?.registeredForDustGeneration !== true,
);

if (unregistered.length > 0) {
  const recipe = await wallet.registerNightUtxosForDustGeneration(
    unregistered,
    unshieldedKeystore.getPublicKey(),
    (payload: Uint8Array) => unshieldedKeystore.signData(payload),
  );
  const finalized = await wallet.finalizeRecipe(recipe);
  await wallet.submitTransaction(finalized);
}

// Wait for DUST to become available
await Rx.firstValueFrom(
  wallet.state().pipe(
    Rx.throttleTime(5_000),
    Rx.filter((s: any) => s.isSynced),
    Rx.filter((s: any) => s.dust.walletBalance(new Date()) > 0n),
  ),
);
读取 DUST 余额
const state = await Rx.firstValueFrom(
  wallet.state().pipe(Rx.filter((s: any) => s.isSynced)),
);

const dustBalance = state.dust.walletBalance(new Date()); // Specks
const dustCoins = state.dust.availableCoins.length;
const dustPending = state.dust.pendingCoins.length;

console.log(`DUST: ${dustBalance.toLocaleString()} Specks`);
console.log(`Coins: ${dustCoins} available, ${dustPending} pending`);

pendingCoins > 0 && availableCoins === 0 → DUST 被待处理或失败的交易锁定。重启钱包进程即可释放。这是一个已知的钱包 SDK 问题。

赞助网络上的 DUST(预览网、主网)

在 preview 和 mainnet 上,1AM ProofStation 会赞助所有费用。用户无需持有 NIGHT 或 DUST。对 1AM 钱包的 balanceUnsealedTransaction 调用会在服务端处理费用支付。请勿在这些网络上尝试 DUST 注册流程——这些流程没有必要。


5) 合约代币(FungibleToken 模式)

对于应用代币,请使用 Compact 实现。生产环境请使用 OpenZeppelin Contracts for Compact 库;下方的合约只是账户模型模式的最简示例。

Midnight 上没有 msg.sender。 circuit 无法查询是谁调用了它。身份必须通过证明来验证,而证明身份的唯一方法,是根据调用者作为 witness 提供的密钥推导账户:circuit 会约束 account == hash(domain, secretKey()),因此,若要指定他人的账户,就必须逆转哈希。切勿通过 witness 接收账户本身——witness 是调用者自己的 TypeScript 代码,因此 witness callerAddress(): Bytes<32> 会返回调用者想要的任何值,任何账户都可能被任何人扣款。请参阅下方的“常见问题”。

最简 FungibleToken 合约
pragma language_version >= 0.23;

import CompactStandardLibrary;

export ledger name: Opaque<"string">;
export ledger symbol: Opaque<"string">;
export ledger decimals: Uint<8>;
export ledger totalSupply: Uint<128>;
export ledger balances: Map<Bytes<32>, Uint<128>>;
export ledger minter: Bytes<32>;

// The caller's secret. Never disclosed — only its hash reaches the ledger.
witness secretKey(): Bytes<32>;

// An account id is the hash of a secret, so holding the secret IS the
// authorisation. Domain-separated so the same secret cannot be replayed
// as an identity in another contract.
circuit accountOf(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "fungible:account:v1"), sk]);
}

constructor(
  _name: Opaque<"string">,
  _symbol: Opaque<"string">,
  _decimals: Uint<8>,
) {
  name = disclose(_name);
  symbol = disclose(_symbol);
  decimals = disclose(_decimals);
  totalSupply = 0;
  // Whoever deploys becomes the minter.
  minter = disclose(accountOf(secretKey()));
}

// A holder needs their own id in order to be paid. It is public by
// construction — it is a hash, and it reveals nothing about the secret.
export circuit myAccount(): Bytes<32> {
  return disclose(accountOf(secretKey()));
}

export circuit mint(to: Bytes<32>, amount: Uint<128>): [] {
  // Without this, anyone can mint any amount and the supply means nothing.
  assert(disclose(accountOf(secretKey())) == minter, "only the minter may mint");

  const recipient = disclose(to);
  const current = balances.member(recipient)
    ? balances.lookup(recipient)
    : 0;
  balances.insert(recipient, disclose((current + amount) as Uint<128>));
  totalSupply = disclose((totalSupply + amount) as Uint<128>);
}

export circuit transfer(to: Bytes<32>, amount: Uint<128>): Boolean {
  // The sender is derived, never supplied: a caller can only ever spend from
  // the account whose secret they hold.
  const sender = disclose(accountOf(secretKey()));
  assert(balances.member(sender), "sender has no balance");
  const senderBal = balances.lookup(sender);
  assert(senderBal >= amount, "insufficient balance");

  balances.insert(sender, disclose((senderBal - amount) as Uint<128>));

  const recipient = disclose(to);
  const recipientBal = balances.member(recipient)
    ? balances.lookup(recipient)
    : 0;
  balances.insert(recipient, disclose((recipientBal + amount) as Uint<128>));

  return true;
}

export circuit balanceOf(account: Bytes<32>): Uint<128> {
  // `account` is a circuit parameter, so it is a witness value until disclosed;
  // passing it straight to a ledger op fails the disclosure check at compile time.
  const a = disclose(account);
  if (!balances.member(a)) { return 0; }
  return balances.lookup(a);
}

配套的 witness 实现——密钥来自用户自己的私有状态,不会离开设备:

// witnesses.ts
export type TokenPrivateState = { secretKey: Uint8Array };

export const witnesses = {
  secretKey: ({ privateState }: WitnessContext<Ledger, TokenPrivateState>):
    [TokenPrivateState, Uint8Array] => [privateState, privateState.secretKey],
};
OpenZeppelin FungibleToken(功能完整)

OpenZeppelin 库提供了适用于生产环境的实现,包含 Ownable、Pausable 和 FungibleToken 模块:

# Install as a git submodule
git init && git submodule add https://github.com/OpenZeppelin/compact-contracts.git
cd compact-contracts && nvm install && yarn && SKIP_ZK=true yarn compact
pragma language_version >= 0.22;

import CompactStandardLibrary;
import "./compact-contracts/node_modules/@openzeppelin/compact-contracts/src/access/Ownable"
  prefix Ownable_;
import "./compact-contracts/node_modules/@openzeppelin/compact-contracts/src/security/Pausable"
  prefix Pausable_;
import "./compact-contracts/node_modules/@openzeppelin/compact-contracts/src/token/FungibleToken"
  prefix FungibleToken_;

constructor(
  _name: Opaque<"string">,
  _symbol: Opaque<"string">,
  _decimals: Uint<8>,
  _recipient: Either<ZswapCoinPublicKey, ContractAddress>,
  _amount: Uint<128>,
  _initOwner: Either<ZswapCoinPublicKey, ContractAddress>,
) {
  Ownable_initialize(_initOwner);
  FungibleToken_initialize(_name, _symbol, _decimals);
  FungibleToken__mint(_recipient, _amount);
}

export circuit transfer(
  to: Either<ZswapCoinPublicKey, ContractAddress>,
  value: Uint<128>,
): Boolean {
  Pausable_assertNotPaused();
  return FungibleToken_transfer(to, value);
}

export circuit pause(): [] {
  Ownable_assertOnlyOwner();
  Pausable__pause();
}

export circuit unpause(): [] {
  Ownable_assertOnlyOwner();
  Pausable__unpause();
}

编译输出示例:

circuit "pause"    (k=10, rows=125)
circuit "transfer" (k=11, rows=1180)
circuit "unpause"  (k=10, rows=121)

6) 从 TypeScript 调用代币 circuit

import { findDeployedContract } from '@midnight-ntwrk/midnight-js-contracts';

const contract = await findDeployedContract(providers, {
  contractAddress: '09dbe05f...',
  compiledContract,
  privateStateId: 'tokenState',
  initialPrivateState: {},
});

// Transfer tokens
const result = await contract.callTx.transfer(recipientAddressBytes, 100n);
console.log('txId:', result.public.txId);
console.log('blockHeight:', result.public.blockHeight);

// Read balance (no transaction — direct state query)
import { ContractState } from '@midnight-ntwrk/compact-runtime';
import { YourToken } from './managed/your-token';

const stateRaw = await providers.publicDataProvider.queryContractState(contractAddress);
if (stateRaw) {
  const ledgerState = YourToken.ledger(stateRaw.data);
  const balance = ledgerState.balances.lookup(myAccountBytes); // from myAccount()
  console.log('Balance:', balance);
}

7) 通过索引器读取代币余额

合约代币余额存储在 export ledger 中——与其他合约状态一样,可通过索引器读取:

import { ContractState } from '@midnight-ntwrk/compact-runtime';

async function getTokenBalance(
  indexerUrl: string,
  contractAddress: string,
  holderAddress: Uint8Array,
  ledgerFn: (data: any) => any,
): Promise<bigint> {
  const res = await fetch(indexerUrl, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      query: `
        query($address: HexEncoded!) {
          contractAction(address: $address) { state }
        }
      `,
      variables: { address: contractAddress },
    }),
  });
  const payload = await res.json();
  const stateHex = payload.data?.contractAction?.state;
  if (!stateHex) return 0n;

  const normalized = stateHex.startsWith('0x') ? stateHex.slice(2) : stateHex;
  const bytes = new Uint8Array(normalized.length / 2);
  for (let i = 0; i < normalized.length; i += 2) {
    bytes[i / 2] = parseInt(normalized.slice(i, i + 2), 16);
  }

  const contractState = ContractState.deserialize(bytes);
  const ledgerState = ledgerFn(contractState.data);
  return ledgerState.balances.member(holderAddress)
    ? ledgerState.balances.lookup(holderAddress)
    : 0n;
}

8) Zswap——参与方之间的原子交换

Zswap 是 Midnight 基于 ZK-SNARK 证明构建的多资产原子交换协议。它通过交易合并实现非交互式原子交换。

Zswap 的功能
  • 原子交换:Alice 的 NIGHT ↔ Bob 的合约代币,合并在一笔交易中
  • 屏蔽式交换:发送方、接收方和金额都无需公开
  • 非交互式合并:参与方可以在链下合并报价,再提交到链上
  • 抗抢先交易:屏蔽式资产转账可隐藏交易前信息
面向开发者的 Zswap 接口

作为 DApp 开发者,你会间接使用 Zswap:

  • wallet.makeTransfer(outputs) ——为 NIGHT 发起 Zswap 交易
  • walletProvider.balanceTx(tx) ——平衡步骤会在内部使用 Zswap 添加 DUST 费用和找零输出
  • ZswapChainState ——由索引器查询返回,某些 SDK provider 方法需要用到
  • ZswapSecretKeys ——从 HD 钱包种子(Roles.Zswap)派生,供 ShieldedWallet 使用

Zswap 目前尚未在 DApp 层提供直接的“交换两个合约”API——这属于交易所基础设施。在当前 SDK 层级,Zswap 主要用于 NIGHT 转账和支付 DUST 费用。


9) 代币单位参考

代币单位换算
NIGHTStar1 NIGHT = 1,000,000 Stars
DUSTSpeck1 DUST = 10^15 Specks
合约代币由 decimals 定义通常为 18 位小数(1 个代币 = 10^18 个基本单位)

金额始终使用 BigInt——在实际余额下,NIGHT Stars 和 DUST Specks 会超出 JavaScript number 类型的范围。

const ONE_NIGHT = 1_000_000n;          // Stars
const ONE_DUST  = 1_000_000_000_000_000n; // Specks
const DUST_PER_NIGHT_MAX = 5_000_000_000n; // Specks per Star at full cap

10)常见陷阱

从见证值中获取调用者身份 — 将 witness callerAddress(): Bytes<32> 用作扣款账户是可以伪造的。见证值来自调用者自己的 TypeScript,因此返回值由构建交易的人选择;电路中没有任何内容将其绑定到密钥。这样任何人都能耗尽任何账户。应改为从秘密派生账户,例如 accountOf(secretKey()),这样电路才能对其进行约束。其他授权检查也一样:比较派生身份,绝不要使用原始见证值。

没有权限检查的铸造电路 — export circuit mint(to, amount) 如果没有断言,任何人都能无限铸造代币。在构造时记录权限方,并通过 assert 进行校验。

将电路参数直接传给账本操作 — 参数在公开之前都是见证值,因此 balances.member(account) 会编译失败,并提示 "potential witness-value disclosure must be declared but is not"。先绑定 const a = disclose(account);。

从水龙头向屏蔽地址转账 — 水龙头仅支持非屏蔽地址(mn_addr_preprod1...)。如果提供屏蔽地址,将收不到代币。

在 DUST 可用前花费 NIGHT — 花费 NIGHT UTXO 会使其关联的 DUST 立即开始衰减。如果 DUST 尚未达到可用水平就花费 NIGHT,就会损失积累生成时间所投入的资源。请等到 DUST 可用后再花费 NIGHT。

交易失败后 DUST 被锁定 — 这是已知的钱包 SDK 问题。pendingCoins > 0 && availableCoins === 0 表示 DUST 卡住了。重启钱包/DApp 进程以解除锁定。

将 amount 写成 JS number 而非 BigInt — 1 NIGHT = 1,000,000 Stars。实际余额会超过 Number.MAX_SAFE_INTEGER。始终使用 BigInt 字面量(1_000_000n)。

调用 balances.lookup(k) 时未检查 balances.member(k) — 在 Map 中不存在的键上调用 lookup 会导致 Compact 运行时错误。务必先检查 member。

Uint<128> 运算溢出 — 超过 2^128 - 1 的加法会静默回绕或报错,具体取决于上下文。请显式转换:(a + b) as Uint<128>,并添加 assert 守卫。

unshieldedToken().raw 会因网络而异 — NIGHT 的十六进制代币类型标识因网络而异。始终在运行时调用 unshieldedToken(),绝不要对十六进制字符串进行硬编码。

OZ 合约导入路径随版本变化 — 在不同库版本之间,导入路径从 @openzeppelin-compact/contracts 改为 @openzeppelin/compact-contracts。请查阅所用确切版本的 README。

合约代币不会生成 DUST — 只有 NIGHT(原生账本代币)会生成 DUST。你的自定义 FungibleToken 没有费用角色。

评论

支持 Markdown