Curated Sound Trails
Create and share onchain-curated playlists with gasless user interaction and proof of curation.
The primitive.
Every music curation artefact is pinned to IPFS via Pinata, written as the ENS contenthash on the user's name, and opens at <name>.eth.limo — musicians host their work on a decentralized gateway with zero servers.
Why this primitiveOmnipin turns Curated Sound Trails's music curation artefacts into decentralized pages — pin the bundle to IPFS via Pinata, write it as the ENS contenthash record, and it serves at <name>.eth.limo with no hosting to maintain.
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 →
Build "Curated Sound Trails" in ONE Lovable message. Single-page demo that pins a
music curation artefact to IPFS via Pinata, writes its contenthash
onto a `.eth` name, and serves the result at `<name>.eth.limo`.
CONCEPT
Create and share onchain-curated playlists with gasless user interaction and proof of curation.
Discipline: Music & Sound Design (music curation).
ENS primitive: Omnipin — Pinata IPFS pin -> ENS contenthash -> <name>.eth.limo decentralized site.
Why this primitive: Omnipin turns Curated Sound Trails's music curation artefacts into decentralized pages — pin the bundle to IPFS via Pinata, write it as the ENS contenthash record, and it serves at <name>.eth.limo with no hosting to maintain.
5-CREDIT BUDGET (HARD LIMIT):
- ONE single-page app. No router, no Lovable Cloud, no database, no auth
flows beyond Privy drop-in.
- No custom Solidity unless the hook explicitly deploys one — reuse the
canonical ENS / Durin / Namestone contracts listed below.
- Privy is always the auth + sponsored-tx layer (Google login, embedded
wallet, native gas sponsorship on Sepolia and Base Sepolia).
- At most ONE AI call per user action (Lovable AI Gateway + LOVABLE_API_KEY
if AI is part of the idea).
- Skip tests, skip CI, skip docs pages. Ship the demo, nothing else.
ENS SPECIFICS — Omnipin (get the multicodec wrong and eth.limo returns 404):
Canonical Sepolia contracts (as of 2026):
- PublicResolver 0xE99638b40E4Fff0129D56f03b55b6bbC4BBE49b5
function setContenthash(bytes32 node, bytes calldata hash) external;
function contenthash(bytes32 node) external view returns (bytes memory);
- ENS Registry 0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e
Content-hash encoding — USE THE LIBRARY, never hand-roll the bytes:
import contentHash from '@ensdomains/content-hash'; // ^3
const encoded = '0x' + contentHash.encode('ipfs', cidV1);
// Correct output starts with 0xe3010170... (dag-pb multicodec).
// If your cid is CIDv0 (Qm...), convert to v1 first:
// CID.parse(cidV0).toV1().toString()
Pin an index.html (NOT a JSON blob or metadata manifest — content-type
matters for eth.limo):
const fd = new FormData();
fd.append('file', htmlBlob, 'index.html');
const r = await fetch('https://api.pinata.cloud/pinning/pinFileToIPFS', {
method: 'POST',
headers: { Authorization: `Bearer ${import.meta.env.VITE_PINATA_JWT}` },
body: fd,
});
const { IpfsHash } = await r.json(); // this is CIDv0 by default
Write the contenthash (sponsored tx via Privy on Sepolia):
const node = namehash(`${label}.eth`);
const data = encodeFunctionData(publicResolverAbi, 'setContenthash',
[node, encoded]);
await sendTxFromEoa(embedded, { to: PUBLIC_RESOLVER, data, chainId: 11155111 });
Verify:
- After ~30-60s (eth.limo caches), open
https://<label>.eth.limo/?ts=${Date.now()} (cache-bust)
and confirm the pinned HTML renders. Show a "Waiting for propagation…"
state with a manual "Retry" button for the first minute.
Pre-flight guards:
1. `ENSRegistry.owner(namehash(`${label}.eth`)) === embedded.address` (or
an authorized operator). If not, DO NOT attempt setContenthash — it
reverts. Show a copy-paste "register this name first" panel with a
link to `/showcase/eth-name` or sepolia.app.ens.domains. Never trust
the app.ens.dev UI as proof of ownership.
2. Resolver on the name is the PublicResolver above (or implements
EIP-1577 `contenthash(bytes32)`). If not, `setContenthash` reverts.
3. Pinata JWT is valid (a `pinList` GET returns 200 before allowing pins).
UNIVERSAL RESOLVER V2 (use this for EVERY name read — it is the only
reader that works across v1, ENSv2, offchain ENSIP-25 and Durin L2 names):
- Sepolia address: 0xeEeEEEeE14D718C2B47D9923Deab1335E144EeEe
- ABI subset:
resolve(bytes name, bytes data) view returns (bytes result, address resolver)
findCanonicalName(bytes name) view returns (bytes canonicalName, address resolver)
reverse(bytes lookupAddress, uint256 coinType) view returns (string name, address resolver, address reverseResolver)
- `name` is DNS-WIRE-ENCODED, not a string and not a namehash:
import { toHex, namehash } from 'viem';
import { packetToBytes } from 'viem/ens';
const wire = toHex(packetToBytes('alice.eth'));
- Always read through a viem PUBLIC CLIENT (CCIP-Read is on by default). Reading
through Privy's embedded provider throws `OffchainLookup` and breaks offchain
and L2 names.
- `ResolverNotFound` means "no resolver set", NOT "name unregistered" — show
different copy for each.
- Reverse records are opt-in; most Sepolia addresses have none. Fall back to the
truncated address, never "unknown".
- After every register/mint, verify with a Universal Resolver read and show the
resolved address in the success card. It is a free call.
PRIVY (SSR-safe mount + embedded-EOA routing — the four traps):
- Never import @privy-io/react-auth at module scope of a route file. Use
const Client = lazy(() => import('./privy-client-entry'));
inside <ClientOnly><Suspense>...</Suspense></ClientOnly>. Put
<PrivyProvider> only inside privy-client-entry.tsx.
- PrivyProvider config (do NOT stub defaultChain as { id, name } — pass
viem's `sepolia` or omit; chainId is passed per-call):
<PrivyProvider appId={import.meta.env.VITE_PRIVY_APP_ID}
config={{ loginMethods:['google','email'],
embeddedWallets:{ ethereum:{ createOnLogin:'users-without-wallets' } },
appearance:{ theme:'dark' } }}>
- TRAP 1 — smart account vs EOA. `wallets[0]` and `useSendTransaction`
can pick an EIP-4337 smart account that is EMPTY even when your embedded
EOA is funded. Always pick the embedded EOA explicitly:
const embedded = wallets.find(w => w.walletClientType === 'privy');
const provider = await embedded.getEthereumProvider();
Display `embedded.address` (not smartAccount.address) as the fund-me box
and poll THAT balance.
- TRAP 2 — stale RPC after switchChain. Privy caches the provider's RPC
URL. After every switchChain, re-fetch AND assert:
await embedded.switchChain(11155111);
const p = await embedded.getEthereumProvider();
if ((await p.request({method:'eth_chainId'})) !== '0xaa36a7')
throw new Error('Provider still not on Sepolia — re-open Privy');
- TRAP 3 — `signal is aborted without reason`. Do NOT pass
uiOptions:{ showWalletUIs:false } (or any uiOptions) on the embedded
path. Send with ONLY { to, data, value, chainId }.
- TRAP 4 — sponsored tx hangs. Wrap every send in a 45s Promise.race
whose reject message names the exact dashboard toggle:
await Promise.race([
sendTransaction({ to, data, value, chainId: 11155111 },
{ address: embedded.address, sponsor: true }),
new Promise((_, r) => setTimeout(() => r(new Error(
"Privy sendTransaction timed out after 45s. In Privy dashboard: Gas sponsorship -> App pays -> add Ethereum Sepolia (and Base Sepolia if used) -> Allow transactions from the client."
)), 45_000)),
]);
- Do NOT add ZeroDev / SmartWalletsProvider / paymaster URL. Native
Privy sponsorship works with only the dashboard toggle above.
- DASHBOARD PREREQUISITE (one-time): Privy dashboard -> Gas sponsorship
-> App pays -> add "Ethereum Sepolia" (and "Base Sepolia" if the hook
uses it) -> toggle "Allow transactions from the client" ON.
STACK
- React + Vite single page. No router. No Hardhat, no custom Solidity.
- Deps: viem ^2, @ensdomains/content-hash@^3, multiformats (for CID
v0->v1 conversion if needed).
- src/lib/omnipin.ts: `pinHtml`, `encodeIpfs`, `writeContenthash`,
`awaitPropagation`.
USER FLOW
1. Land -> "Sign in with Google" (Privy) -> embedded EOA on Sepolia.
2. Enter the `.eth` name you own; pre-flight badges must be green.
3. Compose (or upload) the music curation artefact -> "Publish".
4. Steps stream: pin -> encode contenthash -> sponsored setContenthash tx
-> propagation wait -> render live `<name>.eth.limo` iframe/link.
5. Footer renders the credit line below.
REQUIRED SECRETS (Lovable -> Project Settings -> Secrets):
- SEPOLIA_RPC_URL (+ VITE_SEPOLIA_RPC_URL, same value) Alchemy Sepolia HTTPS endpoint. Create a free app at https://dashboard.alchemy.com/. Public RPCs throttle/fail — Alchemy required.
- PRIVY_APP_ID (+ VITE_PRIVY_APP_ID) Google sign-in + sponsored tx. Docs: https://docs.privy.io/llms-full.txt
- PINATA_JWT (+ VITE_PINATA_JWT) IPFS upload JWT. Docs: https://docs.pinata.cloud/llms-full.txt
CREDIT (must appear in UI footer):
Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
Market sizing.
Indicative figures for hackathon pitches — refine with your own research before raising.