token-transfers

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Token Transfers Skill

代币转账技能

Midnight has two distinct token systems that operate independently: ledger tokens (NIGHT, UTXO-based, native to the chain) and contract tokens (account-based, ERC-20-style, implemented in Compact). They work differently, transfer differently, and serve different purposes.
Primary references:
  • docs.midnight.network/concepts/ledgers
    — ledger vs contract token model
  • docs.midnight.network/concepts/utxo
    — UTXO mechanics and nullifier set
  • docs.midnight.network/concepts/dust-architecture
    — DUST generation lifecycle
  • docs.midnight.network/concepts/zswap
    — atomic swap and shielded transfer protocol
  • github.com/OpenZeppelin/compact-contracts
    — reference FungibleToken implementation

Midnight 拥有两个独立运行的代币系统:账本代币(NIGHT,基于UTXO,为链原生代币)和合约代币(基于账户,ERC-20风格,在Compact中实现)。它们的工作方式、转账机制和用途各不相同。
主要参考资料:
  • docs.midnight.network/concepts/ledgers
    — 账本代币与合约代币模型对比
  • docs.midnight.network/concepts/utxo
    — UTXO机制与零知识证明集合
  • docs.midnight.network/concepts/dust-architecture
    — DUST生成生命周期
  • docs.midnight.network/concepts/zswap
    — 原子交换与加密转账协议
  • github.com/OpenZeppelin/compact-contracts
    — FungibleToken参考实现

1) Two Token Systems — Which One You Need

1) 两种代币系统——如何选择

Ledger Tokens (NIGHT)Contract Tokens
Where they liveChain ledger, UTXO-basedInside a Compact contract, account-based
Transfer mechanismZswap (ZK atomic swap)Circuit call (
transfer
,
mint
,
burn
)
PrivacyShielded or unshielded at UTXO levelPrivate state (balances can be private)
Fee resourceNIGHT generates DUST (transaction fees)No fee role — just application logic
Wallet SDK method
wallet.makeTransfer(outputs)
contract.callTx.transfer(to, amount)
Who manages itProtocol + wallet SDKYour Compact contract
AnalogyNative ETH / BTCERC-20
Decision rule: If you are moving NIGHT tokens (the chain's native token), use the wallet SDK transfer. If you are building an application token (governance, game currency, stablecoin, NFT), implement it in Compact using the account/map model.

账本代币(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 Token — Shielded vs Unshielded

2) NIGHT代币——加密与未加密

Every NIGHT UTXO is either shielded (private, hidden from observers) or unshielded (public, visible on-chain).
ShieldedUnshielded
Address prefix
mn_shield1...
mn_addr_preprod1...
/
mn_addr1...
Amount visibleNoYes
Sender/receiver visibleNoYes
DUST generationYes (via Zswap registration)Yes (via registration table)
Faucet/bridge sends toNoYes — always unshielded first
Required forPrivacy-sensitive transfersInterop, faucet, bridge, contracts
Critical: Faucets and bridges always send to the unshielded address. Never give a shielded address to a faucet.
每个NIGHT UTXO要么是加密(私有,对观察者隐藏),要么是未加密(公开,链上可见)。
加密未加密
地址前缀
mn_shield1...
mn_addr_preprod1...
/
mn_addr1...
金额可见性
发送方/接收方可见性
DUST生成是(通过Zswap注册)是(通过注册表)
水龙头/桥接器转账目标是——始终先转到未加密地址
适用场景隐私敏感型转账互操作、水龙头、桥接器、合约交互
关键提示: 水龙头和桥接器始终转账到未加密地址,切勿向水龙头提供加密地址。

Address Types from Wallet SDK

钱包SDK中的地址类型

typescript
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;

typescript
import * as Rx from 'rxjs';

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

// 未加密地址——用于水龙头、桥接器、合约交互
const unshieldedAddress = state.unshielded.address;
// → "mn_addr_preprod1qxy..."

// 加密代币公钥——作为Zswap转账的接收方
const shieldedCoinPublicKey = state.shielded.coinPublicKey.toHexString();
// → "0x3a7f..."

// DUST地址——用于DUST注册查询
const dustAddress = state.dust.address;

3) Sending NIGHT Tokens (Wallet SDK)

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

Use
wallet.makeTransfer(outputs)
to transfer NIGHT. The wallet handles UTXO selection, Zswap proving, and DUST fee payment.
typescript
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);
使用
wallet.makeTransfer(outputs)
转账NIGHT,钱包会自动处理UTXO选择、Zswap证明生成和DUST手续费支付。
typescript
import * as Rx from 'rxjs';
import { unshieldedToken } from '@midnight-ntwrk/ledger-v8';

// 获取当前状态
const state = await Rx.firstValueFrom(
  wallet.state().pipe(Rx.filter((s: any) => s.isSynced)),
);

