🎬 Filmmaking & Animation · prop design collaboration

PropChain Share

Collaboratively design and pin prop assets on IPFS with permanent version history.

IPFS content + on-chain CID· decentralized storage
Section · Onchain

The primitive.

full primer →

The onchain primitive runs at the right moment in the flow and surfaces a clear, verifiable result that filmmakers can act on without web3 jargon.

Why this primitiveThis idea fits IPFS content + on-chain CID because decentralized storage is exactly what a prop design collaboration demo needs: pin creative artefacts to IPFS via Pinata, then commit the CID to the public ledger through the Compact contract so prov...

Kernel
pin creative artefacts to IPFS via Pinata, then commit the CID to the public ledger through the Compact contract so provenance is verifiable but the file itself stays on IPFS
Drives the UI as
a 'pinned to IPFS · logged on Midnight' chip with the CID, gateway preview, and a Midnight explorer link
Appendix · Secrets

Required keys.

VITE_NETWORK_ID
`preview` (or `preprod`). Matches Lace's network setting.
open ↗
VITE_INDEXER_URL
Midnight GraphQL Indexer for public ledger reads.
open ↗
VITE_INDEXER_WS_URL
WebSocket endpoint for realtime ledger subscriptions.
open ↗
VITE_PROOF_SERVER_URL
Local Docker proof server (port 6300) — required for tx submits.
open ↗
VITE_DEFAULT_CONTRACT
Pre-deployed contract address so users skip the deploy step.
open ↗

Add these in your Lovable project under Settings → Secrets before pasting the prompt below.

Appendix · Mega-prompt

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 "PropChain Share" in ONE Lovable message. Single-page Midnight ZK demo.

CONCEPT
Collaboratively design and pin prop assets on IPFS with permanent version history.
Discipline: Filmmaking & Animation (prop design collaboration).
Onchain primitive: IPFS content + on-chain CID (decentralized storage). Why this primitive: This idea fits IPFS content + on-chain CID because decentralized storage is exactly what a prop design collaboration demo needs: pin creative artefacts to IPFS via Pinata, then commit the CID to the public ledger through the Compact contract so prov...

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;
}
```

PINATA STEP (src/lib/pinata.ts):
```ts
export async function pinToIPFS(file: Blob, name = "prop design collaboration") {
  const fd = new FormData(); fd.append("file", file, name);
  const r = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
    method: "POST", headers: { Authorization: `Bearer ${import.meta.env.VITE_PINATA_JWT}` },
    body: fd,
  });
  return (await r.json()).IpfsHash as string;
}
```

CONTRACT
```compact
// contracts/PropChainShare.compact
// Collaboratively design and pin prop assets on IPFS with permanent version history.  (pin-to-IPFS-then-commit-CID pattern)
// Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
pragma language_version 0.23;

import CompactStandardLibrary;

export ledger cid_count: Counter;
export ledger last_cid: Opaque<"string">;
export ledger last_author_commitment: Bytes<32>;

witness localSecretKey(): Bytes<32>;

constructor() { cid_count.increment(1); last_cid = disclose("(empty)"); }

export circuit commitCid(cid: Opaque<"string">): [] {
  const sk = localSecretKey();
  const seq = cid_count as Field as Bytes<32>;
  last_author_commitment = disclose(
    persistentHash<Vector<3, Bytes<32>>>([pad(32, "cid:author:"), seq, sk])
  );
  last_cid = disclose(cid);
  cid_count.increment(1);
}
```

FRONTEND FLOW
1. User creates a prop design collaboration artefact in the UI (upload / draw / record / generate).
2. `const cid = await pinToIPFS(blob)` → show `https://gateway.pinata.cloud/ipfs/<cid>`.
3. `commitCid(cid)` via `findContractInstance(...).callTx.commitCid(witnesses, cid)`.
4. Proving spinner (30–120s) → confirmation → append `{cid, tx, at}` to the on-screen feed.
5. Feed is hydrated from the Indexer (`readLedger`), so refreshes work with just the wallet disconnected.

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 ---
Appendix · Market

Market sizing.

TAM
$350M
3D prop design software
SAM
$90M
prop collaboration platforms
SOM
$12M
niche animation teams

Indicative figures for hackathon pitches — refine with your own research before raising.

See also

Adjacent entries.