1am-wallet について
Midnight DApp のフロントエンドに 1AM と Lace のウォレット拡張機能を組み込むためのスキルです。ウォレットセッション、コントラクト取引、インデクサー対応、任意のペイロード暗号化を扱います。
主な機能
- 01ウォレット拡張機能の検出と接続
- 02Midnight Provider セッションの設定
- 03コントラクトのデプロイと呼び出し
- 04インデクサー状態クエリの修正
- 05デプロイと状態更新のポーリング
- 06任意のペイロード暗号化
活用シーン
- 01Midnight DApp へのウォレット接続の追加
- 02フロントエンドからのコントラクト取引送信
- 03preview ネットワークのインデクサー遅延への対応
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ kali-decoder/ midnight-skills --skill 1am-walletインストール後、Claude Code で/1am-walletを入力して読み込まれたか確認します。
詳細
スコープ
このスキルでは、フロントエンドの dApp で 1AM ブラウザー拡張機能(window.midnight['1am'])を検出、接続し、組み込む方法を説明します。1AM ウォレットは、すべての ZK 証明とダスト手数料のスポンサーを処理するため、ユーザーのガス代はゼロです。このスキルは汎用的なものです。YourContract / yourCircuit / your-contract の各プレースホルダーは、実際のコントラクト名とサーキット ID に置き換えてください。
推奨ファイル構成(プロジェクトに合わせて調整してください):
src/lib/midnight.ts→ ウォレットセッション、プロバイダーの接続、インデクサーのパッチ(正規の情報源:references/midnight-session.md)src/lib/encryption.ts→ ウォレット署名から導出する任意のペイロード暗号化src/hooks/useContract.ts→ アプリのロジックと状態のオーケストレーションsrc/contexts/WalletContext.tsx→ ウォレット接続状態
共有リファレンス: references/midnight-session.md、references/gotchas.md、references/versions.json
1) 依存関係
連携して動作することが確認されている正確なバージョン:
npm install \
@midnight-ntwrk/compact-runtime@^0.15.0 \
@midnight-ntwrk/ledger@^4.0.0 \
@midnight-ntwrk/ledger-v8@^8.0.3 \
@midnight-ntwrk/midnight-js-contracts@^4.0.4 \
@midnight-ntwrk/midnight-js-fetch-zk-config-provider@^4.0.4 \
@midnight-ntwrk/midnight-js-indexer-public-data-provider@^4.0.4 \
@midnight-ntwrk/midnight-js-network-id@^4.0.4 \
@midnight-ntwrk/midnight-js-types@^4.0.4 \
@midnight-ntwrk/wallet-sdk-address-format@^3.1.0
Vite で WASM とトップレベル await を使用するには、次のプラグインが必要です(Compact SDK は両方を使用します):
npm install -D vite-plugin-wasm vite-plugin-top-level-await
Next.js の場合は §12 を参照してください。代わりに webpack 設定が必要です。
2) ウォレットの検出と接続
拡張機能は非同期で注入されるため、必ずポーリングしてください。すぐに利用できると想定してはいけません。1AM と Lace の両方のウォレットに対応しています。
// Inline detection (non-React)
function detectWallet(): Promise<any | null> {
return new Promise((resolve) => {
let attempts = 0;
const check = () => {
const wallet = (window as any).midnight?.['1am'];
if (wallet) { resolve(wallet); return; }
if (++attempts > 50) { resolve(null); return; }
setTimeout(check, 100);
};
check();
});
}
// Connect
const wallet = await detectWallet();
if (!wallet) throw new Error('1AM wallet not installed');
const api = await wallet.connect('preprod'); // 'preview' | 'preprod' | 'mainnet'
React Context + useWallet Hook
アプリを WalletProvider でラップしてから、任意のコンポーネントで useWallet() を呼び出します。
// contexts/WalletContext.tsx
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
type WalletContextType = {
address: string | null;
isConnected: boolean;
walletType: '1am' | 'lace' | null;
isConnecting: boolean;
walletStatus: 'checking' | 'detected' | 'not-found';
session: ConnectedSession | null;
connect: (network?: string) => Promise<ConnectedSession | undefined>;
disconnect: () => void;
};
const WalletContext = createContext<WalletContextType | null>(null);
export function WalletProvider({ children }: { children: React.ReactNode }) {
const [address, setAddress] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [walletType, setWalletType] = useState<'1am' | 'lace' | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [walletStatus, setWalletStatus] = useState<'checking' | 'detected' | 'not-found'>('checking');
const [session, setSession] = useState<ConnectedSession | null>(null);
const connectingRef = useRef(false);
// Poll for wallet injection — runs once on mount
useEffect(() => {
const startedAt = Date.now();
const id = setInterval(() => {
const w1am = (window as any).midnight?.['1am'];
const wLace = (window as any).midnight?.mnLace;
if (w1am) { setWalletType('1am'); setWalletStatus('detected'); clearInterval(id); return; }
if (wLace) { setWalletType('lace'); setWalletStatus('detected'); clearInterval(id); return; }
if (Date.now() - startedAt >= 6000) { setWalletStatus('not-found'); clearInterval(id); }
}, 300);
return () => clearInterval(id);
}, []);
const connect = useCallback(async (network = 'preprod') => {
if (connectingRef.current) return;
connectingRef.current = true;
setIsConnecting(true);
try {
const wallet = (window as any).midnight?.['1am'] ?? (window as any).midnight?.mnLace;
if (!wallet) throw new Error('No wallet found');
const api = await wallet.connect(network);
const { createConnectedSession } = await import('../lib/midnight');
const sess = await createConnectedSession(api);
setSession(sess);
setAddress((await api.getUnshieldedAddress()).unshieldedAddress);
setIsConnected(true);
return sess;
} finally {
connectingRef.current = false;
setIsConnecting(false);
}
}, []);
const disconnect = useCallback(() => {
setAddress(null); setIsConnected(false); setSession(null);
setWalletStatus('checking'); setWalletType(null);
}, []);
return (
<WalletContext.Provider value={{ address, isConnected, walletType, isConnecting, walletStatus, session, connect, disconnect }}>
{children}
</WalletContext.Provider>
);
}
export function useWallet(): WalletContextType {
const ctx = useContext(WalletContext);
if (!ctx) throw new Error('useWallet must be used within a WalletProvider');
return ctx;
}
WalletConnect UI コンポーネント
checking、not-found、未接続(接続 CTA)、接続済み(アドレス+切断)の 4 つの状態をすべて必ず表示します。
import { Loader2, LogOut, Shield, Smartphone } from 'lucide-react';
import { useWallet } from '../contexts/WalletContext';
export default function WalletConnect() {
const { isConnected, address, walletType, walletStatus, isConnecting, connect, disconnect } = useWallet();
if (walletStatus === 'checking')
return <span className="text-zinc-600 text-[11px] font-mono animate-pulse">Checking wallet...</span>;
if (isConnected)
return (
<div className="flex items-center gap-3 border border-white/[0.06] px-4 py-2">
{walletType === 'lace'
? <Smartphone className="w-3.5 h-3.5 text-violet-400" />
: <Shield className="w-3.5 h-3.5 text-violet-400" />}
<div>
<span className="text-[9px] tracking-[0.2em] font-mono text-zinc-600 uppercase block">
{walletType === '1am' ? '1AM' : 'Lace'}
</span>
<span className="text-[11px] font-mono text-zinc-300 truncate max-w-[130px] block">{address}</span>
</div>
<button onClick={disconnect} title="Disconnect" className="text-zinc-600 hover:text-red-400">
<LogOut className="w-3.5 h-3.5" />
</button>
</div>
);
return (
<div className="flex flex-col gap-2">
<button
onClick={() => connect('preprod')}
disabled={isConnecting}
className="flex items-center gap-2 bg-violet-600 hover:bg-violet-500 text-white text-[11px] font-mono tracking-widest uppercase py-2.5 px-5 transition-all disabled:opacity-40"
>
{isConnecting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Smartphone className="w-3.5 h-3.5" />}
Connect Wallet
</button>
{walletStatus === 'not-found' &&
<p className="text-[10px] font-mono text-zinc-600">Install 1AM or Lace wallet extension</p>}
</div>
);
}
3) セッションのセットアップ(createConnectedSession)
設定、ネットワーク ID、すべてのアドレスは並列で取得してください。順番に await してはいけません。
// src/lib/midnight.ts
import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import { FetchZkConfigProvider } from '@midnight-ntwrk/midnight-js-fetch-zk-config-provider';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import type { MidnightProvider, WalletProvider } from '@midnight-ntwrk/midnight-js-types';
export type ConnectedSession = {
api: any;
config: any;
providers: {
privateStateProvider: ReturnType<typeof createPrivateStateProvider>;
publicDataProvider: ReturnType<typeof createPatchedPublicDataProvider>;
zkConfigProvider: FetchZkConfigProvider;
proofProvider: { proveTx: (unprovenTx: any, _config: any) => Promise<any> };
walletProvider: WalletProvider;
midnightProvider: MidnightProvider;
};
unshieldedAddress: string;
};
export async function createConnectedSession(api: any): Promise<ConnectedSession> {
// Fetch in parallel — do not await sequentially
const [config, unshieldedAddress, shieldedAddress] = await Promise.all([
api.getConfiguration(),
api.getUnshieldedAddress(),
api.getShieldedAddresses(),
]);
// Must be called before any SDK operations
setNetworkId(config.networkId);
// ZK assets are served from /contract/collection relative to your origin.
// Adjust the path to match where your compiled contract assets are hosted.
const zkConfigProvider = new FetchZkConfigProvider(
new URL('/contract/your-contract', window.location.origin).toString(),
window.fetch.bind(window),
);
// Optional: smoke-test ZK asset reachability at startup
zkConfigProvider.getZKIR('yourCircuit').then(
(zkir) => console.log('[zkConfigProvider] getZKIR ok, length:', zkir?.length),
(err) => console.error('[zkConfigProvider] getZKIR failed — check ZK asset hosting:', err),
);
const provingProvider = await api.getProvingProvider(zkConfigProvider);
// ✅ Use this custom wrapper — do NOT use createProofProvider() from @midnight-ntwrk/midnight-js-types.
// createProofProvider wraps the provider differently and does not pass CostModel correctly.
// unprovenTx.prove() called directly is the only pattern confirmed to work with 1AM's provingProvider.
const proofProvider = {
async proveTx(unprovenTx: any, _config: any) {
const { CostModel } = await import('@midnight-ntwrk/ledger-v8');
return unprovenTx.prove(provingProvider, CostModel.initialCostModel());
},
};
const walletProvider: WalletProvider = {
getCoinPublicKey: () => shieldedAddress.shieldedCoinPublicKey,
getEncryptionPublicKey: () => shieldedAddress.shieldedEncryptionPublicKey,
balanceTx: async (tx: any) => {
const txHex = toHex(tx.serialize());
const balanced = await api.balanceUnsealedTransaction(txHex);
if (!balanced?.tx) throw new Error('balanceUnsealedTransaction returned invalid result');
const { Transaction } = await import('@midnight-ntwrk/ledger-v8');
return Transaction.deserialize('signature', 'proof', 'binding', fromHex(balanced.tx));
},
};
const midnightProvider: MidnightProvider = {
submitTx: async (tx: any) => {
const txHex = toHex(tx.serialize());
const result = await api.submitTransaction(txHex);
// Accept string txId, or object with transactionId/id, or fall back to hex prefix
if (typeof result === 'string' && result) return result;
if (result?.transactionId) return result.transactionId;
if (result?.id) return result.id;
return txHex.slice(0, 64); // fallback pseudo-txId
},
};
const publicDataProvider = createPatchedPublicDataProvider(config.indexerUri, config.indexerWsUri);
return {
api,
config,
providers: {
privateStateProvider: createPrivateStateProvider(),
publicDataProvider,
zkConfigProvider,
proofProvider,
walletProvider,
midnightProvider,
},
unshieldedAddress: unshieldedAddress.unshieldedAddress,
};
}
Hex ヘルパー(必須 — padStart を省略しないこと)
export function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
export function fromHex(hex: string): Uint8Array {
const normalized = hex.startsWith('0x') ? hex.slice(2) : hex;
if (normalized.length % 2 !== 0) throw new Error('Invalid hex string from wallet.');
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);
}
return bytes;
}
Coin 公開鍵ヘルパー
コントラクトがウォレットアドレスを引数に取る場合(受取人フィールドなど)は、これらを使用してください。
export function coinPublicKeyToBytes(walletProvider: WalletProvider): Uint8Array {
const pk = walletProvider?.getCoinPublicKey?.() ?? '';
const hex = typeof pk === 'string' ? pk : Array.from(pk as number[]).map((b) => b.toString(16).padStart(2, '0')).join('');
const bytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return bytes;
}
// Compact's Either type — Left = shielded coin key, Right = unshielded/raw key
export function makeEitherLeft(bytes: Uint8Array) {
return { is_left: true, left: { bytes }, right: { bytes: new Uint8Array(32) } };
}
// Format an Either<Bytes, Bytes> address for display
export function formatAddress(either: any): string {
if (!either) return '—';
const bytes = either.is_left ? either.left?.bytes : either.right?.bytes;
if (!bytes) return '—';
return '0x' + Array.from(bytes as number[]).map((b) => b.toString(16).padStart(2, '0')).join('');
}
4) パッチ適用済みの公開データプロバイダー ⚠️ 重要
preview と preprod のインデクサーには、最新状態のクエリで offset: null を使用すると GraphQL のバグが発生します。デフォルトの SDK の queryContractState() は、設定ブロックなしで呼び出すとこのバグに遭遇します。必ず indexerPublicDataProvider をこのパッチでラップしてください。これがないと、これらのネットワークで状態の読み取りに失敗します。
import { ContractState } from '@midnight-ntwrk/compact-runtime';
import { LedgerParameters, ZswapChainState } from '@midnight-ntwrk/ledger-v8';
export function createPatchedPublicDataProvider(queryUrl: string, subscriptionUrl: string) {
const base = indexerPublicDataProvider(queryUrl, subscriptionUrl);
async function queryLatest(query: string, address: string) {
const res = await fetch(queryUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query, variables: { address } }),
});
if (!res.ok) throw new Error(`Indexer HTTP error: ${res.status}`);
const payload = await res.json();
if (payload.errors?.length) throw new Error(payload.errors.map((e: any) => e.message).join('; '));
return payload.data?.contractAction ?? null;
}
return {
...base,
async queryContractState(contractAddress: string, config?: any) {
if (config) return base.queryContractState(contractAddress, config);
const action = await queryLatest(`
query LATEST_CONTRACT_STATE($address: HexEncoded!) {
contractAction(address: $address) { state }
}`, contractAddress);
return action ? ContractState.deserialize(fromHex(action.state)) : null;
},
async queryZSwapAndContractState(contractAddress: string, config?: any) {
if (config) return base.queryZSwapAndContractState(contractAddress, config);
const action = await queryLatest(`
query LATEST_BOTH_STATE($address: HexEncoded!) {
contractAction(address: $address) {
state
zswapState
transaction { block { ledgerParameters } }
}
}`, contractAddress);
if (!action?.zswapState) return null;
return [
ZswapChainState.deserialize(fromHex(action.zswapState)),
ContractState.deserialize(fromHex(action.state)),
action.transaction?.block?.ledgerParameters
? LedgerParameters.deserialize(fromHex(action.transaction.block.ledgerParameters))
: LedgerParameters.initialParameters(),
];
},
};
}
5) プライベート状態プロバイダー(メモリ内)
ほとんどの dApp ではこれで十分です。プライベート状態はページを再読み込みすると保持されません。これは最小限のリファレンス実装を意図した動作です。本番環境で状態を保持するには、localStorage または暗号化されたサーバーストアに置き換えてください。
export function createPrivateStateProvider() {
let scope = '';
const stateStore = new Map<string, unknown>();
const signingKeyStore = new Map<string, unknown>();
const key = (id: string) => `${scope}:${id}`;
return {
setContractAddress(address: string) { scope = address; },
async set(id: string, state: unknown) { stateStore.set(key(id), state); },
async get(id: string) { return stateStore.get(key(id)) ?? null; },
async remove(id: string) { stateStore.delete(key(id)); },
async clear() { stateStore.clear(); },
async setSigningKey(addr: string, k: unknown) { signingKeyStore.set(addr, k); },
async getSigningKey(addr: string) { return signingKeyStore.get(addr) ?? null; },
async removeSigningKey(addr: string) { signingKeyStore.delete(addr); },
async clearSigningKeys() { signingKeyStore.clear(); },
async exportPrivateStates(): Promise<never> { throw new Error('Not implemented.'); },
async importPrivateStates(): Promise<never> { throw new Error('Not implemented.'); },
async exportSigningKeys(): Promise<never> { throw new Error('Not implemented.'); },
async importSigningKeys(): Promise<never> { throw new Error('Not implemented.'); },
};
}
6) コントラクトのデプロイと呼び出し
YourContract、your-contract、yourCircuit は実際の名前に置き換えてください。
import { CompiledContract } from '@midnight-ntwrk/compact-js';
import { deployContract, submitCallTx } from '@midnight-ntwrk/midnight-js-contracts';
import { Contract } from './your-compiled-contract'; // generated by `compact` compiler
// Build the compiled contract handle (do this once, cache it)
function getCompiledContract() {
return CompiledContract.make('YourContract', Contract).pipe(
CompiledContract.withVacantWitnesses,
CompiledContract.withCompiledFileAssets('./contract/your-contract'),
) as any; // TypeScript: compiled contract generics are too narrow; cast is safe at runtime
}
// Call any circuit by name
async function callCircuit(
session: ConnectedSession,
contractAddress: string,
circuitId: string,
args: any[],
) {
const compiledContract = getCompiledContract();
const result = await submitCallTx(session.providers as any, {
compiledContract,
contractAddress,
circuitId,
args,
});
console.log('Tx hash:', result.public.txHash);
return result;
}
⚠️ preprod/preview へのデプロイに
deployContract()を使用しないでください。deployContractは内部でwatchForTxDataを呼び出し、トランザクションがインデックス化されるまでインデクサーをポーリングします。preprod ではフィードバックのないまま 30~120 秒かかることがあり、インデクサーが遅延すると無期限に停止します。代わりに §8 の低レベルなcreateUnprovenDeployTx+submitTxAsyncのパターンを使用してください。 コントラクトアドレスは、送信前にdeployTxData.public.contractAddressからすぐに取得できます。submitTxAsyncはブロッキングするwatchForTxDataの呼び出しを完全に省略します。
7) トランザクションフロー(ダスト手数料不要)
dApp builds unproven tx
↓
proofProvider.proveTx() → 1AM / ProofStation → ZK proof (~2–5s)
↓
walletProvider.balanceTx() → api.balanceUnsealedTransaction() → server adds dust fees
↓
midnightProvider.submitTx() → api.submitTransaction() → Midnight chain
Total user cost: 0 NIGHT, 0 dust.
balanceUnsealedTransaction では、ProofStation のサーバーウォレットがユーザーに代わって手数料を支払います。必ず実行してください。
8) 低レベルのデプロイと呼び出し(インデクサーのポーリングあり)
deployContract / submitCallTx よりも細かく制御する必要がある場合(プライベート状態の保存、インデクサーの確認待ち、トランザクション反映後の UI 更新など)は、これらを使用してください。
プライベート状態を保持するデプロイ
import { createUnprovenDeployTx, submitTxAsync } from '@midnight-ntwrk/midnight-js-contracts';
import { sampleSigningKey } from '@midnight-ntwrk/compact-runtime';
async function deployAndPersist(
session: ConnectedSession,
constructorArgs: any[],
onDeployed?: (address: string) => Promise<void>,
): Promise<string> {
const compiledContract = getCompiledContract();
const deployTxData = await createUnprovenDeployTx(
{ zkConfigProvider: session.providers.zkConfigProvider, walletProvider: session.providers.walletProvider },
{ compiledContract, args: constructorArgs, signingKey: sampleSigningKey() },
);
const contractAddress = deployTxData.public.contractAddress;
await submitTxAsync(session.providers, { unprovenTx: deployTxData.private.unprovenTx });
// Persist private state so subsequent circuit calls can find it
await session.providers.privateStateProvider.setContractAddress(contractAddress);
await session.providers.privateStateProvider.setSigningKey(contractAddress, deployTxData.private.signingKey);
// Optional: persist to your backend
await onDeployed?.(contractAddress);
await waitForContractDeployment(session.providers.publicDataProvider, contractAddress);
return contractAddress;
}
状態変更をポーリングするサーキットの呼び出し
import { createUnprovenCallTx } from '@midnight-ntwrk/midnight-js-contracts';
async function callAndWait(
session: ConnectedSession,
contractAddress: string,
circuitId: string,
args: any[],
// Provide a predicate that returns true when the indexed state has advanced past the pre-call snapshot
hasStateAdvanced: (publicDataProvider: any) => Promise<boolean>,
): Promise<string> {
const compiledContract = getCompiledContract();
const callTxData = await createUnprovenCallTx(session.providers, {
compiledContract,
contractAddress,
circuitId,
args,
});
const txId = await submitTxAsync(session.providers, {
unprovenTx: callTxData.private.unprovenTx,
circuitId,
});
await waitForStateAdvance(session.providers.publicDataProvider, hasStateAdvanced);
return txId;
}
9) ポーリングヘルパー
// Wait until a newly deployed contract appears in the indexer
export async function waitForContractDeployment(
publicDataProvider: ReturnType<typeof createPatchedPublicDataProvider>,
contractAddress: string,
pollIntervalMs = 2000,
maxAttempts = 30,
): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
const state = await publicDataProvider.queryContractState(contractAddress);
if (state?.data) return;
await new Promise(r => setTimeout(r, pollIntervalMs));
}
throw new Error(`Contract not indexed after ${maxAttempts * pollIntervalMs}ms — check address or indexer lag`);
}
// Wait until a caller-supplied predicate signals that state has advanced
// The predicate receives publicDataProvider so it can query whatever ledger field is relevant
export async function waitForStateAdvance(
publicDataProvider: ReturnType<typeof createPatchedPublicDataProvider>,
hasAdvanced: (provider: typeof publicDataProvider) => Promise<boolean>,
pollIntervalMs = 2000,
maxAttempts = 30,
): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
if (await hasAdvanced(publicDataProvider)) return;
await new Promise(r => setTimeout(r, pollIntervalMs));
}
throw new Error(`State did not advance after ${maxAttempts * pollIntervalMs}ms`);
}
使用例 — 呼び出し前のスナップショットをキャプチャする述語を渡します:
const stateBefore = await getRelevantLedgerValue(session, contractAddress);
await callAndWait(session, contractAddress, 'increment', [arg1], async (provider) => {
const contractState = await provider.queryContractState(contractAddress);
if (!contractState?.data) return false;
// Always pass contractState.data (ChargedState) to your ledger() function, not contractState itself
const current = yourLedgerReader(contractState.data).someField;
return current !== stateBefore;
});
10) 汎用 React フックのパターン
これは最小限のテンプレートです。YourState、yourLedger、回路名を、実際のコントラクトの構造に置き換えてください。
// hooks/useContract.ts
import { useCallback, useEffect, useState } from 'react';
import { useWallet } from '../contexts/WalletContext';
import { waitForContractDeployment, waitForStateAdvance } from '../lib/midnight';
export function useContract(contractAddress: string | null) {
const { session } = useWallet();
const [contractState, setContractState] = useState<YourState | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchState = useCallback(async () => {
if (!session || !contractAddress) return;
const contractState = await session.providers.publicDataProvider.queryContractState(contractAddress);
// ⚠️ Pass contractState.data (a ChargedState), NOT the ContractState itself.
// The compiled contract's ledger() function expects a ChargedState and accesses .state on it.
// Passing a raw ContractState (from ContractState.deserialize) will fail with
// "expected instance of ChargedState" because ContractState has no .state property.
if (contractState?.data) setContractState(yourLedger(contractState.data));
}, [session, contractAddress]);
useEffect(() => { fetchState(); }, [fetchState]);
const callSomeCircuit = useCallback(async (...args: any[]) => {
if (!session || !contractAddress) return;
setIsLoading(true);
setError(null);
try {
const snapshotBefore = contractState?.someField;
const callTxData = await createUnprovenCallTx(session.providers, {
compiledContract: getCompiledContract(),
contractAddress,
circuitId: 'someCircuit',
args,
});
await submitTxAsync(session.providers, { unprovenTx: callTxData.private.unprovenTx, circuitId: 'someCircuit' });
await waitForStateAdvance(session.providers.publicDataProvider, async (provider) => {
const s = await provider.queryContractState(contractAddress);
return s?.data ? yourLedger(s.data).someField !== snapshotBefore : false;
});
// Optimistic local update (optional — feels instant)
setContractState(prev => prev ? { ...prev /* apply expected delta */ } : prev);
await fetchState(); // reconcile with chain
} catch (e: any) {
setError(e.message);
} finally {
setIsLoading(false);
}
}, [session, contractAddress, contractState, fetchState]);
return { contractState, isLoading, error, callSomeCircuit, refreshState: fetchState };
}
設計上のポイント:
- 呼び出し前にスナップショットを取得する — 変更されると予想されるフィールドを送信前に取得し、ポーリングの述語で使用します。
- 楽観的更新 — tx が反映された直後に、
fetchState()が返る前に、予想される差分をローカル状態に適用します。 - サーバーとの同期 — 楽観的更新の後に
fetchState()を呼び出し、インデックス化された実際の状態と同期します。
11) オプション:ペイロードの暗号化
ウォレット署名から決定論的に導出したキーを使って、オンチェーン文字列を暗号化します。api.signData が必要です。
// src/lib/encryption.ts
// Derive a scoped AES-GCM key from the user's wallet signature.
// Key is deterministic: same wallet + same contract = same key across sessions.
export async function deriveContractKey(api: any, networkId: string, contractAddress: string): Promise<CryptoKey> {
const message = `midnight-app-key|${networkId}|${contractAddress}`;
const signature = await api.signData(message, { encoding: 'text' });
if (!signature) throw new Error('signData returned empty — cannot derive encryption key');
const keyMaterial = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(signature), 'HKDF', false, ['deriveKey'],
);
return crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: new TextEncoder().encode(`midnight-salt|${networkId}`),
info: new TextEncoder().encode(`midnight-contract|${contractAddress}`),
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
}
// Encrypt a string → versioned envelope: "enc:v1:<base64url(iv+ciphertext)>"
export async function encryptPayload(key: CryptoKey, plaintext: string): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(plaintext),
);
const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ciphertext), iv.byteLength);
return 'enc:v1:' + btoa(String.fromCharCode(...combined)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
// Decrypt a versioned envelope → original string
export async function decryptPayload(key: CryptoKey, envelope: string): Promise<string> {
if (!envelope.startsWith('enc:v1:')) throw new Error('Not an encrypted payload');
const b64 = envelope.slice(7).replace(/-/g, '+').replace(/_/g, '/');
const combined = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
return new TextDecoder().decode(plain);
}
export const isEncryptedPayload = (s: string) => s.startsWith('enc:v1:');
設計上の判断:
- URL や JSON でのパディングの問題を避けるため、
base64url(+/=を置き換えたもの)を使用します。 - KDF の salt と info の両方に
networkIdとcontractAddressを含めます。キーはコントラクトごとにスコープされ、同じウォレットでもコントラクトやネットワークが異なれば別のキーが生成されます。 - 復号を試みる前に
isEncryptedPayload()で暗号化された値を検出します。 signDataが利用できない場合は、すぐにエラーをスローします。暗号化されていないストレージにひそかにフォールバックしないでください。
12) Next.js との互換性
SDK は isomorphic-ws、非同期 WebAssembly、トップレベル await を使用しています。これらは Next.js では標準で動作しません。以下の手順が必要です。
1. WebSocket shim を作成する(Next.js はブラウザー向けターゲットに壊れた isomorphic-ws をバンドルします):
// lib/isomorphic-ws-fix.mjs
export default globalThis.WebSocket;
export const WebSocket = globalThis.WebSocket;
2. next.config.mjs を設定する:
// next.config.mjs
const nextConfig = {
webpack(config) {
config.resolve.alias['isomorphic-ws'] = new URL('./lib/isomorphic-ws-fix.mjs', import.meta.url).pathname;
config.resolve.fallback = { fs: false, net: false, tls: false, child_process: false };
config.experiments = { asyncWebAssembly: true, topLevelAwait: true };
return config;
},
};
export default nextConfig;
3. Turbopack を無効にする — Next.js 15 以降では、Turbopack がデフォルトで有効です。カスタム webpack 設定には webpack モードが必要です。
// package.json
{
"scripts": {
"dev": "next dev --webpack"
}
}
または、Next.js の設定ベースの Turbopack オプトインを使用する場合は、カスタム webpack 実験を使用しないときに限り、明示的に turbopack: {} を設定してください。両方を同時に使用することはできません。
TypeScript の型アサーション — SDK のジェネリクスは、コンパイル済みコントラクトの出力ときれいに組み合わせられません。createUnprovenDeployTx、submitTxAsync、submitCallTx の呼び出し箇所で as any を使用してください。実行時の型は正しいですが、静的なジェネリクスはコンパイラーが生成するコントラクトの形状に対して狭すぎます。FetchZkConfigProvider<any> が適切な型注釈です。
13) ZK キーのホスティング
コンパイル済みコントラクトは、CORS を有効にして HTTP 経由で配信する必要があるアセットを生成します。FetchZkConfigProvider は実行時にそれらを取得します。
your-server.com/<zk-path>/
keys/
circuitName.prover # 2–10 MB each
circuitName.verifier # ~2 KB each
zkir/
circuitName.bzkir # 1–3 KB each
必須ヘッダー:Access-Control-Allow-Origin: *
ローカルの Vite 開発では、npm スクリプトを使ってアセットを public/ に同期します。
{
"scripts": {
"sync:zk": "mkdir -p public/contract/your-contract && cp -r contracts/managed/your-contract/keys public/contract/your-contract/ && cp -r contracts/managed/your-contract/zkir public/contract/your-contract/"
}
}
プロバイダーエラーをデバッグする前に、アセットの URL をブラウザーで直接開いてください。 ここで発生した 404 や CORS エラーは、わかりにくい SDK エラーとして表面化します。npm run dev の前に sync:zk を実行してください。Vite は起動時に public/ に存在するファイルのみを配信します。
14) ProofStation API
1AM ウォレットは、内部で balanceUnsealedTransaction を通じて ProofStation を呼び出します。通常、直接呼び出す必要はありません。参考情報のみ:
| エンドポイント | メソッド | 説明 |
|---|---|---|
/health | GET | サーバーのヘルス状態と上流のステータス |
/prove | POST | ZK 証明を生成 |
/verify | POST | ZK 証明を検証 |
/prove-and-balance | POST | 1 回の呼び出しで証明と残高調整を実行 |
/balance-only | POST | 証明済み tx の残高を調整 |
/wallet-status | GET | スポンサーシップウォレットのダスト残高 |
ベース URL:
- プレビュー:
https://api-preview.1am.xyz - プレプロッド:
https://api-preprod.1am.xyz - メインネット:
https://api.1am.xyz
認証:X-API-Key: pk_live_xxx(直接呼び出す場合のみ必要です。api.balanceUnsealedTransaction 経由でのルーティングを推奨します)。
15) ネットワーク
| ネットワーク | 用途 | インデクサー | RPC |
|---|---|---|---|
preview | 開発中 | indexer.preview.midnight.network | rpc.preview.midnight.network |
preprod | リリース前のテスト | indexer.preprod.midnight.network | rpc.preprod.midnight.network |
mainnet | 本番環境 | indexer.mainnet.midnight.network | rpc.mainnet.midnight.network |
開発中は preview を使用してください。getConfiguration() は、ユーザーがウォレットで選択したネットワークに応じた正しい URL を返します。常にこれらの値を動的に使用し、ハードコードしないでください。
16) ウォレット API リファレンス
window.midnight['1am'] (InitialAPI)
| メソッド | 戻り値 | 備考 |
|---|---|---|
connect(networkId) | ConnectedAPI | 'preview' | 'preprod' | 'mainnet' |
name | string | '1AM' |
apiVersion | string | '4.0.0' |
ConnectedAPI
| メソッド | 戻り値 | 備考 |
|---|---|---|
getConfiguration() | { networkId, indexerUri, indexerWsUri, proverServerUri, substrateNodeUri } | すべての URL の信頼できる情報源です。常に動的に使用してください |
getShieldedAddresses() | { shieldedAddress, shieldedCoinPublicKey, shieldedEncryptionPublicKey } | |
getUnshieldedAddress() | { unshieldedAddress } | |
getDustAddress() | { dustAddress } | |
getShieldedBalances() | Record<string, bigint> | |
getUnshieldedBalances() | Record<string, bigint> | |
getDustBalance() | { balance, cap } | |
getProvingProvider(zkConfigProvider) | ProvingProvider | FetchZkConfigProvider を渡します |
balanceUnsealedTransaction(hex) | { tx: string } | ダスト手数料を追加します。必ず実行してください |
submitTransaction(hex) | string | void | txId または void を返します |
signData(data, options) | string | 鍵導出用の署名 |
makeTransfer(outputs) | { tx } | トークンの送金 |
17) よくある落とし穴
| 落とし穴 | 対処方法 |
|---|---|
toHex で padStart(2, '0') を付け忘れる | 1 桁の 16 進数バイトによってトランザクションが破損します。必ず使用してください。 |
最初に setNetworkId() を呼び出していない | getConfiguration() の直後に呼び出してください。呼び出さないと、SDK 全体で型の不一致が発生しても気づけません。 |
fromHex で 0x プレフィックスを取り除いていない | ウォレットが 0x 付きまたはなしの 16 進数を返す場合があります。上記の fromHex ヘルパーはどちらにも対応しています。 |
preprod/preview で deployContract() を使用する | watchForTxData を呼び出し、フィードバックがないまま 30~120 秒間ブロックします。代わりに createUnprovenDeployTx + submitTxAsync を使用してください(§8)。 |
@midnight-ntwrk/midnight-js-types の createProofProvider() を使用する | CostModel が正しく渡されません。§3 のカスタム proveTx ラッパーを使用し、unprovenTx.prove(provingProvider, CostModel.initialCostModel()) を直接呼び出してください。 |
api.submitTransaction() が文字列以外を返す | 戻り値を正規化します。文字列かどうかを確認し、次に .transactionId、続いて .id を確認し、それらがなければ txHex.slice(0, 64) にフォールバックしてください。§3 の midnightProvider ではすでに対応済みです。 |
balanceUnsealedTransaction が null を返す | fromHex を呼び出す前に if (!balanced?.tx) throw new Error(...) でガードしてください。§3 の walletProvider.balanceTx ではすでに対応済みです。 |
ChargedState ではなく ContractState を ledger() に渡す | queryContractState() は ContractState を返します。その .data プロパティが、ledger() が想定する ChargedState です。必ず yourLedger(contractState.data) を呼び出し、yourLedger(contractState) は呼び出さないでください。 |
| デプロイ直後に状態を読み取る | インデクサーはチェーンのファイナリティと同期して動作するわけではありません。必ずポーリングしてください。送信直後に状態を読み取らないでください。 |
| ZK アセットが 404 になる | npm run dev の前に sync:zk を実行してください。Vite は起動時に public/ に存在するファイルのみを配信します。 |
| 再コンパイル後に古いコントラクトアドレスを再利用する | コントラクトに変更を加えると、検証キーが再生成されます。古いアドレスでは証明の検証に失敗します。必ず再デプロイしてください。 |
| preview で回路が小さすぎる | Preview ProofStation では、回路サイズ k≥6 が最小要件です。prove: no SRS params for k=6 と表示された場合は、回避策としてダミーの台帳フィールドを追加し、回路サイズを大きくしてください。 |
| インデクサー/RPC の URL をハードコードする | 必ず getConfiguration() から URL を読み取ってください。ユーザーが想定とは異なるネットワークを使用している場合があります。 |
Next.js:isomorphic-ws/WASM/トップレベル await のエラー | 必要な webpack 設定、WebSocket シム、Turbopack の無効化手順については §12 を参照してください。 |
| SDK 呼び出し箇所で TypeScript エラーが発生する | createUnprovenDeployTx、submitTxAsync、submitCallTx、FetchZkConfigProvider<any> に as any キャストを使用してください。実行時の型は正しいですが、ジェネリクスがコンパイラー生成のコントラクト出力に対して狭すぎます。 |