Genre Mapper
Attach permanent genre and style metadata to tracks for better discovery and curation.
IPFS via Pinata· decentralized storage
Section · Onchain
full primer →The primitive.
Every music classification artefact is pinned to IPFS through Pinata; musicians get a permanent CID and a public gateway preview instead of a fragile cloud URL.
Why this primitiveIPFS stores genre tags linked to audio files permanently and transparently.
Kernel
a Pinata JWT upload that pins images / JSON / manifests to IPFS and returns a permanent CID
Drives the UI as
a 'pinned to IPFS' chip with the CID and an ipfs.io gateway preview
Required keys.
ROOTSTOCK_TESTNET_RPC_URL
Alchemy BOB Mainnet HTTPS endpoint. Create a free app → copy the HTTPS URL.
open ↗Add these in your Lovable project under Settings → Secrets before pasting the prompt below.
Appendix · Mega-prompt
The build prompt.
budget · 1 message
Paste into a fresh Lovable project. Make sure all five secrets above are set first. read the build strategy →
Build "Genre Mapper" in ONE Lovable message. Single-page demo. This prompt is self-contained: everything you need is below — do not ask follow-up questions.
CONCEPT
Attach permanent genre and style metadata to tracks for better discovery and curation.
Discipline: Music & Sound Design (music classification).
Onchain primitive: IPFS via Pinata. Why this primitive: IPFS stores genre tags linked to audio files permanently and transparently.
5-CREDIT BUDGET (HARD LIMIT):
- ONE single-page app. No router, no Lovable Cloud, no database, no auth flows beyond Privy drop-in.
- ONE Solidity contract, <=80 lines, deployed to BOB Sepolia, verified on BOB Sepolia Explorer (Blockscout).
- Privy is always the auth + sponsored-tx layer (Google login, embedded wallet).
- Pinata/IPFS only if the idea genuinely needs to store a file or metadata.
- At most ONE AI call per user action (use 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.
NETWORK — BOB Sepolia (testnet, everything below is required verbatim)
- chainId 808813 (hex 0xc57cd). BOB is an OP-Stack L2 secured by Bitcoin.
- Gas token is ETH. Never BTC, and never the BOB ERC-20 token — holding BOB
tokens on Ethereum L1 does not pay for gas on the BOB network.
- RPC: https://bob-sepolia.rpc.gobob.xyz
- Explorer: https://bob-sepolia.explorer.gobob.xyz (Blockscout, not Etherscan)
- Funding the deployer: claim Sepolia ETH at https://sepolia-faucet.pk910.de/
then bridge it to BOB Sepolia at https://bob-sepolia.gobob.xyz/
- Reference: https://docs.gobob.xyz/docs/user-hub/networks
- Create src/lib/chains.ts exactly like this:
```ts
import type { Chain } from "viem";
export const bobSepolia: Chain = {
id: 808813,
name: "BOB Sepolia",
nativeCurrency: { name: "Sepolia Ether", symbol: "ETH", decimals: 18 },
rpcUrls: {
default: { http: ["https://bob-sepolia.rpc.gobob.xyz"] },
public: { http: ["https://bob-sepolia.rpc.gobob.xyz"] },
},
blockExplorers: {
default: { name: "BOB Sepolia Explorer", url: "https://bob-sepolia.explorer.gobob.xyz" },
},
testnet: true,
};
export const EXPLORER = "https://bob-sepolia.explorer.gobob.xyz";
```
FILE LAYOUT (create every one of these — nothing else)
```text
contracts/CIDLogGenreMapper.sol the contract source below
hardhat.config.cjs Blockscout verify config below
scripts/deploy.cjs deploy script below
src/lib/chains.ts bobSepolia viem Chain (above)
src/lib/contract.ts ABI + address helper, viem public client
src/data/contract.json { "address": "0x…", "deployTx": "0x…" } written after deploy
src/components/privy-root.tsx ClientOnly + Suspense + lazy wrapper
src/components/privy-client-entry.tsx the ONLY file importing @privy-io/react-auth
src/pages/Index.tsx the entire demo UI (single page)
```
STACK
- React + Vite single page (the index route).
- SSR-safe Privy mount is mandatory. Never import @privy-io/react-auth at
module scope of a route file — it crashes SSR. Use
lazy(() => import('./privy-client-entry')) inside <ClientOnly> + <Suspense>,
and put <PrivyProvider> only inside privy-client-entry.tsx.
```tsx
// src/components/privy-root.tsx
import { lazy, Suspense, type ReactNode } from "react";
import { ClientOnly } from "@tanstack/react-router"; // or a { mounted } useEffect guard on plain Vite
const PrivyClientEntry = lazy(() => import("./privy-client-entry"));
export function PrivyRoot({ children }: { children: ReactNode }) {
return (
<ClientOnly fallback={<div className="p-6 text-sm opacity-60">Loading wallet…</div>}>
<Suspense fallback={<div className="p-6 text-sm opacity-60">Loading wallet…</div>}>
<PrivyClientEntry>{children}</PrivyClientEntry>
</Suspense>
</ClientOnly>
);
}
```
- PrivyProvider config — pass bobSepolia as `defaultChain` + `supportedChains`.
chainId is also passed per-call:
```tsx
// src/components/privy-client-entry.tsx
import { PrivyProvider } from "@privy-io/react-auth";
import { bobSepolia } from "@/lib/chains";
export default function PrivyClientEntry({ children }: { children: React.ReactNode }) {
return (
<PrivyProvider
appId={import.meta.env.VITE_PRIVY_APP_ID}
config={{
loginMethods: ["google", "email"],
embeddedWallets: { ethereum: { createOnLogin: "users-without-wallets" } },
defaultChain: bobSepolia,
supportedChains: [bobSepolia],
appearance: { theme: "dark" },
}}
>
{children}
</PrivyProvider>
);
}
```
- Read the embedded wallet from useWallets, not user.wallet:
const embedded = wallets.find(w => w.walletClientType === 'privy');
- Every send goes through Privy `useSendTransaction` with BOTH `address`
and `sponsor`, wrapped in a 45s Promise.race timeout whose reject message
names the exact dashboard toggles:
await Promise.race([
sendTransaction(
{ to, data, chainId: 808813 },
{ address: embedded.address, sponsor: true }
),
new Promise((_, r) => setTimeout(() => r(new Error(
"Privy sendTransaction timed out after 45s. Enable Gas sponsorship -> App pays -> BOB Sepolia -> Allow transactions from the client."
)), 45_000)),
]);
- Do NOT pass uiOptions:{ showWalletUIs:false } — it aborts with
"signal is aborted without reason". The approval sheet still shows on
the embedded-EOA path; the fee reads US$0.00.
- Do NOT add ZeroDev / SmartWalletsProvider / a paymaster URL. Native
Privy sponsorship on BOB Sepolia works with the toggles above and nothing else.
- DASHBOARD PREREQUISITE (one-time): Privy dashboard -> Gas sponsorship
-> App pays -> add custom chain "BOB Sepolia" (chainId 808813, rpc
https://bob-sepolia.rpc.gobob.xyz) -> toggle "Allow transactions from
the client" ON. Without this, sendTransaction hangs silently.
- src/lib/pinata.ts uploads via `fetch('https://api.pinata.cloud/pinning/pinFileToIPFS', { method:'POST', headers:{ Authorization: `Bearer ${import.meta.env.VITE_PINATA_JWT}` }, body: fd })`.
CONTRACT (contracts/CIDLogGenreMapper.sol):
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title CIDLogGenreMapper
/// @notice Attach permanent genre and style metadata to tracks for better discovery and curation.
/// @notice Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
contract CIDLogGenreMapper {
event Logged(address indexed author, string cid, uint256 at);
/// @notice Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
function log(string calldata cid) external {
emit Logged(msg.sender, cid, block.timestamp);
}
}
```
DEPLOY + VERIFY
- Hardhat lives at the repo root with sources in /contracts (kept outside the
Vite bundle). Install `@nomicfoundation/hardhat-toolbox` AND
`@nomicfoundation/hardhat-verify@latest`.
- hardhat.config.cjs uses hardhat-verify's Blockscout customChain shape
(BOB Sepolia Explorer is Blockscout-based, not Etherscan):
```js
require("@nomicfoundation/hardhat-toolbox");
require("@nomicfoundation/hardhat-verify");
module.exports = {
solidity: { version: "0.8.24", settings: { optimizer: { enabled: true, runs: 200 } } },
networks: {
bobSepolia: {
url: process.env.BOB_SEPOLIA_RPC_URL || "https://bob-sepolia.rpc.gobob.xyz",
accounts: [process.env.METAMASK_PRIVATE_KEY.startsWith("0x")
? process.env.METAMASK_PRIVATE_KEY : "0x" + process.env.METAMASK_PRIVATE_KEY],
chainId: 808813,
},
},
etherscan: {
apiKey: { bobSepolia: "empty" }, // Blockscout ignores the key but the field is required
customChains: [{
network: "bobSepolia",
chainId: 808813,
urls: {
apiURL: "https://bob-sepolia.explorer.gobob.xyz/api",
browserURL: "https://bob-sepolia.explorer.gobob.xyz/",
},
}],
},
sourcify: { enabled: false },
};
```
```js
// scripts/deploy.cjs
const hre = require("hardhat");
const fs = require("fs");
async function main() {
const f = await hre.ethers.getContractFactory("CIDLogGenreMapper");
const c = await f.deploy();
await c.waitForDeployment();
const address = await c.getAddress();
console.log("deployed:", address);
fs.writeFileSync("src/data/contract.json", JSON.stringify({
address,
deployTx: c.deploymentTransaction().hash,
chainId: 808813,
explorer: "https://bob-sepolia.explorer.gobob.xyz",
}, null, 2));
}
main().catch((e) => { console.error(e); process.exit(1); });
```
- Deploy: `npx hardhat run scripts/deploy.cjs --network bobSepolia`.
- Verify (run RIGHT AFTER deploy, no constructor args for these contracts):
`npx hardhat verify --network bobSepolia <address>`.
On success Blockscout returns "Successfully verified contract" and the
source becomes readable at
`https://bob-sepolia.explorer.gobob.xyz/address/<address>#code`.
Blockscout does NOT auto-verify — always run the command explicitly.
- Frontend reads: create a viem public client with the BOB Sepolia RPC —
`createPublicClient({ chain: bobSepolia, transport: http(import.meta.env.VITE_BOB_SEPOLIA_RPC_URL ?? "https://bob-sepolia.rpc.gobob.xyz") })`.
FRONTEND CODE (src/pages/Index.tsx — adapt copy, keep the choreography)
```tsx
import { useState } from "react";
import { usePrivy, useWallets, useSendTransaction } from "@privy-io/react-auth";
import { createPublicClient, encodeFunctionData, http } from "viem";
import { bobSepolia, EXPLORER } from "@/lib/chains";
import { ABI } from "@/lib/contract";
import contract from "@/data/contract.json";
const client = createPublicClient({ chain: bobSepolia, transport: http() });
export default function Index() {
const { ready, authenticated, login, logout } = usePrivy();
const { wallets } = useWallets();
const { sendTransaction } = useSendTransaction();
const embedded = wallets.find((w) => w.walletClientType === "privy");
const [input, setInput] = useState("");
const [status, setStatus] = useState<"idle" | "pending" | "done" | "error">("idle");
const [txHash, setTxHash] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function submit() {
if (!embedded) return;
setStatus("pending"); setError(null); setTxHash(null);
try {
const data = encodeFunctionData({ abi: ABI, functionName: "log", args: [input] });
const res: any = await Promise.race([
sendTransaction(
{ to: contract.address as `0x${string}`, data, chainId: 808813 },
{ address: embedded.address, sponsor: true }
),
new Promise((_, r) => setTimeout(() => r(new Error(
"Privy sendTransaction timed out after 45s. Enable Gas sponsorship -> App pays -> BOB Sepolia -> Allow transactions from the client."
)), 45_000)),
]);
const hash = res?.hash ?? res;
setTxHash(hash);
await client.waitForTransactionReceipt({ hash });
setStatus("done");
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setStatus("error");
}
}
if (!ready) return <main className="p-10">Loading…</main>;
return (
<main className="min-h-screen mx-auto max-w-2xl px-6 py-16">
<h1 className="text-4xl font-semibold tracking-tight">Genre Mapper</h1>
<p className="mt-3 opacity-70">Attach permanent genre and style metadata to tracks for better discovery and curation.</p>
{!authenticated ? (
<button onClick={login} className="mt-8 px-6 py-3 rounded-md bg-primary text-primary-foreground">
Sign in with Google
</button>
) : (
<section className="mt-8 space-y-4">
<div className="text-xs font-mono opacity-60">{embedded?.address}</div>
<input value={input} onChange={(e) => setInput(e.target.value)}
placeholder="Describe the music classification entry…"
className="w-full rounded-md border px-4 py-3 bg-transparent" />
<button onClick={submit} disabled={status === "pending" || !input}
className="px-6 py-3 rounded-md bg-primary text-primary-foreground disabled:opacity-40">
{status === "pending" ? "Writing to BOB Sepolia…" : "Pin + record onchain"}
</button>
{txHash && (
<a href={`${EXPLORER}/tx/${txHash}`} target="_blank" rel="noreferrer"
className="block text-sm underline break-all">
{status === "done" ? "Confirmed" : "Pending"} · {txHash}
</a>
)}
{error && <p className="text-sm text-red-500 break-all">{error}</p>}
<button onClick={logout} className="text-xs uppercase tracking-widest opacity-60">Sign out</button>
</section>
)}
<footer className="mt-20 text-xs opacity-60">Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14</footer>
</main>
);
}
```
Also export the ABI from src/lib/contract.ts so the page and any read call share it.
HOOK INTEGRATION — IPFS via Pinata
```ts
// src/lib/pinata.ts
export async function pinFile(file: File): Promise<string> {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
method: "POST",
headers: { Authorization: `Bearer ${import.meta.env.VITE_PINATA_JWT}` },
body: fd,
});
if (!res.ok) throw new Error(`Pinata ${res.status}: ${await res.text()}`);
const { IpfsHash } = await res.json();
return IpfsHash as string;
}
export async function pinJson(json: unknown): Promise<string> {
const res = await fetch("https://api.pinata.cloud/pinning/pinJSONToIPFS", {
method: "POST",
headers: {
Authorization: `Bearer ${import.meta.env.VITE_PINATA_JWT}`,
"Content-Type": "application/json",
},
body: JSON.stringify(json),
});
if (!res.ok) throw new Error(`Pinata ${res.status}: ${await res.text()}`);
return (await res.json()).IpfsHash as string;
}
```
Pin FIRST, then send the onchain tx with the returned CID. Preview the pinned
asset at `https://gateway.pinata.cloud/ipfs/<cid>` and show the CID as a chip
next to the explorer link. Never send the file itself onchain.
USER FLOW
1. Land on page -> 'Sign in with Google' (Privy) -> embedded wallet auto-provisioned.
2. On submit, pin the music classification artefact to Pinata, then call `log(cid)` on the contract via Privy sponsored tx. Render the CID, IPFS gateway preview, and BOB Sepolia Explorer tx link.
3. While the tx is in flight the button reads 'Writing to BOB Sepolia…' and the hash appears as a pending link; on receipt it flips to 'Confirmed'.
4. The page re-reads chain state so a refresh shows the same result.
5. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14"
DESIGN DIRECTION
Commit to one distinctive look drawn from music & sound design — specifically
music classification. No purple-on-white gradient hero, no Inter/Poppins default stack, no
generic three-card feature row. Pick a real typeface pairing, one accent colour
plus a near-monochrome base, and a layout with an obvious focal point. Define
colours as CSS variables/tokens; never hardcode text-white or bg-black. Dark by
default. The proof-of-onchain elements (address, tx hash, CID) should read as
deliberate typographic detail in a mono face, not as debug output.
TROUBLESHOOTING (these are the failures you will actually hit)
- Blank page / "document is not defined" on load -> @privy-io/react-auth was
imported at module scope of a route. Move it behind the lazy client entry.
- sendTransaction never resolves, no error -> the Privy dashboard gas-sponsorship
toggle for BOB Sepolia is off. Add the custom chain and enable "Allow
transactions from the client".
- "signal is aborted without reason" -> you passed uiOptions:{ showWalletUIs:false }.
Remove it.
- Tx reverts with EMPTY revert data -> you encoded a function the deployed
contract does not have. Check the verified ABI on Blockscout before
debugging the UI.
- Tx sits pending forever -> underpriced. Resend the same nonce with a higher
maxFeePerGas (~5 gwei) and 1 gwei priority fee.
- `hardhat verify` fails with "missing API key" -> keep the dummy
`apiKey: { bobSepolia: "empty" }` entry; Blockscout ignores the value but
hardhat-verify requires the field.
- Explorer shows "not verified" after deploy -> Blockscout never auto-verifies.
Run the verify command.
- Reads return nothing -> you are pointed at the wrong chain. Confirm chainId
808813 and the BOB Sepolia RPC, not Ethereum Sepolia (11155111).
REQUIRED SECRETS (Lovable -> Project Settings -> Secrets):
- METAMASK_PRIVATE_KEY BOB Sepolia deployer key. Fund flow: get Sepolia ETH from https://sepolia-faucet.pk910.de/ then bridge to BOB Sepolia at https://bob-sepolia.gobob.xyz/
- BOB_SEPOLIA_RPC_URL HTTPS RPC (default: https://bob-sepolia.rpc.gobob.xyz). Public RPC works for hackathon load; swap for a dedicated node provider if you need higher rate limits.
- PRIVY_APP_ID Google sign-in + sponsored tx. Docs: https://docs.privy.io/llms-full.txt
- PINATA_JWT IPFS uploads (only if app pins media). Docs: https://docs.pinata.cloud/llms-full.txt
CREDIT (must appear in UI footer AND as NatSpec on every deployed contract):
Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
Market sizing.
TAM
$800M
music metadata services
SAM
$180M
genre tagging platforms
SOM
$15M
producers using classified samples
Indicative figures for hackathon pitches — refine with your own research before raising.
Adjacent entries.
sample lineage
Loop Provenance
Track and verify origin of audio loops to ensure authenticity for musicians and producers.
synth preset archivingPatch Vault
Securely store and share synthesizer presets with verified authenticity and version control.
composition notationScore Archive
Permanently archive music scores and compositions with guaranteed timestamped records.
sample licensingSampleChain
Manage and prove sample license ownership with permanent onchain records for producers.