📷 Photography · time-lapse photography

TimeLapse Token

Record and prove time-lapse photo sequences provenance on blockchain.

Omnipin decentralized website· contenthash + .eth.limo
Section · Onchain

The primitive.

full primer →

Every time-lapse photography artefact is pinned to IPFS via Pinata, written as the ENS contenthash on the user's name, and opens at <name>.eth.limo — photographers host their work on a decentralized gateway with zero servers.

Why this primitiveOmnipin turns TimeLapse Token's time-lapse photography 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.

Kernel
a Pinata upload of the artefact / page bundle, encoded as an EIP-1577 contenthash and written to PublicResolver.setContenthash on the user's ENS name
Drives the UI as
an 'open at <name>.eth.limo' link that serves the pinned site from a decentralized gateway 30-60s after the tx confirms
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 "TimeLapse Token" in ONE Lovable message. Single-page demo that pins a
time-lapse photography artefact to IPFS via Pinata, writes its contenthash
onto a `.eth` name, and serves the result at `<name>.eth.limo`.

CONCEPT
Record and prove time-lapse photo sequences provenance on blockchain.
Discipline: Photography (time-lapse photography).
ENS primitive: Omnipin — Pinata IPFS pin -> ENS contenthash -> <name>.eth.limo decentralized site.
Why this primitive: Omnipin turns TimeLapse Token's time-lapse photography 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 time-lapse photography 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
Appendix · Market

Market sizing.

TAM
$2.4B
photo software industry
SAM
$80M
time-lapse production tools
SOM
$8M
provenance for time-lapse creators

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

See also

Adjacent entries.