EchoEssays
Publish and securely timestamp essays with gasless wallet transactions for provenance.
Privy social + sponsored tx· wallet UX
Section · Onchain
full primer →The primitive.
Writers sign in with Google through Privy — no seed phrase, no MetaMask popup — and their narrative essays actions are sent as sponsored transactions on World Chain so they never see gas.
Why this primitivePrivy and sponsored tx ensure effortless essay proofing onchain without gas costs.
Kernel
Privy embedded wallet bootstrapped with PRIVY_APP_ID — Google sign-in plus native sponsored transactions on World Chain Sepolia so users never see gas
Drives the UI as
a one-click 'Sign in with Google' that drops the user straight into the app with an onchain identity
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 "EchoEssays" in ONE Lovable message. Single-page demo.
CONCEPT
Publish and securely timestamp essays with gasless wallet transactions for provenance.
Discipline: Writing, Poetry & Narrative (narrative essays).
Onchain primitive: Privy social + sponsored tx. Why this primitive: Privy and sponsored tx ensure effortless essay proofing onchain without gas costs.
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/EchoEssays.sol):
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title SocialLogEchoEssays
/// @notice Publish and securely timestamp essays with gasless wallet transactions for provenance.
/// @notice Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
contract SocialLogEchoEssays {
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. Every narrative essays action the user performs is sent as a sponsored World Chain Sepolia tx (`log(payload)`) and displayed with an Worldscan link. No wallet popups.
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
$500M
essay publishing tools
SAM
$120M
digital provenance software
SOM
$14M
essayists using blockchain
Indicative figures for hackathon pitches — refine with your own research before raising.
Adjacent entries.
poetry drafting
VerseVault
Securely draft and store poems with seamless gasless transaction experience.
interactive narrativeStoryStake
Engage writers in creating branching stories with frictionless onchain ownership of choices.
screenwriting collaborationScriptSponsor
Collaborate on scripts with transparent, gasless edits tracked via embedded wallets.
narrative designNarrateNexus
Create and monetize narrative assets securely with seamless wallet integration and no fees.