Gasless Loop Licensing
License loops onchain with frictionless onboarding and no blockchain transaction fees.
Mint an NFT· asset primitive
Section · Onchain
full primer →The primitive.
A native-script policy signs a CIP-25 metadata payload. The IPFS-hosted image becomes an on-chain asset in the connected wallet.
Kernel
A native-script policy signs a CIP-25 metadata payload. The IPFS-hosted image becomes an on-chain asset in the connected wallet.
Drives the UI as
An upload / name / description form, an in-flight minting state, and a gallery card once the tx settles.
Required keys.
BLOCKFROST_PROJECT_ID_PREVIEW
Server-only. Get one free at blockfrost.io; create a Preview project.
open ↗Add these in Lovable Settings → Secrets before pasting the prompt below. Blockfrost keys stay server-side.
Appendix · Mega-prompt
The build prompt.
Network
SDK
Shape
PreviewFastest to iterate on. Testnet resets more often. Faucet from docs.cardano.org.
Shape · Mint an NFTA native-script policy signs a CIP-25 metadata payload. The IPFS-hosted image becomes an on-chain asset in the connected wallet.
# Gasless Loop Licensing
> License loops onchain with frictionless onboarding and no blockchain transaction fees.
_Context: Music & Sound Design · loop rights_
Build a Cardano Preview dApp on Lovable using **Lace + Mesh SDK**.
This prompt is self-contained — no external skill required.
**READ THE "Hard-won lessons" SECTION BELOW BEFORE WRITING ANY CODE.**
The order of setup steps matters; skipping ahead reproduces well-known bugs.
## Frontend stack (fixed by Lovable — do not swap)
- TanStack Start + Vite. Do NOT install Next.js, Remix, or `react-router-dom`.
- shadcn/ui + Tailwind v4 (already installed).
- Server code uses `createServerFn` from `@tanstack/react-start`. Raw HTTP
endpoints (webhook, submit proxy, Blockfrost proxy) go under
`src/routes/api/public/`.
- Runtime: Cloudflare Workers with `nodejs_compat`. Mesh, Lucid, `blakejs`,
`bech32` all work. Avoid Node-native packages (sharp, canvas, node-gyp).
## Testnet-only banner (REQUIRED, render on every page)
```tsx
<div className="bg-amber-500/10 border-b border-amber-500/30 text-amber-600 text-xs text-center py-2">
Testnet only — do not send mainnet ADA to any address on this dApp.
</div>
```
## Provider secret (Blockfrost) — server-only, with GET + POST proxy
Blockfrost project IDs are secrets. They must never touch the browser bundle.
1. In Lovable, open the secrets UI and add:
- `BLOCKFROST_PROJECT_ID_PREVIEW` = your Preview project ID from https://blockfrost.io
2. Never prefix with `VITE_`. Never read it at module scope. Read it ONLY
inside a `createServerFn(...).handler()` or a server route handler.
3. Expose TWO same-origin proxies so the browser never sees the key:
- a **read proxy** the Lucid/Mesh clients can point at for GET requests,
- a **submit proxy** for POSTing signed CBOR.
### Read proxy — splat route (GET everything under /api/v0/*)
```ts
// src/routes/api/public/cardano-blockfrost/$.ts
import { createFileRoute } from "@tanstack/react-router";
const BASE: Record<string, string> = {
preview: "https://cardano-preview.blockfrost.io/api/v0",
preprod: "https://cardano-preprod.blockfrost.io/api/v0",
};
export const Route = createFileRoute("/api/public/cardano-blockfrost/$")({
server: {
handlers: {
GET: async ({ request, params }) => {
const rest = (params as { _splat?: string })._splat ?? "";
const [network, ...tail] = rest.split("/");
const base = BASE[network];
if (!base) return new Response("Unknown network", { status: 400 });
const projectId = process.env["BLOCKFROST_PROJECT_ID_" + network.toUpperCase()];
if (!projectId) return new Response("Missing project id", { status: 500 });
const url = new URL(request.url);
const target = base + "/" + tail.join("/") + url.search;
const res = await fetch(target, { headers: { project_id: projectId } });
return new Response(await res.arrayBuffer(), {
status: res.status,
headers: { "content-type": res.headers.get("content-type") ?? "application/json" },
});
},
POST: async ({ request, params }) => {
const rest = (params as { _splat?: string })._splat ?? "";
const [network, ...tail] = rest.split("/");
const base = BASE[network];
if (!base) return new Response("Unknown network", { status: 400 });
const projectId = process.env["BLOCKFROST_PROJECT_ID_" + network.toUpperCase()];
if (!projectId) return new Response("Missing project id", { status: 500 });
const body = await request.arrayBuffer();
const res = await fetch(base + "/" + tail.join("/"), {
method: "POST",
headers: {
project_id: projectId,
"content-type": request.headers.get("content-type") ?? "application/cbor",
},
body,
});
return new Response(await res.arrayBuffer(), { status: res.status });
},
},
},
});
```
### Dedicated submit endpoint (accepts hex CBOR from the browser)
```ts
// src/routes/api/public/cardano-submit.ts
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/api/public/cardano-submit")({
server: {
handlers: {
POST: async ({ request }) => {
const url = new URL(request.url);
const network = url.searchParams.get("network") ?? "preview";
const projectId = process.env["BLOCKFROST_PROJECT_ID_" + network.toUpperCase()];
if (!projectId) return new Response("Missing project id", { status: 500 });
const cborHex = (await request.text()).trim();
if (!/^[0-9a-fA-F]+$/.test(cborHex)) {
return new Response("Invalid CBOR hex", { status: 400 });
}
const res = await fetch(
"https://cardano-" + network + ".blockfrost.io/api/v0/tx/submit",
{
method: "POST",
headers: { "Content-Type": "application/cbor", project_id: projectId },
body: Buffer.from(cborHex, "hex"),
},
);
return new Response(await res.text(), { status: res.status });
},
},
},
});
```
The browser talks ONLY to `/api/public/cardano-blockfrost/preview/*` and
`/api/public/cardano-submit` — never to `blockfrost.io` directly.
## Wallet: Lace (CIP-30) on Preview
1. Install Lace from https://www.lace.io/ (desktop browser extension).
2. Open Settings → Network → **Preview**.
3. Create/import a wallet and copy the receive address (`addr_test1...`).
4. Fund it at https://docs.cardano.org/cardano-testnets/tools/faucet (choose **Preview**).
### Mobile reality
Lace Mobile 1.0 has NO in-app browser for testnet dApps and NO native CIP-45
scanner. Preview/Preprod flows are **desktop-first**. Show desktop-required
messaging on mobile viewports; do not build a QR pairing flow into the MVP.
### Connect (browser-only — never call during SSR)
```ts
// src/lib/use-lace.ts
export async function connectLace() {
if (typeof window === "undefined") throw new Error("SSR");
const lace = (window as any).cardano?.lace;
if (!lace) throw new Error("Install Lace and switch to Preview.");
const api = await lace.enable();
const networkId = await api.getNetworkId(); // 0 = testnet, 1 = mainnet
if (networkId !== 0) throw new Error("Switch Lace to Preview testnet.");
return { api, address: await api.getChangeAddress() };
}
```
Gate all wallet reads with `useEffect` or `<ClientOnly>`. Never invoke
`lace.enable()` at module scope.
### Same-network invariant
`getNetworkId()` returns 0 for both Preview and Preprod. Enforce the exact
network by matching wallet + provider + UI:
```ts
const UI_NETWORK = "preview" as const;
// Refuse to submit if the Blockfrost proxy route or provider config
// disagrees with UI_NETWORK.
```
## Off-chain SDK: Mesh — AVOID IN THE BROWSER
Pick ONE SDK per project. Do NOT mix Mesh and Lucid in the same tx path.
**KNOWN BROWSER ISSUE (2026-07):** `@meshsdk/core@1.9.x` throws
`TypeError: Cannot read properties of undefined (reading 'from')` deep
inside its tx builders when bundled by Vite. The root cause is CJS/ESM
interop with `@cardano-sdk/core` + `@harmoniclabs/*` — one of the interop
shims resolves to `undefined` and the next `.from(...)` call blows up.
A Buffer polyfill does NOT fix it. Switching from the deprecated
`Transaction` class to `MeshTxBuilder` does NOT fix it. The bug is inside
`MeshTxBuilder` itself.
**What this means for you today:**
- Do NOT use `new Transaction(...)` in the browser.
- Do NOT use `new MeshTxBuilder(...)` in the browser either.
- Mesh is still safe for:
- `@meshsdk/react` wallet UI components on read-only pages
- Server-side flows that don't go through Vite's browser bundler
For any transaction you build in the browser, use **Lucid Evolution** (see
the Lucid block). Migrating Mesh → Lucid mid-project is a full rewrite of
every submit path — start on Lucid.
## Buffer polyfill (browser)
Cardano SDKs pull in code that assumes Node's global `Buffer`. In the
browser, polyfill it inside the submit function via **dynamic import** —
never at module scope.
```ts
export async function submitSomething() {
const { Buffer } = await import("buffer");
(globalThis as any).Buffer ??= Buffer;
// …now build/sign/submit
}
```
Do NOT do `import "buffer/"` or `import { Buffer } from "buffer"` at the
top of a module — Vite's dep optimizer will hang or ship the polyfill into
the initial bundle unnecessarily. Dynamic import in the code path that
actually needs it keeps the initial bundle small and predictable.
## App shape: Mint an NFT
- Native-script policy expiring at (currentSlot + 3600).
- Metadata under label 721 (CIP-25): `name`, `image` (ipfs:// URL), `mediaType`.
- Mint amount = 1. `policyId` derived from the wallet's payment key hash.
- **Min-ADA**: token-carrying outputs need ≥ ~1.5 ADA attached. Send at least
2_000_000 lovelace with the mint output or the tx will fail with
`OutputTooSmallUTxO`.
- After submission, poll the explorer for confirmation before enabling
"mint again".
## Debug panel — add on day 1 (do not retrofit)
Cardano SDK errors are opaque. A React toast usually swallows
`error.cause` and screenshots come back with a one-line
`TypeError: …` that tells you nothing. Print the full unwrapped chain to
the DOM when `?debug=1` is in the URL.
```tsx
// src/components/ErrorPanel.tsx
export function ErrorPanel({ err }: { err: unknown }) {
const unwrap = (e: any, depth = 0): string => {
if (!e || depth > 5) return "";
const head = " ".repeat(depth) + (e.name ?? "Error") + ": " + (e.message ?? e);
return head + "\n" + (e.stack ?? "") + "\n" +
(e.cause ? unwrap(e.cause, depth + 1) : "");
};
return (
<pre className="text-xs bg-black/80 text-red-300 p-3 overflow-auto max-h-80 whitespace-pre-wrap">
{unwrap(err)}
</pre>
);
}
```
Render it on every submit page behind `new URLSearchParams(location.search).has("debug")`.
This one panel will save you a day.
## Tx submission checklist (every submit path must handle these)
1. **No UTxOs** — wallet empty. Surface a "Fund via faucet" CTA linking to
https://docs.cardano.org/cardano-testnets/tools/faucet.
2. **Wrong network** — provider network ≠ wallet `getNetworkId()` (or the
UI's declared network). Refuse to submit and show a "Switch Lace" hint.
3. **Min-ADA** — datum-carrying / token-carrying UTxOs need ≥ ~1.5 ADA
attached (2_000_000 lovelace is the safe default).
4. **Submit timeout** — treat submission as fire-and-forget. After the proxy
returns a hash, poll the explorer (Blockfrost `/txs/{hash}`) before
enabling the next action.
5. **User rejection** — Lace throws `{ code: 2, info: "User declined to sign" }`.
Catch and show a friendly "Signing cancelled" message; do NOT bubble the
raw error to the UI.
6. **`?debug=1` panel** — render the full `error.message + error.cause`
chain to the DOM when `?debug=1` is in the URL. See the Debug panel
block. Toasts alone lose the underlying stack.
## Known misleading errors → real cause
| Symptom in the browser | Real cause / fix |
|---|---|
| `Cannot read properties of undefined (reading 'from')` inside Mesh | `@meshsdk/core@1.9.x` CJS/ESM interop under Vite. Switch to Lucid Evolution. Buffer polyfills and swapping `Transaction` for `MeshTxBuilder` do NOT help. |
| `(void 0) is not a function` from Lucid | UPLC wasm never instantiated. Add `vite-plugin-wasm` + `optimizeDeps.exclude` (see the Vite setup block). |
| `[UNLOADABLE_DEPENDENCY] .../uplc_tx_bg.wasm` at build | Same fix — install `vite-plugin-wasm`. |
| `Buffer is not defined` inside SDK | Add the browser `Buffer` polyfill via **dynamic** `await import("buffer")` inside the submit function (see Buffer polyfill block). |
| `Duplicate __tla declaration` at build | You added `vite-plugin-top-level-await` on top of `vite-plugin-wasm`. Remove it; rely on `build.target: "es2022"`. |
| `Duplicate routes found with id: /` in dev-server log | Stale HMR artifact from a mid-edit route regeneration. Reload; do NOT delete route files. |
## Hard-won lessons — read this BEFORE writing code
These are ordered. Each one cost a real hackathon day the last time it
was ignored.
1. **Pick Lucid Evolution first, not Mesh.** Mesh v1 in the browser under
Vite throws `undefined (reading 'from')` from deep inside its tx
builders regardless of Buffer polyfills or dropping the deprecated
`Transaction` class. Migrating Mesh → Lucid mid-project rewrites every
submit path. Start on Lucid.
2. **Configure `vite-plugin-wasm` before writing any tx code.** Lucid's
UPLC dependency is wasm-pack bundler-target. Without the plugin +
`optimizeDeps.exclude` you either fail the build or ship a runtime
`(void 0) is not a function`.
3. **Use native ES2022 top-level await** (`build.target: "es2022"`).
Do NOT add `vite-plugin-top-level-await` on top of `vite-plugin-wasm` —
both inject a `__tla` symbol and the duplicate declaration fails the
build.
4. **Polyfill `Buffer` via dynamic import inside the submit function**,
not at module scope:
`const { Buffer } = await import("buffer"); (globalThis as any).Buffer ??= Buffer;`.
Module-scope `import "buffer/"` hangs the Vite optimizer.
5. **Never ship a Blockfrost project ID to the browser.** Lucid's
`Blockfrost` client wants the key at construction — proxy through
`src/routes/api/public/cardano-blockfrost/$.ts` and pass `""` as the
client's project ID.
6. **Treat "Duplicate routes found with id: /" in the dev-server log as a
stale HMR artifact.** It clears on the next successful compile. Do NOT
delete `src/routes/index.tsx` chasing it.
7. **Add the `?debug=1` panel on day 1**, not after the third mystery
`TypeError`. Cardano SDK errors are opaque; screenshots without the
`error.cause` chain waste an entire iteration.
## Red flags to avoid (auto-fail in review)
- Any `VITE_*` variable holding a Blockfrost / Maestro / Koios project ID.
- Calling `window.cardano.lace.enable()` at module scope or in an SSR loader.
- Using `new Transaction(...)` or `new MeshTxBuilder(...)` in the browser
under Vite (see Mesh block).
- Mixing Mesh and Lucid in the same tx builder.
- Missing `vite-plugin-wasm` while using Lucid.
- Adding `vite-plugin-top-level-await` on top of `vite-plugin-wasm`.
- Static `import "buffer/"` at module scope.
- Hard-coding a network in the SDK that disagrees with the wallet's reported
network — refuse to submit if they don't match.
- Any Mainnet code path (this project is testnet-only).
- Calling Blockfrost directly from the browser instead of the server proxy.
Market sizing.
TAM
$650M
loop licensing market
SAM
$180M
loop licensing tech
SOM
$12M
gasless licensing apps
Indicative figures for hackathon pitches — refine with your own research before raising.
Adjacent entries.
beat collaboration
Collaborative Beat Ledger
Record and timestamp collaborative beat creation sessions with immutable proof of contribution.
AI-generated musicAI Audio License
Onchain licenses for AI-created audio to prevent misuse and ensure creator credit.
synth programmingOnchain Synth Presets
Distribute unique synthesizer presets as verifiable NFTs with built-in royalty logic.
music marketplacesSample Chain Marketplace
Create a decentralized marketplace for verified, licensed music samples with automatic royalties.