🎨 Visual Art · art provenance tracking

Gallery Ledger

Provide gallerists with gasless, onchain tools to prove artwork provenance and authenticity effortlessly.

Send test ADA· transfer primitive
Section · Onchain

The primitive.

full primer →

Build, sign, and submit a plain payment transaction — the smallest possible round-trip against the Cardano ledger.

Kernel
Build, sign, and submit a plain payment transaction — the smallest possible round-trip against the Cardano ledger.
Drives the UI as
A recipient / amount form, a confirm sheet, and a receipt with the transaction hash linked out to Cardanoscan.
Appendix · Secrets

Required keys.

BLOCKFROST_PROJECT_ID_PREVIEW
Server-only. Get one free at blockfrost.io; create a Preview project.
open ↗
BLOCKFROST_PROJECT_ID_PREPROD
Server-only. Same account, second project scoped to Preprod.
open ↗
VITE_CARDANO_NETWORK
`preview` or `preprod`. Enforced against Lace's reported networkId.
open ↗
VITE_APP_NAME
Shown in the CIP-30 wallet consent prompt. Keep it short.
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 · Send test ADABuild, sign, and submit a plain payment transaction — the smallest possible round-trip against the Cardano ledger.

# Gallery Ledger

> Provide gallerists with gasless, onchain tools to prove artwork provenance and authenticity effortlessly.

_Context: Visual Art · art provenance tracking_

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.



## App shape: Send test ADA
- Form: recipient address + amount in lovelace (1 ADA = 1_000_000).
- Build with the chosen SDK, sign via Lace, submit through the server proxy,
  show tx hash + explorer link (`https://<network>.cardanoscan.io/transaction/<hash>`).
- Minimum practical send: 1_500_000 lovelace to satisfy min-ADA at the output.



## 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.

Appendix · Market

Market sizing.

TAM
$15B
global art provenance solutions
SAM
$4B
provenance tools for galleries
SOM
$200M
gasless provenance management users

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

See also

Adjacent entries.