token-transfers について
Midnight 開発者向けに、NIGHT の台帳トークン、DUST 手数料、Compact によるコントラクトトークンを解説するスキルです。転送、残高確認、トークン設計を扱います。
主な機能
- 01台帳トークンとコントラクトトークンの比較
- 02shielded と unshielded の NIGHT の説明
- 03転送と DUST 手数料処理の解説
- 04Compact のファンジブルトークンパターン
- 05Zswap とトークン残高照会の解説
活用シーン
- 01アプリケーションに使うトークンモデルの選択
- 021 人以上の受取人への NIGHT 送金
- 03Compact アプリケーショントークンの設計
- 04残高確認と DUST 問題の調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ kali-decoder/ midnight-skills --skill token-transfersインストール後、Claude Code で/token-transfersを入力して読み込まれたか確認します。
詳細
トークンの送金スキル
Midnight には、互いに独立して機能する 2 種類のトークンシステムがあります。台帳トークン(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) 2 種類のトークンシステム — 必要なのはどちらですか
| 台帳トークン(NIGHT) | コントラクトトークン | |
|---|---|---|
| 保管場所 | チェーン台帳、UTXO ベース | Compact コントラクト内、アカウントベース |
| 送金方法 | Zswap(ZK アトミックスワップ) | 回路呼び出し(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 登録経由) | あり(登録テーブル経由) |
| Faucet/ブリッジからの送金先 | なし | あり — 常に最初は非シールド |
| 必要な用途 | プライバシーが重要な送金 | 相互運用、Faucet、ブリッジ、コントラクト |
重要: Faucet とブリッジは必ず非シールドアドレスに送金します。Faucet にシールドアドレスを絶対に渡さないでください。
ウォレット 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)
NIGHT を送金するには、wallet.makeTransfer(outputs) を使います。ウォレットが 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 を送金することはできません
- 裏付けとなる 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
DUST 生成のための NIGHT の登録
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がありません。 回路は誰が呼び出したかを確認できません。ID は証明する必要があり、証明する唯一の方法は、呼び出し元が witness として提供するシークレットからアカウントを導出することです。回路は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 からトークン回路を呼び出す
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 は、ZK-SNARK 証明を基盤とする Midnight のマルチアセット・アトミックスワッププロトコルです。トランザクションのマージによる、非対話型のアトミックスワップを可能にします。
Zswap で実現できること
- アトミック交換:1 つのトランザクションで、Alice の NIGHT と Bob のコントラクトトークンを交換
- シールドされたスワップ:送信者、受信者、金額を公開する必要はありません
- 非対話型のマージ:オンチェーンに送信する前に、当事者はオフチェーンでオファーをマージできます
- フロントランニング耐性:シールドされた資産の送金により、取引前の情報が隠されます
開発者向けの Zswap インターフェース
DApp 開発者は、Zswap を間接的に利用します。
wallet.makeTransfer(outputs)— NIGHT の Zswap トランザクションを実行しますwalletProvider.balanceTx(tx)— 残高調整のステップで、Zswap を内部的に使用して DUST 手数料とお釣りの出力を追加しますZswapChainState— インデクサークエリから返される、一部の SDK プロバイダーメソッドで必要な値ですZswapSecretKeys— HD ウォレットのシード(Roles.Zswap)から導出され、ShieldedWalletで使用されます
DApp 層では、Zswap はまだ「2 つのコントラクトをスワップする」ための直接的な 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 の Star と DUST の Speck は 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 から呼び出し元の ID を取得する — witness callerAddress(): Bytes<32> を引き落とし対象のアカウントとして使うと、偽装可能です。witness は呼び出し元自身の TypeScript であり、返される値はトランザクションを構築する人が選べるためです。回路内では、その値を鍵に結び付けるものはありません。そのため、誰でも任意のアカウントから資金を引き出せてしまいます。代わりに、accountOf(secretKey()) のようにシークレットからアカウントを導出し、回路で制約してください。認可チェックにも同じことが当てはまります。生の witness 値ではなく、必ず 導出した ID を比較してください。
権限チェックのないミント回路 — assert のない export circuit mint(to, amount) では、誰でも無制限にトークンをミントできます。コントラクトの構築時に権限を記録し、それと照合する assert を追加してください。
回路パラメーターを台帳操作にそのまま渡す — パラメーターは開示されるまで witness 値であるため、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 を使用すると、生成に費やした時間が無駄になります。NIGHT を使用する前に、DUST が利用可能になるまで待ってください。
トランザクション失敗後に DUST がロックされる — 既知のウォレット SDK の問題です。pendingCoins > 0 && availableCoins === 0 の場合、DUST がロックされています。ウォレット/DApp プロセスを再起動すると、ロックが解除されます。
amount に BigInt ではなく JavaScript の number を使う — 1 NIGHT = 1,000,000 Stars です。実際の残高は Number.MAX_SAFE_INTEGER を超えます。必ず BigInt リテラル(1_000_000n)を使用してください。
balances.member(k) を確認せずに balances.lookup(k) を呼び出す — Map に存在しないキーに対して lookup を呼び出すと、Compact では実行時エラーになります。必ず先に member を確認してください。
Uint<128> の演算でのオーバーフロー — 2^128 - 1 を超える加算は、状況に応じて暗黙にラップするか、エラーになります。(a + b) as Uint<128> のように明示的にキャストし、assert によるガードを追加してください。
unshieldedToken().raw はネットワークによって異なる — NIGHT の 16 進数のトークン型識別子はネットワーク固有です。必ず実行時に unshieldedToken() を呼び出し、16 進数の文字列をハードコードしないでください。
OZ コントラクトのインポートパスはバージョン間で変更されている — ライブラリのバージョン間で、インポートパスは @openzeppelin-compact/contracts から @openzeppelin/compact-contracts に変更されました。使用しているバージョンの README を確認してください。
コントラクトトークンは DUST を生成しない — DUST を生成するのは、ネイティブ台帳トークンである NIGHT のみです。独自の FungibleToken には手数料の役割がありません。