Skip to main content
For the complete documentation index, see llms.txt

Part 3: Browser DApp

이 장에서는 Lace wallet에 연결하고 indexer에서 리더보드 데이터를 읽는 React 프론트엔드를 만듭니다. 이 프론트엔드는 점수를 온체인에 제출하고, 자신의 항목에 대한 소유권을 증명합니다.

Set up the UI package

UI workspace 디렉터리 구조와 package.json을 생성합니다.

mkdir -p leaderboard-ui/src/contexts leaderboard-ui/src/hooks
touch leaderboard-ui/package.json

leaderboard-contract workspace 의존성을 통해 UI는 컴파일된 컨트랙트 바인딩에 접근합니다. dev 스크립트는 circuit 키를 public/으로 복사해, 브라우저가 런타임에 가져올 수 있게 합니다.

{
"name": "leaderboard-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "mkdir -p ./public/keys ./public/zkir && cp -r ../contract/managed/leaderboard/keys/* ./public/keys/ && cp -r ../contract/managed/leaderboard/zkir/* ./public/zkir/ && vite",
"build": "tsc && vite build --mode preprod && cp -r ../contract/managed/leaderboard/keys ./dist/keys && cp -r ../contract/managed/leaderboard/zkir ./dist/zkir",
"preview": "vite preview"
},
"dependencies": {
"@midnight-ntwrk/dapp-connector-api": "4.0.1",
"@midnight-ntwrk/midnight-js-contracts": "4.1.1",
"@midnight-ntwrk/midnight-js-fetch-zk-config-provider": "4.1.1",
"@midnight-ntwrk/midnight-js-http-client-proof-provider": "4.1.1",
"@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.1.1",
"@midnight-ntwrk/midnight-js-network-id": "4.1.1",
"@midnight-ntwrk/midnight-js-protocol": "4.1.1",
"@midnight-ntwrk/midnight-js-types": "4.1.1",
"@midnight-ntwrk/midnight-js-utils": "4.1.1",
"buffer": "^6.0.3",
"fp-ts": "^2.16.11",
"leaderboard-contract": "*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rxjs": "^7.8.2",
"semver": "^7.7.4",
"pino": "^10.3.1"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/semver": "^7.7.1",
"@vitejs/plugin-react": "^5.1.4",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0"
}
}

Vite configuration

Midnight SDK는 암호화 연산에 WebAssembly 모듈을 사용합니다. 브라우저에서 WASM import를 처리하려면 Vite에 특정 플러그인이 필요합니다.

touch leaderboard-ui/vite.config.ts

이 Vite 설정은 WebAssembly 지원을 켜고, Midnight 런타임용 WASM 모듈 resolver를 구성하며, 개발 서버를 3000번 포트로 설정합니다.

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import wasm from 'vite-plugin-wasm';
import topLevelAwait from 'vite-plugin-top-level-await';

export default defineConfig({
cacheDir: './.vite',
build: {
target: 'esnext',
minify: false,
rollupOptions: {
output: {
manualChunks: {
wasm: ['@midnight-ntwrk/onchain-runtime-v3'],
},
},
},
commonjsOptions: {
transformMixedEsModules: true,
extensions: ['.js', '.cjs'],
ignoreDynamicRequires: true,
},
},
plugins: [
react(),
wasm(),
topLevelAwait({
promiseExportName: '__tla',
promiseImportName: (i) => `__tla_${i}`,
}),
{
name: 'wasm-module-resolver',
resolveId(source, importer) {
if (
source === '@midnight-ntwrk/onchain-runtime-v3' &&
importer &&
importer.includes('@midnight-ntwrk/compact-runtime')
) {
return { id: source, external: false, moduleSideEffects: true };
}
return null;
},
},
],
optimizeDeps: {
esbuildOptions: {
target: 'esnext',
supported: { 'top-level-await': true },
platform: 'browser',
format: 'esm',
loader: { '.wasm': 'binary' },
},
include: ['@midnight-ntwrk/compact-runtime'],
exclude: [
'@midnight-ntwrk/onchain-runtime-v3',
'@midnight-ntwrk/onchain-runtime-v3/midnight_onchain_runtime_wasm_bg.wasm',
'@midnight-ntwrk/onchain-runtime-v3/midnight_onchain_runtime_wasm.js',
],
},
resolve: {
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.wasm'],
mainFields: ['browser', 'module', 'main'],
},
server: { port: 3000, open: true },
});

TypeScript config

import.meta.env 참조가 올바르게 컴파일되려면 TypeScript 설정에 "types": ["vite/client"]가 필요합니다. Midnight SDK의 subpath import에는 "bundler" 모듈 해석 방식이 필요합니다.

touch leaderboard-ui/tsconfig.json

이 설정은 ES2022를 타깃으로 하며 React JSX transform을 활성화합니다.

{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}

Environment variables

로컬 개발용 환경 파일을 생성합니다. VITE_DEFAULT_CONTRACT는 이후 단계에서 컨트랙트를 배포하기 전까지 비워 두세요.

touch leaderboard-ui/.env

Vite는 이 변수들을 빌드 시점에 읽어 들입니다. VITE_ 접두어가 붙은 변수는 import.meta.env를 통해 브라우저에서 사용할 수 있습니다.

VITE_NETWORK_ID=preprod
VITE_INDEXER_URL=https://indexer.preprod.midnight.network/api/v4/graphql
VITE_INDEXER_WS_URL=wss://indexer.preprod.midnight.network/api/v4/graphql/ws
VITE_DEFAULT_CONTRACT=

HTML entry point

Vite가 애플리케이션 진입점으로 사용하는 HTML 셸을 생성합니다. 브라우저는 src/main.tsx 스크립트를 ES 모듈로 로드합니다.

touch leaderboard-ui/index.html

개발 중에 Vite는 이 파일을 변환하여 hot module reload 지원을 주입하고 TypeScript 진입점을 해석합니다.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Midnight Leaderboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

Application entry point

일부 Midnight SDK 패키지는 브라우저에 존재하지 않는 Node.js Buffer를 사용합니다. 진입점에서는 다른 import보다 먼저 이를 polyfill해야 합니다.

touch leaderboard-ui/src/main.tsx
touch leaderboard-ui/src/App.css

진입점은 Buffer를 전역 스코프에 polyfill한 다음 React 애플리케이션을 마운트합니다.

import { Buffer } from 'buffer';
(globalThis as any).Buffer = Buffer;

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './App.css';

createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

In-memory private state provider

midnight-js-contracts 라이브러리에는 PrivateStateProvider가 필요합니다. 브라우저에서는 in-memory 구현으로 충분합니다.

touch leaderboard-ui/src/in-memory-private-state-provider.ts

이 provider는 private state와 signing 키를 컨트랙트 주소별로 구분해 메모리에 저장합니다. import와 export 동작을 포함한 PrivateStateProvider 인터페이스 전체를 구현합니다.

import type { ContractAddress, SigningKey } from '@midnight-ntwrk/midnight-js-protocol/compact-runtime';
import type {
ExportPrivateStatesOptions,
ExportSigningKeysOptions,
ImportPrivateStatesOptions,
ImportPrivateStatesResult,
ImportSigningKeysOptions,
ImportSigningKeysResult,
PrivateStateExport,
PrivateStateId,
PrivateStateProvider,
SigningKeyExport,
} from '@midnight-ntwrk/midnight-js-types';

export const inMemoryPrivateStateProvider = <PSI extends PrivateStateId, PS = unknown>(): PrivateStateProvider<PSI, PS> => {
const privateStates = new Map<ContractAddress, Map<PSI, PS>>();
const signingKeys = new Map<ContractAddress, SigningKey>();
let contractAddress: ContractAddress | null = null;

const requireContractAddress = (): ContractAddress => {
if (contractAddress === null) throw new Error('Contract address not set');
return contractAddress;
};

const getScopedStates = (address: ContractAddress): Map<PSI, PS> => {
let scopedStates = privateStates.get(address);
if (!scopedStates) {
scopedStates = new Map<PSI, PS>();
privateStates.set(address, scopedStates);
}
return scopedStates;
};

const encode = <T>(value: T): string => JSON.stringify(value);
const decode = <T>(value: string): T => JSON.parse(value) as T;

return {
setContractAddress(address: ContractAddress): void { contractAddress = address; },
set(key: PSI, state: PS): Promise<void> {
getScopedStates(requireContractAddress()).set(key, state);
return Promise.resolve();
},
get(key: PSI): Promise<PS | null> {
return Promise.resolve(getScopedStates(requireContractAddress()).get(key) ?? null);
},
remove(key: PSI): Promise<void> {
getScopedStates(requireContractAddress()).delete(key);
return Promise.resolve();
},
clear(): Promise<void> {
privateStates.delete(requireContractAddress());
return Promise.resolve();
},
setSigningKey(addr: ContractAddress, key: SigningKey): Promise<void> {
signingKeys.set(addr, key);
return Promise.resolve();
},
getSigningKey(addr: ContractAddress): Promise<SigningKey | null> {
return Promise.resolve(signingKeys.get(addr) ?? null);
},
removeSigningKey(addr: ContractAddress): Promise<void> {
signingKeys.delete(addr);
return Promise.resolve();
},
clearSigningKeys(): Promise<void> {
signingKeys.clear();
return Promise.resolve();
},
exportPrivateStates(_options?: ExportPrivateStatesOptions): Promise<PrivateStateExport> {
const address = requireContractAddress();
const states = Object.fromEntries(
Array.from(getScopedStates(address).entries()).map(([k, v]) => [k, encode(v)]),
);
return Promise.resolve({
format: 'midnight-private-state-export',
encryptedPayload: encode({ contractAddress: address, states }),
salt: 'in-memory',
});
},
importPrivateStates(exportData: PrivateStateExport, options?: ImportPrivateStatesOptions): Promise<ImportPrivateStatesResult> {
const address = requireContractAddress();
const strategy = options?.conflictStrategy ?? 'error';
const payload = decode<{ states?: Record<string, string> }>(exportData.encryptedPayload);
const scopedStates = getScopedStates(address);
let imported = 0, skipped = 0, overwritten = 0;
for (const [rawId, serialized] of Object.entries(payload.states ?? {})) {
const id = rawId as PSI;
if (scopedStates.has(id)) {
if (strategy === 'skip') { skipped++; continue; }
if (strategy === 'error') return Promise.reject(new Error(`Conflict: ${id}`));
overwritten++;
} else { imported++; }
scopedStates.set(id, decode<PS>(serialized));
}
return Promise.resolve({ imported, skipped, overwritten });
},
exportSigningKeys(_options?: ExportSigningKeysOptions): Promise<SigningKeyExport> {
return Promise.resolve({
format: 'midnight-signing-key-export',
encryptedPayload: encode({ keys: Object.fromEntries(signingKeys.entries()) }),
salt: 'in-memory',
});
},
importSigningKeys(exportData: SigningKeyExport, options?: ImportSigningKeysOptions): Promise<ImportSigningKeysResult> {
const strategy = options?.conflictStrategy ?? 'error';
const payload = decode<{ keys?: Record<string, SigningKey> }>(exportData.encryptedPayload);
let imported = 0, skipped = 0, overwritten = 0;
for (const [addr, key] of Object.entries(payload.keys ?? {})) {
if (signingKeys.has(addr)) {
if (strategy === 'skip') { skipped++; continue; }
if (strategy === 'error') return Promise.reject(new Error(`Conflict: ${addr}`));
overwritten++;
} else { imported++; }
signingKeys.set(addr, key);
}
return Promise.resolve({ imported, skipped, overwritten });
},
};
};

Read on-chain state

이 hook은 GraphQL로 Midnight indexer에 직접 질의하고, contract state를 deserialize한 다음, 컴파일된 컨트랙트의 ledger() 함수로 파싱합니다. Part 2에서 만든 API utils의 decodeDisplayName을 import해 원시 Bytes<32>를 읽을 수 있는 이름으로 변환합니다. 15초마다 자동으로 새로고침하며 지갑 연결이 필요 없습니다.

touch leaderboard-ui/src/hooks/useLeaderboard.ts

이 hook은 indexer에서 contract state를 가져와 컴파일된 컨트랙트의 ledger() 함수로 deserialize한 뒤, 정렬된 항목 목록을 반환합니다.

import { useState, useEffect, useCallback } from 'react';
import { ContractState } from '@midnight-ntwrk/midnight-js-protocol/compact-runtime';
import { Leaderboard } from 'leaderboard-contract';
import { decodeDisplayName } from '../../../api/src/utils/index.js';

const INDEXER_URL = import.meta.env.VITE_INDEXER_URL ?? 'https://indexer.preprod.midnight.network/api/v4/graphql';

const CONTRACT_STATE_QUERY = `
query ContractState($address: HexEncoded!) {
contractAction(address: $address) {
state
}
}
`;

export interface LeaderboardEntry {
id: number;
score: number;
displayName: string;
ownerHash: string;
}

function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes;
}

export function useLeaderboard(contractAddress: string | null, refreshInterval = 15_000) {
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
const [entryCount, setEntryCount] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const fetchLeaderboard = useCallback(async () => {
if (!contractAddress || !/^[0-9a-fA-F]{64}$/.test(contractAddress)) return;
try {
setLoading(true);
const res = await fetch(INDEXER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: CONTRACT_STATE_QUERY, variables: { address: contractAddress } }),
});
const gql = await res.json();
if (gql.errors) throw new Error(gql.errors[0]?.message ?? 'Indexer query failed');
const stateHex = gql.data?.contractAction?.state;
if (!stateHex) throw new Error('Contract not found');

const contractState = ContractState.deserialize(hexToBytes(stateHex));
const ledgerState = Leaderboard.ledger(contractState.data);

const parsed: LeaderboardEntry[] = [];
for (const [key, entry] of ledgerState.scores) {
parsed.push({
id: Number(key),
score: Number(entry.score),
displayName: decodeDisplayName(entry.displayName, Number(key), Number(entry.score)),
ownerHash: entry.ownerHash.toString(),
});
}
parsed.sort((a, b) => b.score - a.score);
setEntries(parsed);
setEntryCount(Number(ledgerState.nextId));
setError(null);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [contractAddress]);

useEffect(() => { fetchLeaderboard(); }, [fetchLeaderboard]);
useEffect(() => {
if (!contractAddress) return;
const interval = setInterval(fetchLeaderboard, refreshInterval);
return () => clearInterval(interval);
}, [contractAddress, refreshInterval, fetchLeaderboard]);

return { entries, entryCount, loading, error, refresh: fetchLeaderboard };
}

Wallet bridge

BrowserLeaderboardManager는 DApp Connector API를 통해 Lace wallet에 연결하고, 모든 provider를 초기화하며, 컨트랙트 동작을 LeaderboardAPI에 위임합니다.

이 manager는 처음 사용할 때 무작위 32바이트 secret 키를 생성해 localStorage에 저장합니다. 이후 방문할 때는 같은 키를 다시 가져옵니다. 덕분에 페이지를 새로고침하거나 브라우저를 재시작한 뒤에도 소유권 검증이 동작합니다.

walletProvider.balanceTx 메서드는 DApp과 지갑을 이어 줍니다. DApp은 contract circuit proof가 담긴 미정산(unbalanced) 트랜잭션을 만들어 정산을 위해 Lace로 보냅니다. Lace는 DUST 수수료와 Zswap proof를 추가한 다음, 제출할 수 있는 완성된 트랜잭션을 돌려줍니다.

provider를 초기화하기 전에 setNetworkId()를 먼저 호출하세요.

touch leaderboard-ui/src/contexts/BrowserLeaderboardManager.ts

이 manager 클래스는 지갑 연결, provider 초기화, secret 키 영속화, 컨트랙트 배포 또는 합류를 처리합니다.

import { LeaderboardAPI, type LeaderboardCircuitKeys, type LeaderboardProviders } from '../../../api/src/index';
import { type ContractAddress, fromHex, toHex } from '@midnight-ntwrk/midnight-js-protocol/compact-runtime';
import { BehaviorSubject, catchError, concatMap, filter, firstValueFrom, interval, map, type Observable, take, throwError, timeout } from 'rxjs';
import { pipe as fnPipe } from 'fp-ts/function';
import { type Logger } from 'pino';
import { type ConnectedAPI, type InitialAPI } from '@midnight-ntwrk/dapp-connector-api';
import { FetchZkConfigProvider } from '@midnight-ntwrk/midnight-js-fetch-zk-config-provider';
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import semver from 'semver';
import { Binding, type FinalizedTransaction, Proof, SignatureEnabled, Transaction, type TransactionId } from '@midnight-ntwrk/midnight-js-protocol/ledger';
import { type LeaderboardPrivateState } from 'leaderboard-contract';
import { inMemoryPrivateStateProvider } from '../in-memory-private-state-provider';
import { type NetworkId, setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import type { UnboundTransaction } from '@midnight-ntwrk/midnight-js-types';

export type LeaderboardDeployment =
| { readonly status: 'in-progress' }
| { readonly status: 'deployed'; readonly api: LeaderboardAPI }
| { readonly status: 'failed'; readonly error: Error };

export class BrowserLeaderboardManager {
readonly #deploymentsSubject = new BehaviorSubject<Array<BehaviorSubject<LeaderboardDeployment>>>([]);
#initializedProviders: Promise<LeaderboardProviders> | undefined;

constructor(private readonly logger: Logger) {}

readonly deployments$: Observable<Array<Observable<LeaderboardDeployment>>> = this.#deploymentsSubject;

resolve(contractAddress?: ContractAddress): Observable<LeaderboardDeployment> {
const deployments = this.#deploymentsSubject.value;
const existing = deployments.find(
(d) => d.value.status === 'deployed' && d.value.api.deployedContractAddress === contractAddress,
);
if (existing) return existing;
const secretKey = this.getSecretKey();
const deployment = new BehaviorSubject<LeaderboardDeployment>({ status: 'in-progress' });
if (contractAddress) {
void this.run(deployment, (providers) => LeaderboardAPI.join(providers, contractAddress, secretKey, this.logger));
} else {
void this.run(deployment, (providers) => LeaderboardAPI.deploy(providers, secretKey, this.logger));
}
this.#deploymentsSubject.next([...deployments, deployment]);
return deployment;
}

private getSecretKey(): Uint8Array {
const storageKey = 'midnight-leaderboard-secret';
const stored = localStorage.getItem(storageKey);
if (stored) {
return Uint8Array.from(atob(stored), (c) => c.charCodeAt(0));
}
const secret = crypto.getRandomValues(new Uint8Array(32));
localStorage.setItem(storageKey, btoa(String.fromCharCode(...secret)));
return secret;
}

private getProviders(): Promise<LeaderboardProviders> {
return this.#initializedProviders ?? (this.#initializedProviders = initializeProviders(this.logger));
}

private async run(
deployment: BehaviorSubject<LeaderboardDeployment>,
factory: (providers: LeaderboardProviders) => Promise<LeaderboardAPI>,
): Promise<void> {
try {
const providers = await this.getProviders();
const api = await factory(providers);
deployment.next({ status: 'deployed', api });
} catch (error: unknown) {
console.error('Contract operation failed:', error);
let err: Error;
if (error instanceof Error) { err = error; }
else if (typeof error === 'string') { err = new Error(error); }
else { err = new Error(JSON.stringify(error) || 'Unknown error during contract operation'); }
deployment.next({ status: 'failed', error: err });
}
}
}

const COMPATIBLE_CONNECTOR_API_VERSION = '4.x';

const initializeProviders = async (logger: Logger): Promise<LeaderboardProviders> => {
const networkId = import.meta.env.VITE_NETWORK_ID as NetworkId;
setNetworkId(networkId);
const connectedAPI = await connectToWallet(logger, networkId);
const config = await connectedAPI.getConfiguration();
const proofServerUri = config.proverServerUri!;
const shieldedAddresses = await connectedAPI.getShieldedAddresses();
const zkConfigProvider = new FetchZkConfigProvider<LeaderboardCircuitKeys>(window.location.origin, fetch.bind(window));

return {
privateStateProvider: inMemoryPrivateStateProvider<string, LeaderboardPrivateState>(),
zkConfigProvider,
proofProvider: httpClientProofProvider(proofServerUri, zkConfigProvider),
publicDataProvider: indexerPublicDataProvider(config.indexerUri, config.indexerWsUri),
walletProvider: {
getCoinPublicKey: () => shieldedAddresses.shieldedCoinPublicKey,
getEncryptionPublicKey: () => shieldedAddresses.shieldedEncryptionPublicKey,
balanceTx: async (tx: UnboundTransaction): Promise<FinalizedTransaction> => {
const received = await connectedAPI.balanceUnsealedTransaction(toHex(tx.serialize()));
return Transaction.deserialize<SignatureEnabled, Proof, Binding>('signature', 'proof', 'binding', fromHex(received.tx));
},
},
midnightProvider: {
submitTx: async (tx: FinalizedTransaction): Promise<TransactionId> => {
await connectedAPI.submitTransaction(toHex(tx.serialize()));
return tx.identifiers()[0];
},
},
};
};

const getFirstCompatibleWallet = (): InitialAPI | undefined => {
if (!window.midnight) return undefined;
return Object.values(window.midnight).find(
(wallet): wallet is InitialAPI =>
!!wallet && typeof wallet === 'object' && 'apiVersion' in wallet &&
semver.satisfies(wallet.apiVersion, COMPATIBLE_CONNECTOR_API_VERSION),
);
};

const connectToWallet = (logger: Logger, networkId: string): Promise<ConnectedAPI> =>
firstValueFrom(
fnPipe(
interval(100),
map(() => getFirstCompatibleWallet()),
filter((api): api is InitialAPI => !!api),
take(1),
timeout({ first: 3_000, with: () => throwError(() => new Error('Could not find Midnight Lace wallet.')) }),
concatMap(async (initialAPI) => initialAPI.connect(networkId)),
timeout({ first: 5_000, with: () => throwError(() => new Error('Lace wallet failed to respond.')) }),
catchError((error) => throwError(() => error instanceof Error ? error : new Error('Wallet not authorized'))),
),
);

Application component

App.tsx 컴포넌트는 지갑 감지, 클릭 챌린지 게임, 프라이버시 모드 선택을 통한 점수 제출, 리더보드 표시, 소유권 검증을 모두 하나로 묶습니다.

UI의 세 가지 프라이버시 모드는 두 개의 컨트랙트 경로로 이어집니다. Anonymous는 custom 이름으로 undefined를 보내, 해시를 display 이름으로 저장하는 컨트랙트의 else 분기를 실행합니다. Public과 Custom은 모두 witness를 통해 문자열(지갑 주소 또는 사용자가 입력한 이름)을 보내, 컨트랙트의 if 분기를 실행합니다.

소유권 검증은 API의 verifyOwnership을 호출하고, 검증된 항목을 로컬 React state에 기록합니다. 호출자가 소유자가 아니면 컨트랙트의 assert가 실패하고 네트워크가 트랜잭션을 거부합니다.

touch leaderboard-ui/src/App.tsx

이 컴포넌트는 useRef로 공유된 BrowserLeaderboardManager 인스턴스를 사용해, 한 세션 안의 모든 컨트랙트 동작에서 동일한 secret 키와 provider를 쓰도록 보장합니다.

import { useState, useEffect, useCallback, useRef } from 'react';
import type { InitialAPI, ConnectedAPI } from '@midnight-ntwrk/dapp-connector-api';
import { useLeaderboard } from './hooks/useLeaderboard';
import { BrowserLeaderboardManager } from './contexts/BrowserLeaderboardManager';
import pino from 'pino';

const NETWORK_ID = import.meta.env.VITE_NETWORK_ID ?? 'preprod';
const DEFAULT_CONTRACT = import.meta.env.VITE_DEFAULT_CONTRACT ?? '';

enum DisplayMode { PUBLIC = 0, ANONYMOUS = 1, CUSTOM = 2 }
type WalletState = 'detecting' | 'no-wallet' | 'ready' | 'connecting' | 'connected';

function findWallet(): InitialAPI | undefined {
const midnight = (window as any).midnight;
if (!midnight) return undefined;
return Object.values(midnight).find(
(w): w is InitialAPI => !!w && typeof w === 'object' && 'apiVersion' in w,
);
}

function truncAddr(addr: string): string {
return addr.length <= 24 ? addr : `${addr.slice(0, 14)}...${addr.slice(-8)}`;
}

function friendlyError(e: any): string {
const msg = extractErrorMessage(e);
if (msg.includes('User rejected')) return 'Transaction cancelled.';
if (msg.includes('not the owner')) return 'This entry does not belong to your wallet.';
if (msg.includes('entry not found')) return 'Entry not found on the leaderboard.';
if (msg.includes('Failed to fetch') || msg.includes('Failed Proof Server')) return 'Could not reach the proof server. Check your connection and try again.';
if (msg.includes('mismatched verifier keys')) return 'Contract version mismatch. Try deploying a new leaderboard.';
if (msg.includes('submission') || msg.includes('Submission')) return 'Transaction failed to submit. Please try again.';
return msg || 'An unexpected error occurred. Check the browser console for details.';
}

function extractErrorMessage(e: any): string {
if (!e) return '';
if (e.message && e.message !== '') return e.message;
const failure = e?.cause?.failure;
if (failure?.message) return failure.message;
if (failure?.cause?.message) return failure.cause.message;
if (e?.cause?.message) return e.cause.message;
try { return JSON.stringify(e); } catch { return String(e); }
}

export default function App() {
const [walletState, setWalletState] = useState<WalletState>('detecting');
const [walletAPI, setWalletAPI] = useState<InitialAPI | undefined>();
const [wallet, setWallet] = useState<ConnectedAPI | null>(null);
const [address, setAddress] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [contractAddress, setContractAddress] = useState(DEFAULT_CONTRACT);
const [joinInput, setJoinInput] = useState('');
const [showJoinPanel, setShowJoinPanel] = useState(false);
const [deploying, setDeploying] = useState(false);
const [clicks, setClicks] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [timeLeft, setTimeLeft] = useState(10);
const [showResult, setShowResult] = useState(false);
const [lastScore, setLastScore] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const clickRef = useRef(0);
const [displayMode, setDisplayMode] = useState<DisplayMode>(DisplayMode.ANONYMOUS);
const [customName, setCustomName] = useState('');
const [submitting, setSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<string | null>(null);
const [verifyingId, setVerifyingId] = useState<number | null>(null);
const [verifiedIds, setVerifiedIds] = useState<Set<number>>(new Set());

const managerRef = useRef<BrowserLeaderboardManager | null>(null);

const getManager = useCallback(() => {
if (!managerRef.current) {
const logger = pino({ level: 'warn', browser: { asObject: true } });
managerRef.current = new BrowserLeaderboardManager(logger);
}
return managerRef.current;
}, []);

const { entries: leaderboardEntries, refresh: refreshLeaderboard } = useLeaderboard(contractAddress || null);
const leaderboard = leaderboardEntries.map((e, i) => ({
rank: i + 1, id: e.id, displayName: e.displayName, score: BigInt(e.score),
}));

// Wallet 감지: Lace 확장 프로그램을 폴링
useEffect(() => {
const found = findWallet();
if (found) { setWalletAPI(found); setWalletState('ready'); return; }
let elapsed = 0;
const t = setInterval(() => {
elapsed += 100;
const w = findWallet();
if (w) { setWalletAPI(w); setWalletState('ready'); clearInterval(t); }
else if (elapsed >= 5_000) { setWalletState('no-wallet'); clearInterval(t); }
}, 100);
return () => clearInterval(t);
}, []);

const connect = useCallback(async () => {
if (!walletAPI) return;
setWalletState('connecting');
setError(null);
try {
const c = await walletAPI.connect(NETWORK_ID);
setWallet(c);
const { unshieldedAddress } = await c.getUnshieldedAddress();
setAddress(unshieldedAddress);
setWalletState('connected');
} catch (e) { setError(friendlyError(e)); setWalletState('ready'); }
}, [walletAPI]);

// Contract를 resolve(배포 또는 합류)하는 헬퍼
const resolveContract = useCallback(async (addr?: any) => {
const manager = getManager();
const deployment$ = manager.resolve(addr);
return new Promise<any>((resolve, reject) => {
const sub = deployment$.subscribe((d) => {
if (d.status === 'deployed') { Promise.resolve().then(() => sub.unsubscribe()); resolve(d); }
if (d.status === 'failed') { Promise.resolve().then(() => sub.unsubscribe()); reject(d.error); }
});
});
}, [getManager]);

const deployContract = useCallback(async () => {
if (!wallet) return;
setDeploying(true); setError(null);
try {
const result = await resolveContract();
setContractAddress(result.api.deployedContractAddress);
setShowJoinPanel(false); setClicks(0); setShowResult(false);
} catch (e: any) { setError(friendlyError(e)); }
finally { setDeploying(false); }
}, [wallet, resolveContract]);

const joinContract = useCallback(() => {
const addr = joinInput.trim();
if (!addr || !/^[0-9a-fA-F]{64}$/.test(addr)) {
setError('Invalid contract address. Must be 64 hex characters.'); return;
}
setContractAddress(addr); setShowJoinPanel(false); setJoinInput('');
}, [joinInput]);

// 게임 로직
const startGame = useCallback(() => {
setClicks(0); clickRef.current = 0;
setTimeLeft(10); setIsPlaying(true); setShowResult(false);
timerRef.current = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) {
clearInterval(timerRef.current!);
setIsPlaying(false); setShowResult(true);
setLastScore(clickRef.current);
return 0;
}
return prev - 1;
});
}, 1_000);
}, []);

const handleClick = useCallback(() => {
if (!isPlaying) return;
clickRef.current += 1;
setClicks(clickRef.current);
}, [isPlaying]);

useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current); }, []);

// 점수 제출
const submitScore = useCallback(async () => {
if (lastScore === 0 || !wallet) return;
setSubmitting(true); setSubmitStatus('Joining contract...'); setError(null);
try {
const result = await resolveContract(contractAddress as any);
setSubmitStatus('Generating proof & submitting...');
const name = displayMode === DisplayMode.PUBLIC
? address!.slice(0, 12) + '..' + address!.slice(-12)
: displayMode === DisplayMode.CUSTOM ? customName : undefined;
await result.api.submitScore(lastScore, name);
setSubmitting(false); setSubmitStatus(null);
setShowResult(false); setLastScore(0);
setTimeout(() => refreshLeaderboard(), 3000);
} catch (e: any) { setSubmitting(false); setSubmitStatus(null); setError(friendlyError(e)); }
}, [wallet, lastScore, displayMode, customName, contractAddress, refreshLeaderboard, address, resolveContract]);

// 소유권 검증
const verifyEntry = useCallback(async (entryId: number) => {
if (!wallet) return;
setVerifyingId(entryId); setError(null);
try {
const result = await resolveContract(contractAddress as any);
await result.api.verifyOwnership(entryId);
setVerifiedIds(prev => new Set(prev).add(entryId));
} catch (e: any) { setError(friendlyError(e)); }
finally { setVerifyingId(null); }
}, [wallet, contractAddress, resolveContract]);

const isConnected = walletState === 'connected';

return (
<div>
<h1>Midnight Leaderboard</h1>

{walletState === 'no-wallet' ? (
<a href="https://chromewebstore.google.com/detail/lace/gafhhkghbfjjkeiendhlofajokpaflmk" target="_blank" rel="noopener noreferrer">Install Lace Wallet</a>
) : isConnected && address ? (
<p>Connected: {truncAddr(address)}</p>
) : (
<button onClick={connect} disabled={walletState !== 'ready'}>Connect Wallet</button>
)}

{error && <p style={{ color: 'red' }}>{error} <button onClick={() => setError(null)}>dismiss</button></p>}

<p>Contract: {truncAddr(contractAddress)}</p>
<button onClick={() => setShowJoinPanel(!showJoinPanel)}>{showJoinPanel ? 'Cancel' : 'Switch Contract'}</button>

{showJoinPanel && (
<div>
<input type="text" placeholder="Contract address (64 hex chars)" value={joinInput} onChange={e => setJoinInput(e.target.value)} />
<button onClick={joinContract} disabled={!joinInput.trim()}>Join</button>
{isConnected ? (
<button onClick={deployContract} disabled={deploying}>{deploying ? 'Deploying...' : 'Deploy New'}</button>
) : (
<button onClick={connect} disabled={walletState !== 'ready'}>Connect to Deploy</button>
)}
</div>
)}

<h2>Click Challenge</h2>
<p>Time: {isPlaying ? timeLeft : 10}s | Clicks: {clicks}</p>

{isPlaying && (
<button onPointerDown={handleClick}>CLICK!</button>
)}

{!isPlaying && !showResult && (
<button onClick={startGame}>Start Game</button>
)}

{lastScore > 0 && !isPlaying && (
<div>
<button onClick={startGame}>Try Again</button>
<div>
{([[DisplayMode.ANONYMOUS, 'Anonymous'], [DisplayMode.PUBLIC, 'Public'], [DisplayMode.CUSTOM, 'Custom']] as const).map(([m, label]) => (
<button key={m} onClick={() => setDisplayMode(m)} style={{ fontWeight: displayMode === m ? 'bold' : 'normal' }}>{label}</button>
))}
</div>
{displayMode === DisplayMode.CUSTOM && (
<input type="text" placeholder="Display name (max 32)" maxLength={32} value={customName} onChange={e => setCustomName(e.target.value)} />
)}
{isConnected ? (
<button onClick={submitScore} disabled={submitting || (displayMode === DisplayMode.CUSTOM && !customName.trim())}>
{submitting ? submitStatus : 'Submit to Chain'}
</button>
) : (
<button onClick={connect} disabled={walletState !== 'ready'}>Connect Wallet to Submit</button>
)}
</div>
)}

<h2>Leaderboard ({leaderboard.length} entries)</h2>
{leaderboard.length === 0 ? (
<p>No scores yet.</p>
) : (
<table>
<thead><tr><th>#</th><th>Player</th><th>Score</th><th></th></tr></thead>
<tbody>
{leaderboard.map(e => (
<tr key={e.id}>
<td>{e.rank}</td>
<td>{e.displayName} {verifiedIds.has(e.id) && <span>✓ yours</span>}</td>
<td>{Number(e.score).toLocaleString()}</td>
<td>
{isConnected && !verifiedIds.has(e.id) && (
<button onClick={() => verifyEntry(e.id)} disabled={verifyingId !== null}>
{verifyingId === e.id ? '...' : 'Prove'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

Install and run

새 workspace를 인식하도록 의존성을 다시 설치합니다.

npm install

proof server를 시작합니다.

docker run -d -p 6300:6300 midnightntwrk/proof-server:8.0.3 -- midnight-proof-server --network preprod

개발 서버를 시작합니다.

cd leaderboard-ui
npm run dev

Lace가 설치된 Chrome에서 http://localhost:3000을 엽니다. 리더보드는 indexer에서 곧바로 로드됩니다.

Deploy a contract

점수를 제출하려면 먼저 배포된 컨트랙트가 필요합니다. 지갑을 연결한 다음 Switch ContractDeploy New를 차례로 클릭하세요. 그러면 Preprod에 새 리더보드 컨트랙트가 생성되고 주소가 클립보드에 복사됩니다.

.env 파일의 VITE_DEFAULT_CONTRACT를 새 컨트랙트 주소로 업데이트한 뒤 개발 서버를 다시 시작하면, 다음 방문부터 자동으로 로드됩니다.

tDUST required

contract를 배포하려면 지갑에 tDUST가 있어야 합니다. 트랜잭션 제출 오류가 보이면 Lace를 열고 토큰 탭으로 이동한 뒤 Generate tDUST를 클릭하세요.

배포가 끝나면 클릭 챌린지를 플레이하고, 프라이버시 모드를 고른 뒤, 점수를 체인에 제출하세요.

Next steps

DApp이 로컬에서 실행되면, Production deployment로 넘어가 프론트엔드를 Vercel에 배포할 수 있습니다.