kali-decoder/midnight-skills

token-transfers

Shielded and unshielded NIGHT token transfers, contract token (FungibleToken) design, balance queries, DUST mechanics, multi-party Zswap transactions, and the Either<ZswapCoinPublicKey, ContractAddress> recipient pattern for Midnight Network.

First seen Jul 2, 2026

Installation

$ npx skills add kali-decoder/midnight-skills --skill token-transfers

Summary

  • Shielded and unshielded NIGHT token transfers, contract token (FungibleToken) design, balance queries, DUST mechanics, multi-party Zswap transactions, and the Either<ZswapCoinPublicKey, ContractAddress> recipient pattern for Midnight Network.
  • Use when a user asks about sending NIGHT tokens, reading balances, building a token contract, the difference between shielded and unshielded transfers, how DUST is generated and consumed, or how to implement ERC-20-style transfers in Compact.

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from kali-decoder/midnight-skills · top by installs.

npx skills add kali-decoder/midnight-skills

Browse all from kali-decoder/midnight-skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 16
License LICENSE
Default branch main
Open issues 5
Status Active

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 21,731 B
  • docs SUMMARY.md 508 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 115 installs

SKILL.md

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

1) Two Token Systems — Which One You Need

Ledger Tokens (NIGHT) Contract Tokens
Where they live Chain ledger, UTXO-based Inside a Compact contract, account-based
Transfer mechanism Zswap (ZK atomic swap) Circuit call (transfer, mint, burn)
Privacy Shielded or unshielded at UTXO level Private state (balances can be private)
Fee resource NIGHT generates DUST (transaction fees) No fee role — just application logic
Wallet SDK method wallet.makeTransfer(outputs) contract.callTx.transfer(to, amount)
Who manages it Protocol + wallet SDK Your Compact contract
Analogy Native ETH / BTC ERC-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.


2) NIGHT Token — Shielded vs Unshielded

Every NIGHT UTXO is either shielded (private, hidden from observers) or unshielded (public, visible on-chain).

Shielded Unshielded
Address prefix mn_shield1... mnaddrpreprod1... / mn_addr1...
Amount visible No Yes
Sender/receiver visible No Yes
DUST generation Yes (via Zswap registration) Yes (via registration table)
Faucet/bridge sends to No Yes — always unshielded first
Required for Privacy-sensitive transfers Interop, faucet, bridge, contracts

Critical: Faucets and bridges always send to the unshielded address. Never give a shielded address to a faucet.

Address Types from Wallet 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) Sending NIGHT Tokens (Wallet SDK)

Use wallet.makeTransfer(outputs) to transfer NIGHT. The wallet handles UTXO selection, Zswap proving, and DUST fee payment.

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

Multi-Output Transfer (Batch)

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

Querying Unshielded NIGHT Balance (Without Wallet)

You can also query the indexer directly for unshielded balances at a contract or address:

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

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.

Mental model

NIGHT  →  generates  →  DUST  →  consumed by  →  transactions
(Solar Panel)          (Electricity)              (Appliances)

Key properties

  • 1 NIGHT = 5 DUST maximum capacity (at full generation: nightdustratio = 5000000_000)
  • 1 DUST = 10^15 Specks (unit used internally)
  • Generation time to capacity: ~1 week (generationdecayrate = 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

DUST lifecycle

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

Registering NIGHT for DUST generation

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

Reading DUST balance

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.

DUST on sponsored networks (preview, mainnet)

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.


5) Contract Tokens (FungibleToken Pattern)

For application tokens, implement them in Compact. For production use the OpenZeppelin Contracts for Compact library; the contract below is a minimal illustration of the account-model pattern.

There is no msg.sender on Midnight. A circuit cannot ask who called it. Identity has to be proved, and the only way to prove it is to derive the account from a secret the caller supplies as a witness: the circuit constrains account == hash(domain, secretKey()), so naming someone else's account means inverting a hash. Never take the account itself from a witness — a witness is the caller's own TypeScript, so witness callerAddress(): Bytes<32> returns whatever the caller wants and any account can be debited by anyone. See "Common Pitfalls" below.

Minimal FungibleToken Contract

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

The matching witness implementation — the secret comes from the user's own private state and never leaves the device:

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

export const witnesses = {
  secretKey: ({ privateState }: WitnessContext<Ledger, TokenPrivateState>):
    [TokenPrivateState, Uint8Array] => [privateState, privateState.secretKey],
};

OpenZeppelin FungibleToken (Full-Featured)

The OpenZeppelin library provides a production-grade implementation with Ownable, Pausable, and FungibleToken modules:

# 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();
}

Compile output example:

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

6) Calling Token Circuits from 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(myAccountBytes); // from myAccount()
  console.log('Balance:', balance);
}

7) Reading Token Balances via Indexer

Contract token balances live in export ledger — readable from the indexer like any other contract state:

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

Zswap is Midnight's multi-asset atomic swap protocol built on ZK-SNARK proofs. It enables non-interactive atomic swaps with transaction merging.

What Zswap enables

  • 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

Developer-facing Zswap surface

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.


9) Token Units Reference

Token Unit Conversion
NIGHT Star 1 NIGHT = 1,000,000 Stars
DUST Speck 1 DUST = 10^15 Specks
Contract tokens Defined 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.

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) Common Pitfalls

Taking the caller's identity from a witnesswitness callerAddress(): Bytes<32> used as the account to debit is forgeable. A witness is the caller's own TypeScript, so the returned value is chosen by whoever builds the transaction; nothing in the circuit binds it to a key. Any account can then be drained by anyone. Derive the account from a secret instead — accountOf(secretKey()) — so the circuit constrains it. The same applies to any authorisation check: compare derived identities, never a raw witness value.

Mint circuits with no authority checkexport circuit mint(to, amount) with no assert lets anyone mint unlimited supply. Record an authority at construction and assert against it.

Passing a circuit parameter straight to a ledger op — parameters are witness values until disclosed, so balances.member(account) fails to compile with "potential witness-value disclosure must be declared but is not". Bind const a = disclose(account); first.

Sending to shielded address from faucet — faucets only support unshielded (mnaddrpreprod1...) 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.MAXSAFEINTEGER. Always use BigInt literals (1000000n).

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.