关于 token-transfers
面向 Midnight 开发者的代币技能,介绍 NIGHT 账本代币、DUST 费用和基于 Compact 的合约代币,并说明转账、余额查询与代币设计。
主要功能
- 01比较账本代币与合约代币
- 02说明 shielded 与 unshielded NIGHT
- 03涵盖转账和 DUST 费用处理
- 04展示 Compact 同质化代币模式
- 05介绍 Zswap 和代币余额查询
适用场景
- 01为应用选择代币模型
- 02向一个或多个收款方发送 NIGHT
- 03设计 Compact 应用代币
- 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) | 合约代币 | |
|---|---|---|
| 存储位置 | 链上账本,基于 UTXO | Compact 合约内,基于账户 |
| 转账机制 | Zswap(零知识原子交换) | 电路调用(transfer、mint、burn) |
| 隐私性 | 在 UTXO 层屏蔽或不屏蔽 | 私有状态(余额可以是私有的) |
| 费用资源 | NIGHT 生成 DUST(交易费用) | 不承担费用作用——仅用于应用逻辑 |
| 钱包 SDK 方法 | wallet.makeTransfer(outputs) | contract.callTx.transfer(to, amount) |
| 管理方 | 协议 + 钱包 SDK | 你的 Compact 合约 |
| 类比 | 原生 ETH / BTC | ERC-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) 代币单位参考
| 代币 | 单位 | 换算 |
|---|---|---|
| NIGHT | Star | 1 NIGHT = 1,000,000 Stars |
| DUST | Speck | 1 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 没有费用角色。