Loading...
Loading...
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.
npx skill4agent add kali-decoder/midnight-skills token-transfersdocs.midnight.network/concepts/ledgersdocs.midnight.network/concepts/utxodocs.midnight.network/concepts/dust-architecturedocs.midnight.network/concepts/zswapgithub.com/OpenZeppelin/compact-contracts| 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 ( |
| 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 | | |
| Who manages it | Protocol + wallet SDK | Your Compact contract |
| Analogy | Native ETH / BTC | ERC-20 |
| Shielded | Unshielded | |
|---|---|---|
| Address prefix | | |
| 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 |
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;wallet.makeTransfer(outputs)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...',
},
]);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)]));
}NIGHT → generates → DUST → consumed by → transactions
(Solar Panel) (Electricity) (Appliances)night_dust_ratio = 5_000_000_000generation_decay_rate = 8267NIGHT 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 zeroconst 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),
),
);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 === 0previewmainnetbalanceUnsealedTransactionpragma 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);
}OwnablePausableFungibleToken# 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 compactpragma 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)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);
}export ledgerimport { 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;
}wallet.makeTransfer(outputs)walletProvider.balanceTx(tx)ZswapChainStateZswapSecretKeysRoles.ZswapShieldedWallet| Token | Unit | Conversion |
|---|---|---|
| NIGHT | Star | 1 NIGHT = 1,000,000 Stars |
| DUST | Speck | 1 DUST = 10^15 Specks |
| Contract tokens | Defined by | Typically 18 decimals (1 token = 10^18 base units) |
BigIntnumberconst 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 capmn_addr_preprod1...pendingCoins > 0 && availableCoins === 0amountnumberBigIntNumber.MAX_SAFE_INTEGERBigInt1_000_000nbalances.lookup(k)balances.member(k)lookupmemberUint<128>2^128 - 1(a + b) as Uint<128>assertunshieldedToken().rawunshieldedToken()@openzeppelin-compact/contracts@openzeppelin/compact-contractsFungibleToken