SceneMint
Mint individual screenplay scenes as NFTs to license or sell specific script parts securely.
Sepolia smart contract· onchain logic
Section · Onchain
full primer →The primitive.
Script segments gets a tiny Solidity contract deployed to World Chain Sepolia; writers see a 'verified onchain' badge with the live contract address and a one-tap Worldscan link.
Why this primitiveSmart contracts verify uniqueness and ownership of discrete narrative elements.
Kernel
a Solidity contract deployed to World Chain Sepolia via MetaMask private key, then verified on Worldscan (Etherscan v2, chainid=4801)
Drives the UI as
a 'verified onchain' badge with the live contract address and a Worldscan link
Required keys.
METAMASK_PRIVATE_KEY
Exported from MetaMask. Fund on World Chain Sepolia via the Alchemy faucet.
open ↗SEPOLIA_RPC_URL
Alchemy World Chain Sepolia 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 "SceneMint" in ONE Lovable message. Single-page demo.
CONCEPT
Mint individual screenplay scenes as NFTs to license or sell specific script parts securely.
Discipline: Writing, Poetry & Narrative (script segments).
Onchain primitive: World Chain smart contract. Why this primitive: Smart contracts verify uniqueness and ownership of discrete narrative elements.
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 World Chain Sepolia, verified on Worldscan.
- 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.
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.
- PrivyProvider config (do NOT stub defaultChain as { id, name } — omit it
or pass viem's `sepolia`; 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' } }}>
- 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: 4801 },
{ address: embedded.address, sponsor: true }
),
new Promise((_, r) => setTimeout(() => r(new Error(
"Privy sendTransaction timed out after 45s. Enable Gas sponsorship -> App pays -> World Chain 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.
- World ID v4 humanness gate is mandatory before any onchain write.
Production `app_id`s no longer accept the classic `worldapp://` WalletConnect
deep link, so the v2 `IDKitWidget` "Open World App" button silently does
nothing on real devices. Use v4: `IDKitRequestWidget` + server-signed
`rp_context` + a server-side verify proxy. Modern World ID 4.0 apps are
Orb-backed — you MUST use `orbLegacy({ signal })` as the preset;
`deviceLegacy` triggers `failed_by_host_app` and `handleVerify` never fires.
PORTAL SETUP (do this in https://developer.world.org before coding):
1. Create/open the app -> copy `app_id` (public, ships to browser).
2. Actions tab -> create an action slug (e.g. `log-action`). Verification
level: Orb (World ID 4.0 default).
3. Relying Parties tab (or "World ID 4.0" tab) -> create an RP. Copy the
`rp_id` (public, `rp_...`) AND the signing key (hex private key, shown
ONCE).
4. Allow legacy proofs: World ID 4.0 apps have this implicitly enabled;
the widget still needs `allow_legacy_proofs={true}`. Only revisit if
verify returns `invalid_proof` while the client flag is set.
PACKAGE: `bun add @worldcoin/idkit` (v4+; re-exports
`@worldcoin/idkit-core/signing`). Do NOT install v2.
FILE LAYOUT (5 files):
- src/config/worldid.ts — PUBLIC ONLY. `{ appId: 'app_...' as `app_${string}`,
action: 'log-action' }`. Never put rp_id or signing key here; anything
under src/config/* ships to the browser.
- src/components/worldid-gate.tsx — trigger button + `open` state. Lazy-load
the inner widget:
const IDKitInner = lazy(() => import('./worldid-inner'));
// <ClientOnly fallback={null}><Suspense><IDKitInner open={open} .../></Suspense></ClientOnly>
- src/components/worldid-inner.tsx — the actual widget. FREEZE the wallet
address into a ref/state the moment the widget opens; use that frozen
value in `orbLegacy({ signal })` AND in the verify POST body. Privy/wagmi
can re-hydrate mid-flow and mutate `signal`, producing a valid-looking
proof that server-verifies against the wrong signal.
On `open` -> POST `/api/public/idkit/rp-signature` with `{ action }`,
store the returned `{ rp_id, nonce, created_at, expires_at, signature }`
as `rp_context`, then render:
import { IDKitRequestWidget, orbLegacy } from '@worldcoin/idkit';
const frozenSignal = useRef(''); // set in useEffect when open becomes true
<IDKitRequestWidget open={open} onOpenChange={onOpenChange}
app_id={appId} action={action} rp_context={rp}
allow_legacy_proofs environment="production"
preset={orbLegacy({ signal: frozenSignal.current })}
handleVerify={async (result) => {
const res = await fetch('/api/public/idkit/verify', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
rp_id: rp.rp_id, app_id: appId, idkitResponse: result,
}),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(`verify ${res.status}: ${j.detail ?? j.code}`);
}
}}
onSuccess={onVerified} />
- src/routes/api/public/idkit/rp-signature.ts — TSS server route. Reads
`process.env.WORLDID_RP_ID` and `process.env.WORLDID_RP_SIGNING_KEY`
INSIDE the handler (never at module scope), then:
import { signRequest } from '@worldcoin/idkit-core/signing';
const sig = signRequest({ signingKeyHex: signingKey, action, ttl: 300 });
return Response.json({ rp_id: rpId, nonce: sig.nonce,
created_at: sig.createdAt, expires_at: sig.expiresAt,
signature: sig.sig });
- src/routes/api/public/idkit/verify.ts — TSS server route. Reads the
POST body `{ rp_id, app_id, idkitResponse }`, then UNWRAPS and forwards
`idkitResponse` (top-level proof fields — nullifier_hash, merkle_root,
proof, verification_level, action, signal_hash) to the CURRENT v4 host
`https://developer.world.org/api/v4/verify/${rp_id}` with explicit
Accept + User-Agent headers (the World edge returns text/html 403s
without them). On a non-JSON 403 from the rp_id path, retry ONCE using
`app_id` before giving up. Return diagnostic metadata (upstreamStatus,
upstreamContentType, verifyIdSource, fallbackTried) so the UI can show
a safe debug blob:
const payload = body.idkitResponse ?? body;
async function post(id) {
const url = `https://developer.world.org/api/v4/verify/${id}`;
const r = await fetch(url, {
method:'POST',
headers:{ 'Accept':'application/json',
'Content-Type':'application/json',
'User-Agent':'yourapp-worldid/1.0' },
body: JSON.stringify(payload),
});
const text = await r.text();
const ct = r.headers.get('content-type') ?? '';
let parsed = null; try { parsed = JSON.parse(text); } catch {}
return { r, text, ct, parsed, isJson: !!parsed || ct.includes('json') };
}
let a = await post(rpId); let fallbackTried = false;
if (a.r.status === 403 && !a.isJson && appId && appId !== rpId) {
fallbackTried = true;
const b = await post(appId); if (b.r.ok) a = b;
}
Wrapping the proof under `{ rp_id, idkitResponse }` in the upstream POST
returns `invalid_proof` immediately after World App shows "successful".
UI RULES (avoid the "same issue" loop):
* Separate "proof received" from "server verified" state. On backend
failure, KEEP the proof-received state — do NOT bounce the user back to
"Connect World App". Show the server error inline.
* Add a "Copy debug" button that dumps a SAFE non-secret JSON blob:
{ appId (prefix...suffix), action, rpId (prefix...suffix),
protocolVersion, environment, responseIdentifiers, upstreamStatus,
upstreamHost, upstreamContentType, fallbackTried, verifyIdSource }.
* On success show an explicit "Verified human" line; collapse the debug
JSON behind a <details>. If the debug shows upstreamStatus 200 +
application/json + verifyIdSource "body.rp_id" the flow IS working —
the UI just needs to celebrate it.
RULES:
* `/api/public/*` prefix is mandatory — bypasses Lovable's published-site
auth so World App can reach the endpoints cross-origin.
* `<ClientOnly>` + lazy import is mandatory — IDKit references `window` at
import time; a top-level import in a route file crashes SSR with
`window is not defined`.
* Fetch a fresh rp_signature on every `open` (5-min TTL); caching across
the widget lifetime causes stale-nonce failures.
* Read `process.env.*` INSIDE handlers, never at module scope of route
files — Cloudflare Workers inject env per-request.
* `handleVerify` MUST throw on non-OK so IDKit fires `onError` and skips
`onSuccess`. Swallowing the error shows a false-positive success.
* Signing key stays server-side only. Never place it in src/config/*, any
*.functions.ts module, or any file the router entry imports.
VERIFIED-WORKING SHAPE (target this):
preset=orbLegacy, protocol_version="3.0", responses[0].identifier="orb",
upstream POST https://developer.world.org/api/v4/verify/${rp_id},
upstreamStatus=200, upstreamContentType="application/json",
verifyIdSource="body.rp_id", fallbackTried=false.
FAILURE -> DIAGNOSIS:
* `onError { errorCode: "failed_by_host_app" }` and `/verify` never called
-> using `deviceLegacy` on an Orb-backed World ID 4.0 app. Switch preset
to `orbLegacy({ signal })`.
* Upstream returns 403 with `text/html` body -> World edge blocked the
request shape. Add `Accept: application/json` + `User-Agent`, then fall
back to `app_id` on non-JSON 403.
* Proof succeeds once but UI re-mounts to "Connect World App" -> failure
branch cleared the proof-received state. Keep it; only reset on explicit
"Re-verify".
* Signal-mismatch verify failure right after wallet auth -> `signal`
mutated between widget open and handleVerify. Freeze it in a ref.
* "Open World App" silent on prod -> using v2 widget with production
app_id. Switch to v4 `IDKitRequestWidget` as above.
* `rp-signature 500 no_rp_config` -> `WORLDID_RP_ID` or
`WORLDID_RP_SIGNING_KEY` missing. Add via Lovable secrets, redeploy.
* `verify 400 invalid_proof` right after World App shows "successful" ->
server is forwarding `{ rp_id, idkitResponse }` wrapper instead of the
unwrapped `idkitResponse` proof. Fix per rule above.
* `verify 400 action_not_found` -> action string doesn't match the one
created in the portal. Match verbatim (case-sensitive).
* Build crashes with `window is not defined` -> IDKit imported outside
`<ClientOnly>`. Move the import into the lazy `worldid-inner.tsx`.
* User reports "same issue" but debug shows upstreamStatus 200 JSON with
fallbackTried=false -> it IS working; the UI never displayed a clear
success state. Add an explicit "Verified human" line and collapse debug.
- DASHBOARD PREREQUISITE (one-time): Privy dashboard -> Gas sponsorship
-> App pays -> add "World Chain Sepolia" -> toggle "Allow transactions
from the client" ON. Without this, sendTransaction hangs silently.
- Hardhat in /contracts (kept outside the Vite bundle). Install
`@nomicfoundation/hardhat-toolbox` AND `@nomicfoundation/hardhat-verify@latest`
(>=3.x — older versions still hit Worldscan v1 and fail with
"You are using a deprecated V1 endpoint, switch to Worldscan V2 (chainid=4801)").
- hardhat.config.cjs MUST use the Worldscan v2 (chainid=4801) single-key shape (NOT the per-network map):
require("@nomicfoundation/hardhat-toolbox");
require("@nomicfoundation/hardhat-verify");
module.exports = {
solidity: { version: "0.8.24", settings: { optimizer: { enabled: true, runs: 200 } } },
networks: { worldchainSepolia: {
url: process.env.SEPOLIA_RPC_URL || "https://worldchain-sepolia.g.alchemy.com/public",
accounts: [process.env.METAMASK_PRIVATE_KEY.startsWith("0x")
? process.env.METAMASK_PRIVATE_KEY : "0x" + process.env.METAMASK_PRIVATE_KEY],
chainId: 4801,
} },
etherscan: { apiKey: process.env.ETHERSCAN_API_KEY }, // single string, NOT { sepolia: ... }
sourcify: { enabled: false }, // silences the v2.x prompt
};
- Deploy: `npx hardhat run scripts/deploy.cjs --network worldchainSepolia`.
- Verify (run RIGHT AFTER deploy, no constructor args for these contracts):
`npx hardhat verify --network worldchainSepolia <address>`
On success Worldscan returns "Successfully verified contract … on the block explorer"
and the source becomes readable at
`https://sepolia.worldscan.org/address/<address>#code`.
- Write the deployed address to `src/data/contract.json` so the UI links to
`https://sepolia.worldscan.org/address/<address>`.
CONTRACT (contracts/SceneMint.sol):
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title SceneMint
/// @notice Mint individual screenplay scenes as NFTs to license or sell specific script parts securely.
/// @notice Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
contract SceneMint {
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);
}
}
```
USER FLOW
1. Land on page -> 'Sign in with Google' (Privy) -> embedded wallet auto-provisioned.
2. User performs a script segments action; the app calls `log(payload)` on the contract via Privy sponsored tx and shows the Worldscan link as proof.
3. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14"
REQUIRED SECRETS (Lovable -> Project Settings -> Secrets):
- METAMASK_PRIVATE_KEY World Chain Sepolia deployer key. Fund it: https://www.alchemy.com/faucets/world-chain-sepolia
- SEPOLIA_RPC_URL Alchemy World Chain Sepolia HTTPS endpoint (https://eth-sepolia.g.alchemy.com/v2/<key>). Create a free app at https://dashboard.alchemy.com/ -> copy the HTTPS URL. Public RPCs throttle/fail under hackathon load — Alchemy is required.
- ETHERSCAN_API_KEY For `npx hardhat verify`. Get: https://etherscan.io/myapikey
- 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
$1.5B
screenwriting tools
SAM
$350M
digital script licensing
SOM
$25M
NFT-based script segment trading
Indicative figures for hackathon pitches — refine with your own research before raising.
Adjacent entries.
poetry archiving
VerseLedger
Securely timestamp and prove original poem authorship on the blockchain to prevent plagiarism.
interactive storytellingNarrativeVote
Enable readers to vote on story plot directions through blockchain-based decision contracts.
screenwriting rightsScriptRoyalty
Automate royalty distribution for collaborative screenplays using verified smart contract agreements.
collaborative poetryPoetChain
Facilitate co-authored poems with edit histories securely stored on blockchain for trust.