// 转账前检查未加密NIGHT余额
const nightTokenType = unshieldedToken().raw; // 十六进制代币类型标识符
const nightBalance = state.unshielded.balances[nightTokenType] ?? 0n;
console.log('NIGHT余额(Stars):', nightBalance);
// 1 NIGHT = 1_000_000 Stars

// 向未加密地址发送未加密NIGHT
const transferRecipe = await wallet.makeTransfer([
  {
    value: 1_000_000n,                    // 1 NIGHT(以Stars为单位)
    tokenType: nightTokenType,
    receiverAddress: 'mn_addr_preprod1...', // 接收方的未加密地址
  },
]);

const finalized = await wallet.finalizeRecipe(transferRecipe);
const txId = await wallet.submitTransaction(finalized);
console.log('转账已提交:', txId);

Multi-Output Transfer (Batch)

多输出转账(批量)

typescript
// 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...',
  },
]);
typescript
// 在一笔原子交易中向多个接收方转账
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...',
  },
]);

Querying Unshielded NIGHT Balance (Without Wallet)

查询未加密NIGHT余额(无需钱包)

You can also query the indexer directly for unshielded balances at a contract or address:
typescript
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)]));
}

也可直接通过索引器查询合约或地址的未加密余额:
typescript
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 — Mechanics and Developer Implications

4) DUST——机制与开发者注意事项

DUST is the non-transferable fee resource generated by holding NIGHT. It is not a token you send — it is a capacity resource consumed automatically when you submit transactions.
DUST是持有NIGHT生成的不可转账费用资源,它不是可发送的代币,而是提交交易时自动消耗的容量资源。

Mental model

心智模型

NIGHT  →  generates  →  DUST  →  consumed by  →  transactions
(Solar Panel)          (Electricity)              (Appliances)
NIGHT  →  生成  →  DUST  →  被消耗于  →  交易
(太阳能板)          (电力)              (电器)

Key properties

核心属性

  • 1 NIGHT = 5 DUST maximum capacity (at full generation:
    night_dust_ratio = 5_000_000_000
    )
  • 1 DUST = 10^15 Specks (unit used internally)
  • Generation time to capacity: ~1 week (
    generation_decay_rate = 8267
    )
  • Grace period: 3 hours (timestamp window for proof acceptance)
  • DUST is shielded and non-transferable — you cannot send DUST to another user
  • DUST starts decaying immediately when its backing NIGHT UTXO is spent
  • 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 lifecycle

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 UTXO创建
注册:DustRegistration将NIGHT公钥关联到DUST公钥
DUST UTXO开始生成价值(约1周内增长至上限)
提交交易:消耗DUST UTXO → 创建新的DUST UTXO(价值扣除手续费)
NIGHT UTXO被花费 → DUST UTXO立即开始衰减至零

Registering NIGHT for DUST generation

注册NIGHT以生成DUST

typescript
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),
  ),
);
typescript
const state = await Rx.firstValueFrom(
  wallet.state().pipe(Rx.filter((s: any) => s.isSynced)),
);

// 查找未注册的NIGHT UTXO
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);
}

// 等待DUST可用
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),
  ),
);

Reading DUST balance

查询DUST余额

typescript
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 is locked by a pending or failed transaction. Restart the wallet process to release it. This is a known wallet SDK issue.
typescript
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(`代币数量: ${dustCoins}可用, ${dustPending}待处理`);
pendingCoins > 0 && availableCoins === 0
→ DUST被待处理或失败的交易锁定。重启钱包进程即可释放,这是钱包SDK的已知问题。

DUST on sponsored networks (preview, mainnet)

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

On
preview
and
mainnet
, 1AM ProofStation sponsors all fees. Users need zero NIGHT and zero DUST. The
balanceUnsealedTransaction
call to the 1AM wallet handles fee payment server-side. Do not attempt DUST registration flows on these networks — they are unnecessary.

preview
mainnet
上,1AM ProofStation会赞助所有手续费。用户无需持有NIGHT或DUST,调用1AM钱包的
balanceUnsealedTransaction
接口即可由服务器端支付手续费。请勿在这些网络上尝试DUST注册流程——完全不必要。

5) Contract Tokens (FungibleToken Pattern)

5) 合约代币(FungibleToken模式)

For application tokens, implement them in Compact. The standard pattern follows the OpenZeppelin Contracts for Compact library.
对于应用代币,需在Compact中实现,标准模式遵循OpenZeppelin Contracts for Compact库。

Minimal FungibleToken Contract

极简FungibleToken合约

compact
pragma language_version >= 0.22;

import CompactStandardLibrary;

// Either<ZswapCoinPublicKey, ContractAddress> = shielded wallet OR another contract
// This is the standard recipient type for contract tokens

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>>;

