🎵 Music & Sound Design · integrated art & sound

Album Art Audio Tokens

Combine album art and music provenance by minting unified NFT tokens.

L1 .eth registration· onchain identity
Section · Onchain

The primitive.

full primer →

Musicians claim a real .eth name on Sepolia in one commit-and-reveal flow; every integrated art & sound action after that is signed by an ENS identity that travels across every wallet and dapp.

Why this primitiveAn ENS name is the perfect identity anchor for integrated art & sound work — Album Art Audio Tokens needs a portable, censorship-resistant handle that travels across every wallet, resolver, and dapp in the ecosystem.

Kernel
a commit/reveal registration against Sepolia's ETHRegistrarController that mints an ENS name owned by the user, with resolver + addr records set in a single multicall
Drives the UI as
a 'claim your .eth' flow that returns the registered name, transaction link, and live reverse-resolves the user's address
Appendix · Secrets

Required keys.

METAMASK_PRIVATE_KEY
Exported from MetaMask. Fund on Sepolia via the Google Cloud faucet before registering a .eth name.
open ↗
SEPOLIA_RPC_URL
Alchemy Sepolia HTTPS endpoint. viem uses this to resolve every .eth name.
open ↗
ETHERSCAN_API_KEY
Required for npx hardhat verify on ENS resolvers / L2 registrars.
open ↗
PRIVY_APP_ID
Google sign-in + sponsored transactions so users never see gas.
open ↗
PINATA_JWT
Pin site bundles / metadata to IPFS, then write the CID as the ENS contenthash.
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 →

Build "Album Art Audio Tokens" in ONE Lovable message. Single-page demo that lets a
user register a `.eth` name on Sepolia end-to-end and use it inside the app.

CONCEPT
Combine album art and music provenance by minting unified NFT tokens.
Discipline: Music & Sound Design (integrated art & sound).
ENS primitive: L1 .eth registration on Sepolia via the canonical ETHRegistrarController (commit/reveal, tuple RegistrationArgs ABI).
Why this primitive: An ENS name is the perfect identity anchor for integrated art & sound work — Album Art Audio Tokens needs a portable, censorship-resistant handle that travels across every wallet, resolver, and dapp in the ecosystem.

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 — Sepolia L1 register (get any of this wrong and register() reverts):

Canonical Sepolia contracts (as of 2026):
- ETHRegistrarController  0xfb3cE5D01e0f33f41DbB39035dB9745962F1f968  (DAO-approved, tuple ABI)
- PublicResolver          0xE99638b40E4Fff0129D56f03b55b6bbC4BBE49b5
- ENS Registry            0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e
- BaseRegistrar           0x0635513f179D50A207757E05759CbD106d7dFcE8
- ReverseRegistrar        0xA0a1AbcDAe1a2a4A2EF8e9113Ff0e02DD81DC0C6

ABI — the current controller uses a SINGLE RegistrationArgs tuple, NOT the
legacy 8-positional-arg signature. If you use an old ABI, viem cannot decode
reverts and every call fails silently. Include this exact ABI subset:

  struct RegistrationArgs {
    string  label;                  // "alice" — do NOT include .eth
    address owner;
    uint256 duration;               // seconds; MIN 28 days (28*24*3600)
    bytes32 secret;
    address resolver;               // PublicResolver above
    bytes[] data;                   // multicall bytes for setAddr etc.
    bool    reverseRecord;
    uint16  ownerControlledFuses;   // 0
  }
  function rentPrice(string label, uint256 duration) view returns (uint256 base, uint256 premium);
  function commitment(RegistrationArgs) view returns (bytes32);
  function commit(bytes32 commitment);
  function register(RegistrationArgs) payable;

  // Custom errors — MUST be in your ABI or decoding a revert returns garbage:
  error CommitmentTooNew();
  error CommitmentTooOld();
  error CommitmentDoesNotExist();
  error NameNotAvailable(string label);
  error DurationTooShort(uint256 duration);
  error ResolverRequiredWhenDataSupplied();
  error InsufficientValue();
  error Unauthorised(bytes32 node);
  error MaxCommitmentAgeTooLow();
  error MaxCommitmentAgeTooHigh();

Pre-flight guards (render the "Register" button DISABLED until all pass):
1. `BaseRegistrar.controllers(controller)` returns `true`. If false, the
   controller isn't DAO-approved — refuse to register and show a copy-paste
   "use the canonical controller above" panel. You CANNOT self-approve a
   custom controller for a hackathon.
2. Provider is on chainId 11155111 (see Privy TRAP 2).
3. Label matches `/^[a-z0-9-]{3,}$/`. Warn: names <= 4 chars carry a
   premium (see rentPrice).
4. `simulateContract(register, args)` succeeds — surfaces
   Unauthorised / NameNotAvailable / InsufficientValue BEFORE burning gas
   AND before the 60s wait.

Flow (viem + Privy sponsored tx):
  1. Compute label, owner (embedded EOA), duration = 365*24*3600.
  2. Read [base, premium] = rentPrice(label, duration).
     value = (base + premium) * 105n / 100n   // 5% buffer for price drift.
  3. secret = 32 random bytes. Persist in sessionStorage keyed by
     `commit:${label}`; a page refresh mid-wait loses the reveal.
  4. args = tuple above with resolver = PublicResolver, data = [
       encodeFunctionData(publicResolverAbi, 'setAddr',
         [namehash(`${label}.eth`), owner])
     ], reverseRecord = false, ownerControlledFuses = 0.
  5. commitmentHash = commitment(args); send commit(commitmentHash) via
     Privy sponsored tx.
  6. Wait 65s (chain time drift). If register still reverts with
     CommitmentTooNew, poll block.timestamp until
     `block.timestamp - controller.commitments(hash) >= minCommitmentAge()`
     and auto-retry once.
  7. Send register(args) with value. Refunds go back to the EOA.
  8. Verify: viem.getEnsAddress({ name: `${label}.eth` }) === owner.
  9. Show Etherscan links for commit + register, plus the resolved address.

"app.ens.dev" is NOT proof of ownership — always verify with
`ENSRegistry.owner(namehash(`${label}.eth`))` before showing "registered".

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 (index route). No router.
- viem ^2 for reads/writes. `@ensdomains/ensjs` optional for name utilities.
- src/lib/ens.ts: ABIs above + `getSepoliaController()` + `simulate()` helpers.
- src/lib/privy-tx.ts: `sendTxFromEoa(embedded, tx)` implementing the four
  Privy traps.
- No Hardhat, no custom contract. Uses the canonical controller directly.

USER FLOW
1. Land -> "Sign in with Google" (Privy) -> embedded EOA auto-provisioned.
2. Enter a label; the UI shows live price + all pre-flight checks green/red.
3. Click "Register" -> commit -> 65s countdown -> register -> success card
   with Etherscan link, resolved address, and a "your ENS name" pill used
   throughout the rest of the app for the integrated art & sound workflow.
4. 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

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

Market sizing.

TAM
$2.2B
multimedia album markets
SAM
$600M
artist merchandise
SOM
$90M
indie visual musicians

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

See also

Adjacent entries.