Studio Access Club
Create token-gated memberships for dance studios with seamless gas-free onboarding.
The primitive.
The onchain primitive runs at the right moment in the flow and surfaces a clear, verifiable result that choreographers can act on without web3 jargon.
Why this primitiveThis idea fits Lace wallet + tDUST because wallet UX is exactly what a membership management demo needs: detect `window.midnight`, call `initialAPI.connect('preview')`, let Lace balance the transaction with tDUST and submit i...
Required keys.
Add these in your Lovable project under Settings → Secrets before pasting the prompt below.
The build prompt.
Paste into a fresh Lovable project. Make sure all five secrets above are set first. read the build strategy →
The prompt below includes a self-contained Connect-Lace step (DApp Connector v4) — no extra setup needed on the target project.
Build "Studio Access Club" in ONE Lovable message. Single-page Midnight ZK demo.
CONCEPT
Create token-gated memberships for dance studios with seamless gas-free onboarding.
Discipline: Dance & Choreography (membership management).
Onchain primitive: Lace wallet + tDUST (wallet UX). Why this primitive: This idea fits Lace wallet + tDUST because wallet UX is exactly what a membership management demo needs: detect `window.midnight`, call `initialAPI.connect('preview')`, let Lace balance the transaction with tDUST and submit i...
5-CREDIT BUDGET (HARD LIMIT):
- ONE single-page Vite + React app. No router, no Lovable Cloud, no database, no server-side auth.
- ONE Compact contract, ≤80 lines, deployed to Midnight preview testnet.
- Lace wallet is the auth + tx layer. `window.midnight` is polled; the shielded address is the identity.
- A locally-run proof server (Docker port 6300) is REQUIRED for any tx submit; the UI must show a
"Proving… this can take 30–120s" state and stay usable while proofs generate.
- Pinata / IPFS only if the idea genuinely stores a file or artefact — then the CID is committed on-chain.
- At most ONE AI call per user action (Lovable AI Gateway with LOVABLE_API_KEY if AI is part of the idea).
- Skip tests, skip CI, skip docs pages. Ship the demo, nothing else.
STACK
- React + Vite single page (index route only).
- Midnight preview testnet. Compact language 0.23. MidnightJS SDK 4.1.1.
- Lace wallet is the sole auth surface — no Privy, no MetaMask, no OAuth.
- Local proof server (Docker port 6300) does all ZK proving. The UI shows Proving state.
- No SSR. All MidnightJS imports live behind `<ClientOnly>` + `useEffect`.
PACKAGES (all pinned to the versions Midnight ships together):
- @midnight-ntwrk/dapp-connector-api@4.0.1
- @midnight-ntwrk/midnight-js-contracts@4.1.1
- @midnight-ntwrk/midnight-js-types@4.1.1
- @midnight-ntwrk/midnight-js-protocol@4.1.1
- @midnight-ntwrk/midnight-js-network-id@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-utils@4.1.1
- @midnight-ntwrk/compact-runtime@0.16.0
- rxjs fp-ts semver buffer pino
- vite-plugin-wasm vite-plugin-top-level-await (dev)
COMPACT TOOLCHAIN (one-time setup — the human runs this in a terminal, not Lovable):
```bash
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh
source ~/.bashrc && compact update
compact compile contracts/YourContract.compact contracts/managed/your-contract
cp -r contracts/managed/your-contract/keys public/keys
cp -r contracts/managed/your-contract/zkir public/zkir
docker run -p 6300:6300 midnightntwrk/proof-server:latest midnight-proof-server -v
```
VITE CONFIG (vite.config.ts) — WASM + top-level await are MANDATORY for MidnightJS:
```ts
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({
build: { target: 'esnext', commonjsOptions: { transformMixedEsModules: true, extensions: ['.js','.cjs'] } },
plugins: [react(), wasm(), topLevelAwait()],
optimizeDeps: {
esbuildOptions: { target: 'esnext', supported: { 'top-level-await': true } },
include: ['@midnight-ntwrk/compact-runtime'],
exclude: ['@midnight-ntwrk/onchain-runtime-v3',
'@midnight-ntwrk/onchain-runtime-v3/midnight_onchain_runtime_wasm_bg.wasm'],
},
});
```
SSR RULE: never import a `@midnight-ntwrk/*` package at module scope of a route file — it uses
Node Buffer + browser globals + WASM top-level await and crashes SSR. Load providers behind
`useEffect` or a dynamic `import()` inside a `<ClientOnly>` boundary.
WALLET DETECT (src/lib/lace.ts) — poll window.midnight up to 5s:
```ts
import type { InitialAPI } from '@midnight-ntwrk/dapp-connector-api';
import semver from 'semver';
export async function waitForLace(timeoutMs = 5000): Promise<InitialAPI> {
return new Promise((resolve, reject) => {
const start = Date.now();
const t = setInterval(() => {
const m = (window as any).midnight ?? {};
const w = Object.values(m).find((x: any) =>
x && typeof x === 'object' && 'apiVersion' in x &&
semver.satisfies(x.apiVersion, '4.x')) as InitialAPI | undefined;
if (w) { clearInterval(t); resolve(w); return; }
if (Date.now() - start > timeoutMs) { clearInterval(t);
reject(new Error('Lace Midnight wallet not found. Install it: https://www.lace.io/')); }
}, 100);
});
}
```
BUFFER POLYFILL (src/main.tsx, MUST be the very first line):
```ts
import { Buffer } from 'buffer'; (globalThis as any).Buffer = Buffer;
```
PROVIDERS (src/lib/providers.ts) — chain Lace + proof server + indexer:
```ts
import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
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 { waitForLace } from './lace';
export async function initProviders() {
setNetworkId(import.meta.env.VITE_NETWORK_ID ?? 'preview');
const lace = await waitForLace();
const connectedAPI = await lace.connect(import.meta.env.VITE_NETWORK_ID ?? 'preview');
const cfg = await connectedAPI.getConfiguration();
const zk = new FetchZkConfigProvider(window.location.origin, fetch.bind(window));
return {
connectedAPI,
zkConfigProvider: zk,
proofProvider: httpClientProofProvider(cfg.proverServerUri ?? import.meta.env.VITE_PROOF_SERVER_URL, zk),
publicDataProvider: indexerPublicDataProvider(cfg.indexerUri, cfg.indexerWsUri),
};
}
```
READ-ONLY LEDGER FETCH (no wallet needed — great for public feeds):
```ts
const INDEXER = import.meta.env.VITE_INDEXER_URL;
export async function readLedger(address: string) {
const r = await fetch(INDEXER, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `query($a:HexEncoded!){ contractAction(address:$a){ state } }`,
variables: { address },
}),
});
return (await r.json()).data?.contractAction?.state as string | null;
}
```
CONTRACT
```compact
// contracts/StudioAccessClub.compact
// Create token-gated memberships for dance studios with seamless gas-free onboarding.
// Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
pragma language_version 0.23;
import CompactStandardLibrary;
// Public ledger state — visible to everyone via the Indexer
export ledger entry_count: Counter;
export ledger last_message: Opaque<"string">;
export ledger last_author_commitment: Bytes<32>;
// Private callback wired from TypeScript; the returned bytes never touch chain
witness localSecretKey(): Bytes<32>;
constructor() {
entry_count.increment(1);
last_message = disclose("(empty)");
}
export circuit authorCommitment(sk: Bytes<32>, seq: Bytes<32>): Bytes<32> {
return persistentHash<Vector<3, Bytes<32>>>(
[pad(32, "studio_access_club:author:"), seq, sk]
);
}
export circuit appendEntry(newMessage: Opaque<"string">): [] {
const sk = localSecretKey();
const seq = entry_count as Field as Bytes<32>;
last_author_commitment = disclose(authorCommitment(sk, seq));
last_message = disclose(newMessage); // disclose is REQUIRED before writing to ledger
entry_count.increment(1);
}
```
FRONTEND FLOW — Lace is the whole auth story
1. Landing page shows one button: "Connect Lace". No email, no password, no OAuth.
2. On click: `initProviders()` → shielded address becomes the identity.
3. tDUST balance strip: `const bal = await connectedAPI.balanceAndProofOfBalance();` render it.
4. Every membership management action = `contract.callTx.appendEntry(witnesses, payload)`. Show status:
`Proving → Balancing (Lace adds tDUST fees) → Submitting → Confirmed` with a Midnight explorer link.
5. If `waitForLace` rejects, render the exact install URL for Lace with a friendly nudge —
no fake "click here to install" placeholder. This is the single biggest onboarding drop-off.
RED FLAGS — DO NOT ATTEMPT:
- No bridging to Ethereum / any EVM chain. Midnight is a standalone L1; there is no bridge.
- No oracle / external HTTP data inside a circuit. Circuits are bounded and cannot do I/O.
- No sub-second finality UX. Proofs for k=14 circuits take 30–120s — build for that latency.
- No recursion in Compact. Loops must be bounded by compile-time constants.
- No SSR. MidnightJS uses browser globals + WASM + top-level-await; ClientOnly is mandatory.
REQUIRED SECRETS (Lovable → Project Settings → Secrets):
- VITE_NETWORK_ID preview (Midnight preview testnet; set to `preprod` for the more stable network)
- VITE_INDEXER_URL https://indexer.preview.midnight.network/api/v4/graphql
- VITE_INDEXER_WS_URL wss://indexer.preview.midnight.network/api/v4/graphql/ws
- VITE_PROOF_SERVER_URL http://localhost:6300 (run `docker run -p 6300:6300 midnightntwrk/proof-server:latest midnight-proof-server -v`)
- VITE_DEFAULT_CONTRACT the hex address printed by your first deploy — paste it here so users skip the deploy step
Faucets: Preview https://midnight-tmnight-preview.nethermind.dev/ · Preprod https://midnight-tmnight-preprod.nethermind.dev/ (dispense tNIGHT — generate tDUST in Lace)
Explorer: Preview https://preview.midnightexplorer.com/ · Preprod https://preprod.midnightexplorer.com/
Docs: https://docs.midnight.network/llms-full.txt
CREDIT (must appear in UI footer AND as a header comment on every Compact contract):
Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
--- BEGIN: Connect-Lace boilerplate (self-contained, DApp Connector v4) ---
Add a working "Connect Lace" button to the primary page of the app. This
section is self-contained — do NOT install any @midnight-ntwrk/* package for
the connect step; the Lace browser extension injects everything you need at
`window.midnight`. Follow the DApp Connector API v4 exactly as written below.
HARD RULES
- Do NOT call `enable()` or `state()` — DApp Connector v4.0 removed them.
- Do NOT read `window.midnight` at module scope or during SSR — only inside
`useEffect`. Gate the panel render until after hydration.
- Do NOT hardcode the network — try Preview, Preprod, and Mainnet in order
and use whichever `connect(networkId)` accepts.
- Reading a shielded address is a permission-only handshake — no signing,
no funds moved.
CREATE FILE: src/lib/use-midnight-wallet.ts
```ts
import { useCallback, useEffect, useState } from "react";
export type WalletStatus =
| "idle" | "detecting" | "ready" | "connecting" | "connected" | "error";
type Connector = {
apiVersion: string;
name?: string;
connect: (networkId: string) => Promise<ConnectedApi>;
isEnabled?: () => Promise<boolean>;
};
type ConnectedApi = {
getShieldedAddresses?: () => Promise<string[] | Record<string, string>>;
getUnshieldedAddress?: () => Promise<string>;
getDustAddress?: () => Promise<string>;
getConfiguration?: () => Promise<{
indexerUri?: string; indexerWsUri?: string; proverServerUri?: string;
}>;
};
function pickConnector(): Connector | null {
if (typeof window === "undefined") return null;
const m = (window as unknown as { midnight?: Record<string, Connector> }).midnight;
if (!m) return null;
for (const v of Object.values(m)) {
if (v && typeof v === "object" && "apiVersion" in v && /^4\./.test(String(v.apiVersion))) {
return v as Connector;
}
}
const first = Object.values(m)[0];
return first && "apiVersion" in first ? (first as Connector) : null;
}
function inferNetwork(addr: string): string {
const m = addr.match(/^mn_(?:shield-)?addr_([a-z0-9]+?)1/i);
if (!m) return "unknown";
const s = m[1].toLowerCase();
if (s === "test") return "preprod";
if (s === "undeployed") return "preview";
return s;
}
export function useMidnightWallet() {
const [status, setStatus] = useState<WalletStatus>("idle");
const [address, setAddress] = useState<string | null>(null);
const [apiVersion, setApiVersion] = useState<string | null>(null);
const [network, setNetwork] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [tick, setTick] = useState(0);
useEffect(() => {
if (typeof window === "undefined") return;
setStatus((p) => (p === "connected" ? p : "detecting"));
setError(null);
const t0 = Date.now();
const iv = window.setInterval(() => {
const c = pickConnector();
if (c) {
window.clearInterval(iv);
setApiVersion(c.apiVersion);
setStatus((p) => (p === "connected" ? p : "ready"));
if (!/^4\./.test(c.apiVersion)) {
setStatus("error");
setError(`Lace connector ${c.apiVersion} is not compatible. Update Lace.`);
}
} else if (Date.now() - t0 > 5000) {
window.clearInterval(iv);
setStatus("error");
setError("No Midnight wallet detected. Install Lace from lace.io.");
}
}, 100);
return () => window.clearInterval(iv);
}, [tick]);
const connect = useCallback(async () => {
try {
setError(null);
setStatus("connecting");
const c = pickConnector();
if (!c) throw new Error("No Midnight wallet detected.");
const preferred = (import.meta.env.VITE_NETWORK_ID as string) || "preprod";
const candidates = Array.from(new Set([preferred, "preview", "preprod", "mainnet"]));
let api: ConnectedApi | null = null;
let used: string | null = null;
let mismatch: unknown = null;
for (const n of candidates) {
try { api = await c.connect(n); used = n; break; }
catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (/network|mismatch/i.test(msg)) { mismatch = e; continue; }
throw e;
}
}
if (!api || !used) {
throw new Error(
mismatch
? "Lace is on a different network than this app supports. Switch Lace to Preview or Preprod and retry."
: "Failed to connect to Lace.",
);
}
let addr: string | null = null;
if (typeof api.getShieldedAddresses === "function") {
try {
const s = await api.getShieldedAddresses();
if (Array.isArray(s)) addr = s[0] ?? null;
else if (s && typeof s === "object") addr = Object.values(s)[0] ?? null;
} catch {}
}
if (!addr && typeof api.getUnshieldedAddress === "function") {
try { addr = await api.getUnshieldedAddress(); } catch {}
}
if (!addr) throw new Error("Connected but couldn't read an address. Update Lace.");
setAddress(addr);
setNetwork(used ?? inferNetwork(addr));
setApiVersion(c.apiVersion);
setStatus("connected");
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setStatus("error");
}
}, []);
return {
status, address, apiVersion, network, error,
connect,
disconnect: () => { setAddress(null); setNetwork(null); setStatus("ready"); setError(null); },
redetect: () => setTick((n) => n + 1),
};
}
```
CREATE FILE: src/components/WalletConnectPanel.tsx
```tsx
import { useEffect, useState } from "react";
import { useMidnightWallet } from "@/lib/use-midnight-wallet";
function truncate(a: string, h = 12, t = 8) {
return a.length <= h + t + 1 ? a : `${a.slice(0, h)}…${a.slice(-t)}`;
}
export function WalletConnectPanel({ expectedNetwork = "preprod" }: { expectedNetwork?: string }) {
const [hydrated, setHydrated] = useState(false);
useEffect(() => setHydrated(true), []);
const w = useMidnightWallet();
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const t = setTimeout(() => setCopied(false), 1400);
return () => clearTimeout(t);
}, [copied]);
if (!hydrated) {
return (
<div className="p-5 border rounded-md">
<div className="text-xs uppercase tracking-widest">connect lace</div>
<div className="mt-3 h-10 bg-muted animate-pulse rounded" />
</div>
);
}
const wrong = w.status === "connected" && w.network && w.network !== "unknown" && w.network !== expectedNetwork;
return (
<div className="p-5 border rounded-md space-y-3">
<div className="flex items-center justify-between gap-3">
<span className="text-xs uppercase tracking-widest">connect lace</span>
{w.apiVersion && <span className="text-[10px] font-mono opacity-60">connector v{w.apiVersion}</span>}
</div>
{w.status === "detecting" && <p className="text-sm opacity-70">Detecting Midnight wallet…</p>}
{w.status === "ready" && (
<div className="flex items-center gap-3 flex-wrap">
<button onClick={() => void w.connect()}
className="px-4 py-2 bg-primary text-primary-foreground text-xs font-semibold uppercase tracking-wider rounded">
Connect wallet
</button>
<span className="text-xs opacity-70">Reads your shielded address — no signing, no funds moved.</span>
</div>
)}
{w.status === "connecting" && <p className="text-sm opacity-70">Approve the connection in Lace…</p>}
{w.status === "connected" && w.address && (
<div className="space-y-2">
<div className="text-[10px] uppercase tracking-widest opacity-60">shielded address</div>
<div className="flex items-center gap-2 flex-wrap">
<code className="font-mono text-xs break-all">{truncate(w.address, 16, 12)}</code>
<button onClick={() => { void navigator.clipboard.writeText(w.address ?? ""); setCopied(true); }}
className="text-[10px] uppercase tracking-widest text-primary">
{copied ? "copied" : "copy"}
</button>
</div>
<div className="flex items-center gap-4 text-[11px] flex-wrap">
<span>network · <span className="font-mono">{w.network}</span></span>
<button onClick={w.disconnect} className="text-[10px] uppercase tracking-widest opacity-60">disconnect</button>
</div>
{wrong && (
<p className="text-[12px] opacity-80">
Lace is on <span className="font-mono">{w.network}</span> but this app expects{" "}
<span className="font-mono">{expectedNetwork}</span>. Switch networks inside Lace.
</p>
)}
</div>
)}
{w.status === "error" && (
<div className="space-y-2">
<p className="text-sm opacity-80">{w.error ?? "Something went wrong."}</p>
<div className="flex gap-3 flex-wrap">
<button onClick={w.redetect} className="px-3 py-2 border text-[10px] uppercase tracking-widest rounded">Retry</button>
<a href="https://www.lace.io/" target="_blank" rel="noreferrer"
className="px-3 py-2 border text-[10px] uppercase tracking-widest rounded">Install Lace ↗</a>
</div>
</div>
)}
</div>
);
}
```
MOUNT on the primary page (e.g. src/routes/index.tsx or wherever the main
demo lives):
```tsx
import { WalletConnectPanel } from "@/components/WalletConnectPanel";
// Inside your JSX:
<WalletConnectPanel expectedNetwork={import.meta.env.VITE_NETWORK_ID || "preprod"} />
```
PREREQUISITES to tell the end user in your UI copy:
1. Install Lace from https://www.lace.io/ (desktop browser extension).
2. Switch Lace to Midnight Preview or Preprod.
3. Get tNIGHT from the matching faucet, then click "Generate tDUST" in Lace
to delegate — deploys and shielded writes spend tDUST, not tNIGHT.
--- END: Connect-Lace boilerplate ---
Market sizing.
Indicative figures for hackathon pitches — refine with your own research before raising.