witness callerAddress(): Bytes<32>;

constructor(
  _name: Opaque<"string">,
  _symbol: Opaque<"string">,
  _decimals: Uint<8>,
) {
  name = disclose(_name);
  symbol = disclose(_symbol);
  decimals = disclose(_decimals);
  totalSupply = 0;
}

export circuit mint(to: Bytes<32>, amount: Uint<128>): [] {
  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 {
  const sender = disclose(callerAddress());
  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 recipientBal = balances.member(disclose(to))
    ? balances.lookup(disclose(to))
    : 0;
  balances.insert(disclose(to), disclose((recipientBal + amount) as Uint<128>));

  return true;
}

export circuit balanceOf(account: Bytes<32>): Uint<128> {
  if (!balances.member(account)) { return 0; }
  return balances.lookup(account);
}
compact
pragma language_version >= 0.22;

import CompactStandardLibrary;

// Either<ZswapCoinPublicKey, ContractAddress> = 加密钱包 OR 其他合约
// 这是合约代币的标准接收方类型

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>>;

witness callerAddress(): Bytes<32>;

constructor(
  _name: Opaque<"string">,
  _symbol: Opaque<"string">,
  _decimals: Uint<8>,
) {
  name = disclose(_name);
  symbol = disclose(_symbol);
  decimals = disclose(_decimals);
  totalSupply = 0;
}

export circuit mint(to: Bytes<32>, amount: Uint<128>): [] {
  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 {
  const sender = disclose(callerAddress());
  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 recipientBal = balances.member(disclose(to))
    ? balances.lookup(disclose(to))
    : 0;
  balances.insert(disclose(to), disclose((recipientBal + amount) as Uint<128>));

  return true;
}

export circuit balanceOf(account: Bytes<32>): Uint<128> {
  if (!balances.member(account)) { return 0; }
  return balances.lookup(account);
}

OpenZeppelin FungibleToken (Full-Featured)

OpenZeppelin FungibleToken(全功能版)

The OpenZeppelin library provides a production-grade implementation with
Ownable
,
Pausable
, and
FungibleToken
modules:
bash
undefined
OpenZeppelin库提供了生产级实现,包含
Ownable
Pausable
FungibleToken
模块:
bash
undefined

Install as a git submodule

作为git子模块安装

git init && git submodule add https://github.com/OpenZeppelin/compact-contracts.git cd compact-contracts && nvm install && yarn && SKIP_ZK=true yarn compact

```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();
}
Compile output example:
circuit "pause"    (k=10, rows=125)
circuit "transfer" (k=11, rows=1180)
circuit "unpause"  (k=10, rows=121)

git init && git submodule add https://github.com/OpenZeppelin/compact-contracts.git cd compact-contracts && nvm install && yarn && SKIP_ZK=true yarn compact

```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) Calling Token Circuits from TypeScript

6) 从TypeScript调用代币电路

typescript
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(callerAddressBytes);
  console.log('Balance:', balance);
}

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

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

// 转账代币
const result = await contract.callTx.transfer(recipientAddressBytes, 100n);
console.log('txId:', result.public.txId);
console.log('blockHeight:', result.public.blockHeight);

// 查询余额(无需交易——直接状态查询)
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(callerAddressBytes);
  console.log('余额:', balance);
}

7) Reading Token Balances via Indexer

7) 通过索引器查询代币余额

Contract token balances live in
export ledger
— readable from the indexer like any other contract state:
typescript
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;
}

合约代币余额存储在
export ledger
中,可像其他合约状态一样通过索引器查询:
typescript
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 — Atomic Swaps Between Parties

8) Zswap——多方原子交换

Zswap is Midnight's multi-asset atomic swap protocol built on ZK-SNARK proofs. It enables non-interactive atomic swaps with transaction merging.
Zswap是Midnight基于ZK-SNARK证明构建的多资产原子交换协议,支持非交互式原子交换与交易合并。

What Zswap enables

Zswap的能力

  • Atomic exchange: Alice's NIGHT ↔ Bob's contract token in one transaction
  • Shielded swaps: Neither sender, receiver, nor amounts need to be publicly visible
  • Non-interactive merging: Parties can merge offers off-chain before on-chain submission
  • Front-running resistance: Shielded asset transfers hide pre-trade information
  • 原子兑换:Alice的NIGHT ↔ Bob的合约代币,在一笔交易中完成
  • 加密交换:发送方、接收方和金额均无需公开可见
  • 非交互式合并:各方可在链下合并报价后再提交链上交易
  • 抗抢先交易:加密资产转账可隐藏交易前信息

Developer-facing Zswap surface

面向开发者的Zswap接口

As a DApp developer, you interact with Zswap indirectly:
  • wallet.makeTransfer(outputs)
    — triggers a Zswap transaction for NIGHT
  • walletProvider.balanceTx(tx)
    — the balancing step uses Zswap internally to add DUST fees and change outputs
  • ZswapChainState
    — returned by indexer queries, needed by some SDK provider methods
  • ZswapSecretKeys
    — derived from HD wallet seed (
    Roles.Zswap
    ), used by
    ShieldedWallet
Zswap does not expose a direct "swap two contracts" API at the DApp layer yet — that is exchange infrastructure. At the current SDK level, Zswap is primarily surfaced as the mechanism for NIGHT transfers and DUST fee payment.

作为DApp开发者,你将间接与Zswap交互:
  • wallet.makeTransfer(outputs)
    — 触发NIGHT的Zswap交易
  • walletProvider.balanceTx(tx)
    — 余额平衡步骤内部使用Zswap添加DUST手续费和找零输出
  • ZswapChainState
    — 索引器查询返回结果,部分SDK提供方法需要此数据
  • ZswapSecretKeys
    — 从HD钱包种子派生(
    Roles.Zswap
    ),供
    ShieldedWallet
    使用
目前Zswap在DApp层尚未暴露直接的“交换两个合约”API——这属于交易所基础设施范畴。在当前SDK层面,Zswap主要作为NIGHT转账和DUST手续费支付的机制。

9) Token Units Reference

9) 代币单位参考

TokenUnitConversion
NIGHTStar1 NIGHT = 1,000,000 Stars
DUSTSpeck1 DUST = 10^15 Specks
Contract tokensDefined by
decimals
Typically 18 decimals (1 token = 10^18 base units)
Always use
BigInt
for amounts — NIGHT Stars and DUST Specks overflow JavaScript's
number
type at realistic balances.
typescript
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

代币单位换算关系
NIGHTStar1 NIGHT = 1,000,000 Stars
DUSTSpeck1 DUST = 10^15 Specks
合约代币
decimals
定义
通常为18位小数(1代币 = 10^18基础单位)
金额请始终使用
BigInt
——NIGHT Stars和DUST Specks的实际余额会超出JavaScript
number
类型的安全范围。
typescript
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; // 满额时每Star对应的Specks数

10) Common Pitfalls

10) 常见陷阱

Sending to shielded address from faucet — faucets only support unshielded (
mn_addr_preprod1...
) addresses. Giving a shielded address gets zero tokens.
Spending NIGHT before DUST is available — spending a NIGHT UTXO causes its associated DUST to start decaying immediately. If you spend NIGHT before DUST reaches useful levels, you lose the generation time invested. Wait until DUST is available before spending NIGHT.
DUST locked after failed transaction — known wallet SDK issue.
pendingCoins > 0 && availableCoins === 0
means DUST is stuck. Restart the wallet/DApp process to release the lock.
amount
as JS
number
instead of
BigInt
— 1 NIGHT = 1,000,000 Stars. Realistic balances exceed
Number.MAX_SAFE_INTEGER
. Always use
BigInt
literals (
1_000_000n
).
balances.lookup(k)
without
balances.member(k)
check
— calling
lookup
on a key that doesn't exist in the Map is a runtime error in Compact. Always check
member
first.
Overflow on
Uint<128>
arithmetic
— addition that exceeds
2^128 - 1
wraps silently or errors depending on context. Cast explicitly:
(a + b) as Uint<128>
and add
assert
guards.
unshieldedToken().raw
varies by network
— the hex token type identifier for NIGHT is network-specific. Always call
unshieldedToken()
at runtime; never hardcode the hex string.
OZ contract import path changed between versions — the import path changed from
@openzeppelin-compact/contracts
to
@openzeppelin/compact-contracts
between library versions. Check the README of the exact version you are using.
Contract tokens do not generate DUST — only NIGHT (the native ledger token) generates DUST. Your custom
FungibleToken
has no fee role.
向水龙头提供加密地址——水龙头仅支持未加密(
mn_addr_preprod1...
)地址,提供加密地址将无法收到代币。
DUST可用前花费NIGHT——花费NIGHT UTXO会导致关联的DUST立即开始衰减。若在DUST达到可用水平前花费NIGHT,将浪费已投入的生成时间,需等待DUST可用后再花费NIGHT。
交易失败后DUST被锁定——钱包SDK的已知问题。
pendingCoins > 0 && availableCoins === 0
表示DUST被卡住,重启钱包/DApp进程即可释放锁定。
使用JS
number
而非
BigInt
表示金额
——1 NIGHT = 1,000,000 Stars,实际余额会超过
Number.MAX_SAFE_INTEGER
,请始终使用
BigInt
字面量(如
1_000_000n
)。
未检查
balances.member(k)
就调用
balances.lookup(k)
——在Compact中,对Map中不存在的键调用
lookup
会导致运行时错误,务必先检查
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
不具备费用角色。